@streamlayer/feature-gamification 0.39.1 → 0.40.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.
@@ -34,7 +34,7 @@ export declare class GamificationBackground {
34
34
  /** opened question statistics */
35
35
  openedQuestion: ReturnType<typeof detail>;
36
36
  /** last active question in feed */
37
- activeQuestionId: ApiStore<GetApiResponseType<typeof queries.$activeQuestion>>;
37
+ activeQuestionId: ReturnType<typeof queries.$activeQuestion>;
38
38
  feedList: ApiStore<GetApiResponseType<typeof queries.$feedList>>;
39
39
  /** moderation id */
40
40
  moderationId: ReadableAtom<string | undefined>;
@@ -47,6 +47,7 @@ export declare class GamificationBackground {
47
47
  private notifications;
48
48
  private log;
49
49
  private transport;
50
+ private cancels;
50
51
  constructor(instance: StreamLayerContext);
51
52
  /**
52
53
  * Get id for notifications and link with current session
package/lib/background.js CHANGED
@@ -42,6 +42,7 @@ export class GamificationBackground {
42
42
  notifications;
43
43
  log;
44
44
  transport;
45
+ cancels = new Set();
45
46
  constructor(instance) {
46
47
  this.transport = instance.transport;
47
48
  this.log = createLogger('gamification-background');
@@ -54,9 +55,9 @@ export class GamificationBackground {
54
55
  this.notifications = instance.notifications;
55
56
  this.moderation = new ApiStore(queries.$moderation(this.slStreamId, instance.transport), 'gamification:moderation');
56
57
  this.feedList = new ApiStore(queries.$feedList(this.slStreamId, this.interactiveAllowed, instance.transport), 'gamification:feedList');
57
- this.activeQuestionId = new ApiStore(queries.$activeQuestion(this.slStreamId, instance.transport), 'gamification:activeQuestionId');
58
+ this.activeQuestionId = queries.$activeQuestion(this.slStreamId, instance.transport);
58
59
  this.openedQuestion = detail(instance.transport, this.openedQuestionId, this.feedList.getStore());
59
- this.openedQuestionId.listen((item) => {
60
+ this.cancels.add(this.openedQuestionId.listen((item) => {
60
61
  this.log.debug({ item }, 'received question');
61
62
  if (item?.questionId) {
62
63
  this.questionSubscription = queries.questionSubscription(item.questionId, instance.transport);
@@ -73,13 +74,10 @@ export class GamificationBackground {
73
74
  this.questionSubscription = undefined;
74
75
  }
75
76
  }
76
- });
77
+ }));
77
78
  this.feedSubscription = queries.feedSubscription(this.slStreamId, instance.transport);
78
- this.feedSubscription.addListener('feed-subscription-active-question', (response) => {
79
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
80
- // @ts-ignore
81
- const $activeQuestionId = this.activeQuestionId.store;
82
- const activeQuestionId = $activeQuestionId.get().data?.question?.id;
79
+ this.cancels.add(this.feedSubscription.addListener('feed-subscription-active-question', (response) => {
80
+ const activeQuestionId = this.activeQuestionId.get().data?.question?.id;
83
81
  const question = response.data?.attributes?.question;
84
82
  if (!question) {
85
83
  return;
@@ -88,12 +86,10 @@ export class GamificationBackground {
88
86
  if (activeQuestionId && question.status === QuestionStatus.RESOLVED && question.id !== activeQuestionId) {
89
87
  return;
90
88
  }
91
- if ($activeQuestionId) {
92
- $activeQuestionId.mutate(response.data?.attributes);
93
- }
94
- });
89
+ this.activeQuestionId.mutate(response.data?.attributes);
90
+ }));
95
91
  // refresh moderation if question empty, it`s mean that moderation was changed
96
- this.feedSubscription.addListener('moderation update', (response) => {
92
+ this.cancels.add(this.feedSubscription.addListener('moderation update', (response) => {
97
93
  window.requestAnimationFrame(() => {
98
94
  if (response.data?.attributes?.question === undefined) {
99
95
  if (response.data?.attributes?.moderation) {
@@ -101,17 +97,26 @@ export class GamificationBackground {
101
97
  }
102
98
  }
103
99
  });
104
- });
100
+ }));
105
101
  this.feedSubscription.connect();
106
102
  /**
107
103
  * invalidate active question on interactiveAllowed change
108
104
  * close question if interactiveAllowed changed to disallowed
109
105
  * open question if interactiveAllowed changed to allowed
110
106
  */
111
- this.interactiveAllowed.listen(() => {
107
+ this.cancels.add(this.interactiveAllowed.listen(() => {
112
108
  window.requestAnimationFrame(() => {
113
109
  this.activeQuestionId.invalidate();
114
110
  });
111
+ }));
112
+ instance.sdk.onMount(() => {
113
+ return () => {
114
+ this.activeQuestionId.off();
115
+ for (const cancel of this.cancels) {
116
+ cancel();
117
+ this.cancels.delete(cancel);
118
+ }
119
+ };
115
120
  });
116
121
  }
117
122
  /**
@@ -0,0 +1,5 @@
1
+ import type { Transport } from '@streamlayer/sdk-web-api';
2
+ import { ReadableAtom } from 'nanostores';
3
+ import { LeaderboardItem } from '@streamlayer/sl-eslib/interactive/leaderboard/interactive.leaderboard_pb';
4
+ import { Gamification } from '.';
5
+ export declare const friendSummary: ($eventId: ReadableAtom<string | undefined>, $userId: ReadableAtom<string | undefined>, $friends: Gamification['friends'], friendId: string, transport: Transport) => Promise<LeaderboardItem | undefined>;
@@ -0,0 +1,18 @@
1
+ import { createUserSummaryFetch } from './queries/leaderboard';
2
+ export const friendSummary = async ($eventId, $userId, $friends, friendId, transport) => {
3
+ const fetch = createUserSummaryFetch(transport);
4
+ const eventId = $eventId.get();
5
+ const userId = $userId.get();
6
+ const usersIds = $friends
7
+ .getStore()
8
+ .get()
9
+ .data?.map((friend) => friend.slId) || [];
10
+ const request = {
11
+ eventId: eventId,
12
+ userId: friendId,
13
+ usersIds: [...usersIds, userId],
14
+ };
15
+ const res = await fetch(request);
16
+ const summary = res.data?.attributes?.summary;
17
+ return summary;
18
+ };
@@ -55,17 +55,18 @@ export declare class Gamification extends AbstractFeature<'games', PlainMessage<
55
55
  /** Browser cache */
56
56
  private storage;
57
57
  private submitAnswerTimeout;
58
+ private cancels;
58
59
  constructor(config: FeatureProps, source: FeatureSource, instance: StreamLayerContext);
59
60
  get isInteractiveAllowed(): boolean;
60
61
  checkInteractiveFlag: () => void;
61
62
  connect: () => void;
62
63
  disconnect: () => void;
63
64
  submitAnswer: (questionId: string, answerId: string) => Promise<void>;
64
- openQuestion: (questionId: string, question?: FeedItem & {
65
+ openQuestion: (questionId?: string, question?: FeedItem & {
65
66
  openedFrom?: 'list' | 'notification';
66
- }) => void;
67
+ }) => void | (() => void);
67
68
  closeQuestion: (questionId?: string) => void;
68
- openUser: (userId: string) => void;
69
+ openUser: (friendId: string) => Promise<void>;
69
70
  closeUser: () => void;
70
71
  /**
71
72
  * Show in-app notification for active question
@@ -13,6 +13,7 @@ import { GamificationBackground, InteractiveAllowed } from './background';
13
13
  import { ERROR } from './constants';
14
14
  import { $questionByUser } from './queries';
15
15
  import { summary } from './userSummary';
16
+ import { friendSummary } from './friendSummary';
16
17
  const InteractiveQuestionTypes = new Set([QuestionType.POLL, QuestionType.PREDICTION, QuestionType.TRIVIA]);
17
18
  /**
18
19
  * Gamification (Games) Overlay
@@ -55,6 +56,7 @@ export class Gamification extends AbstractFeature {
55
56
  /** Browser cache */
56
57
  storage;
57
58
  submitAnswerTimeout;
59
+ cancels = new Set();
58
60
  constructor(config, source, instance) {
59
61
  super(config, source);
60
62
  this.background = new GamificationBackground(instance);
@@ -78,21 +80,44 @@ export class Gamification extends AbstractFeature {
78
80
  this.leaderboardList = leaderboard(this.transport, this.background.slStreamId, this.background.userId, this.friends);
79
81
  this.connect();
80
82
  // refresh leaderboard on user summary update after earning points
81
- this.userSummary.$store.listen((userSummary) => {
82
- if (this.leaderboardList.$store.lc !== 0 && userSummary?.summary) {
83
+ this.cancels.add(this.userSummary.$store.listen((userSummary, prevValue) => {
84
+ if (prevValue?.summary && userSummary?.summary && !userSummary.fromLeaderboard) {
83
85
  this.leaderboardList.invalidate(); // verified, it's necessary
84
86
  }
85
- });
87
+ }));
88
+ this.cancels.add(this.leaderboardList.$store.subscribe((leaderboard) => {
89
+ const userSummary = { ...(this.userSummary.$store.get() || {}) };
90
+ const userId = userSummary?.summary?.userId;
91
+ if (leaderboard.data.length && userId) {
92
+ const userRank = leaderboard.data.find((item) => item.userId === userId)?.rank;
93
+ if (userRank !== undefined) {
94
+ if (userSummary?.summary) {
95
+ userSummary.fromLeaderboard = true;
96
+ userSummary.summary.friendsRank = userRank;
97
+ // @ts-ignore
98
+ this.userSummary.$store.set(userSummary);
99
+ }
100
+ }
101
+ }
102
+ }));
86
103
  /**
87
104
  * listen for active question and show in-app notification
88
105
  */
89
- this.background.activeQuestionId.listen(this.showInApp);
106
+ this.cancels.add(this.background.activeQuestionId.listen(this.showInApp));
90
107
  /**
91
108
  * listen for onboarding status, moderation onboarding changes and opt-in settings
92
109
  */
93
- this.onboardingStatus.$store.listen(this.checkInteractiveFlag);
94
- this.background.moderation.getStore().listen(this.checkInteractiveFlag);
95
- this.settings.subscribe(this.checkInteractiveFlag);
110
+ this.cancels.add(this.onboardingStatus.$store.listen(this.checkInteractiveFlag));
111
+ this.cancels.add(this.background.moderation.getStore().listen(this.checkInteractiveFlag));
112
+ this.cancels.add(this.settings.subscribe(this.checkInteractiveFlag));
113
+ instance.sdk.onMount(() => {
114
+ return () => {
115
+ for (const cancel of this.cancels) {
116
+ cancel();
117
+ this.cancels.delete(cancel);
118
+ }
119
+ };
120
+ });
96
121
  }
97
122
  get isInteractiveAllowed() {
98
123
  return this.background.interactiveAllowed.get() === InteractiveAllowed.ALLOWED;
@@ -106,7 +131,7 @@ export class Gamification extends AbstractFeature {
106
131
  this.background.interactiveAllowed.set(allowed ? InteractiveAllowed.ALLOWED : InteractiveAllowed.DISALLOWED);
107
132
  };
108
133
  connect = () => {
109
- this.background.feedSubscription.addListener('feed-subscription-prediction-close', async (response) => {
134
+ this.cancels.add(this.background.feedSubscription.addListener('feed-subscription-prediction-close', async (response) => {
110
135
  if (!this.isInteractiveAllowed) {
111
136
  return;
112
137
  }
@@ -194,18 +219,23 @@ export class Gamification extends AbstractFeature {
194
219
  }
195
220
  this.userSummary.invalidate(); // verified, it's necessary
196
221
  }
197
- });
222
+ }));
198
223
  // update feed list on question update received from subscription
199
224
  // add new question to the top of the list
200
- this.background.feedSubscription.addListener('feed-subscription-questions-list', (response) => {
225
+ this.cancels.add(this.background.feedSubscription.addListener('feed-subscription-questions-list', (response) => {
201
226
  const feedList = [...(this.feedList.getStore().value?.data || [])];
202
227
  const feedItem = response.data?.attributes?.feedItem;
203
228
  const questionIndex = feedList.findIndex((item) => item.id === feedItem?.id);
204
- if (!feedItem) {
229
+ if (!feedItem?.attributes) {
230
+ return;
231
+ }
232
+ // skip questions with status other than active or resolved
233
+ if (feedItem.attributes.status !== QuestionStatus.ACTIVE &&
234
+ feedItem.attributes.status !== QuestionStatus.RESOLVED) {
205
235
  return;
206
236
  }
207
237
  if (questionIndex !== -1) {
208
- if (feedItem.attributes?.attributes.case === 'question' &&
238
+ if (feedItem.attributes.attributes.case === 'question' &&
209
239
  feedList[questionIndex].attributes?.attributes.case === 'question') {
210
240
  const prev = feedList[questionIndex];
211
241
  if (prev.attributes) {
@@ -267,7 +297,7 @@ export class Gamification extends AbstractFeature {
267
297
  });
268
298
  }
269
299
  this.feedList.getStore().mutate(feedList);
270
- });
300
+ }));
271
301
  };
272
302
  // not used
273
303
  disconnect = () => {
@@ -345,6 +375,9 @@ export class Gamification extends AbstractFeature {
345
375
  }
346
376
  };
347
377
  openQuestion = (questionId, question) => {
378
+ if (!questionId) {
379
+ return () => { };
380
+ }
348
381
  this.notifications.close(this.background.getCurrentSessionId({
349
382
  prefix: 'notification',
350
383
  entity: questionId,
@@ -367,9 +400,34 @@ export class Gamification extends AbstractFeature {
367
400
  closeQuestion = (questionId) => {
368
401
  return this.background.closeQuestion(questionId);
369
402
  };
370
- openUser = (userId) => {
371
- const user = this.leaderboardList.$store.get().data?.find((item) => item.userId === userId);
372
- this.openedUser.set(user);
403
+ openUser = async (friendId) => {
404
+ const user = this.leaderboardList.$store.get().data?.find((item) => item.userId === friendId);
405
+ if (!user) {
406
+ this.openedUser.set(user);
407
+ return;
408
+ }
409
+ if (user.summaryLoaded) {
410
+ this.openedUser.set(user);
411
+ return;
412
+ }
413
+ const userCopy = { ...user };
414
+ try {
415
+ const friendDetail = await friendSummary(this.background.slStreamId, this.background.userId, this.friends, friendId, this.transport);
416
+ if (friendDetail?.inTop !== undefined) {
417
+ this.leaderboardList.$store.setKey('data', this.leaderboardList.$store.get().data?.map((item) => {
418
+ if (item.userId === friendId) {
419
+ item.inTop = friendDetail.inTop;
420
+ }
421
+ return item;
422
+ }));
423
+ userCopy.inTop = friendDetail.inTop;
424
+ }
425
+ }
426
+ catch (err) {
427
+ console.error(err);
428
+ }
429
+ // @ts-ignore
430
+ this.openedUser.set(userCopy);
373
431
  };
374
432
  closeUser = () => {
375
433
  this.openedUser.set(undefined);
@@ -392,8 +450,8 @@ export class Gamification extends AbstractFeature {
392
450
  if (this.isInteractiveAllowed) {
393
451
  this.notifications.add({
394
452
  type: NotificationType.QUESTION,
395
- action: () => question.data?.question && this.openQuestion(question.data.question.id, question.data.feedItem),
396
- close: () => question.data?.question && this.closeQuestion(question.data.question.id),
453
+ action: () => this.openQuestion(question.data?.question?.id, question.data?.feedItem),
454
+ close: () => this.closeQuestion(question.data?.question?.id),
397
455
  autoHideDuration: 1000 * 60,
398
456
  id: this.background.getCurrentSessionId({
399
457
  prefix: 'notification',
@@ -429,8 +487,8 @@ export class Gamification extends AbstractFeature {
429
487
  };
430
488
  this.notifications.add({
431
489
  type: NotificationType.QUESTION,
432
- action: () => question.data?.question && this.openQuestion(question.data.question.id, question.data.feedItem),
433
- close: () => question.data?.question && this.closeQuestion(question.data.question.id),
490
+ action: () => this.openQuestion(question?.data?.question?.id, question?.data?.feedItem),
491
+ close: () => this.closeQuestion(question?.data?.question?.id),
434
492
  autoHideDuration: 1000 * 120,
435
493
  emitEvent: true,
436
494
  id: this.background.getCurrentSessionId({ prefix: 'notification', entity: question.data.question.id }),
@@ -454,8 +512,8 @@ export class Gamification extends AbstractFeature {
454
512
  };
455
513
  this.notifications.add({
456
514
  type: NotificationType.QUESTION,
457
- action: () => question.data?.question && this.openQuestion(question.data.question.id, question.data.feedItem),
458
- close: () => question.data?.question && this.closeQuestion(question.data.question.id),
515
+ action: () => this.openQuestion(question.data?.question?.id, question.data?.feedItem),
516
+ close: () => this.closeQuestion(question.data?.question?.id),
459
517
  autoHideDuration: 1000 * 120,
460
518
  emitEvent: true,
461
519
  id: this.background.getCurrentSessionId({ prefix: 'notification', entity: question.data.question.id }),
@@ -6,7 +6,9 @@ type LeaderboardOptions = {
6
6
  pageSize?: number;
7
7
  };
8
8
  type LeaderboardStore = {
9
- data: LeaderboardItem[];
9
+ data: Array<LeaderboardItem & {
10
+ summaryLoaded?: boolean;
11
+ }>;
10
12
  loading?: boolean;
11
13
  key: number;
12
14
  hasMore: boolean;
@@ -37,7 +37,10 @@ export const leaderboard = (transport, $eventId, $userId, $friends, options) =>
37
37
  };
38
38
  const newData = await fetch(request);
39
39
  $store.set({
40
- data: newData.data.map((item) => item.attributes),
40
+ data: newData.data.map((item, i) => ({
41
+ ...item.attributes,
42
+ rank: i + 1,
43
+ })),
41
44
  hasMore: false,
42
45
  key: Date.now(),
43
46
  loading: false,
package/lib/onboarding.js CHANGED
@@ -154,6 +154,7 @@ export const onboarding = (service, background, transport, notifications) => {
154
154
  action: 'onboardingPassed',
155
155
  payload: {},
156
156
  });
157
+ service.openFeature();
157
158
  const notificationId = background.getCurrentSessionId({ prefix: 'onboarding' });
158
159
  notifications.close(notificationId);
159
160
  }
@@ -3,6 +3,8 @@ import { ReadableAtom } from 'nanostores';
3
3
  import { LeaderboardSummaryItem } from '@streamlayer/sl-eslib/interactive/leaderboard/interactive.leaderboard_pb';
4
4
  import { Gamification } from '.';
5
5
  export declare const summary: ($eventId: ReadableAtom<string | undefined>, $userId: ReadableAtom<string | undefined>, $friends: Gamification['friends'], transport: Transport) => {
6
- $store: import("nanostores").MapStore<LeaderboardSummaryItem | undefined>;
6
+ $store: import("nanostores").MapStore<(LeaderboardSummaryItem & {
7
+ fromLeaderboard?: boolean | undefined;
8
+ }) | undefined>;
7
9
  invalidate: () => void;
8
10
  };
@@ -12,13 +12,20 @@ export const summary = ($eventId, $userId, $friends, transport) => {
12
12
  const usersIds = $friends
13
13
  .getStore()
14
14
  .get()
15
- .data?.map((friend) => friend.slId) || [];
15
+ .data?.map((friend) => friend.slId);
16
+ if (!usersIds) {
17
+ return;
18
+ }
16
19
  const request = {
17
20
  eventId: eventId,
18
21
  userId: userId,
19
22
  usersIds: [...usersIds, userId],
20
23
  };
21
24
  const res = await fetch(request);
25
+ const prevData = $store.get()?.summary?.friendsRank;
26
+ if (res.data?.attributes?.summary?.friendsRank && prevData !== undefined) {
27
+ res.data.attributes.summary.friendsRank = prevData;
28
+ }
22
29
  $store.set(res.data?.attributes);
23
30
  };
24
31
  const invalidate = () => {
package/package.json CHANGED
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "name": "@streamlayer/feature-gamification",
3
- "version": "0.39.1",
3
+ "version": "0.40.0",
4
4
  "peerDependencies": {
5
5
  "@bufbuild/protobuf": "^1.7.2",
6
6
  "@fastify/deepmerge": "^1.3.0",
7
7
  "@streamlayer/sl-eslib": "^5.83.1",
8
8
  "nanostores": "^0.10.0",
9
- "@streamlayer/sdk-web-api": "^0.24.1",
10
- "@streamlayer/sdk-web-core": "^0.22.1",
9
+ "@streamlayer/sdk-web-api": "^0.24.2",
10
+ "@streamlayer/sdk-web-core": "^0.22.2",
11
11
  "@streamlayer/sdk-web-interfaces": "^0.21.0",
12
12
  "@streamlayer/sdk-web-logger": "^0.5.18",
13
- "@streamlayer/sdk-web-notifications": "^0.15.0",
13
+ "@streamlayer/sdk-web-notifications": "^0.15.1",
14
14
  "@streamlayer/sdk-web-storage": "^0.4.5",
15
15
  "@streamlayer/sdk-web-types": "^0.23.0"
16
16
  },