File size: 8,779 Bytes
dbb1bf9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 | /**
* NotifyCountryLink β inline "Notify me about this country" sub-action for U8.
*
* Mounted on the Country Deep Dive panel right next to the FollowButton.
* Visible only when the user is currently following the target country
* (FollowButton in filled state); hidden otherwise. Clicking the link
* opens the user's notifications settings page so they can configure /
* create an alert rule for this country.
*
* Degraded path (this PR): the link just opens the existing notifications
* settings tab β no pre-fill. The future PR (after the alertRules
* `countries` schema lands) will replace `openNotificationsForCountry`'s
* implementation with a pre-filled-form open. The injection point lives
* here so the future change is a single-helper swap rather than a
* cross-file edit.
*
* TODO: when alertRules.countries schema lands, pre-fill the create form
* with this country code via routing param or query string. See plan
* U8 R9.
*
* Pattern: `{ html, attach } β teardown`, mirroring
* `src/utils/follow-button.ts`
* `src/utils/followed-only-chip.ts`
* so the host element owns the placement and this helper owns the
* innerHTML + listener lifecycle inside that host.
*/
import {
isFollowed,
isFollowFeatureEnabled,
subscribe,
} from '@/services/followed-countries';
import { escapeHtml } from '@/utils/sanitize';
import { setTrustedHtml, trustedHtml } from '@/utils/dom-utils';
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export interface NotifyCountryLinkProps {
countryCode: string;
/** Optional country display name for tooltip / aria-label. */
countryName?: string;
}
export interface NotifyCountryLinkHandle {
html: string;
attach: (host: HTMLElement) => () => void;
}
// ---------------------------------------------------------------------------
// Global custom event β injection point for the future pre-fill PR.
// ---------------------------------------------------------------------------
/**
* Window event the App listens for in `event-handlers.ts::setupUnifiedSettings`.
* Detail carries the country code so the future PR can read it from the
* event listener and pre-fill the alert-rule create form. Today the
* listener ignores the detail and just opens the notifications tab.
*/
export const WM_OPEN_NOTIFICATIONS_FOR_COUNTRY =
'wm-open-notifications-for-country';
export interface OpenNotificationsForCountryDetail {
country: string;
}
// ---------------------------------------------------------------------------
// Test-injection seam β a swappable open-helper closure.
//
// Mirrors the `_setUpgradeTriggerForTests` pattern in
// `src/utils/follow-button.ts`. Tests assert "click invokes the
// helper" without needing a real window event listener; production
// uses the default closure that dispatches the CustomEvent.
// ---------------------------------------------------------------------------
type OpenHelper = (countryCode: string) => void;
const _defaultOpen: OpenHelper = (countryCode) => {
if (typeof window === 'undefined') return;
try {
const detail: OpenNotificationsForCountryDetail = { country: countryCode };
window.dispatchEvent(
new CustomEvent(WM_OPEN_NOTIFICATIONS_FOR_COUNTRY, { detail }),
);
} catch {
/* swallow β non-browser env or CustomEvent unavailable */
}
};
let _openHelper: OpenHelper = _defaultOpen;
/**
* Opens the notifications settings page for the given country.
*
* Today: dispatches a window CustomEvent that the App listens for and
* forwards to `unifiedSettings.open('notifications')`. No pre-fill.
*
* TODO: when alertRules.countries schema lands, this helper should
* resolve the user's saved rules and either open the create form
* pre-filled with `[countryCode]` (no matching rule) or scroll to /
* focus the existing rule (rule already covers this country). See
* plan U8 R9. The injection point is intentionally THIS function so
* the future change is a single-helper swap.
*/
export function openNotificationsForCountry(countryCode: string): void {
_openHelper(countryCode);
}
/**
* Test-only override for the open-helper. Pass `null` to restore the
* production CustomEvent dispatch.
*/
export function _setOpenNotificationsForCountryForTests(
fn: OpenHelper | null,
): void {
_openHelper = fn ?? _defaultOpen;
}
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
interface ViewState {
visible: boolean;
}
function computeViewState(props: NotifyCountryLinkProps): ViewState {
if (!isFollowFeatureEnabled()) return { visible: false };
// Only show the sub-action when the user is currently following the
// country β the link is the bridge between "I'm interested" and
// "I want to be notified." If the user isn't following, the link is
// noise.
return { visible: isFollowed(props.countryCode) };
}
function renderHtml(state: ViewState, props: NotifyCountryLinkProps): string {
if (!state.visible) return '';
const displayName = props.countryName?.trim() || props.countryCode;
const safeCode = escapeHtml(props.countryCode);
const safeName = escapeHtml(displayName);
const tooltip = `Notify me about ${displayName}`;
const safeTooltip = escapeHtml(tooltip);
// Real <button type="button"> so keyboard activation + focus work.
// Visually styled as an inline link via .cdp-notify-link CSS.
return (
`<button type="button" class="cdp-notify-link"` +
` data-country="${safeCode}"` +
` aria-label="${safeTooltip}"` +
` title="${safeTooltip}">` +
// Bell icon β outlined, 12px, centred against text baseline.
`<svg class="cdp-notify-link-icon" width="12" height="12" viewBox="0 0 24 24"` +
` fill="none" stroke="currentColor" stroke-width="2"` +
` stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">` +
`<path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/>` +
`<path d="M13.73 21a2 2 0 0 1-3.46 0"/>` +
`</svg>` +
`<span class="cdp-notify-link-label">Notify me about ${safeName}</span>` +
`</button>`
);
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
export function renderNotifyCountryLink(
props: NotifyCountryLinkProps,
): NotifyCountryLinkHandle {
// Feature flag off β empty html, no-op attach. Mirrors FollowButton.
if (!isFollowFeatureEnabled()) {
return {
html: '',
attach: (_host: HTMLElement) => () => {
/* no-op */
},
};
}
const initialState = computeViewState(props);
const initialHtml = renderHtml(initialState, props);
return {
html: initialHtml,
attach(host: HTMLElement): () => void {
let tornDown = false;
const rerender = (): void => {
if (tornDown) return;
const next = computeViewState(props);
setTrustedHtml(host, trustedHtml(renderHtml(next, props), "legacy direct innerHTML migration"));
};
// Render once on attach to resolve any state drift between the
// initial `html` snapshot and `attach()` time.
rerender();
const clickHandler = (ev: Event): void => {
if (tornDown) return;
const target = ev.target as Element | null;
const btn =
target && typeof (target as Element).closest === 'function'
? (target as Element).closest<HTMLElement>('.cdp-notify-link')
: null;
if (!btn) return;
ev.preventDefault();
try {
// TODO: when alertRules.countries schema lands, pre-fill the
// create form with this country code via routing param or
// query string. See plan U8 R9. The future PR replaces
// `openNotificationsForCountry`'s body β this call site
// stays the same.
openNotificationsForCountry(props.countryCode);
} catch (err) {
console.warn('[notify-country-link] open helper threw:', err);
}
};
host.addEventListener('click', clickHandler);
// Re-render whenever the watchlist changes β entering / leaving
// the followed state for THIS country flips visibility.
const unsubWatchlist = subscribe(rerender);
return () => {
if (tornDown) return;
tornDown = true;
try {
host.removeEventListener('click', clickHandler);
} catch {
/* swallow */
}
try {
unsubWatchlist();
} catch {
/* swallow */
}
};
},
};
}
|