@streamlayer/feature-gamification 0.37.4 → 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.
@@ -1,5 +1,6 @@
1
1
  import { ApiStore, type StreamLayerContext } from '@streamlayer/sdk-web-interfaces';
2
2
  import type { GetApiResponseType } from '@streamlayer/sdk-web-api';
3
+ import { FeedItem } from '@streamlayer/sdk-web-types';
3
4
  import '@streamlayer/sdk-web-core/store';
4
5
  import { ReadableAtom, WritableAtom } from 'nanostores';
5
6
  import * as queries from './queries';
@@ -24,7 +25,12 @@ export declare class GamificationBackground {
24
25
  */
25
26
  interactiveAllowed: WritableAtom<InteractiveAllowed>;
26
27
  /** opened question, using to download statistics */
27
- openedQuestionId: WritableAtom<string | undefined>;
28
+ openedQuestionId: WritableAtom<{
29
+ questionId: string;
30
+ question?: FeedItem & {
31
+ openedFrom?: 'list' | 'notification';
32
+ };
33
+ } | undefined>;
28
34
  /** opened question statistics */
29
35
  openedQuestion: ReturnType<typeof detail>;
30
36
  /** last active question in feed */
@@ -40,6 +46,7 @@ export declare class GamificationBackground {
40
46
  questionSubscription?: ReturnType<typeof queries.questionSubscription>;
41
47
  private notifications;
42
48
  private log;
49
+ private transport;
43
50
  constructor(instance: StreamLayerContext);
44
51
  /**
45
52
  * Get id for notifications and link with current session
@@ -60,7 +67,9 @@ export declare class GamificationBackground {
60
67
  /**
61
68
  * Open question and mark notification for this question as viewed
62
69
  */
63
- openQuestion: (questionId: string) => void;
70
+ openQuestion: (questionId: string, question?: FeedItem & {
71
+ openedFrom?: 'list' | 'notification';
72
+ }) => void;
64
73
  /**
65
74
  * Close question and mark notification for this question as viewed
66
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();
@@ -53,14 +55,12 @@ export class GamificationBackground {
53
55
  this.feedList = new ApiStore(queries.$feedList(this.slStreamId, this.interactiveAllowed, instance.transport), 'gamification:feedList');
54
56
  this.activeQuestionId = new ApiStore(queries.$activeQuestion(this.slStreamId, instance.transport), 'gamification:activeQuestionId');
55
57
  this.openedQuestion = detail(instance.transport, this.openedQuestionId, this.feedList.getStore());
56
- this.openedQuestionId.listen((questionId) => {
57
- this.log.debug({ questionId }, 'received question');
58
- if (questionId) {
59
- this.questionSubscription = queries.questionSubscription(questionId, instance.transport);
58
+ this.openedQuestionId.listen((item) => {
59
+ this.log.debug({ item }, 'received question');
60
+ if (item?.questionId) {
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
  }
@@ -120,12 +120,17 @@ export class GamificationBackground {
120
120
  };
121
121
  disconnect = () => {
122
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
+ }
123
128
  };
124
129
  /**
125
130
  * Open question and mark notification for this question as viewed
126
131
  */
127
- openQuestion = (questionId) => {
128
- this.openedQuestionId.set(questionId);
132
+ openQuestion = (questionId, question) => {
133
+ this.openedQuestionId.set({ questionId, question });
129
134
  this.notifications.markAsViewed(this.getCurrentSessionId({ prefix: 'notification', entity: questionId }));
130
135
  };
131
136
  /**
@@ -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
@@ -1,16 +1,16 @@
1
1
  import type { Transport } from '@streamlayer/sdk-web-api';
2
+ import { FeedItem, ExtendedQuestion } from '@streamlayer/sdk-web-types';
2
3
  import { ReadableAtom } from 'nanostores';
3
- import { getQuestionByUser } from './queries';
4
4
  import { type GamificationBackground } from './background';
5
- type ExtendedQuestion = Awaited<ReturnType<typeof getQuestionByUser>>;
6
- type ExtendedQuestionStore = {
7
- data?: ExtendedQuestion;
8
- loading?: boolean;
9
- error?: string;
5
+ export declare const detail: (transport: Transport, $openedQuestionId: ReadableAtom<{
6
+ questionId: string;
7
+ question?: FeedItem & {
8
+ openedFrom?: 'list' | 'notification';
9
+ };
10
+ } | undefined>, $feedList: ReturnType<GamificationBackground['feedList']['getStore']>) => {
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>;
15
+ updateExtendedQuestion: (question: ExtendedQuestion | undefined) => void;
10
16
  };
11
- export declare const detail: (transport: Transport, $openedQuestionId: ReadableAtom<string | undefined>, $feedList: ReturnType<GamificationBackground['feedList']['getStore']>) => {
12
- $store: ReadableAtom<import("@streamlayer/sl-eslib/interactive/feed/interactive.feed_pb").FeedItem | undefined>;
13
- $extendedStore: import("nanostores").MapStore<ExtendedQuestionStore>;
14
- updateExtendedQuestion: (question: ExtendedQuestion) => void;
15
- };
16
- export {};
package/lib/detail.js CHANGED
@@ -1,71 +1,45 @@
1
- import { createMapStore } from '@streamlayer/sdk-web-interfaces';
2
- import { deepmerge } from '@fastify/deepmerge';
3
- import { computed } 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 = computed([$openedQuestionId, $feedList], (openedQuestion, feedList) => {
24
- if (openedQuestion) {
25
- const question = feedList.data?.find((item) => item.id === openedQuestion);
26
- if (question) {
27
- return question;
28
- }
29
- else {
30
- console.error('Feed list is not loaded yet. Issue with the opened question.');
31
- }
4
+ const $store = batched([$openedQuestionId, $feedList], () => {
5
+ const openedQuestion = $openedQuestionId.get();
6
+ if (!openedQuestion) {
7
+ return undefined;
32
8
  }
33
- return undefined;
34
- });
35
- const $extendedStore = createMapStore({
36
- data: undefined,
37
- loading: undefined,
38
- error: undefined,
39
- });
40
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
41
- $store.subscribe(async (item) => {
42
- if (item) {
43
- if (item.type === 'question') {
44
- $extendedStore.setKey('loading', true);
45
- const question = await getQuestionByUser(item.id, transport);
46
- $extendedStore.set({ data: question, loading: false });
47
- return;
48
- }
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;
49
16
  }
50
- $extendedStore.set({ data: undefined, loading: false });
17
+ return openedQuestion.question;
51
18
  });
19
+ const $storeQuestionId = batched($store, (item) => (item && item.type === 'question' ? item.id : undefined));
20
+ const $extendedStore = $questionByUser($storeQuestionId, transport);
52
21
  const updateExtendedQuestion = (question) => {
53
22
  const currentQuestion = $extendedStore.get().data;
54
- if (currentQuestion && question?.answers) {
55
- /**
56
- * We do not merge youVoted property, because it
57
- * can be overwritten by the subscription response,
58
- * which does not include user-specific data.
59
- */
60
- for (const answer of question.answers) {
61
- if (answer.youVoted !== true) {
62
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
63
- // @ts-ignore
64
- delete answer.youVoted;
65
- }
23
+ const mergeQuestionAnswers = (currentAnswers, newAnswers) => {
24
+ if (!currentAnswers || !newAnswers) {
25
+ return currentAnswers || newAnswers || [];
66
26
  }
67
- }
68
- $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
+ });
69
43
  };
70
44
  return { $store, $extendedStore, updateExtendedQuestion };
71
45
  };
