@financial-times/qanda-ui 0.0.1-beta.26 → 0.0.1-beta.28

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.
@@ -14,7 +14,7 @@ const tracking_1 = require("../../utils/tracking");
14
14
  function LiveIndicator() {
15
15
  return ((0, jsx_runtime_1.jsx)("div", { className: "o-labels-indicator--live o3-type-label floating-action-bar__live-container", children: (0, jsx_runtime_1.jsx)("span", { className: "o-labels-indicator__status floating-action-bar__live-label", children: "Live" }) }));
16
16
  }
17
- function FloatingActionBar({ showLiveIndicator = true, }) {
17
+ function FloatingActionBar({ isLive = true }) {
18
18
  const dispatch = (0, react_redux_1.useDispatch)();
19
19
  const isMobile = (0, useIsMobile_1.useIsMobile)();
20
20
  const { storyId, useStaging, commentsAPIUrl } = (0, react_1.useContext)(QandaProvider_1.QandaContext);
@@ -22,39 +22,41 @@ function FloatingActionBar({ showLiveIndicator = true, }) {
22
22
  storyId,
23
23
  useStaging,
24
24
  commentsAPIUrl,
25
+ }, {
26
+ skip: !isLive,
25
27
  });
26
28
  const handleShowUpdates = () => {
27
29
  (0, updateComments_1.updateComments)(dispatch, updatedComments, storyId, useStaging, commentsAPIUrl);
28
- // Scrolling to start of qanda blocks container
29
- const element = document.getElementById('qanda__blocks-container');
30
+ // Scrolling to start of qanda stream title
31
+ const element = document.getElementById('qanda__stream-title');
30
32
  if (element) {
31
33
  element.scrollIntoView({ behavior: 'smooth', block: 'start' });
32
34
  }
33
35
  };
34
36
  const showExpertView = (0, react_redux_1.useSelector)((state) => state.expert.expertView);
35
37
  const userConfig = {
36
- descripton: 'Ask a question',
38
+ description: 'Ask a question',
37
39
  testIdSuffix: 'ask-a-question-button',
38
40
  onClick: () => dispatch((0, questionSlice_1.openForm)()),
39
41
  dataTrackable: 'open_question_form',
40
42
  };
41
43
  const expertConfig = {
42
- descripton: 'Answer Q&A',
44
+ description: 'Answer Q&A',
43
45
  testIdSuffix: 'answer-a-qanda-button',
44
46
  onClick: () => dispatch((0, expertSlice_1.openExpertForm)()),
45
47
  dataTrackable: 'open_expert_drawer',
46
48
  };
47
49
  const view = showExpertView ? expertConfig : userConfig;
48
- return ((0, jsx_runtime_1.jsxs)("div", { className: "floating-action-bar__container", "data-trackable": (0, tracking_1.trackingNS)('floating-action-bar'), children: [(0, jsx_runtime_1.jsx)("div", { className: "floating-action-bar__fade-overlay" }), (0, jsx_runtime_1.jsxs)("div", { className: "floating-action-bar", "data-testid": "floating-action-bar", children: [showLiveIndicator && (0, jsx_runtime_1.jsx)(LiveIndicator, {}), (0, jsx_runtime_1.jsxs)("div", { className: "floating-action-bar__buttons", children: [(0, jsx_runtime_1.jsx)(o3_button_1.Button, { attributes: {
49
- ariaLabel: view.descripton,
50
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "floating-action-bar__container", "data-trackable": (0, tracking_1.trackingNS)('floating-action-bar'), children: [(0, jsx_runtime_1.jsx)("div", { className: "floating-action-bar__fade-overlay" }), (0, jsx_runtime_1.jsxs)("div", { className: "floating-action-bar", "data-testid": "floating-action-bar", children: [isLive && (0, jsx_runtime_1.jsx)(LiveIndicator, {}), (0, jsx_runtime_1.jsxs)("div", { className: "floating-action-bar__buttons", children: [(0, jsx_runtime_1.jsx)(o3_button_1.Button, { attributes: {
51
+ ariaLabel: view.description,
50
52
  'data-trackable': (0, tracking_1.trackingNS)(view.dataTrackable),
51
- 'data-trackable-context-action': view.descripton,
53
+ 'data-trackable-context-action': view.description,
52
54
  'data-testid': `floating-action-bar__${view.testIdSuffix}`,
53
- }, label: view.descripton, type: "ghost", theme: "mono", icon: "edit", iconOnly: isMobile, onClick: view.onClick }), (0, jsx_runtime_1.jsx)(o3_button_1.Button, { attributes: {
55
+ }, label: view.description, type: "ghost", theme: "mono", icon: "edit", iconOnly: isMobile, onClick: view.onClick }), (0, jsx_runtime_1.jsx)(o3_button_1.Button, { attributes: {
54
56
  ariaLabel: 'Back to top',
55
57
  'data-trackable': (0, tracking_1.trackingNS)('back_to_top'),
56
58
  'data-trackable-context-action': 'Back to top',
57
59
  'data-testid': 'floating-action-bar__back-to-top-button',
58
- }, label: "Back to top", type: "ghost", theme: "mono", icon: "scroll-to", iconOnly: isMobile, onClick: handleShowUpdates })] }), updatedComments?.length > 0 && ((0, jsx_runtime_1.jsx)("div", { className: "floating-action-bar__updates-counter o3-type-detail", children: updatedComments.length }))] })] }));
60
+ }, label: "Back to top", type: "ghost", theme: "mono", icon: "scroll-to", iconOnly: isMobile, onClick: handleShowUpdates })] }), updatedComments?.length > 0 && ((0, jsx_runtime_1.jsx)("div", { className: "floating-action-bar__updates-counter o3-type-detail", "aria-label": `${updatedComments.length} new ${updatedComments.length === 1 ? 'update' : 'updates'} available`, role: "status", "aria-live": "polite", children: updatedComments.length }))] })] }));
59
61
  }
60
62
  exports.default = FloatingActionBar;
@@ -87,6 +87,6 @@ function QandaContainer({ embedFormFieldContainer, countdownTimerContainer, }) {
87
87
  },
88
88
  });
89
89
  }, [data, status]);
