@streamlayer/feature-gamification 0.22.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/gamification.d.ts +7 -25
- package/lib/gamification.js +5 -121
- package/lib/onboarding.d.ts +23 -0
- package/lib/onboarding.js +116 -0
- package/lib/storage.d.ts +1 -1
- package/package.json +7 -7
package/lib/gamification.d.ts
CHANGED
|
@@ -6,23 +6,9 @@ import type { PlainMessage } from '@bufbuild/protobuf';
|
|
|
6
6
|
import { WritableAtom } from 'nanostores';
|
|
7
7
|
import * as queries from './queries';
|
|
8
8
|
import { leaderboard } from './leaderboard';
|
|
9
|
+
import { OnboardingStatus } from './onboarding';
|
|
9
10
|
import { LeaderboardItem } from './queries/leaderboard';
|
|
10
11
|
import { GamificationBackground } from './';
|
|
11
|
-
/**
|
|
12
|
-
* Required: in-app should be displayed and questions not available
|
|
13
|
-
* Optional: in-app should be displayed but questions are available
|
|
14
|
-
* Completed: user completed onboarding, cached in browser. Linked by eventId, organizationId and userId
|
|
15
|
-
* Disabled: no in-app but questions are available
|
|
16
|
-
* Unavailable: no in-app and questions not available [behavior is discussed]
|
|
17
|
-
*/
|
|
18
|
-
export declare enum OnboardingStatus {
|
|
19
|
-
Unset = "unset",
|
|
20
|
-
Required = "required",
|
|
21
|
-
Optional = "optional",
|
|
22
|
-
Completed = "completed",
|
|
23
|
-
Disabled = "disabled",
|
|
24
|
-
Unavailable = "unavailable"
|
|
25
|
-
}
|
|
26
12
|
/**
|
|
27
13
|
* Gamification (Games) Overlay
|
|
28
14
|
* Includes:
|
|
@@ -42,29 +28,25 @@ export declare class Gamification extends AbstractFeature<'games', PlainMessage<
|
|
|
42
28
|
/** leaderboard list */
|
|
43
29
|
leaderboardList: ReturnType<typeof leaderboard>;
|
|
44
30
|
/** onboarding status */
|
|
45
|
-
onboardingStatus:
|
|
31
|
+
onboardingStatus: {
|
|
32
|
+
$store: WritableAtom<OnboardingStatus | undefined>;
|
|
33
|
+
submitInplay: () => Promise<void>;
|
|
34
|
+
};
|
|
46
35
|
/** opened question */
|
|
47
36
|
openedQuestion: GamificationBackground['openedQuestion'];
|
|
48
37
|
/** pinned leaderboard id */
|
|
49
38
|
openedUser: WritableAtom<LeaderboardItem | undefined>;
|
|
39
|
+
closeFeature: () => void;
|
|
40
|
+
openFeature: () => void;
|
|
50
41
|
private notifications;
|
|
51
42
|
private transport;
|
|
52
|
-
private closeFeature;
|
|
53
|
-
private openFeature;
|
|
54
43
|
/** gamification background class, handle subscriptions and notifications for closed overlay */
|
|
55
44
|
private background;
|
|
56
45
|
/** Browser cache */
|
|
57
46
|
private storage;
|
|
58
47
|
constructor(config: FeatureProps, source: FeatureSource, instance: StreamLayerContext);
|
|
59
|
-
/**
|
|
60
|
-
* check onboarding status, sync with browser cache
|
|
61
|
-
* retrieve onboarding settings from api
|
|
62
|
-
*/
|
|
63
|
-
onboardingProcess: () => Promise<void>;
|
|
64
|
-
showOnboardingInApp: () => void;
|
|
65
48
|
connect: (transport: StreamLayerContext['transport']) => void;
|
|
66
49
|
disconnect: () => void;
|
|
67
|
-
submitInplay: () => Promise<void>;
|
|
68
50
|
submitAnswer: (questionId: string, answerId: string) => Promise<void>;
|
|
69
51
|
skipQuestion: (questionId: string) => Promise<void>;
|
|
70
52
|
openQuestion: (questionId: string) => void;
|
package/lib/gamification.js
CHANGED
|
@@ -6,24 +6,9 @@ import * as queries from './queries';
|
|
|
6
6
|
import * as actions from './queries/actions';
|
|
7
7
|
import { GamificationStorage } from './storage';
|
|
8
8
|
import { leaderboard } from './leaderboard';
|
|
9
|
+
import { onboarding } from './onboarding';
|
|
9
10
|
import { gamificationBackground } from './';
|
|
10
11
|
const GamificationQuestionTypes = new Set([QuestionType.POLL, QuestionType.PREDICTION, QuestionType.TRIVIA]);
|
|
11
|
-
/**
|
|
12
|
-
* Required: in-app should be displayed and questions not available
|
|
13
|
-
* Optional: in-app should be displayed but questions are available
|
|
14
|
-
* Completed: user completed onboarding, cached in browser. Linked by eventId, organizationId and userId
|
|
15
|
-
* Disabled: no in-app but questions are available
|
|
16
|
-
* Unavailable: no in-app and questions not available [behavior is discussed]
|
|
17
|
-
*/
|
|
18
|
-
export var OnboardingStatus;
|
|
19
|
-
(function (OnboardingStatus) {
|
|
20
|
-
OnboardingStatus["Unset"] = "unset";
|
|
21
|
-
OnboardingStatus["Required"] = "required";
|
|
22
|
-
OnboardingStatus["Optional"] = "optional";
|
|
23
|
-
OnboardingStatus["Completed"] = "completed";
|
|
24
|
-
OnboardingStatus["Disabled"] = "disabled";
|
|
25
|
-
OnboardingStatus["Unavailable"] = "unavailable";
|
|
26
|
-
})(OnboardingStatus || (OnboardingStatus = {}));
|
|
27
12
|
/**
|
|
28
13
|
* Gamification (Games) Overlay
|
|
29
14
|
* Includes:
|
|
@@ -48,10 +33,10 @@ export class Gamification extends AbstractFeature {
|
|
|
48
33
|
openedQuestion;
|
|
49
34
|
/** pinned leaderboard id */
|
|
50
35
|
openedUser;
|
|
51
|
-
notifications;
|
|
52
|
-
transport;
|
|
53
36
|
closeFeature;
|
|
54
37
|
openFeature;
|
|
38
|
+
notifications;
|
|
39
|
+
transport;
|
|
55
40
|
/** gamification background class, handle subscriptions and notifications for closed overlay */
|
|
56
41
|
background;
|
|
57
42
|
/** Browser cache */
|
|
@@ -64,37 +49,26 @@ export class Gamification extends AbstractFeature {
|
|
|
64
49
|
this.feedList = this.background.feedList;
|
|
65
50
|
this.openedUser = createSingleStore(undefined);
|
|
66
51
|
this.leaderboardId = new SingleStore(createSingleStore(this.settings.getValue('pinnedLeaderboardId')), 'pinnedLeaderboardId').getStore();
|
|
67
|
-
this.onboardingStatus =
|
|
52
|
+
this.onboardingStatus = onboarding(this, this.background, instance.transport, instance.notifications);
|
|
68
53
|
this.notifications = instance.notifications;
|
|
69
54
|
this.transport = instance.transport;
|
|
70
55
|
this.closeFeature = instance.sdk.closeFeature;
|
|
71
56
|
this.openFeature = () => instance.sdk.openFeature(FeatureType.GAMES);
|
|
72
57
|
this.openedQuestion = this.background.openedQuestion;
|
|
73
58
|
this.leaderboardList = leaderboard(this.transport, this.background.slStreamId);
|
|
74
|
-
this.onboardingStatus.subscribe((onboardingStatus) => {
|
|
75
|
-
if (onboardingStatus === OnboardingStatus.Optional || OnboardingStatus.Required) {
|
|
76
|
-
this.showOnboardingInApp();
|
|
77
|
-
}
|
|
78
|
-
});
|
|
79
59
|
this.status.subscribe((status) => {
|
|
80
60
|
if (status === FeatureStatus.Ready) {
|
|
81
|
-
this.notifications.close(this.background.getCurrentSessionId({ prefix: 'onboarding' }));
|
|
82
61
|
this.connect(instance.transport);
|
|
83
62
|
}
|
|
84
63
|
else {
|
|
85
64
|
this.disconnect();
|
|
86
65
|
}
|
|
87
66
|
});
|
|
88
|
-
this.onboardingStatus.subscribe((onboardingStatus) => {
|
|
89
|
-
if (onboardingStatus) {
|
|
90
|
-
this.background.activeQuestionId.invalidate();
|
|
91
|
-
}
|
|
92
|
-
});
|
|
93
67
|
/**
|
|
94
68
|
* listen for active question and show in-app notification
|
|
95
69
|
*/
|
|
96
70
|
this.background.activeQuestionId.listen((question) => {
|
|
97
|
-
if (question && question.data && this.onboardingStatus.get()) {
|
|
71
|
+
if (question && question.data && this.onboardingStatus.$store.get()) {
|
|
98
72
|
if (question.data.question?.id !== undefined &&
|
|
99
73
|
question.data.question.notification !== undefined &&
|
|
100
74
|
question.data.moderation?.bypassNotifications?.inAppSilence !== SilenceSetting.ON &&
|
|
@@ -144,84 +118,7 @@ export class Gamification extends AbstractFeature {
|
|
|
144
118
|
}
|
|
145
119
|
}
|
|
146
120
|
});
|
|
147
|
-
void this.onboardingProcess();
|
|
148
|
-
this.background.userId.subscribe((userId) => {
|
|
149
|
-
if (userId) {
|
|
150
|
-
void this.onboardingProcess();
|
|
151
|
-
}
|
|
152
|
-
});
|
|
153
|
-
this.background.moderation.subscribe((value) => {
|
|
154
|
-
if (value.data) {
|
|
155
|
-
void this.onboardingProcess();
|
|
156
|
-
}
|
|
157
|
-
});
|
|
158
121
|
}
|
|
159
|
-
/**
|
|
160
|
-
* check onboarding status, sync with browser cache
|
|
161
|
-
* retrieve onboarding settings from api
|
|
162
|
-
*/
|
|
163
|
-
onboardingProcess = async () => {
|
|
164
|
-
const userId = this.background.userId.get();
|
|
165
|
-
if (!userId) {
|
|
166
|
-
return;
|
|
167
|
-
}
|
|
168
|
-
const onboardingStatus = this.storage.getOnboardingStatus({
|
|
169
|
-
userId,
|
|
170
|
-
organizationId: this.background.organizationId.get() || '',
|
|
171
|
-
eventId: this.background.slStreamId.get() || '',
|
|
172
|
-
});
|
|
173
|
-
if (onboardingStatus === OnboardingStatus.Completed) {
|
|
174
|
-
this.onboardingStatus.set(OnboardingStatus.Completed);
|
|
175
|
-
}
|
|
176
|
-
const moderation = await this.background.moderation.getValue();
|
|
177
|
-
if (this.onboardingStatus.get() === OnboardingStatus.Completed) {
|
|
178
|
-
return;
|
|
179
|
-
}
|
|
180
|
-
const onboardingEnabled = !!(moderation?.options?.onboardingEnabled && this.featureSettings.get().inplayGame?.onboarding?.completed);
|
|
181
|
-
const optIn = !!this.featureSettings.get().inplayGame?.titleCard?.optIn;
|
|
182
|
-
if (onboardingEnabled) {
|
|
183
|
-
if (optIn) {
|
|
184
|
-
this.onboardingStatus.set(OnboardingStatus.Required);
|
|
185
|
-
}
|
|
186
|
-
else {
|
|
187
|
-
this.onboardingStatus.set(OnboardingStatus.Optional);
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
else {
|
|
191
|
-
if (optIn) {
|
|
192
|
-
this.onboardingStatus.set(OnboardingStatus.Unavailable);
|
|
193
|
-
}
|
|
194
|
-
else {
|
|
195
|
-
this.onboardingStatus.set(OnboardingStatus.Disabled);
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
};
|
|
199
|
-
showOnboardingInApp = () => {
|
|
200
|
-
const { inplayGame } = this.featureSettings.get();
|
|
201
|
-
if (!inplayGame) {
|
|
202
|
-
return;
|
|
203
|
-
}
|
|
204
|
-
const { titleCard, overview } = inplayGame;
|
|
205
|
-
this.notifications.add({
|
|
206
|
-
type: NotificationType.ONBOARDING,
|
|
207
|
-
id: this.background.getCurrentSessionId({ prefix: 'onboarding' }),
|
|
208
|
-
action: this.openFeature,
|
|
209
|
-
close: this.closeFeature,
|
|
210
|
-
autoHideDuration: 100000,
|
|
211
|
-
data: {
|
|
212
|
-
questionType: QuestionType.UNSET,
|
|
213
|
-
onboarding: {
|
|
214
|
-
header: titleCard?.header,
|
|
215
|
-
title: titleCard?.title,
|
|
216
|
-
subtitle: titleCard?.subtitle,
|
|
217
|
-
graphicBg: titleCard?.appearance?.graphic,
|
|
218
|
-
icon: titleCard?.media?.icon,
|
|
219
|
-
sponsorLogo: titleCard?.media?.sponsorLogo,
|
|
220
|
-
primaryColor: overview?.appearance?.primaryColor,
|
|
221
|
-
},
|
|
222
|
-
},
|
|
223
|
-
});
|
|
224
|
-
};
|
|
225
122
|
connect = (transport) => {
|
|
226
123
|
this.userSummary.invalidate();
|
|
227
124
|
this.leaderboardList.invalidate();
|
|
@@ -266,19 +163,6 @@ export class Gamification extends AbstractFeature {
|
|
|
266
163
|
disconnect = () => {
|
|
267
164
|
this.background.feedSubscription.removeListener('feed-subscription-questions-list');
|
|
268
165
|
};
|
|
269
|
-
// onboarding
|
|
270
|
-
submitInplay = async () => {
|
|
271
|
-
const eventId = this.background.slStreamId.get();
|
|
272
|
-
if (eventId) {
|
|
273
|
-
await actions.submitInplay(this.transport, eventId);
|
|
274
|
-
this.onboardingStatus.set(OnboardingStatus.Completed);
|
|
275
|
-
this.storage.saveOnboardingStatus({
|
|
276
|
-
organizationId: this.background.organizationId.get() || '',
|
|
277
|
-
userId: this.background.userId.get() || '',
|
|
278
|
-
eventId,
|
|
279
|
-
}, OnboardingStatus.Completed);
|
|
280
|
-
}
|
|
281
|
-
};
|
|
282
166
|
submitAnswer = async (questionId, answerId) => {
|
|
283
167
|
await actions.submitAnswer(this.transport, { questionId, answerId });
|
|
284
168
|
// Todo: add invalidate openedQuestion
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Transport } from '@streamlayer/sdk-web-api';
|
|
2
|
+
import { type Notifications } from '@streamlayer/sdk-web-notifications';
|
|
3
|
+
import { GamificationBackground } from './background';
|
|
4
|
+
import { Gamification } from './';
|
|
5
|
+
/**
|
|
6
|
+
* Required: in-app should be displayed and questions not available
|
|
7
|
+
* Optional: in-app should be displayed but questions are available
|
|
8
|
+
* Completed: user completed onboarding, cached in browser. Linked by eventId, organizationId and userId
|
|
9
|
+
* Disabled: no in-app but questions are available
|
|
10
|
+
* Unavailable: no in-app and questions not available [behavior is discussed]
|
|
11
|
+
*/
|
|
12
|
+
export declare enum OnboardingStatus {
|
|
13
|
+
Unset = "unset",
|
|
14
|
+
Required = "required",
|
|
15
|
+
Optional = "optional",
|
|
16
|
+
Completed = "completed",
|
|
17
|
+
Disabled = "disabled",
|
|
18
|
+
Unavailable = "unavailable"
|
|
19
|
+
}
|
|
20
|
+
export declare const onboarding: (service: Gamification, background: GamificationBackground, transport: Transport, notifications: Notifications) => {
|
|
21
|
+
$store: import("nanostores").WritableAtom<OnboardingStatus>;
|
|
22
|
+
submitInplay: () => Promise<void>;
|
|
23
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { createSingleStore } from '@streamlayer/sdk-web-interfaces';
|
|
2
|
+
import { NotificationType } from '@streamlayer/sdk-web-notifications';
|
|
3
|
+
import { QuestionType } from '@streamlayer/sdk-web-types';
|
|
4
|
+
import { GamificationStorage } from './storage';
|
|
5
|
+
import { submitInplay as submitInplayApi } from './queries/actions';
|
|
6
|
+
/**
|
|
7
|
+
* Required: in-app should be displayed and questions not available
|
|
8
|
+
* Optional: in-app should be displayed but questions are available
|
|
9
|
+
* Completed: user completed onboarding, cached in browser. Linked by eventId, organizationId and userId
|
|
10
|
+
* Disabled: no in-app but questions are available
|
|
11
|
+
* Unavailable: no in-app and questions not available [behavior is discussed]
|
|
12
|
+
*/
|
|
13
|
+
export var OnboardingStatus;
|
|
14
|
+
(function (OnboardingStatus) {
|
|
15
|
+
OnboardingStatus["Unset"] = "unset";
|
|
16
|
+
OnboardingStatus["Required"] = "required";
|
|
17
|
+
OnboardingStatus["Optional"] = "optional";
|
|
18
|
+
OnboardingStatus["Completed"] = "completed";
|
|
19
|
+
OnboardingStatus["Disabled"] = "disabled";
|
|
20
|
+
OnboardingStatus["Unavailable"] = "unavailable";
|
|
21
|
+
})(OnboardingStatus || (OnboardingStatus = {}));
|
|
22
|
+
export const onboarding = (service, background, transport, notifications) => {
|
|
23
|
+
const storage = new GamificationStorage();
|
|
24
|
+
const $store = createSingleStore(OnboardingStatus.Unset);
|
|
25
|
+
const showOnboardingInApp = () => {
|
|
26
|
+
const { inplayGame = {} } = service.featureSettings.get();
|
|
27
|
+
const notificationId = background.getCurrentSessionId({ prefix: 'onboarding' });
|
|
28
|
+
notifications.add({
|
|
29
|
+
type: NotificationType.ONBOARDING,
|
|
30
|
+
id: notificationId,
|
|
31
|
+
action: service.openFeature,
|
|
32
|
+
close: () => {
|
|
33
|
+
notifications.markAsViewed(notificationId);
|
|
34
|
+
},
|
|
35
|
+
autoHideDuration: 1000000,
|
|
36
|
+
data: {
|
|
37
|
+
questionType: QuestionType.UNSET,
|
|
38
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
39
|
+
// @ts-ignore
|
|
40
|
+
onboarding: { ...inplayGame },
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
$store.subscribe((onboardingStatus) => {
|
|
45
|
+
if (onboardingStatus === OnboardingStatus.Optional || OnboardingStatus.Required) {
|
|
46
|
+
showOnboardingInApp();
|
|
47
|
+
}
|
|
48
|
+
if (onboardingStatus === OnboardingStatus.Completed) {
|
|
49
|
+
background.activeQuestionId.invalidate();
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
/**
|
|
53
|
+
* check onboarding status, sync with browser cache
|
|
54
|
+
* retrieve onboarding settings from api
|
|
55
|
+
*/
|
|
56
|
+
const onboardingProcess = async () => {
|
|
57
|
+
const userId = background.userId.get();
|
|
58
|
+
if (!userId) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const onboardingStatus = storage.getOnboardingStatus({
|
|
62
|
+
userId,
|
|
63
|
+
organizationId: background.organizationId.get() || '',
|
|
64
|
+
eventId: background.slStreamId.get() || '',
|
|
65
|
+
});
|
|
66
|
+
if (onboardingStatus === OnboardingStatus.Completed) {
|
|
67
|
+
$store.set(OnboardingStatus.Completed);
|
|
68
|
+
}
|
|
69
|
+
const moderation = await background.moderation.getValue();
|
|
70
|
+
if ($store.get() === OnboardingStatus.Completed) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const onboardingEnabled = !!(moderation?.options?.onboardingEnabled && service.featureSettings.get().inplayGame?.onboarding?.completed);
|
|
74
|
+
const optIn = !!service.featureSettings.get().inplayGame?.titleCard?.optIn;
|
|
75
|
+
if (onboardingEnabled) {
|
|
76
|
+
if (optIn) {
|
|
77
|
+
$store.set(OnboardingStatus.Required);
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
$store.set(OnboardingStatus.Optional);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
if (optIn) {
|
|
85
|
+
$store.set(OnboardingStatus.Unavailable);
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
$store.set(OnboardingStatus.Disabled);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
void onboardingProcess();
|
|
93
|
+
background.userId.subscribe((userId) => {
|
|
94
|
+
if (userId) {
|
|
95
|
+
void onboardingProcess();
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
background.moderation.subscribe((value) => {
|
|
99
|
+
if (value.data) {
|
|
100
|
+
void onboardingProcess();
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
const submitInplay = async () => {
|
|
104
|
+
const eventId = background.slStreamId.get();
|
|
105
|
+
if (eventId) {
|
|
106
|
+
await submitInplayApi(transport, eventId);
|
|
107
|
+
$store.set(OnboardingStatus.Completed);
|
|
108
|
+
storage.saveOnboardingStatus({
|
|
109
|
+
organizationId: background.organizationId.get() || '',
|
|
110
|
+
userId: background.userId.get() || '',
|
|
111
|
+
eventId,
|
|
112
|
+
}, OnboardingStatus.Completed);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
return { $store, submitInplay };
|
|
116
|
+
};
|
package/lib/storage.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@streamlayer/feature-gamification",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"peerDependencies": {
|
|
5
5
|
"@bufbuild/protobuf": "^1.4.2",
|
|
6
6
|
"@streamlayer/sl-eslib": "^5.53.6",
|
|
7
7
|
"@fastify/deepmerge": "*",
|
|
8
8
|
"nanostores": "^0.9.5",
|
|
9
9
|
"@streamlayer/sdk-web-api": "^0.0.1",
|
|
10
|
-
"@streamlayer/sdk-web-
|
|
11
|
-
"@streamlayer/sdk-web-
|
|
12
|
-
"@streamlayer/sdk-web-
|
|
13
|
-
"@streamlayer/sdk-web-notifications": "^0.
|
|
14
|
-
"@streamlayer/sdk-web-storage": "^0.0.
|
|
15
|
-
"@streamlayer/sdk-web-types": "^0.
|
|
10
|
+
"@streamlayer/sdk-web-interfaces": "^0.18.14",
|
|
11
|
+
"@streamlayer/sdk-web-logger": "^0.0.3",
|
|
12
|
+
"@streamlayer/sdk-web-core": "^0.17.7",
|
|
13
|
+
"@streamlayer/sdk-web-notifications": "^0.12.0",
|
|
14
|
+
"@streamlayer/sdk-web-storage": "^0.0.3",
|
|
15
|
+
"@streamlayer/sdk-web-types": "^0.20.0"
|
|
16
16
|
},
|
|
17
17
|
"devDependencies": {
|
|
18
18
|
"tslib": "^2.6.2"
|