@@ -1,5 +1,5 @@
1
1
  import { AbstractFeature, ApiStore, FeatureSource, type FeatureProps, type StreamLayerContext } from '@streamlayer/sdk-web-interfaces';
2
- import { type GamesOverlaySettings } from '@streamlayer/sdk-web-types';
2
+ import { type GamesOverlaySettings, FeedItem } from '@streamlayer/sdk-web-types';
3
3
  import type { GetApiResponseType } from '@streamlayer/sdk-web-api';
4
4
  import '@streamlayer/sdk-web-core/store';
5
5
  import type { PlainMessage } from '@bufbuild/protobuf';
@@ -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) => 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,40 +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
- if (!question) {
118
- 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;
119
140
  }
120
- const { status, type, id } = question;
121
- if (status === QuestionStatus.RESOLVED && type === QuestionType.PREDICTION) {
122
- const notificationId = this.background.getCurrentSessionId({
123
- prefix: `notification-id:${id}`,
124
- });
125
- const question = await queries.getQuestionByUser(id, transport);
126
- const correctAnswer = question?.answers.find(({ correct }) => correct);
127
- const votedAnswer = question?.answers.find(({ youVoted }) => youVoted);
128
- if (!votedAnswer)
129
- 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) {
130
168
  this.notifications.add({
131
169
  type: NotificationType.QUESTION_RESOLVED,
132
- action: () => this.openQuestion(id),
170
+ action: () => this.openQuestion(question.id, feedItem),
133
171
  close: () => this.closeQuestion(id),
134
- autoHideDuration: correctAnswer?.youVoted ? 15000 : 12000,
172
+ autoHideDuration: votedCorrect ? 15000 : 12000,
135
173
  id: notificationId,
174
+ emitEvent: false,
136
175
  data: {
176
+ questionId: id,
137
177
  questionType: QuestionType.PREDICTION,
138
178
  question: {
139
- title: correctAnswer?.youVoted
179
+ title: votedCorrect
140
180
  ? `Congratulations! You answered correctly! You won ${correctAnswer.points} pts!`
141
181
  : `Better luck next time! Correct: ${correctAnswer?.text}!`,
142
182
  votedAnswer: {
@@ -144,43 +184,164 @@ export class Gamification extends AbstractFeature {
144
184
  points: votedAnswer?.points,
145
185
  },
146
186
  correctAnswerTitle: correctAnswer?.text,
147
- correct: correctAnswer?.youVoted,
187
+ correct: !!votedCorrect,
148
188
  predictionResult: status === QuestionStatus.RESOLVED,
149
189
  questionTitle: question?.subject,
150
190
  },
151
191
  },
152
192
  });
153
- this.userSummary.invalidate();
154
193
  }
155
- });
194
+ this.userSummary.invalidate(); // verified, it's necessary
195
+ }
156
196
  });
