@streamlayer/feature-gamification 0.38.0 → 0.39.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.
@@ -27,7 +27,9 @@ export declare class GamificationBackground {
27
27
  /** opened question, using to download statistics */
28
28
  openedQuestionId: WritableAtom<{
29
29
  questionId: string;
30
- question?: FeedItem;
30
+ question?: FeedItem & {
31
+ openedFrom?: 'list' | 'notification';
32
+ };
31
33
  } | undefined>;
32
34
  /** opened question statistics */
33
35
  openedQuestion: ReturnType<typeof detail>;
@@ -44,6 +46,7 @@ export declare class GamificationBackground {
44
46
  questionSubscription?: ReturnType<typeof queries.questionSubscription>;
45
47
  private notifications;
46
48
  private log;
49
+ private transport;
47
50
  constructor(instance: StreamLayerContext);
48
51
  /**
49
52
  * Get id for notifications and link with current session
@@ -64,7 +67,9 @@ export declare class GamificationBackground {
64
67
  /**
65
68
  * Open question and mark notification for this question as viewed
66
69
  */
67
- openQuestion: (questionId: string, question?: FeedItem) => void;
70
+ openQuestion: (questionId: string, question?: FeedItem & {
71
+ openedFrom?: 'list' | 'notification';
72
+ }) => void;
68
73
  /**
69
74
  * Close question and mark notification for this question as viewed
70
75
  */
package/lib/background.js CHANGED
@@ -40,7 +40,9 @@ export class GamificationBackground {
40
40
  questionSubscription;
41
41
  notifications;
42
42
  log;
43
+ transport;
43
44
  constructor(instance) {
45
+ this.transport = instance.transport;
44
46
  this.log = createLogger('gamification-background');
45
47
  this.slStreamId = instance.stores.slStreamId.getAtomStore();
46
48
  this.organizationId = instance.stores.organizationSettings.getAtomStore();
@@ -58,9 +60,7 @@ export class GamificationBackground {
58
60
  if (item?.questionId) {
59
61
  this.questionSubscription = queries.questionSubscription(item.questionId, instance.transport);
60
62
  this.questionSubscription.addListener('feed-subscription-opened-question', (response) => {
61
- window.requestAnimationFrame(() => {
62
- this.openedQuestion.updateExtendedQuestion(response.data?.attributes?.question);
63
- });
63
+ this.openedQuestion.updateExtendedQuestion(response.data?.attributes?.question);
64
64
  });
65
65
  this.questionSubscription.connect();
66
66
  }
@@ -77,7 +77,6 @@ export class GamificationBackground {
77
77
  this.feedSubscription.addListener('feed-subscription-active-question', (response) => {
78
78
  const $activeQuestionId = this.activeQuestionId.getStore();
79
79
  if ($activeQuestionId) {
80
- this.activeQuestionId;
81
80
  $activeQuestionId.mutate(response.data?.attributes);
82
81
  }
83
82
  });
@@ -121,6 +120,11 @@ export class GamificationBackground {
121
120
  };
122
121
  disconnect = () => {
123
122
  this.feedSubscription?.disconnect();
123
+ if (this.questionSubscription !== undefined) {
124
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
125
+ this.transport.removeSubscription(this.questionSubscription);
126
+ this.questionSubscription = undefined;
127
+ }
124
128
  };
125
129
  /**
126
130
  * Open question and mark notification for this question as viewed
@@ -0,0 +1,4 @@
1
+ export declare enum ERROR {
2
+ UNKNOWN = "unknown",
3
+ ALREADY_VOTED = "already_voted"
4
+ }
@@ -0,0 +1,5 @@
1
+ export var ERROR;
2
+ (function (ERROR) {
3
+ ERROR["UNKNOWN"] = "unknown";
4
+ ERROR["ALREADY_VOTED"] = "already_voted";
5
+ })(ERROR || (ERROR = {}));
package/lib/detail.d.ts CHANGED
@@ -2,17 +2,15 @@ import type { Transport } from '@streamlayer/sdk-web-api';
2
2
  import { FeedItem, ExtendedQuestion } from '@streamlayer/sdk-web-types';
3
3
  import { ReadableAtom } from 'nanostores';
4
4
  import { type GamificationBackground } from './background';
5
- type ExtendedQuestionStore = {
6
- data?: ExtendedQuestion;
7
- loading?: boolean;
8
- error?: string;
9
- };
10
5
  export declare const detail: (transport: Transport, $openedQuestionId: ReadableAtom<{
11
6
  questionId: string;
12
- question?: FeedItem;
7
+ question?: FeedItem & {
8
+ openedFrom?: 'list' | 'notification';
9
+ };
13
10
  } | undefined>, $feedList: ReturnType<GamificationBackground['feedList']['getStore']>) => {
14
- $store: import("nanostores").WritableAtom<import("@bufbuild/protobuf").PlainMessage<import("@streamlayer/sl-eslib/interactive/feed/interactive.feed_pb").FeedItem> | undefined>;
15
- $extendedStore: import("nanostores").MapStore<ExtendedQuestionStore>;
11
+ $store: ReadableAtom<import("@streamlayer/sl-eslib/interactive/feed/interactive.feed_pb").FeedItem | (import("@bufbuild/protobuf").PlainMessage<import("@streamlayer/sl-eslib/interactive/feed/interactive.feed_pb").FeedItem> & {
12
+ openedFrom?: "list" | "notification" | undefined;
13
+ }) | undefined>;
14
+ $extendedStore: import("@nanostores/query").FetcherStore<import("@bufbuild/protobuf").PlainMessage<import("@streamlayer/sl-eslib/interactive/interactive.common_pb").ExtendedQuestion>, any>;
16
15
  updateExtendedQuestion: (question: ExtendedQuestion | undefined) => void;
17
16
  };
18
- export {};
package/lib/detail.js CHANGED
@@ -1,96 +1,45 @@
1
- import { createMapStore, createSingleStore } from '@streamlayer/sdk-web-interfaces';
2
- import { deepmerge } from '@fastify/deepmerge';
3
- import { onMount } from 'nanostores';
4
- import { getQuestionByUser } from './queries';
5
- const mergeArray = (options) => (target, source) => {
6
- let i = 0;
7
- const tl = target.length;
8
- const sl = source.length;
9
- const il = Math.max(tl, sl);
10
- const result = new Array(il);
11
- for (i = 0; i < il; ++i) {
12
- if (i < sl) {
13
- result[i] = options.deepmerge(target[i], source[i]);
14
- }
15
- else {
16
- result[i] = options.clone(target[i]);
17
- }
18
- }
19
- return result;
20
- };
21
- const mergeQuestion = deepmerge({ mergeArray });
1
+ import { batched } from 'nanostores';
2
+ import { $questionByUser } from './queries';
22
3
  export const detail = (transport, $openedQuestionId, $feedList) => {
23
- const $store = createSingleStore(undefined);
24
- onMount($store, () => {
25
- const cancel1 = $openedQuestionId.subscribe((openedQuestion) => {
26
- if (openedQuestion) {
27
- if (openedQuestion.question) {
28
- $store.set(openedQuestion.question);
29
- return;
30
- }
31
- const question = $feedList.get().data?.find((item) => item.id === openedQuestion.questionId);
32
- if (question) {
33
- $store.set(question);
34
- }
35
- else {
36
- console.error('Feed list is not loaded yet. Issue with the opened question.');
37
- }
38
- }
39
- else {
40
- $store.set(undefined);
41
- }
42
- });
43
- const cancel2 = $feedList.subscribe((feedList) => {
44
- const openedQuestion = $openedQuestionId.get();
45
- if (feedList.data && openedQuestion) {
46
- const question = $feedList.get().data?.find((item) => item.id === openedQuestion.questionId);
47
- if (question) {
48
- $store.set(question);
49
- }
50
- else {
51
- console.error('Feed list is not loaded yet. Issue with the opened question.');
52
- }
53
- }
54
- });
55
- return () => {
56
- cancel1();
57
- cancel2();
58
- };
59
- });
60
- const $extendedStore = createMapStore({
61
- data: undefined,
62
- loading: undefined,
63
- error: undefined,
64
- });
65
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
66
- $store.subscribe(async (item) => {
67
- if (item) {
68
- if (item.type === 'question') {
69
- $extendedStore.setKey('loading', true);
70
- const question = await getQuestionByUser(item.id, transport);
71
- $extendedStore.set({ data: question, loading: false });
72
- return;
73
- }
4
+ const $store = batched([$openedQuestionId, $feedList], () => {
5
+ const openedQuestion = $openedQuestionId.get();
6
+ if (!openedQuestion) {
7
+ return undefined;
8
+ }
9
+ const question = $feedList.get().data?.find((item) => item.id === openedQuestion?.questionId);
10
+ const openedFrom = openedQuestion?.question?.openedFrom;
11
+ if (question) {
12
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
13
+ // @ts-ignore
14
+ question.openedFrom = openedFrom;
15
+ return question;
74
16
  }
75
- $extendedStore.set({ data: undefined, loading: false });
17
+ return openedQuestion.question;
76
18
  });
19
+ const $storeQuestionId = batched($store, (item) => (item && item.type === 'question' ? item.id : undefined));
20
+ const $extendedStore = $questionByUser($storeQuestionId, transport);
77
21
  const updateExtendedQuestion = (question) => {
78
22
  const currentQuestion = $extendedStore.get().data;
79
- if (currentQuestion && question?.answers) {
80
- /**
81
- * We do not merge youVoted property, because it
82
- * can be overwritten by the subscription response,
83
- * which does not include user-specific data.
84
- */
85
- for (const answer of question.answers) {
86
- if (answer.youVoted !== true) {
87
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
88
- // @ts-ignore
89
- delete answer.youVoted;
90
- }
23
+ const mergeQuestionAnswers = (currentAnswers, newAnswers) => {
24
+ if (!currentAnswers || !newAnswers) {
25
+ return currentAnswers || newAnswers || [];
91
26
  }
92
- }
93
- $extendedStore.set({ data: mergeQuestion(currentQuestion, question) });
27
+ const answers = [];
28
+ for (let i = 0; i < currentAnswers.length; i++) {
29
+ answers.push({
30
+ ...currentAnswers[i],
31
+ ...newAnswers[i],
32
+ correct: currentAnswers[i].correct,
33
+ youVoted: currentAnswers[i].youVoted,
34
+ pointsEarned: currentAnswers[i].pointsEarned,
35
+ });
36
+ }
37
+ return answers;
38
+ };
39
+ $extendedStore.mutate({
40
+ ...question,
41
+ answers: mergeQuestionAnswers(currentQuestion?.answers, question?.answers),
42
+ });
94
43
  };
95
44
  return { $store, $extendedStore, updateExtendedQuestion };
96
45
  };
@@ -44,20 +44,25 @@ export declare class Gamification extends AbstractFeature<'games', PlainMessage<
44
44
  openedUser: WritableAtom<LeaderboardItem | undefined>;
45
45
  closeFeature: () => void;
46
46
  openFeature: () => void;
47
+ feedSubscription: GamificationBackground['feedSubscription'];
48
+ activeQuestionId: GamificationBackground['activeQuestionId'];
49
+ openedQuestionId: GamificationBackground['openedQuestionId'];
47
50
  private notifications;
48
51
  private transport;
49
52
  /** gamification background class, handle subscriptions and notifications for closed overlay */
50
53
  private background;
51
54
  /** Browser cache */
52
55
  private storage;
56
+ private submitAnswerTimeout;
53
57
  constructor(config: FeatureProps, source: FeatureSource, instance: StreamLayerContext);
54
58
  get isInteractiveAllowed(): boolean;
55
59
  checkInteractiveFlag: () => void;
56
- connect: (transport: StreamLayerContext['transport']) => void;
60
+ connect: () => void;
57
61
  disconnect: () => void;
58
62
  submitAnswer: (questionId: string, answerId: string) => Promise<void>;
59
- skipQuestion: (questionId: string) => Promise<void>;
60
- openQuestion: (questionId: string, question?: FeedItem) => void;
63
+ openQuestion: (questionId: string, question?: FeedItem & {
64
+ openedFrom?: 'list' | 'notification';
65
+ }) => void;
61
66
  closeQuestion: (questionId?: string) => void;
62
67
  openUser: (userId: string) => void;
63
68
  closeUser: () => void;
@@ -1,5 +1,6 @@
1
- import { AbstractFeature, ApiStore, FeatureStatus, SingleStore, createSingleStore, } from '@streamlayer/sdk-web-interfaces';
2
- import { QuestionStatus, QuestionType, FeatureType, SilenceSetting, } from '@streamlayer/sdk-web-types';
1
+ /* eslint-disable @typescript-eslint/ban-ts-comment */
2
+ import { AbstractFeature, ApiStore, SingleStore, createSingleStore, eventBus, } from '@streamlayer/sdk-web-interfaces';
3
+ import { QuestionStatus, QuestionType, FeatureType, SilenceSetting, PickHistoryStatus, } from '@streamlayer/sdk-web-types';
3
4
  import { NotificationType } from '@streamlayer/sdk-web-notifications';
4
5
  import '@streamlayer/sdk-web-core/store';
5
6
  import * as queries from './queries';
@@ -9,6 +10,8 @@ import { leaderboard } from './leaderboard';
9
10
  import { deepLink } from './deepLink';
10
11
  import { OnboardingStatus, onboarding } from './onboarding';
11
12
  import { GamificationBackground, InteractiveAllowed } from './background';
13
+ import { ERROR } from './constants';
14
+ import { $questionByUser } from './queries';
12
15
  const InteractiveQuestionTypes = new Set([QuestionType.POLL, QuestionType.PREDICTION, QuestionType.TRIVIA]);
13
16
  /**
14
17
  * Gamification (Games) Overlay
@@ -41,15 +44,22 @@ export class Gamification extends AbstractFeature {
41
44
  openedUser;
42
45
  closeFeature;
43
46
  openFeature;
47
+ feedSubscription;
48
+ activeQuestionId;
49
+ openedQuestionId;
44
50
  notifications;
45
51
  transport;
46
52
  /** gamification background class, handle subscriptions and notifications for closed overlay */
47
53
  background;
48
54
  /** Browser cache */
49
55
  storage;
56
+ submitAnswerTimeout;
50
57
  constructor(config, source, instance) {
51
58
  super(config, source);
52
59
  this.background = new GamificationBackground(instance);
60
+ this.feedSubscription = this.background.feedSubscription;
61
+ this.activeQuestionId = this.background.activeQuestionId;
62
+ this.openedQuestionId = this.background.openedQuestionId;
53
63
  this.storage = new GamificationStorage();
54
64
  this.userSummary = new ApiStore(queries.$userSummary(this.background.slStreamId, this.background.userId, instance.transport), 'gamification:userSummary');
55
65
  this.feedList = this.background.feedList;
@@ -65,20 +75,11 @@ export class Gamification extends AbstractFeature {
65
75
  this.openedQuestion = this.background.openedQuestion;
66
76
  this.deepLink = deepLink(this.transport, this.background.slStreamId, instance.stores.providerStreamId.getStore(), this.background.userId);
67
77
  this.leaderboardList = leaderboard(this.transport, this.background.slStreamId, this.background.userId, this.friends);
68
- this.status.subscribe((status) => {
69
- if (status === FeatureStatus.Ready) {
70
- this.connect(instance.transport);
71
- }
72
- else {
73
- this.disconnect();
74
- }
75
- });
78
+ this.connect();
76
79
  // refresh leaderboard on user summary update after earning points
77
80
  this.userSummary.listen((userSummary) => {
78
81
  if (this.leaderboardList.$store.lc !== 0 && userSummary?.data?.summary) {
79
- window.requestAnimationFrame(() => {
80
- this.leaderboardList.invalidate();
81
- });
82
+ this.leaderboardList.invalidate(); // verified, it's necessary
82
83
  }
83
84
  });
84
85
  /**
@@ -103,41 +104,79 @@ export class Gamification extends AbstractFeature {
103
104
  const allowed = !onboardingEnabled || onboardingCompleted || optInEnabled !== true;
104
105
  this.background.interactiveAllowed.set(allowed ? InteractiveAllowed.ALLOWED : InteractiveAllowed.DISALLOWED);
105
106
  };
106
- connect = (transport) => {
107
- this.userSummary.invalidate();
108
- this.leaderboardList.invalidate();
109
- this.feedList.invalidate();
110
- this.friends.invalidate();
111
- this.background.feedSubscription.addListener('feed-subscription-prediction-close', (response) => {
107
+ connect = () => {
108
+ this.background.feedSubscription.addListener('feed-subscription-prediction-close', async (response) => {
112
109
  if (!this.isInteractiveAllowed) {
113
110
  return;
114
111
  }
115
- window.requestAnimationFrame(async () => {
116
- const question = response.data?.attributes?.question;
117
- const feedItem = response.data?.attributes?.feedItem;
118
- if (!question) {
119
- return;
112
+ const question = response.data?.attributes?.question;
113
+ const feedItem = response.data?.attributes?.feedItem;
114
+ if (!question || !feedItem?.attributes) {
115
+ return;
116
+ }
117
+ const { status, type, id, answers } = question;
118
+ if (status === QuestionStatus.RESOLVED && type === QuestionType.PREDICTION) {
119
+ const notificationId = this.background.getCurrentSessionId({
120
+ prefix: `notification-id:${id}`,
121
+ });
122
+ const feedList = [...(this.feedList.getValues().data || [])];
123
+ const questionFromFeedListIndex = feedList.findIndex((item) => item.id === id);
124
+ const questionFromFeedList = feedList[questionFromFeedListIndex];
125
+ // @ts-ignore
126
+ let votedAnswerId = questionFromFeedList?.attributes?.attributes?.value?.answerId;
127
+ // get voted answer id from extended question or feed list
128
+ const data = $questionByUser(id, this.transport);
129
+ // order of operations is important here
130
+ const cancel = data.subscribe(() => { });
131
+ await data.get().promise;
132
+ const extendedQuestion = data.get().data;
133
+ cancel();
134
+ window.requestAnimationFrame(() => {
135
+ data.invalidate();
136
+ });
137
+ // get extended question data and mark as dirty
138
+ if (!votedAnswerId) {
139
+ votedAnswerId = extendedQuestion?.answers.find(({ youVoted }) => youVoted)?.id;
120
140
  }
121
- const { status, type, id } = question;
122
- if (status === QuestionStatus.RESOLVED && type === QuestionType.PREDICTION) {
123
- const notificationId = this.background.getCurrentSessionId({
124
- prefix: `notification-id:${id}`,
125
- });
126
- const question = await queries.getQuestionByUser(id, transport);
127
- const correctAnswer = question?.answers.find(({ correct }) => correct);
128
- const votedAnswer = question?.answers.find(({ youVoted }) => youVoted);
129
- if (!votedAnswer || !question)
130
- return;
141
+ const correctAnswer = answers.find(({ correct }) => correct);
142
+ const votedAnswer = votedAnswerId ? answers.find(({ id }) => id === votedAnswerId) : undefined;
143
+ const votedCorrect = !!votedAnswer?.correct;
144
+ // update question in feed list if it's there
145
+ if (questionFromFeedList) {
146
+ if (feedList[questionFromFeedListIndex]?.attributes?.attributes.case === 'question') {
147
+ try {
148
+ // @ts-ignore
149
+ feedList[questionFromFeedListIndex].attributes.attributes.value.answerId = votedAnswerId;
150
+ // @ts-ignore
151
+ feedList[questionFromFeedListIndex].attributes.attributes.value.openForVoting = false;
152
+ if (votedAnswerId) {
153
+ // @ts-ignore
154
+ feedList[questionFromFeedListIndex].attributes.attributes.value.status = votedCorrect
155
+ ? PickHistoryStatus.WON
156
+ : PickHistoryStatus.LOST;
157
+ }
158
+ // eslint-disable-next-line no-empty
159
+ }
160
+ catch (e) { }
161
+ this.feedList.getStore().mutate(feedList);
162
+ }
163
+ }
164
+ if (!votedAnswer || !correctAnswer)
165
+ return;
166
+ // avoid showing notification if question already opened
167
+ if (this.openedQuestionId.get()?.questionId !== question.id) {
131
168
  this.notifications.add({
132
169
  type: NotificationType.QUESTION_RESOLVED,
133
170
  action: () => this.openQuestion(question.id, feedItem),
134
171
  close: () => this.closeQuestion(id),
135
- autoHideDuration: correctAnswer?.youVoted ? 15000 : 12000,
172
+ autoHideDuration: votedCorrect ? 15000 : 12000,
136
173
  id: notificationId,
174
+ emitEvent: false,
137
175
  data: {
176
+ questionId: id,
138
177
  questionType: QuestionType.PREDICTION,
139
178
  question: {
140
- title: correctAnswer?.youVoted
179
+ title: votedCorrect
141
180
  ? `Congratulations! You answered correctly! You won ${correctAnswer.points} pts!`
142
181
  : `Better luck next time! Correct: ${correctAnswer?.text}!`,
143
182
  votedAnswer: {
@@ -145,41 +184,163 @@ export class Gamification extends AbstractFeature {
145
184
  points: votedAnswer?.points,
146
185
  },
147
186
  correctAnswerTitle: correctAnswer?.text,
148
- correct: correctAnswer?.youVoted,
187
+ correct: !!votedCorrect,
149
188
  predictionResult: status === QuestionStatus.RESOLVED,
150
189
  questionTitle: question?.subject,
151
190
  },
152
191
  },
153
192
  });
154
- this.userSummary.invalidate();
155
193
  }
156
- });
194
+ this.userSummary.invalidate(); // verified, it's necessary
195
+ }
157
196
  });
158
- this.background.feedSubscription.addListener('feed-subscription-questions-list', () => {
159
- window.requestAnimationFrame(() => {
160
- this.feedList?.invalidate();
161
- });
197
+ // update feed list on question update received from subscription
198
+ // add new question to the top of the list
199
+ this.background.feedSubscription.addListener('feed-subscription-questions-list', (response) => {
200
+ const feedList = [...(this.feedList.getStore().value?.data || [])];
201
+ const feedItem = response.data?.attributes?.feedItem;
202
+ const questionIndex = feedList.findIndex((item) => item.id === feedItem?.id);
203
+ if (!feedItem) {
204
+ return;
205
+ }
206
+ if (questionIndex !== -1) {
207
+ if (feedItem.attributes?.attributes.case === 'question' &&
208
+ feedList[questionIndex].attributes?.attributes.case === 'question') {
209
+ const prev = feedList[questionIndex];
210
+ if (prev.attributes) {
211
+ feedList[questionIndex] = {
212
+ ...feedList[questionIndex],
213
+ attributes: {
214
+ ...prev.attributes,
215
+ attributes: {
216
+ ...prev.attributes.attributes,
217
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
218
+ // @ts-ignore
219
+ value: {
220
+ ...prev.attributes.attributes.value,
221
+ ...feedItem.attributes.attributes.value,
222
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
223
+ // @ts-ignore
224
+ answerId: prev.attributes.attributes.value.answerId,
225
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
226
+ // @ts-ignore
227
+ status: prev.attributes.attributes.value.status,
228
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
229
+ // @ts-ignore
230
+ openForVoting: prev.attributes.attributes.value.openForVoting,
231
+ },
232
+ },
233
+ },
234
+ };
235
+ }
236
+ }
237
+ else {
238
+ feedList[questionIndex] = feedItem;
239
+ }
240
+ }
241
+ if (questionIndex === -1) {
242
+ feedList.unshift(feedItem);
243
+ eventBus.emit('poll', {
244
+ action: 'received',
245
+ payload: {
246
+ questionId: feedItem.id,
247
+ questionType: feedItem.attributes?.type,
248
+ },
249
+ });
250
+ }
251
+ this.feedList.getStore().mutate(feedList);
162
252
  });
163
253
  };
254
+ // not used
164
255
  disconnect = () => {
256
+ this.background.feedSubscription.removeListener('feed-subscription-prediction-close');
165
257
  this.background.feedSubscription.removeListener('feed-subscription-questions-list');
166
258
  };
167
259
  submitAnswer = async (questionId, answerId) => {
168
- await actions.submitAnswer(this.transport, { questionId, answerId });
169
- // Todo: add invalidate openedQuestion
170
- this.feedList.invalidate();
171
- this.userSummary.invalidate();
172
- };
173
- skipQuestion = async (questionId) => {
174
- await actions.skipQuestion(this.transport, questionId);
175
- this.feedList.invalidate();
176
- this.userSummary.invalidate();
260
+ const data = $questionByUser(questionId, this.transport);
261
+ const updateQuestionAndFieldList = () => {
262
+ const feedList = this.feedList.getValues().data;
263
+ if (!feedList) {
264
+ return;
265
+ }
266
+ const questionIndex = feedList.findIndex((item) => item.id === questionId);
267
+ const poll = feedList[questionIndex];
268
+ const question = poll?.attributes?.attributes.case === 'question' && poll.attributes.attributes.value;
269
+ if (question) {
270
+ eventBus.emit('poll', {
271
+ action: 'voted',
272
+ payload: {
273
+ questionId,
274
+ questionType: question.questionType,
275
+ },
276
+ });
277
+ const cancel = data.subscribe(() => { });
278
+ const extendedQuestion = data.get().data;
279
+ cancel();
280
+ if (extendedQuestion) {
281
+ const correctAnswer = extendedQuestion.answers.find((answer) => answer.correct === true);
282
+ const votedAnswerIdx = extendedQuestion.answers.findIndex((answer) => answer.id === answerId);
283
+ const votedAnswer = extendedQuestion.answers[votedAnswerIdx];
284
+ // @ts-ignore
285
+ feedList[questionIndex].attributes.attributes.value.answerId = answerId;
286
+ // @ts-ignore
287
+ feedList[questionIndex].attributes.attributes.value.openForVoting = false;
288
+ // @ts-ignore
289
+ feedList[questionIndex].attributes.attributes.value.text = votedAnswer?.text || '';
290
+ if (correctAnswer) {
291
+ // @ts-ignore
292
+ feedList[questionIndex].attributes.attributes.value.status =
293
+ correctAnswer.id === answerId ? PickHistoryStatus.WON : PickHistoryStatus.LOST;
294
+ }
295
+ this.feedList.getStore().mutate([...feedList]);
296
+ extendedQuestion.answers[votedAnswerIdx].correct = correctAnswer?.id === answerId;
297
+ extendedQuestion.answers[votedAnswerIdx].youVoted = true;
298
+ if (correctAnswer?.id === answerId) {
299
+ extendedQuestion.answers[votedAnswerIdx].pointsEarned =
300
+ extendedQuestion.status === QuestionStatus.RESOLVED ? 0 : correctAnswer.points;
301
+ }
302
+ data.mutate({ ...extendedQuestion });
303
+ }
304
+ if (this.submitAnswerTimeout) {
305
+ clearTimeout(this.submitAnswerTimeout);
306
+ }
307
+ this.submitAnswerTimeout = setTimeout(() => {
308
+ this.userSummary.invalidate(); // verified, it's necessary
309
+ }, 1000);
310
+ }
311
+ };
312
+ try {
313
+ await actions.submitAnswer(this.transport, { questionId, answerId });
314
+ updateQuestionAndFieldList();
315
+ }
316
+ catch (error) {
317
+ if (error.message === ERROR.ALREADY_VOTED) {
318
+ this.userSummary.invalidate();
319
+ const cancel = data.subscribe(() => { });
320
+ data.invalidate();
321
+ cancel();
322
+ }
323
+ throw error;
324
+ }
177
325
  };
178
326
  openQuestion = (questionId, question) => {
179
327
  this.notifications.close(this.background.getCurrentSessionId({
180
328
  prefix: 'notification',
181
329
  entity: questionId,
182
330
  }));
331
+ let questionType = question?.attributes?.type;
332
+ if (!questionType) {
333
+ const feedList = this.feedList.getStore().value?.data || [];
334
+ questionType = feedList.find((item) => item.id === questionId)?.attributes?.type;
335
+ }
336
+ eventBus.emit('poll', {
337
+ action: 'opened',
338
+ payload: {
339
+ questionId,
340
+ questionType,
341
+ questionOpenedFrom: question?.openedFrom,
342
+ },
343
+ });
183
344
  return this.background.openQuestion(questionId, question);
184
345
  };
185
346
  closeQuestion = (questionId) => {
@@ -217,7 +378,9 @@ export class Gamification extends AbstractFeature {
217
378
  prefix: 'notification',
218
379
  entity: question.data.question.id,
219
380
  }),
381
+ emitEvent: true,
220
382
  data: {
383
+ questionId: question.data.question.id,
221
384
  questionType: question.data.question.type,
222
385
  question: {
223
386
  title: question.data.question.notification.title,
@@ -248,8 +411,10 @@ export class Gamification extends AbstractFeature {
248
411
  action: () => question.data?.question && this.openQuestion(question.data.question.id, question.data.feedItem),
249
412
  close: () => question.data?.question && this.closeQuestion(question.data.question.id),
250
413
  autoHideDuration: 1000 * 120,
414
+ emitEvent: true,
251
415
  id: this.background.getCurrentSessionId({ prefix: 'notification', entity: question.data.question.id }),
252
416
  data: {
417
+ questionId: question.data.question.id,
253
418
  questionType: question.data.question.type,
254
419
  insight: instantView,
255
420
  },
@@ -271,8 +436,10 @@ export class Gamification extends AbstractFeature {
271
436
  action: () => question.data?.question && this.openQuestion(question.data.question.id, question.data.feedItem),
272
437
  close: () => question.data?.question && this.closeQuestion(question.data.question.id),
273
438
  autoHideDuration: 1000 * 120,
439
+ emitEvent: true,
274
440
  id: this.background.getCurrentSessionId({ prefix: 'notification', entity: question.data.question.id }),
275
441
  data: {
442
+ questionId: question.data.question.id,
276
443
  questionType: question.data.question.type,
277
444
  tweet: tweetView,
278
445
  },
package/lib/onboarding.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createSingleStore } from '@streamlayer/sdk-web-interfaces';
1
+ import { createSingleStore, eventBus } from '@streamlayer/sdk-web-interfaces';
2
2
  import { NotificationType } from '@streamlayer/sdk-web-notifications';
3
3
  import { QuestionType } from '@streamlayer/sdk-web-types';
4
4
  import { GamificationStorage } from './storage';
@@ -31,6 +31,7 @@ const showOnboardingInApp = (service, background, notifications, storage) => {
31
31
  persistent: true,
32
32
  autoHideDuration: 1000000,
33
33
  data: {
34
+ questionId: 'onboarding',
34
35
  questionType: QuestionType.UNSET,
35
36
  onboarding: {
36
37
  ...inplayGame,
@@ -112,7 +113,7 @@ export const onboarding = (service, background, transport, notifications) => {
112
113
  }
113
114
  }
114
115
  if (onboardingStatus === OnboardingStatus.Completed) {
115
- background.activeQuestionId.invalidate();
116
+ background.activeQuestionId.invalidate(); // verified, it's necessary
116
117
  }
117
118
  storage.setOnboardingInstantOpen({
118
119
  userId: background.userId.get() || '',
@@ -149,6 +150,10 @@ export const onboarding = (service, background, transport, notifications) => {
149
150
  userId: background.userId.get() || '',
150
151
  eventId,
151
152
  }, OnboardingStatus.Completed);
153
+ eventBus.emit('poll', {
154
+ action: 'onboardingPassed',
155
+ payload: {},
156
+ });
152
157
  const notificationId = background.getCurrentSessionId({ prefix: 'onboarding' });
153
158
  notifications.close(notificationId);
154
159
  }
@@ -1,8 +1,19 @@
1
+ import { ConnectError, Code } from '@connectrpc/connect';
1
2
  import { Feed } from '@streamlayer/sl-eslib/interactive/feed/interactive.feed_connect';
2
- export const submitAnswer = (transport, data) => {
3
+ import { ERROR } from '../constants';
4
+ export const submitAnswer = async (transport, data) => {
3
5
  const { client, createRequestOptions } = transport.createPromiseClient(Feed, { method: 'submitAnswer' });
4
6
  const contextValues = createRequestOptions({ retryAttempts: 0 });
5
- return client.submitAnswer({ data }, { contextValues });
7
+ try {
8
+ return await client.submitAnswer({ data }, { contextValues });
9
+ }
10
+ catch (error) {
11
+ const cErr = ConnectError.from(error);
12
+ if (cErr?.code === Code.AlreadyExists) {
13
+ throw new Error(ERROR.ALREADY_VOTED);
14
+ }
15
+ throw new Error(ERROR.UNKNOWN);
16
+ }
6
17
  };
7
18
  export const submitInplay = (transport, eventId) => {
8
19
  const { client, createRequestOptions } = transport.createPromiseClient(Feed, { method: 'submitInplay' });
@@ -15,6 +15,8 @@ export const $deepLink = (transport, params) => {
15
15
  });
16
16
  return res.data?.attributes;
17
17
  },
18
+ dedupeTime: 1000 * 60 * 60 * 24, // 24 hours
19
+ refetchInterval: 0,
18
20
  });
19
21
  };
20
22
  export const generateShortLink = (transport, { web, mobile }) => {
@@ -329,7 +329,7 @@ export declare const questionSubscription: (questionId: string, transport: Trans
329
329
  }, QuestionSubscriptionRequest, QuestionSubscriptionResponse, "subscription" | "votingSubscription" | "questionSubscription" | "feedSubscription", ((request: import("@bufbuild/protobuf").PartialMessage<SubscriptionRequest>, options?: import("@connectrpc/connect").CallOptions | undefined) => AsyncIterable<SubscriptionResponse>) | ((request: import("@bufbuild/protobuf").PartialMessage<VotingSubscriptionRequest>, options?: import("@connectrpc/connect").CallOptions | undefined) => AsyncIterable<VotingSubscriptionResponse>) | ((request: import("@bufbuild/protobuf").PartialMessage<QuestionSubscriptionRequest>, options?: import("@connectrpc/connect").CallOptions | undefined) => AsyncIterable<QuestionSubscriptionResponse>) | ((request: import("@bufbuild/protobuf").PartialMessage<import("@streamlayer/sl-eslib/interactive/feed/interactive.feed_pb").FeedSubscriptionRequest>, options?: import("@connectrpc/connect").CallOptions | undefined) => AsyncIterable<import("@streamlayer/sl-eslib/interactive/feed/interactive.feed_pb").FeedSubscriptionResponse>)>;
330
330
  export declare const getQuestionByUser: (questionId: string, transport: Transport) => Promise<import("@streamlayer/sl-eslib/interactive/interactive.common_pb").ExtendedQuestion | undefined>;
331
331
  export declare const getQuestionDetail: (questionId: string, transport: Transport) => Promise<import("@streamlayer/sl-eslib/interactive/interactive.common_pb").Question | undefined>;
332
- export declare const $questionByUser: ($questionId: ReadableAtom<string | undefined>, transport: Transport) => import("@nanostores/query").FetcherStore<import("@streamlayer/sl-eslib/interactive/interactive.common_pb").ExtendedQuestion | undefined, any>;
332
+ export declare const $questionByUser: ($questionId: ReadableAtom<string | undefined> | string, transport: Transport) => import("@nanostores/query").FetcherStore<import("@bufbuild/protobuf").PlainMessage<import("@streamlayer/sl-eslib/interactive/interactive.common_pb").ExtendedQuestion>, any>;
333
333
  export declare const $pickHistory: (slStreamId: ReadableAtom<string | undefined>, transport: Transport) => import("@nanostores/query").FetcherStore<(import("@streamlayer/sl-eslib/interactive/feed/interactive.feed_pb").PickHistory | undefined)[], any>;
334
334
  export declare const $feedList: ($slStreamId: ReadableAtom<string | undefined>, $interactiveAllowed: ReadableAtom<InteractiveAllowed>, transport: Transport) => import("@nanostores/query").FetcherStore<import("@streamlayer/sl-eslib/interactive/feed/interactive.feed_pb").FeedItem[], any>;
335
335
  export { $userSummary, $leaderboardList } from './leaderboard';
@@ -16,6 +16,8 @@ export const $activeQuestion = (slStreamId, transport) => {
16
16
  });
17
17
  return res.data?.attributes;
18
18
  },
19
+ dedupeTime: 1000 * 60 * 10, // 10 minutes
20
+ refetchInterval: 0,
19
21
  });
20
22
  };
21
23
  export const feedSubscription = ($slStreamId, transport) => {
@@ -60,6 +62,7 @@ export const $questionByUser = ($questionId, transport) => {
60
62
  });
61
63
  return res.data?.attributes?.question;
62
64
  },
65
+ dedupeTime: 1000 * 60 * 5,
63
66
  });
64
67
  };
65
68
  export const $pickHistory = (slStreamId, transport) => {
@@ -97,6 +100,8 @@ export const $feedList = ($slStreamId, $interactiveAllowed, transport) => {
97
100
  });
98
101
  return res.data;
99
102
  },
103
+ dedupeTime: 0,
104
+ refetchInterval: 0,
100
105
  });
101
106
  };
102
107
  export { $userSummary, $leaderboardList } from './leaderboard';
@@ -8,5 +8,7 @@ export const $moderation = (slStreamId, transport) => {
8
8
  });
9
9
  return res.data?.attributes;
10
10
  },
11
+ refetchInterval: 0,
12
+ dedupeTime: 1000 * 60 * 60,
11
13
  });
12
14
  };
package/package.json CHANGED
@@ -1,18 +1,18 @@
1
1
  {
2
2
  "name": "@streamlayer/feature-gamification",
3
- "version": "0.38.0",
3
+ "version": "0.39.0",
4
4
  "peerDependencies": {
5
5
  "@bufbuild/protobuf": "^1.7.2",
6
- "@fastify/deepmerge": "*",
7
- "@streamlayer/sl-eslib": "^5.79.3",
8
- "nanostores": "^0.9.5",
9
- "@streamlayer/sdk-web-api": "^0.23.0",
10
- "@streamlayer/sdk-web-core": "^0.21.3",
11
- "@streamlayer/sdk-web-interfaces": "^0.20.6",
12
- "@streamlayer/sdk-web-logger": "^0.5.17",
13
- "@streamlayer/sdk-web-notifications": "^0.14.2",
14
- "@streamlayer/sdk-web-storage": "^0.4.4",
15
- "@streamlayer/sdk-web-types": "^0.22.4"
6
+ "@fastify/deepmerge": "^1.3.0",
7
+ "@streamlayer/sl-eslib": "^5.83.1",
8
+ "nanostores": "^0.10.0",
9
+ "@streamlayer/sdk-web-api": "^0.24.0",
10
+ "@streamlayer/sdk-web-core": "^0.22.0",
11
+ "@streamlayer/sdk-web-interfaces": "^0.21.0",
12
+ "@streamlayer/sdk-web-logger": "^0.5.18",
13
+ "@streamlayer/sdk-web-notifications": "^0.15.0",
14
+ "@streamlayer/sdk-web-storage": "^0.4.5",
15
+ "@streamlayer/sdk-web-types": "^0.23.0"
16
16
  },
17
17
  "devDependencies": {
18
18
  "tslib": "^2.6.2"