@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.
- package/dist/cjs/components/FloatingActionBar/index.js +12 -10
- package/dist/cjs/components/QandaContainer.js +1 -1
- package/dist/cjs/components/Stream/index.js +1 -1
- package/dist/cjs/services/comments-api.js +43 -8
- package/dist/cjs/services/listeners-middleware.js +103 -0
- package/dist/cjs/store/index.js +2 -0
- package/dist/cjs/store/stream.js +7 -2
- package/dist/esm/components/FloatingActionBar/index.js +12 -10
- package/dist/esm/components/QandaContainer.js +1 -1
- package/dist/esm/components/Stream/index.js +1 -1
- package/dist/esm/services/comments-api.js +41 -6
- package/dist/esm/services/listeners-middleware.js +100 -0
- package/dist/esm/store/index.js +2 -0
- package/dist/esm/store/stream.js +5 -1
- package/dist/qanda.scss +1 -0
- package/dist/types/components/FloatingActionBar/index.d.ts +2 -2
- package/dist/types/services/comments-api.d.ts +13 -1
- package/dist/types/services/listeners-middleware.d.ts +11 -0
- package/dist/types/store/index.d.ts +41 -1
- package/dist/types/store/stream.d.ts +2 -1
- package/package.json +1 -1
|
@@ -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({
|
|
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
|
|
29
|
-
const element = document.getElementById('
|
|
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
|
-
|
|
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
|
-
|
|
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: [
|
|
49
|
-
ariaLabel: view.
|
|
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.
|
|
53
|
+
'data-trackable-context-action': view.description,
|
|
52
54
|
'data-testid': `floating-action-bar__${view.testIdSuffix}`,
|
|
53
|
-
}, label: view.
|
|
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, {
|
|
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
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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
|
+
});
|
package/dist/cjs/store/index.js
CHANGED
|
@@ -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
|
});
|
package/dist/cjs/store/stream.js
CHANGED
|
@@ -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
|
-
|
|
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({
|
|
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
|
|
27
|
-
const element = document.getElementById('
|
|
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
|
-
|
|
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
|
-
|
|
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: [
|
|
47
|
-
ariaLabel: view.
|
|
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.
|
|
51
|
+
'data-trackable-context-action': view.description,
|
|
50
52
|
'data-testid': `floating-action-bar__${view.testIdSuffix}`,
|
|
51
|
-
}, label: view.
|
|
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, {
|
|
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
|
-
|
|
3
|
-
|
|
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
|
+
});
|
package/dist/esm/store/index.js
CHANGED
|
@@ -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
|
});
|
package/dist/esm/store/stream.js
CHANGED
|
@@ -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
|
@@ -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
|
|
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}`>;
|