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