157
- this.background.feedSubscription.addListener('feed-subscription-questions-list', () => {
158
- window.requestAnimationFrame(() => {
159
- this.feedList?.invalidate();
160
- });
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);
161
252
  });
162
253
  };
254
+ // not used
163
255
  disconnect = () => {
256
+ this.background.feedSubscription.removeListener('feed-subscription-prediction-close');
164
257
  this.background.feedSubscription.removeListener('feed-subscription-questions-list');
165
258
  };
166
259
  submitAnswer = async (questionId, answerId) => {
167
- await actions.submitAnswer(this.transport, { questionId, answerId });
168
- // Todo: add invalidate openedQuestion
169
- this.feedList.invalidate();
170
- this.userSummary.invalidate();
171
- };
172
- skipQuestion = async (questionId) => {
173
- await actions.skipQuestion(this.transport, questionId);
174
- this.feedList.invalidate();
175
- 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
+ }
176
325
  };
177
- openQuestion = (questionId) => {
178
- this.openFeature();
326
+ openQuestion = (questionId, question) => {
179
327
  this.notifications.close(this.background.getCurrentSessionId({
180
328
  prefix: 'notification',
181
329
  entity: questionId,
182
330
  }));
183
- return this.background.openQuestion(questionId);
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
+ });
344
+ return this.background.openQuestion(questionId, question);
184
345
  };
