@streamlayer/feature-gamification 1.6.3 → 1.7.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.
@@ -7,6 +7,8 @@ export type Advertisement = {
7
7
  loading?: boolean;
8
8
  hiding?: boolean;
9
9
  isViewed?: boolean;
10
+ isOpened?: boolean;
11
+ hasNotification?: boolean;
10
12
  close?: () => void;
11
13
  error?: string;
12
14
  ctx?: Record<string, unknown>;
@@ -24,8 +26,11 @@ export type Advertisement = {
24
26
  * - we subscribe to $feedSubscription, and show advertisement on activate
25
27
  */
26
28
  export declare const advertisement: ($slStreamId: GamificationBackground["slStreamId"], $feedSubscription: GamificationBackground["feedSubscription"], instance: StreamLayerContext) => {
29
+ connect: () => void;
27
30
  hide: (notificationId?: string) => void;
28
31
  show: (advertisementId: string, data?: Awaited<ReturnType<typeof getPromotionDetail>>) => void;
32
+ open: () => void;
33
+ markAsViewed: () => void;
29
34
  $store: import("nanostores").MapStore<Advertisement>;
30
35
  };
31
36
  export {};
@@ -18,11 +18,33 @@ import { parsePromotion } from './utils';
18
18
  * - we subscribe to $feedSubscription, and show advertisement on activate
19
19
  */
20
20
  export const advertisement = ($slStreamId, $feedSubscription, instance) => {
21
+ let connected = false;
21
22
  const transport = instance.transport;
22
23
  const logger = createLogger('advertisement');
23
24
  const storage = new AdvertisementStorage();
24
25
  const $store = createMapStore({});
25
26
  const $activeAdvertisement = $activePromotionId($slStreamId, transport);
27
+ const open = () => {
28
+ $store.setKey('hasNotification', false);
29
+ };
30
+ const markAsViewed = () => {
31
+ const payload = $store.get();
32
+ const id = payload.data?.question.id;
33
+ const type = payload.data?.promotion?.type;
34
+ const isOpened = $store.get()?.isOpened;
35
+ if (id && !isOpened) {
36
+ logger.debug({ id }, 'markAsViewed: %o');
37
+ storage.setShowed(id);
38
+ $store.setKey('isOpened', true);
39
+ eventBus.emit('advertisement', {
40
+ action: 'opened',
41
+ payload: {
42
+ id,
43
+ type,
44
+ },
45
+ });
46
+ }
47
+ };
26
48
  /**
27
49
  * Show advertisement by id
28
50
  */
@@ -34,6 +56,7 @@ export const advertisement = ($slStreamId, $feedSubscription, instance) => {
34
56
  loading: false,
35
57
  error: undefined,
36
58
  data: response,
59
+ hasNotification: response?.notification?.enabled === NotificationEnabled.NOTIFICATION_ENABLED,
37
60
  close: () => hide(response?.question.id),
38
61
  isViewed: response && !!storage.isViewed(response.question.id),
39
62
  }))
@@ -48,78 +71,73 @@ export const advertisement = ($slStreamId, $feedSubscription, instance) => {
48
71
  loading: false,
49
72
  error: undefined,
50
73
  data,
74
+ hasNotification: data?.notification?.enabled === NotificationEnabled.NOTIFICATION_ENABLED,
51
75
  close: () => hide(data.question.id),
52
76
  isViewed: !!storage.isViewed(data.question.id),
53
77
  });
54
78
  }
55
79
  };
