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 */
        }
      };
    },
  };
}