90
- return ((0, jsx_runtime_1.jsxs)("div", { className: "qanda-container", children: [error && ((0, jsx_runtime_1.jsxs)("p", { children: ["We are not able to load ", qanda_1.PRODUCT_NAME, " at the moment. Please try again later. Error:", error] })), !isLoading && !error && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [status !== 'close' && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [showExpertView ? (0, jsx_runtime_1.jsx)(ExpertDrawer_1.default, {}) : (0, jsx_runtime_1.jsx)(QuestionForm_1.default, {}), (0, jsx_runtime_1.jsx)(CountdownTimer_1.default, { container: countdownTimerContainer })] })), status === 'pre-live' && (0, jsx_runtime_1.jsx)(Calendar_1.default, {}), (0, jsx_runtime_1.jsx)(Stream_1.default, { status: status }), (0, jsx_runtime_1.jsx)(Survey_1.default, { storyId: storyId, status: status }), (status === 'live' || (status === 'pre-live' && showExpertView)) && ((0, jsx_runtime_1.jsx)(FloatingActionBar_1.default, { showLiveIndicator: status === 'live' })), status !== 'close' && !showExpertView && ((0, jsx_runtime_1.jsx)(EmbedFormField_1.default, { container: embedFormFieldContainer }))] })), (0, jsx_runtime_1.jsx)("div", { "aria-live": "polite", "aria-atomic": "true", className: "o3-visually-hidden", children: ariaMessage })] }));
90
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "qanda-container", children: [error && ((0, jsx_runtime_1.jsxs)("p", { children: ["We are not able to load ", qanda_1.PRODUCT_NAME, " at the moment. Please try again later. Error:", error] })), !isLoading && !error && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [status !== 'close' && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [showExpertView ? (0, jsx_runtime_1.jsx)(ExpertDrawer_1.default, {}) : (0, jsx_runtime_1.jsx)(QuestionForm_1.default, {}), (0, jsx_runtime_1.jsx)(CountdownTimer_1.default, { container: countdownTimerContainer })] })), status === 'pre-live' && (0, jsx_runtime_1.jsx)(Calendar_1.default, {}), (0, jsx_runtime_1.jsx)(Stream_1.default, { status: status }), (0, jsx_runtime_1.jsx)(Survey_1.default, { storyId: storyId, status: status }), (status === 'live' || (status === 'pre-live' && showExpertView)) && ((0, jsx_runtime_1.jsx)(FloatingActionBar_1.default, { isLive: status === 'live' })), status !== 'close' && !showExpertView && ((0, jsx_runtime_1.jsx)(EmbedFormField_1.default, { container: embedFormFieldContainer }))] })), (0, jsx_runtime_1.jsx)("div", { "aria-live": "polite", "aria-atomic": "true", className: "o3-visually-hidden", children: ariaMessage })] }));
91
91
  }
92
92
  exports.default = QandaContainer;
@@ -28,7 +28,7 @@ function Stream({ status = 'pre-live' }) {
28
28
  const element = document.getElementById(latestAnsweredQuestionId);
29
29
  element?.scrollIntoView({ behavior: 'smooth' });
30
30
  }, [latestAnsweredQuestionId]);
