@financial-times/qanda-ui 0.0.1-beta.9 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +2 -2
  2. package/dist/cjs/components/Author/index.js +2 -2
  3. package/dist/cjs/components/Calendar/Apps.js +34 -16
  4. package/dist/cjs/components/Calendar/CalendarListItem.js +5 -1
  5. package/dist/cjs/components/Calendar/index.js +7 -2
  6. package/dist/cjs/components/CountdownTimer/index.js +5 -1
  7. package/dist/cjs/components/EmbedFormField/index.js +15 -6
  8. package/dist/cjs/components/ErrorMessage/humanReadableError.js +81 -0
  9. package/dist/cjs/components/ErrorMessage/index.js +60 -0
  10. package/dist/cjs/components/Expander/index.js +16 -3
  11. package/dist/cjs/components/ExpertDrawer/index.js +8 -3
  12. package/dist/cjs/components/FloatingActionBar/index.js +54 -15
  13. package/dist/cjs/components/MessageBox/index.js +7 -0
  14. package/dist/cjs/components/Overlay/index.js +19 -5
  15. package/dist/cjs/components/Qanda.js +1 -7
  16. package/dist/cjs/components/QandaBlock/index.js +7 -4
  17. package/dist/cjs/components/QandaContainer.js +18 -3
  18. package/dist/cjs/components/QandaProvider.js +31 -1
  19. package/dist/cjs/components/QuestionForm/index.js +83 -35
  20. package/dist/cjs/components/QuestionForm/messagesByUserType.js +7 -5
  21. package/dist/cjs/components/Stream/index.js +32 -5
  22. package/dist/cjs/components/Survey/index.js +8 -0
  23. package/dist/cjs/config/qanda.js +6 -0
  24. package/dist/cjs/index.js +33 -4
  25. package/dist/cjs/services/comments-api.js +72 -12
  26. package/dist/cjs/services/listeners-middleware.js +170 -0
  27. package/dist/cjs/store/index.js +2 -0
  28. package/dist/cjs/store/stream.js +20 -2
  29. package/dist/cjs/store/updateComments.js +9 -4
  30. package/dist/cjs/utils/auth.js +3 -1
  31. package/dist/cjs/utils/strings.js +16 -0
  32. package/dist/cjs/utils/tracking.js +34 -0
  33. package/dist/esm/components/Author/index.js +1 -1
  34. package/dist/esm/components/Calendar/Apps.js +34 -16
  35. package/dist/esm/components/Calendar/CalendarListItem.js +5 -1
  36. package/dist/esm/components/Calendar/index.js +7 -2
  37. package/dist/esm/components/CountdownTimer/index.js +6 -2
  38. package/dist/esm/components/EmbedFormField/index.js +14 -8
  39. package/dist/esm/components/ErrorMessage/humanReadableError.js +78 -0
  40. package/dist/esm/components/ErrorMessage/index.js +54 -0
  41. package/dist/esm/components/Expander/index.js +16 -3
  42. package/dist/esm/components/ExpertDrawer/index.js +8 -3
  43. package/dist/esm/components/FloatingActionBar/index.js +55 -16
  44. package/dist/esm/components/MessageBox/index.js +5 -0
  45. package/dist/esm/components/Overlay/index.js +19 -5
  46. package/dist/esm/components/Qanda.js +2 -8
  47. package/dist/esm/components/QandaBlock/index.js +7 -4
  48. package/dist/esm/components/QandaContainer.js +20 -5
  49. package/dist/esm/components/QandaProvider.js +30 -1
  50. package/dist/esm/components/QuestionForm/index.js +82 -34
  51. package/dist/esm/components/QuestionForm/messagesByUserType.js +7 -5
  52. package/dist/esm/components/Stream/index.js +32 -5
  53. package/dist/esm/components/Survey/index.js +6 -0
  54. package/dist/esm/config/qanda.js +3 -0
  55. package/dist/esm/index.js +33 -4
  56. package/dist/esm/services/comments-api.js +71 -11
  57. package/dist/esm/services/listeners-middleware.js +167 -0
  58. package/dist/esm/store/expertSlice.js +1 -1
  59. package/dist/esm/store/index.js +2 -0
  60. package/dist/esm/store/questionSlice.js +1 -1
  61. package/dist/esm/store/stream.js +18 -1
  62. package/dist/esm/store/updateComments.js +9 -4
  63. package/dist/esm/utils/auth.js +3 -1
  64. package/dist/esm/utils/strings.js +13 -0
  65. package/dist/esm/utils/tracking.js +29 -0
  66. package/dist/qanda.scss +125 -43
  67. package/dist/types/components/Author/index.d.ts +1 -1
  68. package/dist/types/components/Calendar/Apps.d.ts +6 -2
  69. package/dist/types/components/Calendar/CalendarList.d.ts +1 -1
  70. package/dist/types/components/Calendar/CalendarListItem.d.ts +1 -1
  71. package/dist/types/components/EmbedFormField/index.d.ts +3 -1
  72. package/dist/types/components/ErrorMessage/humanReadableError.d.ts +29 -0
  73. package/dist/types/components/ErrorMessage/index.d.ts +8 -0
  74. package/dist/types/components/FloatingActionBar/index.d.ts +3 -1
  75. package/dist/types/components/Loader/index.d.ts +1 -1
  76. package/dist/types/components/{Overlay/MessageBox.d.ts → MessageBox/index.d.ts} +3 -2
  77. package/dist/types/components/Overlay/index.d.ts +3 -3
  78. package/dist/types/components/Qanda.d.ts +1 -1
  79. package/dist/types/components/QandaBlock/index.d.ts +1 -1
  80. package/dist/types/components/QandaProvider.d.ts +9 -1
  81. package/dist/types/components/QuestionForm/messagesByUserType.d.ts +2 -2
  82. package/dist/types/components/Stream/index.d.ts +4 -1
  83. package/dist/types/components/Survey/index.d.ts +7 -0
  84. package/dist/types/config/qanda.d.ts +1 -0
  85. package/dist/types/index.d.ts +2 -0
  86. package/dist/types/services/comments-api.d.ts +30 -598
  87. package/dist/types/services/error-middleware.d.ts +2 -3
  88. package/dist/types/services/listeners-middleware.d.ts +11 -0
  89. package/dist/types/store/expertSlice.d.ts +9 -10
  90. package/dist/types/store/index.d.ts +72 -183
  91. package/dist/types/store/questionSlice.d.ts +9 -8
  92. package/dist/types/store/stream.d.ts +14 -7
  93. package/dist/types/store/user.d.ts +3 -2
  94. package/dist/types/utils/strings.d.ts +1 -0
  95. package/dist/types/utils/tracking.d.ts +4 -0
  96. package/package.json +18 -18
  97. package/dist/cjs/components/Overlay/MessageBox.js +0 -7
  98. package/dist/cjs/components/UserInfo.js +0 -22
  99. package/dist/esm/components/Overlay/MessageBox.js +0 -5
  100. package/dist/esm/components/UserInfo.js +0 -20
  101. package/dist/types/components/UserInfo.d.ts +0 -2