56
- $activeAdvertisement.subscribe((active, prevActive) => {
57
- if (active.data) {
80
+ $store.subscribe((active, prevActive) => {
81
+ // skip update on open
82
+ if (active.data && !active.isOpened) {
58
83
  eventBus.emit('advertisement', {
59
84
  action: 'received',
60
85
  payload: {
61
- advertisementId: active.data.question.id,
62
- advertisementType: active.data?.promotion?.type,
86
+ id: active.data.question.id,
87
+ type: active.data?.promotion?.type,
88
+ hasNotification: !!active.hasNotification,
89
+ isViewed: !!storage.isViewed(active.data.question.id),
63
90
  },
91
+ skipAnalytics: active.data.question.id === prevActive?.data?.question.id,
64
92
  });
65
- if (!prevActive?.data || active.data.id !== prevActive.data.id) {
66
- show(active.data.question.id, active.data);
67
- }
68
93
  }
69
- });
70
- $store.subscribe((active, prevActive) => {
71
- if (active.data) {
72
- instance.sdk.onAdvertisementActivate({
73
- stage: 'activate',
74
- id: active.data.question.id,
75
- hasNotification: active.data.notification?.enabled === NotificationEnabled.NOTIFICATION_ENABLED,
76
- isViewed: !!storage.isViewed(active.data.question.id),
77
- });
94
+ if (!active?.data && prevActive?.data) {
78
95
  eventBus.emit('advertisement', {
79
- action: 'opened',
96
+ action: 'closed',
80
97
  payload: {
81
- advertisementId: active.data.question.id,
82
- advertisementType: active.data?.promotion?.type,
98
+ id: prevActive.data.question.id,
99
+ type: prevActive.data.promotion?.type,
100
+ hasNotification: !!prevActive.hasNotification,
101
+ isViewed: !!storage.isViewed(prevActive.data.question.id),
83
102
  },
84
103
  });
85
104
  }
86
- if (!active?.data && prevActive?.data) {
87
- instance.sdk.onAdvertisementActivate({
88
- stage: 'deactivate',
89
- id: prevActive.data.question.id,
90
- isViewed: !!storage.isViewed(prevActive.data.question.id),
91
- });
92
- }
93
105
  });
94
- const markAsViewed = (notificationId) => {
95
- logger.debug({ notificationId }, 'markAsViewed: %o');
96
- storage.setShowed(notificationId);
97
- };
98
106
  const hide = (notificationId) => {
99
107
  if (!notificationId || $store.get()?.data?.question.id === notificationId) {
100
108
  $store.set({});
101
109
  }
102
- if (notificationId) {
103
- markAsViewed(notificationId);
104
- }
105
110
  };
106
- $feedSubscription.addListener('promotion', (response) => {
107
- const promotion = parsePromotion(response);
108
- if (!promotion) {
109
- return;
110
- }
111
- if (promotion.question.status === QuestionStatus.RESOLVED) {
112
- hide(promotion.question.id);
113
- logger.debug({ promotion }, 'resolved: %o');
111
+ const connect = () => {
112
+ if (connected) {
114
113
  return;
115
114
  }
116
- if (promotion.question.status === QuestionStatus.ACTIVE) {
117
- logger.debug({ promotion }, 'active: %o');
118
- show(promotion.question.id, promotion);
115
+ connected = true;
116
+ $activeAdvertisement.subscribe((active, prevActive) => {
117
+ if (active.data) {
118
+ if (!prevActive?.data || active.data.id !== prevActive.data.id) {
119
+ show(active.data.question.id, active.data);
120
+ }
121
+ }
122
+ });
123
+ $feedSubscription.addListener('promotion', (response) => {
124
+ const promotion = parsePromotion(response);
125
+ if (!promotion) {
126
+ return;
127
+ }
128
+ if (promotion.question.status === QuestionStatus.RESOLVED) {
129
+ hide(promotion.question.id);
130
+ logger.debug({ promotion }, 'resolved: %o');
131
+ return;
132
+ }
133
+ if (promotion.question.status === QuestionStatus.ACTIVE) {
134
+ logger.debug({ promotion }, 'active: %o');
135
+ show(promotion.question.id, promotion);
136
+ return;
137
+ }
138
+ logger.debug({ promotion }, 'skip: %o');
119
139
  return;
120
- }
121
- logger.debug({ promotion }, 'skip: %o');
122
- return;
123
- });
124
- return { hide, show, $store };
140
+ });
141
+ };
142
+ return { connect, hide, show, open, markAsViewed, $store };
125
143
  };
package/lib/background.js CHANGED
@@ -90,7 +90,9 @@ export class GamificationBackground {
90
90
  }
91
91
  return;
92
92
  }
93
- this.activeQuestionId.mutate(response.data?.attributes);
93
+ window.requestAnimationFrame(() => {
94
+ this.activeQuestionId.mutate(response.data?.attributes);
95
+ });
94
96
  }));