31
- return ((0, jsx_runtime_1.jsxs)("section", { className: "qanda__stream", children: [data && data.qandas.length > 0 && ((0, jsx_runtime_1.jsx)("h2", { className: "o3-type-title-md qanda__stream-title", children: data.status && data.status === 'pre-live'
31
+ return ((0, jsx_runtime_1.jsxs)("section", { className: "qanda__stream", children: [data && data.qandas.length > 0 && ((0, jsx_runtime_1.jsx)("h2", { className: "o3-type-title-md qanda__stream-title", id: "qanda__stream-title", children: data.status && data.status === 'pre-live'
32
32
  ? 'Top questions so far'
33
33
  : 'What our readers are asking' })), (0, jsx_runtime_1.jsxs)("div", { className: "qanda__blocks-container", "data-testid": "qanda__blocks-container", id: "qanda__blocks-container", "aria-busy": isLoading, children: [isLoading && ((0, jsx_runtime_1.jsx)(Loader_1.default, { theme: "dark", extraClassNames: "qanda__stream-loader" })), error && ((0, jsx_runtime_1.jsx)(ErrorMessage_1.default, { error: error, storyId: storyId, operation: "loading-stream" })), data &&
34
34
  data.qandas &&
@@ -1,11 +1,13 @@
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
  };
@@ -37,13 +39,28 @@ exports.nextCommentsApi = (0, react_1.createApi)({
37
39
  queryFn: async ({ storyId, useStaging, commentsAPIUrl, }) => {
38
40
  return { data: {} };
39
41
  },
40
- async onCacheEntryAdded(arg, { updateCachedData, cacheDataLoaded, cacheEntryRemoved }) {
42
+ async onCacheEntryAdded(arg, { updateCachedData, cacheDataLoaded, cacheEntryRemoved, dispatch, getState, }) {
41
43
  await cacheDataLoaded;
42
44
  const connectToSSE = () => {
43
45
  const sseEndpoint = `${arg.commentsAPIUrl}/story/${arg.storyId}/updates${arg.useStaging ? '?staging=1' : ''}`;
44
46
  exports.eventSource = new EventSource(sseEndpoint);
45
- exports.eventSource.onopen = () => {
47
+ exports.eventSource.onopen = async () => {
46
48
  console.log(`Connected to SSE: ${sseEndpoint}`);
49
+ const state = getState();
50
+ const lastEventId = state.stream.mostRecentUpdate;
51
+ if (lastEventId !== null && lastEventId !== undefined) {
52
+ try {
53
+ await dispatch(exports.nextCommentsApi.endpoints.getCatchupUpdates.initiate({
54
+ storyId: arg.storyId,
55
+ useStaging: arg.useStaging,
56
+ commentsAPIUrl: arg.commentsAPIUrl,
57
+ lastEventId: `${lastEventId}`,
58
+ }));
59
+ }
60
+ catch (error) {
61
+ reliability_1.qandaReliability.failure('qa-updates', error);
62
+ }
63
+ }
47
64
  };
48
65
  exports.eventSource.onmessage = (event) => {
49
66
  try {
@@ -51,10 +68,14 @@ exports.nextCommentsApi = (0, react_1.createApi)({
51
68
  console.log('Received SSE update:', data);
52
69
  if (data && data.type) {
53
70
  updateCachedData((draft) => {
54
- const updatedData = updateTypes[data.type];
71
+ const updatedData = exports.updateTypes[data.type];
55
72
  draft[updatedData] = draft[updatedData] || [];
56
73
  draft[updatedData].push(data.payload);
57
74
  });
75
+ // Store the lastEventId for use in reconnection
76
+ if (event.lastEventId) {
77
+ dispatch((0, stream_1.setMostRecentUpdate)(event.lastEventId));
78
+ }
58
79
  }
59
80
  }
60
81
  catch (error) {
@@ -94,6 +115,19 @@ exports.nextCommentsApi = (0, react_1.createApi)({
94
115
  }
95
116
  },
96
117
  }),
118
+ // CATCHUP UPDATES
119
+ getCatchupUpdates: builder.query({
120
+ query: ({ storyId, useStaging, commentsAPIUrl, lastEventId, }) => `${commentsAPIUrl}/story/${storyId}/catchup-updates?${useStaging ? 'staging=1&' : ''}${`lastEventId=${lastEventId}`}`,
121
+ transformResponse: (response) => {
122
+ const updatedComments = response?.comments.map((comment) => {
123
+ return comment.data;
124
+ });
125
+ return {
126
+ updatedComments,
127
+ lastEventId: response?.lastEventId,
128
+ };
129
+ },
130
+ }),
97
131
  // USER ROLES
98
132
  getUserRoles: builder.query({
99
133
  query: ({ storyId, useStaging, commentsAPIUrl, token, }) => ({
@@ -130,11 +164,12 @@ exports.nextCommentsApi = (0, react_1.createApi)({
130
164
  exports.useGetQandAStreamQuery = exports.nextCommentsApi.useGetQandAStreamQuery, exports.useGetQandAUpdatesQuery = exports.nextCommentsApi.useGetQandAUpdatesQuery, exports.useGetUserRolesQuery = exports.nextCommentsApi.useGetUserRolesQuery, exports.usePostQuestionMutation = exports.nextCommentsApi.usePostQuestionMutation;
131
165
  // UPDATES QUERY CUSTOM HOOKS
132
166
  // custom hook for getting just the updated comments from the updates, so we are more efficient
133
- const useUpdatedComments = ({ storyId, useStaging, commentsAPIUrl, }) => {
167
+ const useUpdatedComments = ({ storyId, useStaging, commentsAPIUrl }, options) => {
134
168
  return exports.nextCommentsApi.useGetQandAUpdatesQuery({ storyId, useStaging, commentsAPIUrl }, {
135
169
  selectFromResult: ({ data }) => ({
136
170
  updatedComments: data?.updatedComments || [],
137
171
  }),
172
+ skip: options?.skip || false,
138
173
  }).updatedComments;
139
174
  };
140
175
  exports.useUpdatedComments = useUpdatedComments;
@@ -0,0 +1,103 @@
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 from a list of Q&A questions
19
+ *
20
+ * @param qandas - Array of Comment objects representing questions with nested answers
21
+ * @returns The timestamp (in milliseconds) of the most recent answer, 0 if no answers found
22
+ *
23
+ * @example
24
+ * const timestamp = getMostRecentUpdateTime(qandas);
25
+ * // Returns: 167253120 (example timestamp)
26
+ */
27
+ // we have a v similar functin in src/utils/qandas.ts
28
+ function getMostRecentUpdateTime(qandas) {
29
+ if (!qandas.length ||
30
+ !Array.isArray(qandas[0].children) ||
31
+ !qandas[0].children.length) {
32
+ return 0; // no answers or no content at all
33
+ }
34
+ // we should be getting the comments back in a date desc by most recent answer from the API
35
+ const mostRecentUpdate = qandas[0].children.reduce((mostRecent, answer) => {
36
+ const publishedDateTimestamp = new Date(answer.publishedDate).getTime();
37
+ return publishedDateTimestamp > mostRecent
38
+ ? publishedDateTimestamp
39
+ : mostRecent;
40
+ }, 0);
41
+ return mostRecentUpdate;
42
+ }
43
+ /**
44
+ * Listener: Updates most recent timestamp when live Q&A stream data is fetched
45
+ *
46
+ * Responds to successful getQandAStream queries with status='live' and extracts
47
+ * the most recent answer timestamp to store in Redux state. This timestamp is
48
+ * used for reconnection logic and catching up on missed updates.
49
+ *
50
+ * @listens nextCommentsApi.endpoints.getQandAStream.matchFulfilled
51
+ * @dispatches setMostRecentUpdate - Updates stream.mostRecentUpdate in Redux store
52
+ */
53
+ exports.listenerMiddleware.startListening({
54
+ matcher: (0, toolkit_1.isAnyOf)(comments_api_1.nextCommentsApi.endpoints.getQandAStream.matchFulfilled),
55
+ effect: (action, listenerApi) => {
56
+ const { payload, meta } = action;
57
+ const { storyId, useStaging, commentsAPIUrl, status } = meta.arg.originalArgs;
58
+ // Check if this is a live stream QUERY, not response. If it is a live query we get questions and answers.
59
+ const isLiveStream = status === 'live';
60
+ if (isLiveStream && payload.qandas) {
61
+ const mostRecentUpdate = getMostRecentUpdateTime(payload.qandas);
62
+ // Update the store with the most recent update as a string
63
+ if (mostRecentUpdate !== undefined) {
64
+ console.log(`Setting most recent update for story ${storyId}: ${mostRecentUpdate}`);
65
+ listenerApi.dispatch((0, stream_1.setMostRecentUpdate)(`${mostRecentUpdate}`));
66
+ }
67
+ listenerApi.dispatch(comments_api_1.nextCommentsApi.endpoints.getCatchupUpdates.initiate({
68
+ storyId: storyId,
69
+ useStaging: useStaging,
70
+ commentsAPIUrl: commentsAPIUrl,
71
+ lastEventId: `${mostRecentUpdate}`,
72
+ }));
73
+ }
74
+ },
75
+ });
76
+ /**
77
+ * Listener: Updates updates cache and most recent update when catchup comments are received
78
+ *
79
+ * Responds to successful getCatchupUpdates queries and adds them to the updates list
80
+ *
81
+ * @listens nextCommentsApi.endpoints.getCatchupUpdates.matchFulfilled
82
+ * @dispatches nextCommentsApi.util.updateQueryData - Updates the cache of updated comments in the store
83
+ * @dispatches setMostRecentUpdate - Updates stream.mostRecentUpdate in Redux store
84
+ */
85
+ exports.listenerMiddleware.startListening({
86
+ matcher: comments_api_1.nextCommentsApi.endpoints.getCatchupUpdates.matchFulfilled,
87
+ effect: (action, listenerApi) => {
88
+ const { payload, meta } = action;
89
+ const { storyId, useStaging, commentsAPIUrl } = meta.arg.originalArgs;
90
+ const { updatedComments, lastEventId } = payload;
91
+ console.log('Received catchup updates, new event id:', lastEventId);
92
+ const updateType = comments_api_1.updateTypes['QA_NEW_REPLY'];
93
+ if (Array.isArray(updatedComments) && updatedComments.length) {
94
+ listenerApi.dispatch(comments_api_1.nextCommentsApi.util.updateQueryData('getQandAUpdates', { storyId, useStaging, commentsAPIUrl }, (draft) => {
95
+ draft[updateType] = draft[updateType] || [];
96
+ draft[updateType].push(...updatedComments);
97
+ }));
98
+ }
99
+ if (typeof lastEventId === 'string') {
100
+ listenerApi.dispatch((0, stream_1.setMostRecentUpdate)(lastEventId));
101
+ }
102
+ },
103
+ });
@@ -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,11 @@
1
1
  "use strict";
2
+ var _a;
2
3
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.setLatestAnsweredQuestionId = exports.stream = exports.initialState = void 0;
4
+ exports.setMostRecentUpdate = exports.setLatestAnsweredQuestionId = exports.stream = exports.initialState = void 0;
4
5
  const toolkit_1 = require("@reduxjs/toolkit");
5
6
  exports.initialState = {
6
7
  latestAnsweredQuestionId: '',
8
+ mostRecentUpdate: null,
7
9
  };
8
10
  exports.stream = (0, toolkit_1.createSlice)({
9
11
  name: 'stream',
@@ -12,6 +14,9 @@ exports.stream = (0, toolkit_1.createSlice)({
12
14
  setLatestAnsweredQuestionId: (state, action) => {
13
15
  state.latestAnsweredQuestionId = action.payload;
14
16
  },
17
+ setMostRecentUpdate: (state, action) => {
18
+ state.mostRecentUpdate = action.payload;
19
+ },
15
20
  },
16
21
  });
17
- exports.setLatestAnsweredQuestionId = exports.stream.actions.setLatestAnsweredQuestionId;
22
+ _a = exports.stream.actions, exports.setLatestAnsweredQuestionId = _a.setLatestAnsweredQuestionId, exports.setMostRecentUpdate = _a.setMostRecentUpdate;
@@ -12,7 +12,7 @@ import { trackingNS } from '../../utils/tracking';
12
12
  function LiveIndicator() {
13
13
  return (_jsx("div", { className: "o-labels-indicator--live o3-type-label floating-action-bar__live-container", children: _jsx("span", { className: "o-labels-indicator__status floating-action-bar__live-label", children: "Live" }) }));
14
14
  }
15
- function FloatingActionBar({ showLiveIndicator = true, }) {
15
+ function FloatingActionBar({ isLive = true }) {
16
16
  const dispatch = useDispatch();
17
17
  const isMobile = useIsMobile();
18
18
  const { storyId, useStaging, commentsAPIUrl } = useContext(QandaContext);
@@ -20,39 +20,41 @@ function FloatingActionBar({ showLiveIndicator = true, }) {
20
20
  storyId,
21
21
  useStaging,
22
22
  commentsAPIUrl,
23
+ }, {
24
+ skip: !isLive,
23
25
  });
24
26
  const handleShowUpdates = () => {
25
27
  updateComments(dispatch, updatedComments, storyId, useStaging, commentsAPIUrl);
26
- // Scrolling to start of qanda blocks container
27
- const element = document.getElementById('qanda__blocks-container');
28
+ // Scrolling to start of qanda stream title
29
+ const element = document.getElementById('qanda__stream-title');
28
30
  if (element) {
29
31
  element.scrollIntoView({ behavior: 'smooth', block: 'start' });
30
32
  }
31
33
  };
32
34
  const showExpertView = useSelector((state) => state.expert.expertView);
33
35
  const userConfig = {
34
- descripton: 'Ask a question',
36
+ description: 'Ask a question',
35
37
  testIdSuffix: 'ask-a-question-button',
36
38
  onClick: () => dispatch(openForm()),
37
39
  dataTrackable: 'open_question_form',
38
40
  };
39
41
  const expertConfig = {
40
- descripton: 'Answer Q&A',
42
+ description: 'Answer Q&A',
41
43
  testIdSuffix: 'answer-a-qanda-button',
42
44
  onClick: () => dispatch(openExpertForm()),
43
45
  dataTrackable: 'open_expert_drawer',
44
46
  };
45
47
  const view = showExpertView ? expertConfig : userConfig;
46
- return (_jsxs("div", { className: "floating-action-bar__container", "data-trackable": trackingNS('floating-action-bar'), children: [_jsx("div", { className: "floating-action-bar__fade-overlay" }), _jsxs("div", { className: "floating-action-bar", "data-testid": "floating-action-bar", children: [showLiveIndicator && _jsx(LiveIndicator, {}), _jsxs("div", { className: "floating-action-bar__buttons", children: [_jsx(Button, { attributes: {
47
- ariaLabel: view.descripton,
48
+ return (_jsxs("div", { className: "floating-action-bar__container", "data-trackable": trackingNS('floating-action-bar'), children: [_jsx("div", { className: "floating-action-bar__fade-overlay" }), _jsxs("div", { className: "floating-action-bar", "data-testid": "floating-action-bar", children: [isLive && _jsx(LiveIndicator, {}), _jsxs("div", { className: "floating-action-bar__buttons", children: [_jsx(Button, { attributes: {
49
+ ariaLabel: view.description,
48
50
  'data-trackable': trackingNS(view.dataTrackable),
49
- 'data-trackable-context-action': view.descripton,
51
+ 'data-trackable-context-action': view.description,
50
52
  'data-testid': `floating-action-bar__${view.testIdSuffix}`,
51
- }, label: view.descripton, type: "ghost", theme: "mono", icon: "edit", iconOnly: isMobile, onClick: view.onClick }), _jsx(Button, { attributes: {
53
+ }, label: view.description, type: "ghost", theme: "mono", icon: "edit", iconOnly: isMobile, onClick: view.onClick }), _jsx(Button, { attributes: {
52
54
  ariaLabel: 'Back to top',
53
55
  'data-trackable': trackingNS('back_to_top'),
54
56
  'data-trackable-context-action': 'Back to top',
55
57
  'data-testid': 'floating-action-bar__back-to-top-button',
56
- }, label: "Back to top", type: "ghost", theme: "mono", icon: "scroll-to", iconOnly: isMobile, onClick: handleShowUpdates })] }), updatedComments?.length > 0 && (_jsx("div", { className: "floating-action-bar__updates-counter o3-type-detail", children: updatedComments.length }))] })] }));
58
+ }, label: "Back to top", type: "ghost", theme: "mono", icon: "scroll-to", iconOnly: isMobile, onClick: handleShowUpdates })] }), updatedComments?.length > 0 && (_jsx("div", { className: "floating-action-bar__updates-counter o3-type-detail", "aria-label": `${updatedComments.length} new ${updatedComments.length === 1 ? 'update' : 'updates'} available`, role: "status", "aria-live": "polite", children: updatedComments.length }))] })] }));
57
59
  }
58
60
  export default FloatingActionBar;
@@ -82,6 +82,6 @@ function QandaContainer({ embedFormFieldContainer, countdownTimerContainer, }) {
82
82
  },
83
83
  });
84
84
  }, [data, status]);
85
- return (_jsxs("div", { className: "qanda-container", children: [error && (_jsxs("p", { children: ["We are not able to load ", PRODUCT_NAME, " at the moment. Please try again later. Error:", error] })), !isLoading && !error && (_jsxs(_Fragment, { children: [status !== 'close' && (_jsxs(_Fragment, { children: [showExpertView ? _jsx(ExpertDrawer, {}) : _jsx(QuestionForm, {}), _jsx(CountdownTimer, { container: countdownTimerContainer })] })), status === 'pre-live' && _jsx(Calendar, {}), _jsx(Stream, { status: status }), _jsx(Survey, { storyId: storyId, status: status }), (status === 'live' || (status === 'pre-live' && showExpertView)) && (_jsx(FloatingActionBar, { showLiveIndicator: status === 'live' })), status !== 'close' && !showExpertView && (_jsx(EmbedFormField, { container: embedFormFieldContainer }))] })), _jsx("div", { "aria-live": "polite", "aria-atomic": "true", className: "o3-visually-hidden", children: ariaMessage })] }));
85
+ return (_jsxs("div", { className: "qanda-container", children: [error && (_jsxs("p", { children: ["We are not able to load ", PRODUCT_NAME, " at the moment. Please try again later. Error:", error] })), !isLoading && !error && (_jsxs(_Fragment, { children: [status !== 'close' && (_jsxs(_Fragment, { children: [showExpertView ? _jsx(ExpertDrawer, {}) : _jsx(QuestionForm, {}), _jsx(CountdownTimer, { container: countdownTimerContainer })] })), status === 'pre-live' && _jsx(Calendar, {}), _jsx(Stream, { status: status }), _jsx(Survey, { storyId: storyId, status: status }), (status === 'live' || (status === 'pre-live' && showExpertView)) && (_jsx(FloatingActionBar, { isLive: status === 'live' })), status !== 'close' && !showExpertView && (_jsx(EmbedFormField, { container: embedFormFieldContainer }))] })), _jsx("div", { "aria-live": "polite", "aria-atomic": "true", className: "o3-visually-hidden", children: ariaMessage })] }));
86
86
  }
87
87
  export default QandaContainer;
@@ -23,7 +23,7 @@ function Stream({ status = 'pre-live' }) {
23
23
  const element = document.getElementById(latestAnsweredQuestionId);
24
24
  element?.scrollIntoView({ behavior: 'smooth' });
25
25
  }, [latestAnsweredQuestionId]);
26
- return (_jsxs("section", { className: "qanda__stream", children: [data && data.qandas.length > 0 && (_jsx("h2", { className: "o3-type-title-md qanda__stream-title", children: data.status && data.status === 'pre-live'
26
+ return (_jsxs("section", { className: "qanda__stream", children: [data && data.qandas.length > 0 && (_jsx("h2", { className: "o3-type-title-md qanda__stream-title", id: "qanda__stream-title", children: data.status && data.status === 'pre-live'
27
27
  ? 'Top questions so far'
28
28
  : 'What our readers are asking' })), _jsxs("div", { className: "qanda__blocks-container", "data-testid": "qanda__blocks-container", id: "qanda__blocks-container", "aria-busy": isLoading, children: [isLoading && (_jsx(Loader, { theme: "dark", extraClassNames: "qanda__stream-loader" })), error && (_jsx(ErrorMessage, { error: error, storyId: storyId, operation: "loading-stream" })), data &&
29
29
  data.qandas &&
@@ -1,8 +1,10 @@
1
1
  import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
2
- // TODO: ensure that users don't miss out on updates in this window
3
- // https://financialtimes.atlassian.net/browse/CI-2715
2
+ import { setMostRecentUpdate } from '../store/stream';
3
+ import { qandaReliability } from './reliability';
4
+ // 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
5
+ // NB: adding retry limit would be a good idea https://financialtimes.atlassian.net/browse/CI-2715
4
6
  const retryDelay = 1000;
5
- const updateTypes = {
7
+ export const updateTypes = {
6
8
  QA_NEW_REPLY: 'updatedComments',
7
9
  QA_UPDATE: 'updatedQA',
8
10
  };
@@ -34,13 +36,28 @@ export const nextCommentsApi = createApi({
34
36
  queryFn: async ({ storyId, useStaging, commentsAPIUrl, }) => {
35
37
  return { data: {} };
36
38
  },
37
- async onCacheEntryAdded(arg, { updateCachedData, cacheDataLoaded, cacheEntryRemoved }) {
39
+ async onCacheEntryAdded(arg, { updateCachedData, cacheDataLoaded, cacheEntryRemoved, dispatch, getState, }) {
38
40
  await cacheDataLoaded;
39
41
  const connectToSSE = () => {
40
42
  const sseEndpoint = `${arg.commentsAPIUrl}/story/${arg.storyId}/updates${arg.useStaging ? '?staging=1' : ''}`;
41
43
  eventSource = new EventSource(sseEndpoint);
42
- eventSource.onopen = () => {
44
+ eventSource.onopen = async () => {
43
45
  console.log(`Connected to SSE: ${sseEndpoint}`);
46
+ const state = getState();
47
+ const lastEventId = state.stream.mostRecentUpdate;
48
+ if (lastEventId !== null && lastEventId !== undefined) {
49
+ try {
50
+ await dispatch(nextCommentsApi.endpoints.getCatchupUpdates.initiate({
51
+ storyId: arg.storyId,
52
+ useStaging: arg.useStaging,
53
+ commentsAPIUrl: arg.commentsAPIUrl,
54
+ lastEventId: `${lastEventId}`,
55
+ }));
56
+ }
57
+ catch (error) {
58
+ qandaReliability.failure('qa-updates', error);
59
+ }
60
+ }
44
61
  };
45
62
  eventSource.onmessage = (event) => {
46
63
  try {
@@ -52,6 +69,10 @@ export const nextCommentsApi = createApi({
52
69
  draft[updatedData] = draft[updatedData] || [];
53
70
  draft[updatedData].push(data.payload);
54
71
  });
72
+ // Store the lastEventId for use in reconnection
73
+ if (event.lastEventId) {
74
+ dispatch(setMostRecentUpdate(event.lastEventId));
75
+ }
55
76
  }
56
77
  }
57
78
  catch (error) {
@@ -91,6 +112,19 @@ export const nextCommentsApi = createApi({
91
112
  }
92
113
  },
93
114
  }),
115
+ // CATCHUP UPDATES
116
+ getCatchupUpdates: builder.query({
117
+ query: ({ storyId, useStaging, commentsAPIUrl, lastEventId, }) => `${commentsAPIUrl}/story/${storyId}/catchup-updates?${useStaging ? 'staging=1&' : ''}${`lastEventId=${lastEventId}`}`,
118
+ transformResponse: (response) => {
119
+ const updatedComments = response?.comments.map((comment) => {
120
+ return comment.data;
121
+ });
122
+ return {
123
+ updatedComments,
124
+ lastEventId: response?.lastEventId,
125
+ };
126
+ },
127
+ }),
94
128
  // USER ROLES
95
129
  getUserRoles: builder.query({
96
130
  query: ({ storyId, useStaging, commentsAPIUrl, token, }) => ({
@@ -127,11 +161,12 @@ export const nextCommentsApi = createApi({
127
161
  export const { useGetQandAStreamQuery, useGetQandAUpdatesQuery, useGetUserRolesQuery, usePostQuestionMutation, } = nextCommentsApi;
128
162
  // UPDATES QUERY CUSTOM HOOKS
129
163
  // custom hook for getting just the updated comments from the updates, so we are more efficient
130
- export const useUpdatedComments = ({ storyId, useStaging, commentsAPIUrl, }) => {
164
+ export const useUpdatedComments = ({ storyId, useStaging, commentsAPIUrl }, options) => {
131
165
  return nextCommentsApi.useGetQandAUpdatesQuery({ storyId, useStaging, commentsAPIUrl }, {
132
166
  selectFromResult: ({ data }) => ({
133
167
  updatedComments: data?.updatedComments || [],
134
168
  }),
169
+ skip: options?.skip || false,
135
170
  }).updatedComments;
136
171
  };
137
172
  // Custom hook to get the updates on the information about the Q&A
@@ -0,0 +1,100 @@
1
+ /**
2
+ * @fileoverview RTK Query listener middleware for Q&A stream events
3
+ *
4
+ * This module contains Redux Toolkit listeners that respond to RTK Query events
5
+ * and update the application state accordingly. Currently handles:
6
+ * - Tracking the most recent update timestamp from live Q&A streams
7
+ *
8
+ * @see https://redux-toolkit.js.org/api/createListenerMiddleware
9
+ */
10
+ import { createListenerMiddleware, isAnyOf } from '@reduxjs/toolkit';
11
+ import { nextCommentsApi, updateTypes } from './comments-api';
12
+ import { setMostRecentUpdate } from '../store/stream';
13
+ export const listenerMiddleware = createListenerMiddleware();
14
+ /**
15
+ * Calculates the most recent update timestamp from a list of Q&A questions
16
+ *
17
+ * @param qandas - Array of Comment objects representing questions with nested answers
18
+ * @returns The timestamp (in milliseconds) of the most recent answer, 0 if no answers found
19
+ *
20
+ * @example
21
+ * const timestamp = getMostRecentUpdateTime(qandas);
22
+ * // Returns: 167253120 (example timestamp)
23
+ */
24
+ // we have a v similar functin in src/utils/qandas.ts
25
+ function getMostRecentUpdateTime(qandas) {
26
+ if (!qandas.length ||
27
+ !Array.isArray(qandas[0].children) ||
28
+ !qandas[0].children.length) {
29
+ return 0; // no answers or no content at all
30
+ }
31
+ // we should be getting the comments back in a date desc by most recent answer from the API
32
+ const mostRecentUpdate = qandas[0].children.reduce((mostRecent, answer) => {
33
+ const publishedDateTimestamp = new Date(answer.publishedDate).getTime();
34
+ return publishedDateTimestamp > mostRecent
35
+ ? publishedDateTimestamp
36
+ : mostRecent;
37
+ }, 0);
38
+ return mostRecentUpdate;
39
+ }
40
+ /**
41
+ * Listener: Updates most recent timestamp when live Q&A stream data is fetched
42
+ *
43
+ * Responds to successful getQandAStream queries with status='live' and extracts
44
+ * the most recent answer timestamp to store in Redux state. This timestamp is
45
+ * used for reconnection logic and catching up on missed updates.
46
+ *
47
+ * @listens nextCommentsApi.endpoints.getQandAStream.matchFulfilled
48
+ * @dispatches setMostRecentUpdate - Updates stream.mostRecentUpdate in Redux store
49
+ */
50
+ listenerMiddleware.startListening({
51
+ matcher: isAnyOf(nextCommentsApi.endpoints.getQandAStream.matchFulfilled),
52
+ effect: (action, listenerApi) => {
53
+ const { payload, meta } = action;
54
+ const { storyId, useStaging, commentsAPIUrl, status } = meta.arg.originalArgs;
55
+ // Check if this is a live stream QUERY, not response. If it is a live query we get questions and answers.
56
+ const isLiveStream = status === 'live';
57
+ if (isLiveStream && payload.qandas) {
58
+ const mostRecentUpdate = getMostRecentUpdateTime(payload.qandas);
59
+ // Update the store with the most recent update as a string
60
+ if (mostRecentUpdate !== undefined) {
61
+ console.log(`Setting most recent update for story ${storyId}: ${mostRecentUpdate}`);
62
+ listenerApi.dispatch(setMostRecentUpdate(`${mostRecentUpdate}`));
63
+ }
64
+ listenerApi.dispatch(nextCommentsApi.endpoints.getCatchupUpdates.initiate({
65
+ storyId: storyId,
66
+ useStaging: useStaging,
67
+ commentsAPIUrl: commentsAPIUrl,
68
+ lastEventId: `${mostRecentUpdate}`,
69
+ }));
70
+ }
71
+ },
72
+ });
73
+ /**
74
+ * Listener: Updates updates cache and most recent update when catchup comments are received
75
+ *
76
+ * Responds to successful getCatchupUpdates queries and adds them to the updates list
77
+ *
78
+ * @listens nextCommentsApi.endpoints.getCatchupUpdates.matchFulfilled
79
+ * @dispatches nextCommentsApi.util.updateQueryData - Updates the cache of updated comments in the store
80
+ * @dispatches setMostRecentUpdate - Updates stream.mostRecentUpdate in Redux store
81
+ */
82
+ listenerMiddleware.startListening({
83
+ matcher: nextCommentsApi.endpoints.getCatchupUpdates.matchFulfilled,
84
+ effect: (action, listenerApi) => {
85
+ const { payload, meta } = action;
86
+ const { storyId, useStaging, commentsAPIUrl } = meta.arg.originalArgs;
87
+ const { updatedComments, lastEventId } = payload;
88
+ console.log('Received catchup updates, new event id:', lastEventId);
89
+ const updateType = updateTypes['QA_NEW_REPLY'];
90
+ if (Array.isArray(updatedComments) && updatedComments.length) {
91
+ listenerApi.dispatch(nextCommentsApi.util.updateQueryData('getQandAUpdates', { storyId, useStaging, commentsAPIUrl }, (draft) => {
92
+ draft[updateType] = draft[updateType] || [];
93
+ draft[updateType].push(...updatedComments);
94
+ }));
95
+ }
96
+ if (typeof lastEventId === 'string') {
97
+ listenerApi.dispatch(setMostRecentUpdate(lastEventId));
98
+ }
99
+ },
100
+ });
@@ -6,6 +6,7 @@ import { user } from './user';
6
6
  import expertReducer from './expertSlice';
7
7
  import { nextCommentsApi } from '../services/comments-api';
8
8
  import { rtkQueryErrorLogger } from '../services/error-middleware';
9
+ import { listenerMiddleware } from '../services/listeners-middleware';
9
10
  export const rootReducer = combineReducers({
10
11
  stream: stream.reducer,
11
12
  question: questionReducer,
@@ -16,6 +17,7 @@ export const rootReducer = combineReducers({
16
17
  export const store = configureStore({
17
18
  reducer: rootReducer,
18
19
  middleware: (getDefaultMiddleware) => getDefaultMiddleware()
20
+ .prepend(listenerMiddleware.middleware)
19
21
  .concat(nextCommentsApi.middleware)
20
22
  .concat(rtkQueryErrorLogger),
21
23
  });
@@ -1,6 +1,7 @@
1
1
  import { createSlice } from '@reduxjs/toolkit';
2
2
  export const initialState = {
3
3
  latestAnsweredQuestionId: '',
4
+ mostRecentUpdate: null,
4
5
  };
5
6
  export const stream = createSlice({
6
7
  name: 'stream',
@@ -9,6 +10,9 @@ export const stream = createSlice({
9
10
  setLatestAnsweredQuestionId: (state, action) => {
10
11
  state.latestAnsweredQuestionId = action.payload;
11
12
  },
13
+ setMostRecentUpdate: (state, action) => {
14
+ state.mostRecentUpdate = action.payload;
15
+ },
12
16
  },
13
17
  });
14
- export const { setLatestAnsweredQuestionId } = stream.actions;
18
+ export const { setLatestAnsweredQuestionId, setMostRecentUpdate } = stream.actions;
package/dist/qanda.scss CHANGED
@@ -252,6 +252,7 @@ $app-header-color: #007acc;
252
252
  border-radius: 100px;
253
253
  padding: 2px var(--o3-spacing-5xs);
254
254
  min-width: var(--o3-spacing-2xs);
255
+ text-align: center;
255
256
  }
256
257
 
257
258
  /* ToDo: using an SVG for this icon until it becomes part of Origami */
@@ -1,4 +1,4 @@
1
- declare function FloatingActionBar({ showLiveIndicator, }: {
2
- showLiveIndicator?: boolean;
1
+ declare function FloatingActionBar({ isLive }: {
2
+ isLive?: boolean;
3
3
  }): import("react").JSX.Element;
4
4
  export default FloatingActionBar;
@@ -1,6 +1,10 @@
1
1
  import type { Comment } from '../types/comment';
2
2
  import type { QA } from '../types/qa';
3
3
  import type { Status } from '../types/qa';
4
+ export declare const updateTypes: {
5
+ QA_NEW_REPLY: string;
6
+ QA_UPDATE: string;
7
+ };
4
8
  export declare let eventSource: EventSource | null;
5
9
  type CommentsStreamResponse = {
6
10
  type: string;
@@ -37,6 +41,12 @@ export declare const nextCommentsApi: import("@reduxjs/toolkit/query/react").Api
37
41
  status: Status;
38
42
  }, import("@reduxjs/toolkit/query/react").BaseQueryFn<string | import("@reduxjs/toolkit/query/react").FetchArgs, unknown, import("@reduxjs/toolkit/query/react").FetchBaseQueryError, {}, import("@reduxjs/toolkit/query/react").FetchBaseQueryMeta>, never, CommentsStreamTransformed, "nextCommentsApi">;
39
43
  getQandAUpdates: import("@reduxjs/toolkit/query/react").QueryDefinition<CommentsAPIUrlParams, import("@reduxjs/toolkit/query/react").BaseQueryFn<string | import("@reduxjs/toolkit/query/react").FetchArgs, unknown, import("@reduxjs/toolkit/query/react").FetchBaseQueryError, {}, import("@reduxjs/toolkit/query/react").FetchBaseQueryMeta>, never, CachedData, "nextCommentsApi">;
44
+ getCatchupUpdates: import("@reduxjs/toolkit/query/react").QueryDefinition<CommentsAPIUrlParams & {
45
+ lastEventId: string;
46
+ }, import("@reduxjs/toolkit/query/react").BaseQueryFn<string | import("@reduxjs/toolkit/query/react").FetchArgs, unknown, import("@reduxjs/toolkit/query/react").FetchBaseQueryError, {}, import("@reduxjs/toolkit/query/react").FetchBaseQueryMeta>, never, {
47
+ updatedComments: Comment[] | undefined;
48
+ lastEventId: string | undefined;
49
+ }, "nextCommentsApi">;
40
50
  getUserRoles: import("@reduxjs/toolkit/query/react").QueryDefinition<CommentsAPIUrlTokenParams, import("@reduxjs/toolkit/query/react").BaseQueryFn<string | import("@reduxjs/toolkit/query/react").FetchArgs, unknown, import("@reduxjs/toolkit/query/react").FetchBaseQueryError, {}, import("@reduxjs/toolkit/query/react").FetchBaseQueryMeta>, never, UserRolesTransformed, "nextCommentsApi">;
41
51
  postQuestion: import("@reduxjs/toolkit/query/react").MutationDefinition<CommentsAPIUrlQuestionParams, import("@reduxjs/toolkit/query/react").BaseQueryFn<string | import("@reduxjs/toolkit/query/react").FetchArgs, unknown, import("@reduxjs/toolkit/query/react").FetchBaseQueryError, {}, import("@reduxjs/toolkit/query/react").FetchBaseQueryMeta>, never, {
42
52
  message: string;
@@ -47,6 +57,8 @@ export declare const useGetQandAStreamQuery: import("@reduxjs/toolkit/dist/query
47
57
  }, import("@reduxjs/toolkit/query/react").BaseQueryFn<string | import("@reduxjs/toolkit/query/react").FetchArgs, unknown, import("@reduxjs/toolkit/query/react").FetchBaseQueryError, {}, import("@reduxjs/toolkit/query/react").FetchBaseQueryMeta>, never, CommentsStreamTransformed, "nextCommentsApi">>, useGetQandAUpdatesQuery: import("@reduxjs/toolkit/dist/query/react/buildHooks").UseQuery<import("@reduxjs/toolkit/query/react").QueryDefinition<CommentsAPIUrlParams, import("@reduxjs/toolkit/query/react").BaseQueryFn<string | import("@reduxjs/toolkit/query/react").FetchArgs, unknown, import("@reduxjs/toolkit/query/react").FetchBaseQueryError, {}, import("@reduxjs/toolkit/query/react").FetchBaseQueryMeta>, never, CachedData, "nextCommentsApi">>, useGetUserRolesQuery: import("@reduxjs/toolkit/dist/query/react/buildHooks").UseQuery<import("@reduxjs/toolkit/query/react").QueryDefinition<CommentsAPIUrlTokenParams, import("@reduxjs/toolkit/query/react").BaseQueryFn<string | import("@reduxjs/toolkit/query/react").FetchArgs, unknown, import("@reduxjs/toolkit/query/react").FetchBaseQueryError, {}, import("@reduxjs/toolkit/query/react").FetchBaseQueryMeta>, never, UserRolesTransformed, "nextCommentsApi">>, usePostQuestionMutation: import("@reduxjs/toolkit/dist/query/react/buildHooks").UseMutation<import("@reduxjs/toolkit/query/react").MutationDefinition<CommentsAPIUrlQuestionParams, import("@reduxjs/toolkit/query/react").BaseQueryFn<string | import("@reduxjs/toolkit/query/react").FetchArgs, unknown, import("@reduxjs/toolkit/query/react").FetchBaseQueryError, {}, import("@reduxjs/toolkit/query/react").FetchBaseQueryMeta>, never, {
48
58
  message: string;
49
59
  }, "nextCommentsApi">>;
50
- export declare const useUpdatedComments: ({ storyId, useStaging, commentsAPIUrl, }: CommentsAPIUrlParams) => Comment[];
60
+ export declare const useUpdatedComments: ({ storyId, useStaging, commentsAPIUrl }: CommentsAPIUrlParams, options?: {
61
+ skip: boolean;
62
+ }) => Comment[];
51
63
  export declare const useUpdatedQA: ({ storyId, useStaging, commentsAPIUrl, }: CommentsAPIUrlParams) => any;
52
64
  export {};
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @fileoverview RTK Query listener middleware for Q&A stream events
3
+ *
4
+ * This module contains Redux Toolkit listeners that respond to RTK Query events
5
+ * and update the application state accordingly. Currently handles:
6
+ * - Tracking the most recent update timestamp from live Q&A streams
7
+ *
8
+ * @see https://redux-toolkit.js.org/api/createListenerMiddleware
9
+ */
10
+ import type { RootState, AppDispatch } from '../store';
11
+ export declare const listenerMiddleware: import("@reduxjs/toolkit").ListenerMiddlewareInstance<RootState, AppDispatch, unknown>;
@@ -33,6 +33,16 @@ export declare const rootReducer: import("@reduxjs/toolkit").Reducer<import("@re
33
33
  updatedComments?: import("../types/comment").Comment[];
34
34
  updateQA?: import("../types/qa").QA;
35
35
  }, "nextCommentsApi">;
36
+ getCatchupUpdates: import("@reduxjs/toolkit/query").QueryDefinition<{
37
+ storyId: string;
38
+ useStaging: boolean;
39
+ commentsAPIUrl: string;
40
+ } & {
41
+ lastEventId: string;
42
+ }, import("@reduxjs/toolkit/query").BaseQueryFn<string | import("@reduxjs/toolkit/query").FetchArgs, unknown, import("@reduxjs/toolkit/query").FetchBaseQueryError, {}, import("@reduxjs/toolkit/query").FetchBaseQueryMeta>, never, {
43
+ updatedComments: import("../types/comment").Comment[] | undefined;
44
+ lastEventId: string | undefined;
45
+ }, "nextCommentsApi">;
36
46
  getUserRoles: import("@reduxjs/toolkit/query").QueryDefinition<{
37
47
  storyId: string;
38
48
  useStaging: boolean;
@@ -92,6 +102,16 @@ export declare const store: import("@reduxjs/toolkit/dist/configureStore").Toolk
92
102
  updatedComments?: import("../types/comment").Comment[];
93
103
  updateQA?: import("../types/qa").QA;
94
104
  }, "nextCommentsApi">;
105
+ getCatchupUpdates: import("@reduxjs/toolkit/query").QueryDefinition<{
106
+ storyId: string;
107
+ useStaging: boolean;
108
+ commentsAPIUrl: string;
109
+ } & {
110
+ lastEventId: string;
111
+ }, import("@reduxjs/toolkit/query").BaseQueryFn<string | import("@reduxjs/toolkit/query").FetchArgs, unknown, import("@reduxjs/toolkit/query").FetchBaseQueryError, {}, import("@reduxjs/toolkit/query").FetchBaseQueryMeta>, never, {
112
+ updatedComments: import("../types/comment").Comment[] | undefined;
113
+ lastEventId: string | undefined;
114
+ }, "nextCommentsApi">;
95
115
  getUserRoles: import("@reduxjs/toolkit/query").QueryDefinition<{
96
116
  storyId: string;
97
117
  useStaging: boolean;
@@ -114,7 +134,7 @@ export declare const store: import("@reduxjs/toolkit/dist/configureStore").Toolk
114
134
  message: string;
115
135
  }, "nextCommentsApi">;
116
136
  }, never, "nextCommentsApi">;
117
- }, import("@reduxjs/toolkit").AnyAction, import("@reduxjs/toolkit").MiddlewareArray<[import("@reduxjs/toolkit").ThunkMiddleware<import("@reduxjs/toolkit").CombinedState<{
137
+ }, import("@reduxjs/toolkit").AnyAction, import("@reduxjs/toolkit").MiddlewareArray<[import("@reduxjs/toolkit").ListenerMiddleware<RootState, AppDispatch, unknown>, import("@reduxjs/toolkit").ThunkMiddleware<import("@reduxjs/toolkit").CombinedState<{
118
138
  stream: any;
119
139
  question: QuestionState;
120
140
  user: any;
@@ -144,6 +164,16 @@ export declare const store: import("@reduxjs/toolkit/dist/configureStore").Toolk
144
164
  updatedComments?: import("../types/comment").Comment[];
145
165
  updateQA?: import("../types/qa").QA;
146
166
  }, "nextCommentsApi">;
167
+ getCatchupUpdates: import("@reduxjs/toolkit/query").QueryDefinition<{
168
+ storyId: string;
169
+ useStaging: boolean;
170
+ commentsAPIUrl: string;
171
+ } & {
172
+ lastEventId: string;
173
+ }, import("@reduxjs/toolkit/query").BaseQueryFn<string | import("@reduxjs/toolkit/query").FetchArgs, unknown, import("@reduxjs/toolkit/query").FetchBaseQueryError, {}, import("@reduxjs/toolkit/query").FetchBaseQueryMeta>, never, {
174
+ updatedComments: import("../types/comment").Comment[] | undefined;
175
+ lastEventId: string | undefined;
176
+ }, "nextCommentsApi">;
147
177
  getUserRoles: import("@reduxjs/toolkit/query").QueryDefinition<{
148
178
  storyId: string;
149
179
  useStaging: boolean;
@@ -191,6 +221,16 @@ export declare const store: import("@reduxjs/toolkit/dist/configureStore").Toolk
191
221
  updatedComments?: import("../types/comment").Comment[];
192
222
  updateQA?: import("../types/qa").QA;
193
223
  }, "nextCommentsApi">;
224
+ getCatchupUpdates: import("@reduxjs/toolkit/query").QueryDefinition<{
225
+ storyId: string;
226
+ useStaging: boolean;
227
+ commentsAPIUrl: string;
228
+ } & {
229
+ lastEventId: string;
230
+ }, import("@reduxjs/toolkit/query").BaseQueryFn<string | import("@reduxjs/toolkit/query").FetchArgs, unknown, import("@reduxjs/toolkit/query").FetchBaseQueryError, {}, import("@reduxjs/toolkit/query").FetchBaseQueryMeta>, never, {
231
+ updatedComments: import("../types/comment").Comment[] | undefined;
232
+ lastEventId: string | undefined;
233
+ }, "nextCommentsApi">;
194
234
  getUserRoles: import("@reduxjs/toolkit/query").QueryDefinition<{
195
235
  storyId: string;
196
236
  useStaging: boolean;
@@ -1,7 +1,8 @@
1
1
  import { Slice } from '@reduxjs/toolkit';
2
2
  export type StreamState = {
3
3
  latestAnsweredQuestionId: string;
4
+ mostRecentUpdate: string | null;
4
5
  };
5
6
  export declare const initialState: StreamState;
6
7
  export declare const stream: Slice;
7
- export declare const setLatestAnsweredQuestionId: import("@reduxjs/toolkit").ActionCreatorWithPayload<any, `${string}/${string}`> | import("@reduxjs/toolkit").ActionCreatorWithoutPayload<`${string}/${string}`>;
8
+ export declare const setLatestAnsweredQuestionId: import("@reduxjs/toolkit").ActionCreatorWithPayload<any, `${string}/${string}`> | import("@reduxjs/toolkit").ActionCreatorWithoutPayload<`${string}/${string}`>, setMostRecentUpdate: import("@reduxjs/toolkit").ActionCreatorWithPayload<any, `${string}/${string}`> | import("@reduxjs/toolkit").ActionCreatorWithoutPayload<`${string}/${string}`>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@financial-times/qanda-ui",
3
- "version": "0.0.1-beta.26",
3
+ "version": "0.0.1-beta.28",
4
4
  "description": "Components for the Live Q&A (AKA Ask an Expert) UI",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",