package/dist/cjs/index.js CHANGED
@@ -9,6 +9,7 @@ const preact_1 = require("preact");
9
9
  const Qanda_1 = __importDefault(require("./components/Qanda"));
10
10
  const QandaBlock_1 = __importDefault(require("./components/QandaBlock"));
11
11
  exports.QandaBlock = QandaBlock_1.default;
12
+ const reliability_1 = require("./services/reliability");
12
13
  class QandaUI {
13
14
  constructor(containerElement, options) {
14
15
  this.containerElement = containerElement;
@@ -20,9 +21,8 @@ class QandaUI {
20
21
  throw new Error('Required properties are missing');
21
22
  }
22
23
  const storyId = options?.storyId ?? containerElement.dataset.qandaStoryid;
23
- const useStaging = options?.useStaging ??
24
- containerElement.dataset.qandaUsestaging === 'true' ??
25
- false;
24
+ const stagingData = containerElement.dataset.qandaUsestaging === 'true' ? true : false;
25
+ const useStaging = options?.useStaging ?? stagingData;
26
26
  const title = options?.title ?? containerElement.dataset.qandaTitle;
27
27
  const commentsAPIHost = options?.commentsAPIHost ?? containerElement.dataset.qandaCommentsapihost;
28
28
  const commentsAPIVersion = options?.commentsAPIVersion ??
@@ -31,10 +31,15 @@ class QandaUI {
31
31
  (containerElement.dataset.qandaMaxquestionlength
32
32
  ? parseInt(containerElement.dataset.qandaMaxquestionlength, 10)
33
33
  : undefined) ??
34
- 2000;
34
+ 300;
35
35
  if (!storyId || !title || !commentsAPIHost || !commentsAPIVersion) {
36
36
  throw new Error('Required properties are missing');
37
37
  }
38
+ // Track the articleId in every metric for reliability
39
+ // Do this as soon as possible
40
+ // as we need to set it before the first event is sent
41
+ // qandaReliability is a singleton, so the id is stored for future usages from other modules
42
+ reliability_1.qandaReliability.setArticleId(storyId);
38
43
  //TODO: test this doesn't break all the other bits of js, needs to be throw because of typescript, can't just bail out normally
39
44
  this.storyId = storyId;
40
45
  this.useStaging = useStaging;
@@ -46,7 +51,31 @@ class QandaUI {
46
51
  init() {
47
52
  (0, preact_1.render)((0, jsx_runtime_1.jsx)(Qanda_1.default, { storyId: this.storyId, useStaging: this.useStaging, title: this.title, commentsAPIHost: this.commentsAPIHost, commentsAPIVersion: this.commentsAPIVersion, maxQuestionLength: this.maxQuestionLength, embedFormFieldContainer: this.embedFormFieldContainer, countdownTimerContainer: this.countdownTimerContainer }), this.containerElement);
48
53
  }
54
+ checkFooterVisibility(footerSelector) {
55
+ const footer = document.querySelector(footerSelector);
56
+ if (footer) {
57
+ this.footerObserver = new IntersectionObserver((entries) => {
58
+ const floatingActionBar = document.querySelector('.floating-action-bar__container');
59
+ if (floatingActionBar) {
60
+ entries.forEach((entry) => {
61
+ if (entry.isIntersecting &&
62
+ !floatingActionBar.classList.contains('o3-visually-hidden')) {
63
+ floatingActionBar.classList.add('o3-visually-hidden');
64
+ }
65
+ else {
66
+ floatingActionBar.classList.remove('o3-visually-hidden');
67
+ }
68
+ });
69
+ }
70
+ });
71
+ this.footerObserver.observe(footer);
72
+ }
73
+ }
49
74
  destroy() {
75
+ if (this.footerObserver) {
76
+ this.footerObserver.disconnect();
77
+ this.footerObserver = undefined;
78
+ }
50
79
  (0, preact_1.render)(null, this.containerElement);
51
80
  }
52
81
  }
@@ -1,14 +1,22 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.useUpdatedQA = exports.useUpdatedComments = exports.usePostQuestionMutation = exports.useGetUserRolesQuery = exports.useGetQandAUpdatesQuery = exports.useGetQandAStreamQuery = exports.nextCommentsApi = exports.eventSource = void 0;
3
+ exports.useUpdatedQA = exports.useUpdatedComments = exports.usePostQuestionMutation = exports.useGetUserRolesQuery = exports.useGetQandAUpdatesQuery = exports.useGetQandAStreamQuery = exports.nextCommentsApi = exports.eventSource = exports.updateTypes = void 0;
4
4
  const react_1 = require("@reduxjs/toolkit/query/react");
5
- // TODO: ensure that users don't miss out on updates in this window
6
- // https://financialtimes.atlassian.net/browse/CI-2715
5
+ const stream_1 = require("../store/stream");
6
+ const reliability_1 = require("./reliability");
7
+ // NB: this could do with a refactor to split things out into smaller files: see https://redux-toolkit.js.org/rtk-query/usage/code-splitting
8
+ // NB: adding retry limit would be a good idea https://financialtimes.atlassian.net/browse/CI-2715
7
9
  const retryDelay = 1000;
8
- const updateTypes = {
10
+ exports.updateTypes = {
9
11
  QA_NEW_REPLY: 'updatedComments',
10
12
  QA_UPDATE: 'updatedQA',
11
13
  };
14
+ class MaintenanceModeError extends Error {
15
+ constructor() {
16
+ super('maintenanceMode');
17
+ this.name = 'maintenanceMode';
18
+ }
19
+ }
12
20
  exports.eventSource = null;
13
21
  exports.nextCommentsApi = (0, react_1.createApi)({
14
22
  reducerPath: 'nextCommentsApi',
@@ -19,8 +27,13 @@ exports.nextCommentsApi = (0, react_1.createApi)({
19
27
  endpoints: (builder) => ({
20
28
  // QA STREAM
21
29
  getQandAStream: builder.query({
22
- query: ({ storyId, useStaging, commentsAPIUrl }) => `${commentsAPIUrl}/story/${storyId}/stream${useStaging ? '?staging=1' : ''}`,
30
+ query: ({ storyId, useStaging, commentsAPIUrl, status, }) =>
31
+ // The usage of status is to create different caches (RTK and Fastly) for different statuses
32
+ `${commentsAPIUrl}/story/${storyId}/stream?status=${status}${useStaging ? '&staging=1' : ''}`,
23
33
  transformResponse: (response) => {
34
+ if (response.type === 'MAINTENANCE_MODE') {
35
+ throw new MaintenanceModeError();
36
+ }
24
37
  return {
25
38
  qandas: response?.children || [],
26
39
  type: response.type,
@@ -35,13 +48,26 @@ exports.nextCommentsApi = (0, react_1.createApi)({
35
48
  queryFn: async ({ storyId, useStaging, commentsAPIUrl, }) => {
36
49
  return { data: {} };
37
50
  },
38
- async onCacheEntryAdded(arg, { updateCachedData, cacheDataLoaded, cacheEntryRemoved }) {
51
+ async onCacheEntryAdded(arg, { updateCachedData, cacheDataLoaded, cacheEntryRemoved, dispatch, getState, }) {
39
52
  await cacheDataLoaded;
40
53
  const connectToSSE = () => {
41
54
  const sseEndpoint = `${arg.commentsAPIUrl}/story/${arg.storyId}/updates${arg.useStaging ? '?staging=1' : ''}`;
42
55
  exports.eventSource = new EventSource(sseEndpoint);
43
- exports.eventSource.onopen = () => {
56
+ exports.eventSource.onopen = async () => {
44
57
  console.log(`Connected to SSE: ${sseEndpoint}`);
58
+ const state = getState();
59
+ const lastEventId = state.stream.mostRecentUpdateTime;
60
+ try {
61
+ await dispatch(exports.nextCommentsApi.endpoints.getCatchupUpdates.initiate({
62
+ storyId: arg.storyId,
63
+ useStaging: arg.useStaging,
64
+ commentsAPIUrl: arg.commentsAPIUrl,
65
+ ...(lastEventId && { lastEventId: `${lastEventId}` }),
66
+ }, { forceRefetch: true }));
67
+ }
68
+ catch (error) {
69
+ reliability_1.qandaReliability.failure('qa-updates', error);
70
+ }
45
71
  };
46
72
  exports.eventSource.onmessage = (event) => {
47
73
  try {
@@ -49,10 +75,14 @@ exports.nextCommentsApi = (0, react_1.createApi)({
49
75
  console.log('Received SSE update:', data);
50
76
  if (data && data.type) {
51
77
  updateCachedData((draft) => {
52
- const updatedData = updateTypes[data.type];
78
+ const updatedData = exports.updateTypes[data.type];
53
79
  draft[updatedData] = draft[updatedData] || [];
54
80
  draft[updatedData].push(data.payload);
55
81
  });
82
+ // Store the lastEventId for use in reconnection
83
+ if (event.lastEventId) {
84
+ dispatch((0, stream_1.setMostRecentUpdateTime)(event.lastEventId));
85
+ }
56
86
  }
57
87
  }
58
88
  catch (error) {
@@ -92,6 +122,32 @@ exports.nextCommentsApi = (0, react_1.createApi)({
92
122
  }
93
123
  },
94
124
  }),
125
+ // CATCHUP UPDATES
126
+ getCatchupUpdates: builder.query({
127
+ query: ({ storyId, useStaging, commentsAPIUrl, lastEventId, }) => {
128
+ const params = new URLSearchParams();
129
+ if (useStaging) {
130
+ params.append('staging', '1');
131
+ }
132
+ if (lastEventId) {
133
+ params.append('lastEventId', lastEventId);
134
+ }
135
+ const queryString = params.toString();
136
+ return `${commentsAPIUrl}/story/${storyId}/catchup-updates${queryString ? `?${queryString}` : ''}`;
137
+ },
138
+ transformResponse: (response) => {
139
+ const updatedComments = Array.isArray(response?.comments)
140
+ ? response.comments.map((comment) => {
141
+ return comment.data;
142
+ })
143
+ : [];
144
+ return {
145
+ updatedComments,
146
+ status: response?.status ?? null,
147
+ lastEventId: response?.lastEventId ?? null,
148
+ };
149
+ },
150
+ }),
95
151
  // USER ROLES
96
152
  getUserRoles: builder.query({
97
153
  query: ({ storyId, useStaging, commentsAPIUrl, token, }) => ({
@@ -102,6 +158,9 @@ exports.nextCommentsApi = (0, react_1.createApi)({
102
158
  },
103
159
  }),
104
160
  transformResponse: (response, meta, arg) => {
161
+ if (response.type === 'MAINTENANCE_MODE') {
162
+ throw new MaintenanceModeError();
163
+ }
105
164
  const allowedRoles = ['ADMIN', 'MODERATOR'];
106
165
  const { isExpert, isStaffExpert, role } = response;
107
166
  const expertView = isExpert || isStaffExpert || !!(role && allowedRoles.includes(role));
@@ -110,16 +169,16 @@ exports.nextCommentsApi = (0, react_1.createApi)({
110
169
  }),
111
170
  // POST A QUESTION
112
171
  postQuestion: builder.mutation({
113
- query: ({ storyId, useStaging, commentsAPIUrl, token, question, postAnonymously, }) => ({
172
+ query: ({ storyId, useStaging, commentsAPIUrl, token, question, nudge = true, postAnonymously, }) => ({
114
173
  url: `${commentsAPIUrl}/story/${storyId}/question${useStaging ? '?staging=1' : ''}`,
115
174
  method: 'POST',
116
175
  headers: {
117
176
  'Content-Type': 'application/json',
118
177
  Authorization: `Bearer ${token}`,
119
178
  },
120
- body: JSON.stringify({ question, postAnonymously, token }),
179
+ body: JSON.stringify({ question, postAnonymously, token, nudge }),
121
180
  }),
122
- transformResponse: (response, meta, arg) => {
181
+ transformResponse: (response) => {
123
182
  return response.data;
124
183
  },
125
184
  }),
@@ -128,11 +187,12 @@ exports.nextCommentsApi = (0, react_1.createApi)({
128
187
  exports.useGetQandAStreamQuery = exports.nextCommentsApi.useGetQandAStreamQuery, exports.useGetQandAUpdatesQuery = exports.nextCommentsApi.useGetQandAUpdatesQuery, exports.useGetUserRolesQuery = exports.nextCommentsApi.useGetUserRolesQuery, exports.usePostQuestionMutation = exports.nextCommentsApi.usePostQuestionMutation;
129
188
  // UPDATES QUERY CUSTOM HOOKS
130
189
  // custom hook for getting just the updated comments from the updates, so we are more efficient
131
- const useUpdatedComments = ({ storyId, useStaging, commentsAPIUrl, }) => {
190
+ const useUpdatedComments = ({ storyId, useStaging, commentsAPIUrl }, options) => {
132
191
  return exports.nextCommentsApi.useGetQandAUpdatesQuery({ storyId, useStaging, commentsAPIUrl }, {
133
192
  selectFromResult: ({ data }) => ({
134
193
  updatedComments: data?.updatedComments || [],
135
194
  }),
195
+ skip: options?.skip || false,
136
196
  }).updatedComments;
137
197
  };
138
198
  exports.useUpdatedComments = useUpdatedComments;
@@ -0,0 +1,170 @@
1
+ "use strict";
2
+ /**
3
+ * @fileoverview RTK Query listener middleware for Q&A stream events
4
+ *
5
+ * This module contains Redux Toolkit listeners that respond to RTK Query events
6
+ * and update the application state accordingly. Currently handles:
7
+ * - Tracking the most recent update timestamp from live Q&A streams
8
+ *
9
+ * @see https://redux-toolkit.js.org/api/createListenerMiddleware
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.listenerMiddleware = void 0;
13
+ const toolkit_1 = require("@reduxjs/toolkit");
14
+ const comments_api_1 = require("./comments-api");
15
+ const stream_1 = require("../store/stream");
16
+ exports.listenerMiddleware = (0, toolkit_1.createListenerMiddleware)();
17
+ /**
18
+ * Calculates the most recent update timestamp and answer id from a Q&A question
19
+ *
20
+ * @param qandas - A Comment object representing a question with nested answers
21
+ * @returns Object containing mostRecentUpdateTime (answer timestamp) and mostRecentUpdateId (answer id)
22
+ *
23
+ * @example
24
+ * const { mostRecentUpdateTime, mostRecentUpdateId } = getMostRecentUpdate(qandas);
25
+ * // Returns: { mostRecentUpdateTime: 167253120, mostRecentUpdateId: 'answer-123' }
26
+ */
27
+ function getMostRecentUpdate(qandaComment) {
28
+ let mostRecentUpdateTime = 0;
29
+ let mostRecentUpdateId = '';
30
+ if (qandaComment.children && Array.isArray(qandaComment.children)) {
31
+ for (const answer of qandaComment?.children) {
32
+ const publishedDateTimestamp = new Date(answer.publishedDate).getTime();
33
+ if (publishedDateTimestamp > mostRecentUpdateTime) {
34
+ mostRecentUpdateTime = publishedDateTimestamp;
35
+ mostRecentUpdateId = answer.id;
36
+ }
37
+ }
38
+ }
39
+ return { mostRecentUpdateTime, mostRecentUpdateId };
40
+ }
41
+ /**
42
+ * Shallow compares two StreamStatus objects to determine if they are identical
43
+ *
44
+ * Performs a shallow comparison of all properties between the previous and new status
45
+ * entries. Used to prevent unnecessary updates when the status hasn't actually changed.
46
+ *
47
+ * @param previousEntry - The previous StreamStatus object to compare
48
+ * @param newEntry - The new StreamStatus object to compare against
49
+ * @returns True if all properties are identical, false otherwise
50
+ */
51
+ function isSameStatus(previousEntry, newEntry) {
52
+ if (Object.keys(previousEntry).length !== Object.keys(newEntry).length) {
53
+ return false;
54
+ }
55
+ for (const key in previousEntry) {
56
+ const typedKey = key;
57
+ if (previousEntry[typedKey] !== newEntry[typedKey]) {
58
+ return false;
59
+ }
60
+ }
61
+ return true;
62
+ }
63
+ /**
64
+ * Listener: Updates most recent timestamp and initiates catchup when live Q&A stream data is fetched
65
+ *
66
+ * Responds to successful getQandAStream queries with status='live' and extracts
67
+ * the most recent answer timestamp to store in Redux state. This timestamp is
68
+ * used for reconnection logic and catching up on missed updates. Also automatically
69
+ * initiates a catchup request to get any updates that occurred since the last timestamp.
70
+ *
71
+ * @listens nextCommentsApi.endpoints.getQandAStream.matchFulfilled
72
+ * @dispatches setMostRecentUpdateTime - Updates stream.mostRecentUpdateTime in Redux store
73
+ * @dispatches setStreamStartMostRecentAnswerId - Updates stream.streamStartMostRecentAnswerId in Redux store
74
+ * @dispatches nextCommentsApi.endpoints.getCatchupUpdates.initiate - Triggers catchup request for missed updates
75
+ */
76
+ // TODO: consider when this should run now that we have state changes too.
77
+ // consider that it will run on every live stream query, not just the first one.
78
+ // therefore it will happen on state change, and on first load maybe twice.
79
+ exports.listenerMiddleware.startListening({
80
+ matcher: (0, toolkit_1.isAnyOf)(comments_api_1.nextCommentsApi.endpoints.getQandAStream.matchFulfilled),
81
+ effect: (action, listenerApi) => {
82
+ const state = listenerApi.getState();
83
+ const { payload, meta } = action;
84
+ const { storyId, useStaging, commentsAPIUrl, status } = meta.arg.originalArgs;
85
+ // Check if this is a live stream QUERY, not response. If it is a live QUERY we will get questions and answers. This will fire when stream starts in live mode or switches to live mode for first time.
86
+ const isPreLiveStream = status === 'pre-live';
87
+ const isLiveStream = status === 'live';
88
+ if (isLiveStream &&
89
+ Array.isArray(payload.qandas) &&
90
+ payload.qandas.length > 0) {
91
+ const { mostRecentUpdateTime, mostRecentUpdateId } = getMostRecentUpdate(payload.qandas[0]);
92
+ // Update the store with the most recent update as a string
93
+ if (mostRecentUpdateTime !== undefined) {
94
+ console.log(`Setting most recent update for story ${storyId}: ${mostRecentUpdateTime}`);
95
+ listenerApi.dispatch((0, stream_1.setMostRecentUpdateTime)(`${mostRecentUpdateTime}`));
96
+ listenerApi.dispatch((0, stream_1.setStreamLoadMostRecentAnswerId)(mostRecentUpdateId));
97
+ }
98
+ }
99
+ const { mostRecentUpdateTime } = state.stream;
100
+ if (isPreLiveStream || isLiveStream) {
101
+ listenerApi.dispatch(comments_api_1.nextCommentsApi.endpoints.getCatchupUpdates.initiate({
102
+ storyId,
103
+ useStaging,
104
+ commentsAPIUrl,
105
+ ...(mostRecentUpdateTime && {
106
+ lastEventId: `${mostRecentUpdateTime}`,
107
+ }),
108
+ }));
109
+ }
110
+ },
111
+ });
112
+ /**
113
+ * Listener: Processes successful catchup updates and manages comment/status updates
114
+ *
115
+ * Responds to successful getCatchupUpdates queries and processes the response:
116
+ * - Adds new comments to the updates cache, filtering out duplicates
117
+ * - Updates status when changes are detected compared to the last status
118
+ * - Updates the most recent timestamp for future catchup requests
119
+ *
120
+ * @listens nextCommentsApi.endpoints.getCatchupUpdates.matchFulfilled
121
+ * @dispatches nextCommentsApi.util.updateQueryData - Updates the cache of updated comments in the store
122
+ * @dispatches setMostRecentUpdateTime - Updates stream.mostRecentUpdateTime with new lastEventId
123
+ * @dispatches nextCommentsApi.util.updateQueryData - Updates the status if it has changed
124
+ */
125
+ exports.listenerMiddleware.startListening({
126
+ matcher: comments_api_1.nextCommentsApi.endpoints.getCatchupUpdates.matchFulfilled,
127
+ effect: (action, listenerApi) => {
128
+ const { payload, meta } = action;
129
+ const { storyId, useStaging, commentsAPIUrl } = meta.arg.originalArgs;
130
+ const { updatedComments, status, lastEventId = null } = payload;
131
+ if (!updatedComments && !status)
132
+ return;
133
+ const state = listenerApi.getState();
134
+ // check for new updated comments
135
+ if (Array.isArray(updatedComments) && updatedComments.length > 0) {
136
+ const updateType = comments_api_1.updateTypes['QA_NEW_REPLY'];
137
+ const { streamLoadMostRecentAnswerId, mostRecentUpdateTime: mostRecentInStreamTime, } = state.stream;
138
+ // filter out catchup comments that are the same as the most recent update in the stream list, which can happen on first load when the valkey and coral response timestamps don't match
139
+ // there is latency between the comment being saved in coral and then added to our valkey stream. Updates after this will all come from valkey.
140
+ const filteredComments = updatedComments.filter((comment) => {
141
+ const { mostRecentUpdateId, mostRecentUpdateTime } = getMostRecentUpdate(comment);
142
+ if (streamLoadMostRecentAnswerId === mostRecentUpdateId &&
143
+ mostRecentInStreamTime === `${mostRecentUpdateTime}`) {
144
+ return false;
145
+ }
146
+ return true;
147
+ });
148
+ if (Array.isArray(filteredComments) && filteredComments.length) {
149
+ listenerApi.dispatch(comments_api_1.nextCommentsApi.util.updateQueryData('getQandAUpdates', { storyId, useStaging, commentsAPIUrl }, (draft) => {
150
+ draft[updateType] = draft[updateType] || [];
151
+ draft[updateType].push(...filteredComments);
152
+ }));
153
+ }
154
+ listenerApi.dispatch((0, stream_1.setMostRecentUpdateTime)(lastEventId));
155
+ }
156
+ // check for status updates
157
+ if (status) {
158
+ const updateType = comments_api_1.updateTypes['QA_UPDATE'];
159
+ listenerApi.dispatch(comments_api_1.nextCommentsApi.util.updateQueryData('getQandAUpdates', { storyId, useStaging, commentsAPIUrl }, (draft) => {
160
+ draft[updateType] = draft[updateType] || [];
161
+ // in order to avoid unnecessary updates, we check if the last update is identical to the new status. we only push changed status updates.
162
+ if (draft[updateType].length > 0 &&
163
+ isSameStatus(draft[updateType].at(-1), status)) {
164
+ return;
165
+ }
166
+ draft[updateType] = [status];
167
+ }));
168
+ }
169
+ },
170
+ });
@@ -12,6 +12,7 @@ const user_1 = require("./user");
12
12
  const expertSlice_1 = __importDefault(require("./expertSlice"));
13
13
  const comments_api_1 = require("../services/comments-api");
14
14
  const error_middleware_1 = require("../services/error-middleware");
15
+ const listeners_middleware_1 = require("../services/listeners-middleware");
15
16
  exports.rootReducer = (0, toolkit_1.combineReducers)({
16
17
  stream: stream_1.stream.reducer,
17
18
  question: questionSlice_1.default,
@@ -22,6 +23,7 @@ exports.rootReducer = (0, toolkit_1.combineReducers)({
22
23
  exports.store = (0, toolkit_1.configureStore)({
23
24
  reducer: exports.rootReducer,
24
25
  middleware: (getDefaultMiddleware) => getDefaultMiddleware()
26
+ .prepend(listeners_middleware_1.listenerMiddleware.middleware)
25
27
  .concat(comments_api_1.nextCommentsApi.middleware)
26
28
  .concat(error_middleware_1.rtkQueryErrorLogger),
27
29
  });
@@ -1,9 +1,21 @@
1
1
  "use strict";
2
+ /**
3
+ * Redux slice containing stream state and reducers.
4
+ *
5
+ * @remarks
6
+ * This slice provides actions to update:
7
+ * - latestAnsweredQuestionId: he ID for the question with the latest answer, used for scrolling to place
8
+ * - mostRecentUpdateTime: The timestamp or valkey ID of the most recent update
9
+ * - streamLoadMostRecentAnswerId: The ID of the most recent answer when you load the stream data
10
+ */
11
+ var _a;
2
12
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.setLatestAnsweredQuestionId = exports.stream = exports.initialState = void 0;
13
+ exports.setStreamLoadMostRecentAnswerId = exports.setMostRecentUpdateTime = exports.setLatestAnsweredQuestionId = exports.stream = exports.initialState = void 0;
4
14
  const toolkit_1 = require("@reduxjs/toolkit");
5
15
  exports.initialState = {
6
16
  latestAnsweredQuestionId: '',
17
+ mostRecentUpdateTime: null,
18
+ streamLoadMostRecentAnswerId: null,
7
19
  };
8
20
  exports.stream = (0, toolkit_1.createSlice)({
9
21
  name: 'stream',
@@ -12,6 +24,12 @@ exports.stream = (0, toolkit_1.createSlice)({
12
24
  setLatestAnsweredQuestionId: (state, action) => {
13
25
  state.latestAnsweredQuestionId = action.payload;
14
26
  },
27
+ setMostRecentUpdateTime: (state, action) => {
28
+ state.mostRecentUpdateTime = action.payload;
29
+ },
30
+ setStreamLoadMostRecentAnswerId: (state, action) => {
31
+ state.streamLoadMostRecentAnswerId = action.payload;
32
+ },
15
33
  },
16
34
  });
17
- exports.setLatestAnsweredQuestionId = exports.stream.actions.setLatestAnsweredQuestionId;
35
+ _a = exports.stream.actions, exports.setLatestAnsweredQuestionId = _a.setLatestAnsweredQuestionId, exports.setMostRecentUpdateTime = _a.setMostRecentUpdateTime, exports.setStreamLoadMostRecentAnswerId = _a.setStreamLoadMostRecentAnswerId;
@@ -3,7 +3,7 @@
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  exports.updateComments = void 0;
5
5
  const comments_api_1 = require("../services/comments-api");
6
- const stream_1 = require("../store/stream");
6
+ const stream_1 = require("./stream");
7
7
  const qandas_1 = require("../utils/qandas");
8
8
  const updateComments = (dispatch, updatedComments, storyId, useStaging, commentsAPIUrl) => {
9
9
  if (!updatedComments.length) {
@@ -12,13 +12,18 @@ const updateComments = (dispatch, updatedComments, storyId, useStaging, comments
12
12
  const latestAnsweredQuestionId = (0, qandas_1.getLatestAnsweredQuestion)(updatedComments);
13
13
  try {
14
14
  // Update the cache with the new comments
15
- dispatch(comments_api_1.nextCommentsApi.util.updateQueryData('getQandAStream', { storyId, useStaging, commentsAPIUrl }, (draft) => {
16
- // NB - if the same comment is updated more than once, the most recent thing added to the array will be the one that is shown
15
+ dispatch(comments_api_1.nextCommentsApi.util.updateQueryData('getQandAStream',
16
+ // Updates happen only for the live stream, so we pass the status as 'live' hardcoded
17
+ { storyId, useStaging, commentsAPIUrl, status: 'live' }, (draft) => {
18
+ // NB - if the same comment is updated more than once, the most recent thing added to the array will be the one that is shown, as each update is the full question and answer block
19
+ // this will check if a question already exists in the store, and if so, it will update the object and move to the top of the array so that we don't have duplicates and it is in the right order
17
20
  for (let c = 0; c < updatedComments.length; c++) {
18
21
  const commentId = updatedComments[c].id;
19
22
  const commentIndex = draft.qandas.findIndex((d) => d.id === commentId);
20
23
  if (commentIndex > -1) {
21
24
  draft.qandas[commentIndex] = updatedComments[c];
25
+ const commentToMove = draft.qandas.splice(commentIndex, 1);
26
+ draft.qandas.unshift(commentToMove[0]);
22
27
  }
23
28
  else {
24
29
  draft.qandas.unshift(updatedComments[c]);
@@ -32,7 +37,7 @@ const updateComments = (dispatch, updatedComments, storyId, useStaging, comments
32
37
  dispatch((0, stream_1.setLatestAnsweredQuestionId)(latestAnsweredQuestionId));
33
38
  }
34
39
  catch (error) {
35
- console.log('error', error);
40
+ console.error('error', error);
36
41
  }
37
42
  };
38
43
  exports.updateComments = updateComments;
@@ -8,7 +8,9 @@ exports.default = async ({ useStaging = true, displayName = '', commentsAPIUrl =
8
8
  useStagingEnvironment: useStaging,
9
9
  onlySubscribers: true,
10
10
  displayName,
11
- commentsAPIUrl: commentsAPIUrl === PROD_AUTH_URL ? PROD_AUTH_URL : TEST_AUTH_URL,
11
+ commentsAPIUrl: commentsAPIUrl.startsWith(PROD_AUTH_URL)
12
+ ? PROD_AUTH_URL
13
+ : TEST_AUTH_URL,
12
14
  });
13
15
  // if the JWT creation has failed, a `userHasValidSession` value will be returned
14
16
  // our application assumes the values of a JWT are saved to state, so we set the
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.cleanString = cleanString;
4
+ /*
5
+ * Remove special characters and replace spaces with underscores
6
+ * @param {string} str - The input string to be cleaned
7
+ * @returns {string} - The cleaned string with special characters removed and spaces replaced with underscores
8
+ */
9
+ function cleanString(str) {
10
+ return str
11
+ .normalize('NFD') // Decompose accented characters
12
+ .replace(/[\u0300-\u036f]/g, '') // Remove diacritics
13
+ .replace(/[^a-zA-Z0-9 ]/g, '') // Remove non-ASCII letters, numbers, and spaces
14
+ .trim() // Trim leading/trailing spaces
15
+ .replace(/\s+/g, '_'); // Replace spaces with underscores
16
+ }
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.trackEvent = exports.baseTrackingData = void 0;
4
+ exports.trackingNS = trackingNS;
5
+ const trackingNamespace = 'askanexpert_';
6
+ function trackingNS(string) {
7
+ return trackingNamespace + string;
8
+ }
9
+ exports.baseTrackingData = {
10
+ category: 'component',
11
+ action: 'act',
12
+ component: {
13
+ name: 'askanexpert',
14
+ type: 'container',
15
+ id: '663ab2ca-77ca-4b50-873d-1fd2ff21ff08',
16
+ },
17
+ content: {
18
+ asset_type: 'article',
19
+ },
20
+ };
21
+ const trackEvent = (customTrackingData) => {
22
+ // Merge the base tracking data with the provided tracking data
23
+ const trackingData = {
24
+ ...exports.baseTrackingData,
25
+ ...customTrackingData,
26
+ trigger_action: trackingNamespace + customTrackingData.trigger_action,
27
+ };
28
+ const event = new CustomEvent('oTracking.event', {
29
+ detail: trackingData,
30
+ bubbles: true,
31
+ });
32
+ document.body.dispatchEvent(event);
33
+ };
34
+ exports.trackEvent = trackEvent;
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "preact/jsx-runtime";
2
2
  import Headshot from '@financial-times/cp-content-pipeline-ui/lib/components/Headshot';
3
- import { Byline } from '@financial-times/cp-content-pipeline-ui';
3
+ import Byline from '@financial-times/cp-content-pipeline-ui/lib/components/Byline';
4
4
  function Author({ byline, headshot, prefLabel, role, streamPage, }) {
5
5
  const hasHeadshot = headshot && prefLabel;
6
6
  return (_jsxs("div", { className: "qanda-block__answer__author", "data-testid": "qanda-block__answer__author", children: [hasHeadshot ? (_jsx("div", { className: "qanda-block__answer__author-headshot", children: _jsx(Headshot, { headshot: headshot, prefLabel: prefLabel, streamPage: streamPage, className: "qanda-block__answer__author-headshot" }) })) : (_jsx("div", { className: "qanda-block__answer__author-opinion-icon" })), _jsxs("div", { className: "qanda-block__answer__author-info", children: [_jsx("div", { className: "o3-type-body-highlight qanda-block__answer__author-name", children: _jsx(Byline, { structuredContent: byline, showEditedBy: false }) }), _jsx("div", { className: "o3-type-body-base", children: role })] })] }));