95
97
  // refresh moderation if question empty, it`s mean that moderation was changed
96
98
  this.cancels.add(this.feedSubscription.addListener('moderation update', (response) => {
@@ -103,20 +105,26 @@ export class GamificationBackground {
103
105
  });
104
106
  }));
105
107
  this.cancels.add(this.activeQuestionId.subscribe((item, prevItem) => {
106
- if (item.data?.feedItem) {
107
- instance.sdk.onQuestionActivate({
108
+ if (item.data?.feedItem && item.data?.feedItem?.id !== prevItem?.data?.feedItem?.id) {
109
+ instance.onQuestionActivate({
108
110
  stage: 'activate',
109
111
  id: item.data.feedItem.id,
110
- isViewed: !!this.notifications.isViewed(item.data.feedItem.id),
112
+ isViewed: !!this.notifications.isViewed(this.getCurrentSessionId({
113
+ prefix: 'notification',
114
+ entity: item.data.feedItem.id,
115
+ })),
111
116
  hasNotification: true,
112
117
  type: item.data.feedItem.type,
113
118
  });
114
119
  }
115
120
  if (!item.data?.feedItem && prevItem?.data?.feedItem) {
116
- instance.sdk.onQuestionActivate({
121
+ instance.onQuestionActivate({
117
122
  stage: 'deactivate',
118
123
  id: prevItem.data.feedItem.id,
119
- isViewed: !!this.notifications.isViewed(prevItem.data.feedItem.id),
124
+ isViewed: !!this.notifications.isViewed(this.getCurrentSessionId({
125
+ prefix: 'notification',
126
+ entity: prevItem.data.feedItem.id,
127
+ })),
120
128
  hasNotification: true,
121
129
  type: prevItem.data.feedItem.type,
122
130
  });
@@ -43,12 +43,13 @@ export declare class Gamification extends AbstractFeature<'games', PlainMessage<
43
43
  currentUserId: GamificationBackground['userId'];
44
44
  /** pinned leaderboard id */
45
45
  openedUser: WritableAtom<LeaderboardItem | undefined>;
46
- closeFeature: () => void;
46
+ closeFeature: (destroy?: boolean) => void;
47
47
  openFeature: () => void;
48
48
  feedSubscription: GamificationBackground['feedSubscription'];
49
49
  activeQuestionId: GamificationBackground['activeQuestionId'];
50
50
  openedQuestionId: GamificationBackground['openedQuestionId'];
51
51
  advertisement: GamificationBackground['advertisement'];
52
+ onboardingProcessed: WritableAtom<boolean>;
52
53
  private notifications;
53
54
  private transport;
54
55
  /** gamification background class, handle subscriptions and notifications for closed overlay */
@@ -57,6 +58,7 @@ export declare class Gamification extends AbstractFeature<'games', PlainMessage<
57
58
  private storage;
58
59
  private submitAnswerTimeout;
59
60
  private cancels;
61
+ private onQuestionActivate;
60
62
  constructor(config: FeatureProps, source: FeatureSource, instance: StreamLayerContext);
61
63
  get isInteractiveAllowed(): boolean;
62
64
  checkInteractiveFlag: () => void;
@@ -3,7 +3,6 @@ import { AbstractFeature, ApiStore, SingleStore, createSingleStore, eventBus, }
3
3
  import { QuestionStatus, QuestionType, FeatureType, SilenceSetting, PickHistoryStatus, } from '@streamlayer/sdk-web-types';
4
4
  import { NotificationType } from '@streamlayer/sdk-web-notifications';
5
5
  import '@streamlayer/sdk-web-core/store';
6
- import { onMount } from 'nanostores';
7
6
  import * as queries from './queries';
8
7
  import * as actions from './queries/actions';
9
8
  import { GamificationStorage } from './storage';
@@ -12,7 +11,7 @@ import { deepLink } from './deepLink';
12
11
  import { OnboardingStatus, onboarding } from './onboarding';
13
12
  import { GamificationBackground, InteractiveAllowed } from './background';
14
13
  import { ERROR } from './constants';
15
- import { $questionByUser } from './queries';
14
+ import { $questionByUser, questionByUser } from './queries';
16
15
  import { summary } from './userSummary';
17
16
  import { friendSummary } from './friendSummary';
18
17
  const InteractiveQuestionTypes = new Set([QuestionType.POLL, QuestionType.PREDICTION, QuestionType.TRIVIA]);
@@ -51,6 +50,7 @@ export class Gamification extends AbstractFeature {
51
50
  activeQuestionId;
52
51
  openedQuestionId;
53
52
  advertisement;
53
+ onboardingProcessed;
54
54
  notifications;
55
55
  transport;
56
56
  /** gamification background class, handle subscriptions and notifications for closed overlay */
@@ -59,10 +59,12 @@ export class Gamification extends AbstractFeature {
59
59
  storage;
60
60
  submitAnswerTimeout;
61
61
  cancels = new Set();
62
+ onQuestionActivate;
62
63
  constructor(config, source, instance) {
63
64
  super(config, source);
64
65
  this.background = new GamificationBackground(instance);
65
66
  this.advertisement = this.background.advertisement;
67
+ this.onQuestionActivate = instance.onQuestionActivate;
66
68
  this.feedSubscription = this.background.feedSubscription;
67
69
  this.activeQuestionId = this.background.activeQuestionId;
68
70
  this.openedQuestionId = this.background.openedQuestionId;
@@ -71,11 +73,12 @@ export class Gamification extends AbstractFeature {
71
73
  this.friends = new ApiStore(queries.$friends(this.background.userId, instance.transport), 'gamification:friends');
72
74
  this.currentUserId = this.background.userId;
73
75
  this.openedUser = createSingleStore(undefined);
76
+ this.onboardingProcessed = createSingleStore(false);
74
77
  this.leaderboardId = new SingleStore(createSingleStore(this.settings.getValue('pinnedLeaderboardId')), 'pinnedLeaderboardId').getStore();
75
78
  this.onboardingStatus = onboarding(this, this.background, instance.transport, instance.notifications);
76
79
  this.notifications = instance.notifications;
77
80
  this.transport = instance.transport;
78
- this.closeFeature = () => instance.sdk.closeFeature(true);
81
+ this.closeFeature = (destroy = true) => instance.sdk.closeFeature(destroy);
79
82
  this.openFeature = () => instance.sdk.openFeature(FeatureType.GAMES);
80
83
  this.openedQuestion = this.background.openedQuestion;
81
84
  this.deepLink = deepLink(this.transport, this.background.slStreamId, instance.stores.providerStreamId.getStore(), this.background.userId);
@@ -94,12 +97,12 @@ export class Gamification extends AbstractFeature {
94
97
  this.cancels.add(this.onboardingStatus.$store.listen(this.checkInteractiveFlag));
95
98
  this.cancels.add(this.background.moderation.getStore().listen(this.checkInteractiveFlag));
96
99
  this.cancels.add(this.settings.subscribe(this.checkInteractiveFlag));
97
- onMount(this.background.activeQuestionId, () => {
98
- /**
99
- * listen for active question and show in-app notification
100
- */
101
- this.background.activeQuestionId.listen(this.showInApp);
102
- });
100
+ this.cancels.add(this.onboardingStatus.$store.listen((status, prevStatus) => {
101
+ if (prevStatus === undefined || status !== OnboardingStatus.Unset) {
102
+ this.background.activeQuestionId.invalidate();
103
+ }
104
+ }));
105
+ this.background.activeQuestionId.listen(this.showInApp);
103
106
  instance.sdk.onMount({ name: 'gamification', clear: true }, () => {
104
107
  return () => {
105
108
  for (const cancel of this.cancels) {
@@ -121,6 +124,12 @@ export class Gamification extends AbstractFeature {
121
124
  this.background.interactiveAllowed.set(allowed ? InteractiveAllowed.ALLOWED : InteractiveAllowed.DISALLOWED);
122
125
  };
123
126
  connect = () => {
127
+ this.onboardingProcessed.subscribe((status) => {
128
+ console.log('onboardingProcessed status', status);
129
+ if (status) {
130
+ this.advertisement.connect();
131
+ }
132
+ });
124
133
  this.cancels.add(this.background.feedSubscription.addListener('feed-subscription-prediction-close', async (response) => {
125
134
  if (!this.isInteractiveAllowed) {
126
135
  return;
@@ -145,7 +154,11 @@ export class Gamification extends AbstractFeature {
145
154
  // order of operations is important here
146
155
  const cancel = data.subscribe(() => { });
147
156
  await data.get().promise;
148
- const extendedQuestion = data.get().data;
157
+ // if question is not in the feed list, get extended question data from the server
158
+ let extendedQuestion = data.get().data;
159
+ if (!extendedQuestion) {
160
+ extendedQuestion = await questionByUser(id, this.transport);
161
+ }
149
162
  cancel();
150
163
  window.requestAnimationFrame(() => {
151
164
  data.invalidate();
@@ -404,6 +417,16 @@ export class Gamification extends AbstractFeature {
404
417
  return !!this.notifications.isViewed(questionId);
405
418
  };
406
419
  closeQuestion = (questionId) => {
420
+ if (questionId) {
421
+ this.onQuestionActivate({
422
+ stage: 'deactivate',
423
+ id: questionId,
424
+ isViewed: !!this.notifications.isViewed(this.background.getCurrentSessionId({
425
+ prefix: 'notification',
426
+ entity: questionId,
427
+ })),
428
+ });
429
+ }
407
430
  return this.background.closeQuestion(questionId);
408
431
  };
409
432
  openUser = async (friendId) => {
package/lib/onboarding.js CHANGED
@@ -28,6 +28,9 @@ const showOnboardingInApp = (service, background, notifications, storage) => {
28
28
  type: NotificationType.ONBOARDING,
29
29
  id: notificationId,
30
30
  action: service.openFeature,
31
+ close: () => {
32
+ service.onboardingProcessed.set(true);
33
+ },
31
34
  persistent: true,
32
35
  autoHideDuration: 1000000,
33
36
  data: {
@@ -103,6 +106,7 @@ export const onboarding = (service, background, transport, notifications) => {
103
106
  const storage = new GamificationStorage();
104
107
  const $store = createSingleStore(OnboardingStatus.Unset);
105
108
  $store.subscribe((onboardingStatus) => {
109
+ console.log('onboardingStatus', onboardingStatus);
106
110
  if (onboardingStatus === OnboardingStatus.Unset) {
107
111
  return;
108
112
  }
@@ -112,6 +116,9 @@ export const onboarding = (service, background, transport, notifications) => {
112
116
  onboardingShowed = true;
113
117
  }
114
118
  }
119
+ else {
120
+ service.onboardingProcessed.set(true);
121
+ }
115
122
  if (onboardingStatus === OnboardingStatus.Completed) {
116
123
  background.activeQuestionId.invalidate(); // verified, it's necessary
117
124
  }
@@ -349,6 +349,7 @@ export declare const questionSubscription: (questionId: string, transport: Trans
349
349
  }, QuestionSubscriptionRequest, QuestionSubscriptionResponse, "subscription" | "votingSubscription" | "questionSubscription" | "feedSubscription", ((request: import("@bufbuild/protobuf").PartialMessage<SubscriptionRequest>, options?: import("@connectrpc/connect").CallOptions) => AsyncIterable<SubscriptionResponse>) | ((request: import("@bufbuild/protobuf").PartialMessage<VotingSubscriptionRequest>, options?: import("@connectrpc/connect").CallOptions) => AsyncIterable<VotingSubscriptionResponse>) | ((request: import("@bufbuild/protobuf").PartialMessage<QuestionSubscriptionRequest>, options?: import("@connectrpc/connect").CallOptions) => AsyncIterable<QuestionSubscriptionResponse>) | ((request: import("@bufbuild/protobuf").PartialMessage<import("@streamlayer/sl-eslib/interactive/feed/interactive.feed_pb").FeedSubscriptionRequest>, options?: import("@connectrpc/connect").CallOptions) => AsyncIterable<import("@streamlayer/sl-eslib/interactive/feed/interactive.feed_pb").FeedSubscriptionResponse>)>;
350
350
  export declare const getQuestionByUser: (questionId: string, transport: Transport) => Promise<import("@streamlayer/sl-eslib/interactive/interactive.common_pb").ExtendedQuestion | undefined>;
351
351
  export declare const getQuestionDetail: (questionId: string, transport: Transport) => Promise<import("@streamlayer/sl-eslib/interactive/interactive.common_pb").Question | undefined>;
352
+ export declare const questionByUser: ($questionId: string, transport: Transport) => Promise<import("@bufbuild/protobuf").PlainMessage<import("@streamlayer/sl-eslib/interactive/interactive.common_pb").ExtendedQuestion>>;
352
353
  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>;
353
354
  export declare const getPromotionDetail: (promoId: string, transport: Transport) => Promise<{
354
355
  id: string;
@@ -14,6 +14,9 @@ export const $activeQuestion = (slStreamId, transport) => {
14
14
  eventId: id,
15
15
  },
16
16
  });
17
+ if (res.data?.attributes?.question?.type === QuestionType.PROMOTION) {
18
+ return undefined;
19
+ }
17
20
  return res.data?.attributes;
18
21
  },
19
22
  dedupeTime: 1000 * 60 * 10, // 10 minutes
@@ -60,6 +63,13 @@ export const getQuestionDetail = async (questionId, transport) => {
60
63
  });
61
64
  return res.data?.attributes;
62
65
  };
66
+ export const questionByUser = async ($questionId, transport) => {
67
+ const { client } = transport.createPromiseClient(Feed, { method: 'questionByUser', params: [$questionId] });
68
+ const res = await client.questionByUser({
69
+ questionId: $questionId,
70
+ });
71
+ return res.data?.attributes?.question;
72
+ };
63
73
  export const $questionByUser = ($questionId, transport) => {
64
74
  const { client, queryKey } = transport.createPromiseClient(Feed, { method: 'questionByUser', params: [$questionId] });
65
75
  return transport.nanoquery.createFetcherStore(queryKey, {
package/package.json CHANGED
@@ -1,18 +1,18 @@
1
1
  {
2
2
  "name": "@streamlayer/feature-gamification",
3
- "version": "1.6.3",
3
+ "version": "1.7.0",
4
4
  "peerDependencies": {
5
5
  "@bufbuild/protobuf": "^1.10.0",
6
6
  "@fastify/deepmerge": "^2.0.0",
7
- "@streamlayer/sl-eslib": "^5.123.1",
7
+ "@streamlayer/sl-eslib": "^5.130.0",
8
8
  "nanostores": "^0.10.3",
9
- "@streamlayer/sdk-web-api": "^1.5.3",
10
- "@streamlayer/sdk-web-core": "^1.4.3",
11
- "@streamlayer/sdk-web-interfaces": "^1.1.15",
12
- "@streamlayer/sdk-web-logger": "^1.0.20",
13
- "@streamlayer/sdk-web-notifications": "^1.1.15",
14
- "@streamlayer/sdk-web-storage": "^1.0.20",
15
- "@streamlayer/sdk-web-types": "^1.6.3"
9
+ "@streamlayer/sdk-web-core": "^1.5.0",
10
+ "@streamlayer/sdk-web-interfaces": "^1.2.0",
11
+ "@streamlayer/sdk-web-logger": "^1.0.21",
12
+ "@streamlayer/sdk-web-api": "^1.6.0",
13
+ "@streamlayer/sdk-web-notifications": "^1.2.0",
14
+ "@streamlayer/sdk-web-storage": "^1.0.21",
15
+ "@streamlayer/sdk-web-types": "^1.7.0"
16
16
  },
17
17
  "devDependencies": {
18
18
  "tslib": "^2.7.0"