185
346
  closeQuestion = (questionId) => {
186
347
  return this.background.closeQuestion(questionId);
@@ -204,22 +365,22 @@ export class Gamification extends AbstractFeature {
204
365
  if (question.data.question?.id !== undefined &&
205
366
  question.data.question.notification !== undefined &&
206
367
  question.data.question?.bypassNotifications?.inAppSilence !== SilenceSetting.ON &&
207
- question.data.question.status === QuestionStatus.ACTIVE) {
368
+ question.data.question.status === QuestionStatus.ACTIVE &&
369
+ !question.data.question.marketClosed) {
208
370
  if (InteractiveQuestionTypes.has(question.data.question.type)) {
209
371
  if (this.isInteractiveAllowed) {
210
372
  this.notifications.add({
211
373
  type: NotificationType.QUESTION,
212
- action: () => {
213
- question.data?.question && this.openQuestion(question.data.question.id);
214
- this.openFeature();
215
- },
374
+ action: () => question.data?.question && this.openQuestion(question.data.question.id, question.data.feedItem),
216
375
  close: () => question.data?.question && this.closeQuestion(question.data.question.id),
217
376
  autoHideDuration: 1000 * 60,
218
377
  id: this.background.getCurrentSessionId({
219
378
  prefix: 'notification',
220
379
  entity: question.data.question.id,
221
380
  }),
381
+ emitEvent: true,
222
382
  data: {
383
+ questionId: question.data.question.id,
223
384
  questionType: question.data.question.type,
224
385
  question: {
225
386
  title: question.data.question.notification.title,
@@ -233,7 +394,7 @@ export class Gamification extends AbstractFeature {
233
394
  const instantView = {
234
395
  heading: question.data.question.notification.title,
235
396
  body: question.data.question.notification.body,
236
- imageMode: question.data.question.appearance?.images,
397
+ imageMode: optionsValue.imageMode,
237
398
  image: optionsValue?.image,
238
399
  video: {
239
400
  id: optionsValue?.video?.id || '',
@@ -247,14 +408,13 @@ export class Gamification extends AbstractFeature {
247
408
  };
248
409
  this.notifications.add({
249
410
  type: NotificationType.QUESTION,
250
- action: () => {
251
- question.data?.question && this.openQuestion(question.data.question.id);
252
- this.openFeature();
253
- },
411
+ action: () => question.data?.question && this.openQuestion(question.data.question.id, question.data.feedItem),
254
412
  close: () => question.data?.question && this.closeQuestion(question.data.question.id),
255
413
  autoHideDuration: 1000 * 120,
414
+ emitEvent: true,
256
415
  id: this.background.getCurrentSessionId({ prefix: 'notification', entity: question.data.question.id }),
257
416
  data: {
417
+ questionId: question.data.question.id,
258
418
  questionType: question.data.question.type,
259
419
  insight: instantView,
260
420
  },
@@ -269,17 +429,17 @@ export class Gamification extends AbstractFeature {
269
429
  account: optionsValue?.tweetMeta?.account || '',
270
430
  accountVerified: !!optionsValue?.tweetMeta?.accountVerified,
271
431
  tweet: optionsValue?.tweetMeta?.tweet,
432
+ tweetId: question.data.question.id,
272
433
  };
273
434
  this.notifications.add({
274
435
  type: NotificationType.QUESTION,
275
- action: () => {
276
- question.data?.question && this.openQuestion(question.data.question.id);
277
- this.openFeature();
278
- },
436
+ action: () => question.data?.question && this.openQuestion(question.data.question.id, question.data.feedItem),
279
437
  close: () => question.data?.question && this.closeQuestion(question.data.question.id),
280
438
  autoHideDuration: 1000 * 120,
439
+ emitEvent: true,
281
440
  id: this.background.getCurrentSessionId({ prefix: 'notification', entity: question.data.question.id }),
282
441
  data: {
442
+ questionId: question.data.question.id,
283
443
  questionType: question.data.question.type,
284
444
  tweet: tweetView,
285
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,13 +1,27 @@
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
- const { client } = transport.createPromiseClient(Feed, { method: 'submitAnswer' });
4
- return client.submitAnswer({ data });
3
+ import { ERROR } from '../constants';
4
+ export const submitAnswer = async (transport, data) => {
5
+ const { client, createRequestOptions } = transport.createPromiseClient(Feed, { method: 'submitAnswer' });
6
+ const contextValues = createRequestOptions({ retryAttempts: 0 });
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
+ }
5
17
  };
6
18
  export const submitInplay = (transport, eventId) => {
7
- const { client } = transport.createPromiseClient(Feed, { method: 'submitInplay' });
8
- return client.submitInplay({ data: { eventId: +eventId } });
19
+ const { client, createRequestOptions } = transport.createPromiseClient(Feed, { method: 'submitInplay' });
20
+ const contextValues = createRequestOptions({ retryAttempts: 0 });
21
+ return client.submitInplay({ data: { eventId: +eventId } }, { contextValues });
9
22
  };
10
23
  export const skipQuestion = (transport, questionId) => {
11
- const { client } = transport.createPromiseClient(Feed, { method: 'skipQuestion' });
12
- return client.skipQuestion({ data: { id: questionId } });
24
+ const { client, createRequestOptions } = transport.createPromiseClient(Feed, { method: 'skipQuestion' });
25
+ const contextValues = createRequestOptions({ retryAttempts: 0 });
26
+ return client.skipQuestion({ data: { id: questionId } }, { contextValues });
13
27
  };
@@ -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 }) => {
@@ -327,9 +327,9 @@ export declare const questionSubscription: (questionId: string, transport: Trans
327
327
  };
328
328
  };
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
- export declare const getQuestionByUser: (questionId: string, transport: Transport) => Promise<import("@streamlayer/sdk-web-types").ExtendedQuestion | undefined>;
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/sdk-web-types").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.37.4",
3
+ "version": "0.39.0",
4
4
  "peerDependencies": {
5
- "@bufbuild/protobuf": "^1.6.0",
6
- "@fastify/deepmerge": "*",
7
- "@streamlayer/sl-eslib": "^5.67.0",
8
- "nanostores": "^0.9.5",
9
- "@streamlayer/sdk-web-api": "^0.22.0",
10
- "@streamlayer/sdk-web-core": "^0.21.2",
11
- "@streamlayer/sdk-web-interfaces": "^0.20.5",
12
- "@streamlayer/sdk-web-logger": "^0.5.16",
13
- "@streamlayer/sdk-web-notifications": "^0.14.1",
14
- "@streamlayer/sdk-web-storage": "^0.4.3",
15
- "@streamlayer/sdk-web-types": "^0.22.3"
5
+ "@bufbuild/protobuf": "^1.7.2",
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"