@mpgd/cli 0.52.0 → 0.53.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/dist/capacitor-shell-starter.d.ts +22 -0
- package/dist/capacitor-shell-starter.js +387 -18
- package/dist/index.js +35 -4
- package/dist/native-build-info.json +2 -2
- package/dist/native-build-target.js +363 -2
- package/dist/notifications-starter.d.ts +69 -0
- package/dist/notifications-starter.js +513 -0
- package/package.json +17 -17
- package/templates/notifications-overlay/common/docs/NOTIFICATIONS.md +75 -0
- package/templates/notifications-overlay/common/notifications/deployment.json +8 -0
- package/templates/notifications-overlay/common/notifications/topics.json +222 -0
- package/templates/notifications-overlay/common/src/platform/capacitorNotifications.ts +59 -0
- package/templates/notifications-overlay/common/src/platform/notificationOpens.ts +60 -0
- package/templates/notifications-overlay/common/src/platform/notificationSettings.ts +761 -0
- package/templates/notifications-overlay/common/src/platform/notifications.ts +131 -0
- package/templates/notifications-overlay/common/tools/validate-notifications.ts +75 -0
- package/templates/notifications-overlay/lite/src/lite/notificationSettingsPanel.ts +200 -0
- package/templates/notifications-overlay/phaser/src/scenes/NotificationSettingsScene.ts +282 -0
- package/templates/phaser-game/apps/target-ait/src/main.ts +15 -0
- package/templates/phaser-game/apps/target-ait/src/runtime-config.types.ts +5 -0
- package/templates/phaser-game/apps/target-ait/vite.config.ts +22 -1
- package/templates/phaser-game/docs/MPGD_KIT_WORKFLOWS.md +14 -0
|
@@ -0,0 +1,761 @@
|
|
|
1
|
+
import {
|
|
2
|
+
fillNotificationTemplate,
|
|
3
|
+
findNotificationCopy,
|
|
4
|
+
latestNotificationConsent,
|
|
5
|
+
notificationNoticeVariantCopy,
|
|
6
|
+
notificationTopicNeedsNoAgreement,
|
|
7
|
+
toSeoulCalendarDate,
|
|
8
|
+
type NotificationCapabilities,
|
|
9
|
+
type NotificationConsentRecord,
|
|
10
|
+
type NotificationNotice,
|
|
11
|
+
type NotificationNoticeCopy,
|
|
12
|
+
type NotificationPermissionSnapshot,
|
|
13
|
+
type NotificationPermissionState,
|
|
14
|
+
type NotificationProvider,
|
|
15
|
+
type NotificationRequiredAction,
|
|
16
|
+
type NotificationRuntime,
|
|
17
|
+
type NotificationTopicDeclaration,
|
|
18
|
+
} from '@mpgd/platform/notifications';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The notification settings screen's model, shared by the Phaser scene and
|
|
22
|
+
* the lite panel: the OS or host permission, the reminders the player turns
|
|
23
|
+
* on (`requested` topics), and the consent the Korean rules ask for before
|
|
24
|
+
* any advertising: 광고성 정보 수신동의 and the separate night consent
|
|
25
|
+
* (21:00 to 08:00).
|
|
26
|
+
*
|
|
27
|
+
* - Every change starts from a player action (a tap), which is the gesture
|
|
28
|
+
* Apps in Toss and Devvit require. Nothing here prompts on its own, and no
|
|
29
|
+
* switch is on before the player turns it on.
|
|
30
|
+
* - Consent is recorded through the notifications runtime, which sends it to
|
|
31
|
+
* the game backend: the server keeps the records the policy gate reads,
|
|
32
|
+
* and the switches show the player's latest records, read from the
|
|
33
|
+
* backend each time the screen reads (`listConsent`), so a choice changed
|
|
34
|
+
* on another device or in a notice shows here too. Nothing on the device
|
|
35
|
+
* stands in for a record. Without a runtime (see
|
|
36
|
+
* `createStarterNotificationBackend`), the screen asks for (and withdraws,
|
|
37
|
+
* where the game rendered it) only the OS or host permission and shows no
|
|
38
|
+
* reminder or advertising switch; when the records cannot be read, it shows
|
|
39
|
+
* none either and says so.
|
|
40
|
+
* - Night consent (21:00 to 08:00 KST) is an addition to advertising consent:
|
|
41
|
+
* it can be turned on only while advertising consent is granted, and
|
|
42
|
+
* withdrawing advertising consent withdraws a granted night consent in the
|
|
43
|
+
* same `setAdvertisingConsent` call.
|
|
44
|
+
* - A reminder or consent is never paid for: the game gives no reward for
|
|
45
|
+
* turning notifications on, and keeps working when the player declines.
|
|
46
|
+
*/
|
|
47
|
+
export interface StarterNotificationSettingsView {
|
|
48
|
+
/** False when this target exposes no notifications (`gateway.notificationsV2` is absent). */
|
|
49
|
+
readonly available: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Whether the reminder and advertising switches can be shown and changed:
|
|
52
|
+
* a runtime is present and the player's consent records were read.
|
|
53
|
+
*/
|
|
54
|
+
readonly recording: boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Where the switches' states come from: the player's backend `records`,
|
|
57
|
+
* none because the game has no backend for them (`no-server`), or none
|
|
58
|
+
* because the records could not be read just now (`unreadable`).
|
|
59
|
+
*/
|
|
60
|
+
readonly choices: 'records' | 'no-server' | 'unreadable';
|
|
61
|
+
readonly permission: {
|
|
62
|
+
/** The app permission, or for a host that asks per topic, the reminders' combined state. */
|
|
63
|
+
readonly state: NotificationPermissionState;
|
|
64
|
+
/** Whether the permission is per topic (Apps in Toss): it is asked when a reminder is turned on. */
|
|
65
|
+
readonly perTopic: boolean;
|
|
66
|
+
/** Whether a tap may show the OS or host prompt now. */
|
|
67
|
+
readonly canRequest: boolean;
|
|
68
|
+
/** Whether only the system settings can change it, and the game can open them. */
|
|
69
|
+
readonly canOpenSettings: boolean;
|
|
70
|
+
/** Whether the player turned the consent on in the game and can turn it off here (Devvit). */
|
|
71
|
+
readonly canRevoke: boolean;
|
|
72
|
+
/** Whether the host needs a login first and the game can ask for it. */
|
|
73
|
+
readonly canLogin: boolean;
|
|
74
|
+
readonly requiredAction?: NotificationRequiredAction;
|
|
75
|
+
};
|
|
76
|
+
readonly reminders: readonly {
|
|
77
|
+
readonly topic: string;
|
|
78
|
+
readonly label: string;
|
|
79
|
+
/**
|
|
80
|
+
* The player's latest `topic-preference` record for the topic is `granted`,
|
|
81
|
+
* and on a per-topic host its host agreement is granted too.
|
|
82
|
+
*/
|
|
83
|
+
readonly enabled: boolean;
|
|
84
|
+
}[];
|
|
85
|
+
/**
|
|
86
|
+
* On a host that asks per topic (Apps in Toss): the `service` topics whose
|
|
87
|
+
* template needs the player's agreement, which no reminder switch covers.
|
|
88
|
+
* Each is agreed to from a tap (`agree`); the host's settings withdraw it.
|
|
89
|
+
* Empty on hosts that ask once for the app.
|
|
90
|
+
*/
|
|
91
|
+
readonly agreements: readonly {
|
|
92
|
+
readonly topic: string;
|
|
93
|
+
readonly label: string;
|
|
94
|
+
readonly state: NotificationPermissionState;
|
|
95
|
+
/** Whether a tap may show the host's agreement sheet for this topic now. */
|
|
96
|
+
readonly canRequest: boolean;
|
|
97
|
+
}[];
|
|
98
|
+
/** Present when this target delivers an advertising topic. */
|
|
99
|
+
readonly advertising?: {
|
|
100
|
+
/** The latest `advertising` record is `granted`. */
|
|
101
|
+
readonly granted: boolean;
|
|
102
|
+
/** The latest `night` record is `granted`, and so is the latest `advertising` record. */
|
|
103
|
+
readonly night: boolean;
|
|
104
|
+
/** Whether the night switch can be tapped: only while advertising consent is granted. */
|
|
105
|
+
readonly nightSelectable: boolean;
|
|
106
|
+
};
|
|
107
|
+
/** Consent notices the server says are due, in the player's language. */
|
|
108
|
+
readonly notices: readonly { readonly title: string; readonly body: string }[];
|
|
109
|
+
/** The latest action's outcome, for the screen to show. */
|
|
110
|
+
readonly message?: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface StarterNotificationSettingsInput {
|
|
114
|
+
/** `gateway.notificationsV2`; absent on targets without notifications. */
|
|
115
|
+
readonly provider: NotificationProvider | undefined;
|
|
116
|
+
/** The game's notifications runtime; absent until the game has a backend for it. */
|
|
117
|
+
readonly runtime: NotificationRuntime | undefined;
|
|
118
|
+
/**
|
|
119
|
+
* The signed-in player's consent records from the game backend (the
|
|
120
|
+
* game-services Worker's `GET /consent`), in any order. The switches show
|
|
121
|
+
* the latest record of each kind; without it, they are not shown.
|
|
122
|
+
*/
|
|
123
|
+
readonly listConsent?: () => Promise<readonly NotificationConsentRecord[]>;
|
|
124
|
+
/** The topics this target maps (`starterNotificationTopics(configTarget)`). */
|
|
125
|
+
readonly topics: readonly NotificationTopicDeclaration[];
|
|
126
|
+
/** `notices.copy` of the catalog, for the notices the server lists. */
|
|
127
|
+
readonly noticeCopy?: NotificationNoticeCopy;
|
|
128
|
+
readonly defaultLocale: string;
|
|
129
|
+
/** The consent text version recorded with advertising and night consent. */
|
|
130
|
+
readonly consentTextVersion: string;
|
|
131
|
+
/**
|
|
132
|
+
* The topics the player agreed to in this session, shared by every model
|
|
133
|
+
* for the same gateway so a screen opened again still shows them (a host
|
|
134
|
+
* such as Apps in Toss gives no read). A fresh set when absent.
|
|
135
|
+
*/
|
|
136
|
+
readonly agreedThisSession?: Set<string>;
|
|
137
|
+
/** Asks the host to log the player in (`identity.requestUpgrade`), for a host that needs it first. */
|
|
138
|
+
readonly requestLogin?: () => Promise<unknown>;
|
|
139
|
+
readonly locale: string;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface StarterNotificationSettings {
|
|
143
|
+
load(): Promise<StarterNotificationSettingsView>;
|
|
144
|
+
/** From a tap: asks for the OS or host permission (app-scope hosts). */
|
|
145
|
+
turnOn(): Promise<StarterNotificationSettingsView>;
|
|
146
|
+
/** From a tap: withdraws a consent the game rendered (Devvit). */
|
|
147
|
+
turnOff(): Promise<StarterNotificationSettingsView>;
|
|
148
|
+
/** From a tap: opens the system notification settings. */
|
|
149
|
+
openSettings(): Promise<StarterNotificationSettingsView>;
|
|
150
|
+
/** From a tap: asks the host to log the player in, then reads again. */
|
|
151
|
+
logIn(): Promise<StarterNotificationSettingsView>;
|
|
152
|
+
/** From a tap: turns one reminder topic on (asking for its permission first) or off. */
|
|
153
|
+
setReminder(topic: string, enabled: boolean): Promise<StarterNotificationSettingsView>;
|
|
154
|
+
/** From a tap: shows the host's agreement sheet for one of `view.agreements` (per-topic hosts). */
|
|
155
|
+
agree(topic: string): Promise<StarterNotificationSettingsView>;
|
|
156
|
+
/**
|
|
157
|
+
* From a tap: records advertising or night consent, or its withdrawal.
|
|
158
|
+
* Night consent is turned on only while advertising consent is granted (or
|
|
159
|
+
* granted in the same change), and withdrawing advertising consent also
|
|
160
|
+
* withdraws a granted night consent, in one call.
|
|
161
|
+
*/
|
|
162
|
+
setAdvertising(change: { readonly advertising?: boolean; readonly night?: boolean }): Promise<StarterNotificationSettingsView>;
|
|
163
|
+
/**
|
|
164
|
+
* Call once the screen shows `view.notices`: records each shown notice as
|
|
165
|
+
* given in the game, so the server does not push it as well. Only notices
|
|
166
|
+
* the last view rendered are recorded.
|
|
167
|
+
*/
|
|
168
|
+
recordNoticesShown(): Promise<void>;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The screen's words, shared by the Phaser scene and the lite panel so the consent wording cannot drift. */
|
|
172
|
+
export const starterNotificationLabels = {
|
|
173
|
+
ko: {
|
|
174
|
+
title: '알림 설정',
|
|
175
|
+
unavailable: '이 플랫폼에서는 알림을 쓸 수 없어요.',
|
|
176
|
+
permissionOn: '알림: 켜짐',
|
|
177
|
+
permissionOff: '알림: 꺼짐',
|
|
178
|
+
explanation: '알림을 켜면 내가 고른 리마인더와 친구의 도전 소식을 받아요. 켜지 않아도 게임을 그대로 이용할 수 있어요.',
|
|
179
|
+
turnOn: '알림 켜기',
|
|
180
|
+
turnOff: '알림 끄기',
|
|
181
|
+
logIn: '로그인',
|
|
182
|
+
openSettings: '시스템 설정 열기',
|
|
183
|
+
reminders: '리마인더 (내가 켠 알림만 받아요)',
|
|
184
|
+
agreements: '받을 알림 (동의한 알림만 받아요)',
|
|
185
|
+
agree: '동의하기',
|
|
186
|
+
advertising: '광고성 정보 수신동의 (선택)',
|
|
187
|
+
advertisingHelp: '이벤트와 새 소식을 알림으로 받아요. 동의하지 않아도 게임을 그대로 이용할 수 있어요.',
|
|
188
|
+
night: '야간(21시~다음 날 8시) 광고성 정보 수신동의 (선택)',
|
|
189
|
+
on: '켜짐',
|
|
190
|
+
off: '꺼짐',
|
|
191
|
+
notices: '수신동의 안내',
|
|
192
|
+
notConnected: '게임 서버가 연결되면 리마인더와 광고성 정보 수신 설정을 여기서 켜고 끌 수 있어요.',
|
|
193
|
+
choicesUnreadable: '수신 설정을 불러오지 못했어요. 잠시 후 다시 열어 주세요.',
|
|
194
|
+
nightNeedsAdvertising: '광고성 정보 수신에 동의하면 선택할 수 있어요.',
|
|
195
|
+
back: '돌아가기',
|
|
196
|
+
close: '닫기',
|
|
197
|
+
},
|
|
198
|
+
en: {
|
|
199
|
+
title: 'Notifications',
|
|
200
|
+
unavailable: 'Notifications are not available on this platform.',
|
|
201
|
+
permissionOn: 'Notifications: on',
|
|
202
|
+
permissionOff: 'Notifications: off',
|
|
203
|
+
explanation: 'Turn notifications on to get the reminders you choose and friend challenges. The game works the same without them.',
|
|
204
|
+
turnOn: 'Turn on notifications',
|
|
205
|
+
turnOff: 'Turn off notifications',
|
|
206
|
+
logIn: 'Log in',
|
|
207
|
+
openSettings: 'Open system settings',
|
|
208
|
+
reminders: 'Reminders (only the ones you turn on)',
|
|
209
|
+
agreements: 'Notifications you agree to receive',
|
|
210
|
+
agree: 'Agree',
|
|
211
|
+
advertising: 'Promotional notifications (optional)',
|
|
212
|
+
advertisingHelp: 'Get event news as notifications. The game works the same without it.',
|
|
213
|
+
night: 'Promotional notifications at night, 21:00-08:00 (optional)',
|
|
214
|
+
on: 'on',
|
|
215
|
+
off: 'off',
|
|
216
|
+
notices: 'Consent notices',
|
|
217
|
+
notConnected: 'Once the game server is connected, reminders and promotional consent are set here.',
|
|
218
|
+
choicesUnreadable: 'Your notification choices could not be read. Open this screen again later.',
|
|
219
|
+
nightNeedsAdvertising: 'Available once you agree to promotional notifications.',
|
|
220
|
+
back: 'Back',
|
|
221
|
+
close: 'Close',
|
|
222
|
+
},
|
|
223
|
+
} as const;
|
|
224
|
+
|
|
225
|
+
export type StarterNotificationLabels = (typeof starterNotificationLabels)['ko' | 'en'];
|
|
226
|
+
|
|
227
|
+
/** The screen's words in the player's language. */
|
|
228
|
+
export function starterNotificationLabelsFor(locale: string): StarterNotificationLabels {
|
|
229
|
+
return locale.toLowerCase().startsWith('ko')
|
|
230
|
+
? starterNotificationLabels.ko
|
|
231
|
+
: starterNotificationLabels.en;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const messages = {
|
|
235
|
+
ko: {
|
|
236
|
+
reminderLabels: {
|
|
237
|
+
'daily-ready': '오늘의 플레이 알림',
|
|
238
|
+
'streak-at-risk': '연속 기록 알림',
|
|
239
|
+
'friend-challenge': '친구의 도전 알림',
|
|
240
|
+
} as Record<string, string>,
|
|
241
|
+
granted: '알림을 켰어요.',
|
|
242
|
+
denied: '알림이 꺼져 있어요. 원하면 언제든 다시 켤 수 있어요.',
|
|
243
|
+
revoked: '알림을 껐어요.',
|
|
244
|
+
blocked: '알림이 꺼져 있어요. 시스템 설정에서 켤 수 있어요.',
|
|
245
|
+
dismissed: '선택하지 않았어요.',
|
|
246
|
+
login: '알림을 켜려면 먼저 로그인해 주세요.',
|
|
247
|
+
hostSettings: '앱의 개인화 약관에 동의해야 알림을 켤 수 있어요.',
|
|
248
|
+
unavailable: '이 기기에서는 알림을 켤 수 없어요.',
|
|
249
|
+
noRecording: '서버가 연결되지 않아 수신 설정을 저장할 수 없어요.',
|
|
250
|
+
grantedNoServer: '알림 권한을 켰어요. 게임 서버가 연결되면 알림을 받을 수 있어요.',
|
|
251
|
+
failed: '설정을 바꾸지 못했어요. 잠시 후 다시 시도해 주세요.',
|
|
252
|
+
readFailed: '알림 설정을 불러오지 못했어요. 잠시 후 다시 시도해 주세요.',
|
|
253
|
+
advertisingOn: '광고성 정보 수신에 동의했어요.',
|
|
254
|
+
advertisingOff: '광고성 정보 수신동의를 철회했어요.',
|
|
255
|
+
advertisingAndNightOff: '광고성 정보 수신동의와 야간 광고성 정보 수신동의를 철회했어요.',
|
|
256
|
+
advertisingOnStaleNightOff:
|
|
257
|
+
'광고성 정보 수신에 동의했어요. 예전에 남아 있던 야간 광고성 정보 수신동의는 철회했어요. 원하면 다시 켤 수 있어요.',
|
|
258
|
+
nightNeedsAdvertising: '야간 광고성 정보 수신은 광고성 정보 수신에 먼저 동의해야 켤 수 있어요.',
|
|
259
|
+
choicesUnreadable: '수신 설정을 불러오지 못해 바꾸지 않았어요. 잠시 후 다시 시도해 주세요.',
|
|
260
|
+
nightOn: '야간 광고성 정보 수신에 동의했어요.',
|
|
261
|
+
nightOff: '야간 광고성 정보 수신동의를 철회했어요.',
|
|
262
|
+
reminderOn: '리마인더를 켰어요.',
|
|
263
|
+
reminderOff: '리마인더를 껐어요.',
|
|
264
|
+
},
|
|
265
|
+
en: {
|
|
266
|
+
reminderLabels: {
|
|
267
|
+
'daily-ready': "Today's run reminder",
|
|
268
|
+
'streak-at-risk': 'Streak reminder',
|
|
269
|
+
'friend-challenge': 'Friend challenges',
|
|
270
|
+
} as Record<string, string>,
|
|
271
|
+
granted: 'Notifications are on.',
|
|
272
|
+
denied: 'Notifications are off. You can turn them on any time.',
|
|
273
|
+
revoked: 'Notifications are off.',
|
|
274
|
+
blocked: 'Notifications are off. You can turn them on in system settings.',
|
|
275
|
+
dismissed: 'Nothing was changed.',
|
|
276
|
+
login: 'Log in first to turn notifications on.',
|
|
277
|
+
hostSettings: "Agree to the app's personalization terms to turn notifications on.",
|
|
278
|
+
unavailable: 'Notifications are not available on this device.',
|
|
279
|
+
noRecording: 'The game server is not connected, so these choices cannot be saved.',
|
|
280
|
+
grantedNoServer: 'Notifications are allowed. They arrive once the game server is connected.',
|
|
281
|
+
failed: 'The setting could not be changed. Try again later.',
|
|
282
|
+
readFailed: 'The notification settings could not be read. Try again later.',
|
|
283
|
+
advertisingOn: 'You agreed to promotional notifications.',
|
|
284
|
+
advertisingOff: 'You withdrew your consent to promotional notifications.',
|
|
285
|
+
advertisingAndNightOff: 'You withdrew your consent to promotional notifications, by day and at night.',
|
|
286
|
+
advertisingOnStaleNightOff:
|
|
287
|
+
'You agreed to promotional notifications. An earlier consent to night-time promotional notifications was withdrawn; turn it on again if you want it.',
|
|
288
|
+
nightNeedsAdvertising: 'Agree to promotional notifications first to get them at night.',
|
|
289
|
+
choicesUnreadable: 'Your choices could not be read, so nothing was changed. Try again later.',
|
|
290
|
+
nightOn: 'You agreed to promotional notifications at night.',
|
|
291
|
+
nightOff: 'You withdrew your consent to promotional notifications at night.',
|
|
292
|
+
reminderOn: 'The reminder is on.',
|
|
293
|
+
reminderOff: 'The reminder is off.',
|
|
294
|
+
},
|
|
295
|
+
} as const;
|
|
296
|
+
|
|
297
|
+
type Messages = (typeof messages)['en' | 'ko'];
|
|
298
|
+
|
|
299
|
+
const unavailableView = (
|
|
300
|
+
message: string | undefined,
|
|
301
|
+
choices: StarterNotificationSettingsView['choices'],
|
|
302
|
+
): StarterNotificationSettingsView => ({
|
|
303
|
+
available: false,
|
|
304
|
+
recording: false,
|
|
305
|
+
choices,
|
|
306
|
+
permission: {
|
|
307
|
+
state: 'unsupported',
|
|
308
|
+
perTopic: false,
|
|
309
|
+
canRequest: false,
|
|
310
|
+
canOpenSettings: false,
|
|
311
|
+
canRevoke: false,
|
|
312
|
+
canLogin: false,
|
|
313
|
+
},
|
|
314
|
+
reminders: [],
|
|
315
|
+
agreements: [],
|
|
316
|
+
notices: [],
|
|
317
|
+
...(message === undefined ? {} : { message }),
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
export function createStarterNotificationSettings(
|
|
321
|
+
input: StarterNotificationSettingsInput,
|
|
322
|
+
): StarterNotificationSettings {
|
|
323
|
+
const strings: Messages = input.locale.toLowerCase().startsWith('ko') ? messages.ko : messages.en;
|
|
324
|
+
const reminderTopics = input.topics.filter((topic) => topic.purpose === 'requested');
|
|
325
|
+
const advertisingTopics = input.topics.filter((topic) => topic.purpose === 'advertising');
|
|
326
|
+
// On a per-topic host, a service template that needs the player's
|
|
327
|
+
// agreement has no switch of its own: no preference turns it on.
|
|
328
|
+
const agreementTopics = input.topics.filter(
|
|
329
|
+
(topic) => topic.purpose === 'service' && !notificationTopicNeedsNoAgreement(topic),
|
|
330
|
+
);
|
|
331
|
+
// Every topic but advertising asks for delivery permission up front; an
|
|
332
|
+
// advertising topic needs the player's advertising consent first.
|
|
333
|
+
const permissionTopics = input.topics
|
|
334
|
+
.filter((topic) => topic.purpose !== 'advertising')
|
|
335
|
+
.map((topic) => topic.id);
|
|
336
|
+
/** The player's records as the last read gave them; `undefined` when they could not be read. */
|
|
337
|
+
let records: readonly NotificationConsentRecord[] | undefined;
|
|
338
|
+
let missingReaderReported = false;
|
|
339
|
+
/** Agreements the player gave in this session, for a host that gives no read. */
|
|
340
|
+
const agreedThisSession = input.agreedThisSession ?? new Set<string>();
|
|
341
|
+
let capabilities: NotificationCapabilities | undefined;
|
|
342
|
+
let message: string | undefined;
|
|
343
|
+
let notices: StarterNotificationSettingsView['notices'] = [];
|
|
344
|
+
/** The notices the last view rendered, which `recordNoticesShown` records. */
|
|
345
|
+
let rendered: readonly NotificationNotice[] = [];
|
|
346
|
+
|
|
347
|
+
// Without a runtime only the provider can read and ask for the permission;
|
|
348
|
+
// nothing is recorded then, and the host still enforces its own answer.
|
|
349
|
+
const readPermission = async (
|
|
350
|
+
provider: NotificationProvider,
|
|
351
|
+
topics: readonly string[],
|
|
352
|
+
): Promise<NotificationPermissionSnapshot> =>
|
|
353
|
+
input.runtime === undefined
|
|
354
|
+
? provider.getPermission({ topics })
|
|
355
|
+
: input.runtime.getPermission({ topics });
|
|
356
|
+
|
|
357
|
+
const buildView = async (provider: NotificationProvider): Promise<StarterNotificationSettingsView> => {
|
|
358
|
+
capabilities ??= await provider.getCapabilities();
|
|
359
|
+
const perTopic = capabilities.permission.scope === 'topic';
|
|
360
|
+
const gameRendered = capabilities.permission.prompt === 'game';
|
|
361
|
+
const snapshot = await readPermission(provider, permissionTopics);
|
|
362
|
+
const state = perTopic
|
|
363
|
+
? combinedState(
|
|
364
|
+
snapshot,
|
|
365
|
+
reminderTopics.map((topic) => topic.id),
|
|
366
|
+
)
|
|
367
|
+
: (snapshot.app ?? 'unsupported');
|
|
368
|
+
const available = capabilities.availability !== 'unsupported';
|
|
369
|
+
if (!available) {
|
|
370
|
+
// Nothing is shown, so no notice may be recorded as shown, and no switch changed.
|
|
371
|
+
rendered = [];
|
|
372
|
+
records = undefined;
|
|
373
|
+
}
|
|
374
|
+
const choices = available ? await readChoices() : 'no-server';
|
|
375
|
+
const recording = choices === 'records';
|
|
376
|
+
const granted = (kind: 'topic-preference' | 'advertising' | 'night', topic?: string): boolean =>
|
|
377
|
+
recording && records !== undefined && latestNotificationConsent(records, kind, topic)?.state === 'granted';
|
|
378
|
+
const advertising = granted('advertising');
|
|
379
|
+
// Night consent counts only alongside advertising consent: one an older
|
|
380
|
+
// client left standing after advertising was withdrawn is shown as off,
|
|
381
|
+
// and the next advertising consent withdraws it.
|
|
382
|
+
const night = advertising && granted('night');
|
|
383
|
+
return {
|
|
384
|
+
available,
|
|
385
|
+
recording,
|
|
386
|
+
choices,
|
|
387
|
+
permission: {
|
|
388
|
+
state,
|
|
389
|
+
perTopic,
|
|
390
|
+
canRequest: !perTopic && isRequestable(state),
|
|
391
|
+
canOpenSettings: state === 'blocked' && capabilities.permission.settings,
|
|
392
|
+
canRevoke: gameRendered && state === 'granted'
|
|
393
|
+
&& (input.runtime !== undefined || provider.revokePermission !== undefined),
|
|
394
|
+
canLogin: snapshot.requiredAction === 'login' && input.requestLogin !== undefined,
|
|
395
|
+
...(snapshot.requiredAction === undefined ? {} : { requiredAction: snapshot.requiredAction }),
|
|
396
|
+
},
|
|
397
|
+
reminders: reminderTopics.map((topic) => ({
|
|
398
|
+
topic: topic.id,
|
|
399
|
+
label: strings.reminderLabels[topic.id] ?? topic.id,
|
|
400
|
+
// On a per-topic host the agreement can be withdrawn in host settings,
|
|
401
|
+
// which leaves the preference on but stops every send.
|
|
402
|
+
enabled: granted('topic-preference', topic.id)
|
|
403
|
+
&& (!perTopic || snapshot.topics[topic.id] === 'granted'),
|
|
404
|
+
})),
|
|
405
|
+
agreements: perTopic
|
|
406
|
+
? agreementTopics.map((topic) => {
|
|
407
|
+
const read = snapshot.topics[topic.id] ?? 'unsupported';
|
|
408
|
+
// A host that gives no read (Apps in Toss) answers `unknown` (a runtime
|
|
409
|
+
// resolves it from the records when it has one); an agreement made
|
|
410
|
+
// in this session shows as on.
|
|
411
|
+
const topicState = read === 'unknown' && agreedThisSession.has(topic.id) ? 'granted' : read;
|
|
412
|
+
return {
|
|
413
|
+
topic: topic.id,
|
|
414
|
+
label: strings.reminderLabels[topic.id] ?? topic.id,
|
|
415
|
+
state: topicState,
|
|
416
|
+
canRequest: isRequestable(topicState),
|
|
417
|
+
};
|
|
418
|
+
})
|
|
419
|
+
: [],
|
|
420
|
+
...(advertisingTopics.length === 0
|
|
421
|
+
? {}
|
|
422
|
+
: { advertising: { granted: advertising, night, nightSelectable: recording && advertising } }),
|
|
423
|
+
notices: available ? notices : [],
|
|
424
|
+
...(message === undefined ? {} : { message }),
|
|
425
|
+
};
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
const view = async (): Promise<StarterNotificationSettingsView> => {
|
|
429
|
+
const provider = input.provider;
|
|
430
|
+
if (provider === undefined) {
|
|
431
|
+
return unavailableView(message, input.runtime === undefined ? 'no-server' : 'unreadable');
|
|
432
|
+
}
|
|
433
|
+
try {
|
|
434
|
+
return await buildView(provider);
|
|
435
|
+
} catch (error) {
|
|
436
|
+
// A failed host or backend read is shown, never thrown into the screen.
|
|
437
|
+
console.warn('[notifications] settings read failed.', error);
|
|
438
|
+
// Nothing is shown now, so a later view must not show (unrecorded) notices either.
|
|
439
|
+
notices = [];
|
|
440
|
+
rendered = [];
|
|
441
|
+
// Only the message: the permission state is unknown.
|
|
442
|
+
return unavailableView(
|
|
443
|
+
strings.readFailed,
|
|
444
|
+
input.runtime === undefined ? 'no-server' : 'unreadable',
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Reads the player's consent records for the switches, every time the
|
|
451
|
+
* screen reads, so a change made elsewhere shows. Sorted by the backend's
|
|
452
|
+
* time, oldest first, so the latest record of each kind is the last.
|
|
453
|
+
*/
|
|
454
|
+
const readChoices = async (): Promise<StarterNotificationSettingsView['choices']> => {
|
|
455
|
+
records = undefined;
|
|
456
|
+
if (input.runtime === undefined) {
|
|
457
|
+
return 'no-server';
|
|
458
|
+
}
|
|
459
|
+
if (input.listConsent === undefined) {
|
|
460
|
+
if (!missingReaderReported) {
|
|
461
|
+
// A wiring gap, not a passing failure: the backend must pass its reader.
|
|
462
|
+
console.error(
|
|
463
|
+
'[notifications] the runtime has no consent reader (listConsent); no switch is shown.',
|
|
464
|
+
);
|
|
465
|
+
missingReaderReported = true;
|
|
466
|
+
}
|
|
467
|
+
return 'unreadable';
|
|
468
|
+
}
|
|
469
|
+
try {
|
|
470
|
+
const listed = await input.listConsent();
|
|
471
|
+
// The backend dates a player's records at least 1 ms apart. A record
|
|
472
|
+
// whose time does not parse cannot be ordered, and leaving it out could
|
|
473
|
+
// show an older consent as the latest, so nothing is shown instead.
|
|
474
|
+
if (!listed.every((record) => Number.isFinite(Date.parse(record.at)))) {
|
|
475
|
+
console.warn('[notifications] a consent record has no readable time; no switch is shown.');
|
|
476
|
+
return 'unreadable';
|
|
477
|
+
}
|
|
478
|
+
records = [...listed].sort((a, b) => Date.parse(a.at) - Date.parse(b.at));
|
|
479
|
+
return 'records';
|
|
480
|
+
} catch (error) {
|
|
481
|
+
console.warn('[notifications] the consent records could not be read.', error);
|
|
482
|
+
return 'unreadable';
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
const latestState = (kind: 'advertising' | 'night'): string | undefined =>
|
|
487
|
+
records === undefined ? undefined : latestNotificationConsent(records, kind)?.state;
|
|
488
|
+
|
|
489
|
+
const run = async (action: () => Promise<string | undefined>): Promise<StarterNotificationSettingsView> => {
|
|
490
|
+
try {
|
|
491
|
+
message = await action();
|
|
492
|
+
} catch (error) {
|
|
493
|
+
console.warn('[notifications] settings change failed.', error);
|
|
494
|
+
message = strings.failed;
|
|
495
|
+
}
|
|
496
|
+
return view();
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
const request = async (topics: readonly string[]): Promise<string> => {
|
|
500
|
+
const provider = input.provider;
|
|
501
|
+
if (provider === undefined) {
|
|
502
|
+
return strings.unavailable;
|
|
503
|
+
}
|
|
504
|
+
const result = input.runtime === undefined
|
|
505
|
+
? await provider.requestPermission({ topics })
|
|
506
|
+
: await input.runtime.requestPermission({ topics, gesture: true });
|
|
507
|
+
const outcomes = Object.values(result.outcomes);
|
|
508
|
+
if (outcomes.some((outcome) => outcome === 'granted' || outcome === 'already-granted')) {
|
|
509
|
+
// Without a runtime nothing registers the device, so nothing is sent yet.
|
|
510
|
+
return input.runtime === undefined ? strings.grantedNoServer : strings.granted;
|
|
511
|
+
}
|
|
512
|
+
if (result.requiredAction === 'host-settings') {
|
|
513
|
+
return strings.hostSettings;
|
|
514
|
+
}
|
|
515
|
+
if (result.requiredAction === 'login' || outcomes.includes('action-required')) {
|
|
516
|
+
return strings.login;
|
|
517
|
+
}
|
|
518
|
+
if (outcomes.includes('blocked')) {
|
|
519
|
+
return strings.blocked;
|
|
520
|
+
}
|
|
521
|
+
if (outcomes.includes('denied')) {
|
|
522
|
+
return strings.denied;
|
|
523
|
+
}
|
|
524
|
+
return outcomes.includes('dismissed') ? strings.dismissed : strings.unavailable;
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
return {
|
|
528
|
+
async load() {
|
|
529
|
+
notices = [];
|
|
530
|
+
rendered = [];
|
|
531
|
+
// A new visit starts without the previous visit's outcome.
|
|
532
|
+
message = undefined;
|
|
533
|
+
if (input.runtime !== undefined && input.provider !== undefined) {
|
|
534
|
+
try {
|
|
535
|
+
const shown = renderNotices(await input.runtime.notices.list(), input);
|
|
536
|
+
notices = shown.map((entry) => entry.text);
|
|
537
|
+
rendered = shown.map((entry) => entry.notice);
|
|
538
|
+
} catch (error) {
|
|
539
|
+
console.warn('[notifications] consent notices are unavailable.', error);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
return view();
|
|
543
|
+
},
|
|
544
|
+
async recordNoticesShown() {
|
|
545
|
+
const runtime = input.runtime;
|
|
546
|
+
const shown = rendered;
|
|
547
|
+
rendered = [];
|
|
548
|
+
if (runtime === undefined || shown.length === 0) {
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
// `async` so a runtime that throws synchronously is a rejection here too.
|
|
552
|
+
const results = await Promise.allSettled(
|
|
553
|
+
shown.map(async (notice) => runtime.notices.recordShown(notice)),
|
|
554
|
+
);
|
|
555
|
+
for (const result of results) {
|
|
556
|
+
if (result.status === 'rejected') {
|
|
557
|
+
// The server pushes an unrecorded notice before it is due.
|
|
558
|
+
console.warn('[notifications] a shown notice could not be recorded.', result.reason);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
},
|
|
562
|
+
turnOn() {
|
|
563
|
+
return run(() => request(permissionTopics));
|
|
564
|
+
},
|
|
565
|
+
turnOff() {
|
|
566
|
+
return run(async () => {
|
|
567
|
+
const provider = input.provider;
|
|
568
|
+
if (input.runtime !== undefined) {
|
|
569
|
+
await input.runtime.revokePermission({ topics: permissionTopics });
|
|
570
|
+
} else if (provider?.revokePermission !== undefined) {
|
|
571
|
+
// A consent the game rendered is withdrawn in the game too (RFC
|
|
572
|
+
// "Turn off where it was turned on"), with or without a backend.
|
|
573
|
+
await provider.revokePermission({ topics: permissionTopics });
|
|
574
|
+
} else {
|
|
575
|
+
return strings.unavailable;
|
|
576
|
+
}
|
|
577
|
+
return strings.revoked;
|
|
578
|
+
});
|
|
579
|
+
},
|
|
580
|
+
openSettings() {
|
|
581
|
+
return run(async () => {
|
|
582
|
+
const opened = input.runtime === undefined
|
|
583
|
+
? await input.provider?.openSettings?.()
|
|
584
|
+
: await input.runtime.openSettings();
|
|
585
|
+
return opened === 'opened' ? undefined : strings.unavailable;
|
|
586
|
+
});
|
|
587
|
+
},
|
|
588
|
+
logIn() {
|
|
589
|
+
return run(async () => {
|
|
590
|
+
if (input.requestLogin === undefined) {
|
|
591
|
+
return strings.login;
|
|
592
|
+
}
|
|
593
|
+
await input.requestLogin();
|
|
594
|
+
return undefined;
|
|
595
|
+
});
|
|
596
|
+
},
|
|
597
|
+
agree(topic) {
|
|
598
|
+
return run(async () => {
|
|
599
|
+
// Only on a host that asks per topic, as the view lists them: elsewhere
|
|
600
|
+
// the app-wide permission covers every topic.
|
|
601
|
+
if (capabilities?.permission.scope !== 'topic'
|
|
602
|
+
|| !agreementTopics.some((candidate) => candidate.id === topic)) {
|
|
603
|
+
return strings.unavailable;
|
|
604
|
+
}
|
|
605
|
+
// The host sheet for this topic, from the player's tap; the runtime
|
|
606
|
+
// records the acceptance, and the host enforces it on send.
|
|
607
|
+
const answer = await request([topic]);
|
|
608
|
+
if (answer === strings.granted || answer === strings.grantedNoServer) {
|
|
609
|
+
agreedThisSession.add(topic);
|
|
610
|
+
}
|
|
611
|
+
return answer;
|
|
612
|
+
});
|
|
613
|
+
},
|
|
614
|
+
setReminder(topic, enabled) {
|
|
615
|
+
return run(async () => {
|
|
616
|
+
if (input.runtime === undefined) {
|
|
617
|
+
return strings.noRecording;
|
|
618
|
+
}
|
|
619
|
+
if (!reminderTopics.some((candidate) => candidate.id === topic)) {
|
|
620
|
+
return strings.unavailable;
|
|
621
|
+
}
|
|
622
|
+
if ((await readChoices()) !== 'records') {
|
|
623
|
+
// Without the player's records the screen shows no switch, and no change is made blind.
|
|
624
|
+
return strings.choicesUnreadable;
|
|
625
|
+
}
|
|
626
|
+
if (enabled) {
|
|
627
|
+
const snapshot = await input.runtime.getPermission({ topics: [topic] });
|
|
628
|
+
const state = snapshot.app ?? snapshot.topics[topic] ?? 'unsupported';
|
|
629
|
+
if (state !== 'granted') {
|
|
630
|
+
// The prompt is this tap's: an Apps in Toss topic gets its own sheet.
|
|
631
|
+
const answer = await request([topic]);
|
|
632
|
+
if (answer !== strings.granted) {
|
|
633
|
+
return answer;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
await input.runtime.setTopicPreference({ topic, enabled });
|
|
638
|
+
return enabled ? strings.reminderOn : strings.reminderOff;
|
|
639
|
+
});
|
|
640
|
+
},
|
|
641
|
+
setAdvertising(change) {
|
|
642
|
+
return run(async () => {
|
|
643
|
+
if (input.runtime === undefined) {
|
|
644
|
+
return strings.noRecording;
|
|
645
|
+
}
|
|
646
|
+
if (advertisingTopics.length === 0 || (change.advertising === undefined && change.night === undefined)) {
|
|
647
|
+
return strings.unavailable;
|
|
648
|
+
}
|
|
649
|
+
// Decide from the records as they are now, not as the screen last
|
|
650
|
+
// showed them: another device may have changed them since.
|
|
651
|
+
if ((await readChoices()) !== 'records') {
|
|
652
|
+
// The current consent is unknown, so a change could withdraw the wrong one.
|
|
653
|
+
return strings.choicesUnreadable;
|
|
654
|
+
}
|
|
655
|
+
const advertisingGranted = latestState('advertising') === 'granted';
|
|
656
|
+
const nightGranted = latestState('night') === 'granted';
|
|
657
|
+
const advertisingAfter = change.advertising ?? advertisingGranted;
|
|
658
|
+
if (change.night === true && !advertisingAfter) {
|
|
659
|
+
// Night consent adds to advertising consent; alone it allows nothing.
|
|
660
|
+
return strings.nightNeedsAdvertising;
|
|
661
|
+
}
|
|
662
|
+
// Night consent counts only alongside advertising consent: a night
|
|
663
|
+
// consent left standing after advertising was withdrawn (by an older
|
|
664
|
+
// client) is not revived by a new advertising consent, and is
|
|
665
|
+
// withdrawn with the next change. Withdrawing advertising consent
|
|
666
|
+
// withdraws a granted night consent too.
|
|
667
|
+
const nightEffective = advertisingGranted && nightGranted;
|
|
668
|
+
const nightAfter = advertisingAfter ? (change.night ?? nightEffective) : false;
|
|
669
|
+
// Only what changes is written: every advertising or night record is a
|
|
670
|
+
// player's act that may owe a notice, and a withdrawal of a consent
|
|
671
|
+
// never granted is a refusal.
|
|
672
|
+
const writeAdvertising = advertisingAfter === advertisingGranted ? undefined : advertisingAfter;
|
|
673
|
+
// Turning night on writes a fresh consent unless one already counts (a
|
|
674
|
+
// stale one does not); turning it off withdraws any granted record.
|
|
675
|
+
let writeNight: boolean | undefined;
|
|
676
|
+
if (nightAfter) {
|
|
677
|
+
writeNight = nightEffective ? undefined : true;
|
|
678
|
+
} else {
|
|
679
|
+
writeNight = nightGranted ? false : undefined;
|
|
680
|
+
}
|
|
681
|
+
if (writeAdvertising !== undefined || writeNight !== undefined) {
|
|
682
|
+
await input.runtime.setAdvertisingConsent({
|
|
683
|
+
...(writeAdvertising === undefined
|
|
684
|
+
? {}
|
|
685
|
+
: { advertising: writeAdvertising ? 'granted' : 'withdrawn' }),
|
|
686
|
+
...(writeNight === undefined ? {} : { night: writeNight ? 'granted' : 'withdrawn' }),
|
|
687
|
+
textVersion: input.consentTextVersion,
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
// The message says what changed (or, when nothing had to, the state asked for).
|
|
691
|
+
if (writeAdvertising === false && writeNight === false) {
|
|
692
|
+
return strings.advertisingAndNightOff;
|
|
693
|
+
}
|
|
694
|
+
if (writeAdvertising === true && writeNight === false) {
|
|
695
|
+
return strings.advertisingOnStaleNightOff;
|
|
696
|
+
}
|
|
697
|
+
if (writeAdvertising !== undefined) {
|
|
698
|
+
return writeAdvertising ? strings.advertisingOn : strings.advertisingOff;
|
|
699
|
+
}
|
|
700
|
+
if (writeNight !== undefined) {
|
|
701
|
+
return writeNight ? strings.nightOn : strings.nightOff;
|
|
702
|
+
}
|
|
703
|
+
if (change.advertising !== undefined) {
|
|
704
|
+
return advertisingAfter ? strings.advertisingOn : strings.advertisingOff;
|
|
705
|
+
}
|
|
706
|
+
return nightAfter ? strings.nightOn : strings.nightOff;
|
|
707
|
+
});
|
|
708
|
+
},
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function isRequestable(state: NotificationPermissionState): boolean {
|
|
713
|
+
return state === 'prompt' || state === 'prompt-with-rationale' || state === 'denied' || state === 'unknown';
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** A per-topic host's state for the screen: granted when every reminder is, else the first other state. */
|
|
717
|
+
function combinedState(
|
|
718
|
+
snapshot: NotificationPermissionSnapshot,
|
|
719
|
+
topics: readonly string[],
|
|
720
|
+
): NotificationPermissionState {
|
|
721
|
+
const states = topics.map((topic) => snapshot.topics[topic] ?? 'unsupported');
|
|
722
|
+
if (states.length === 0) {
|
|
723
|
+
return 'unsupported';
|
|
724
|
+
}
|
|
725
|
+
return states.find((state) => state !== 'granted') ?? 'granted';
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* The notices the screen can show, in the player's language, filled with the
|
|
730
|
+
* record's Asia/Seoul date as the server fills a pushed one. A notice without
|
|
731
|
+
* text here is left out, and so is not recorded as shown.
|
|
732
|
+
*/
|
|
733
|
+
function renderNotices(
|
|
734
|
+
listed: readonly NotificationNotice[],
|
|
735
|
+
input: StarterNotificationSettingsInput,
|
|
736
|
+
): readonly { readonly notice: NotificationNotice; readonly text: { readonly title: string; readonly body: string } }[] {
|
|
737
|
+
const noticeCopy = input.noticeCopy;
|
|
738
|
+
if (noticeCopy === undefined) {
|
|
739
|
+
return [];
|
|
740
|
+
}
|
|
741
|
+
return listed.flatMap((notice) => {
|
|
742
|
+
const text = findNotificationCopy(
|
|
743
|
+
{ copy: notificationNoticeVariantCopy(noticeCopy, notice.variant) },
|
|
744
|
+
input.locale,
|
|
745
|
+
input.defaultLocale,
|
|
746
|
+
);
|
|
747
|
+
if (text === undefined) {
|
|
748
|
+
return [];
|
|
749
|
+
}
|
|
750
|
+
const consentDate = toSeoulCalendarDate(notice.record.at);
|
|
751
|
+
return [
|
|
752
|
+
{
|
|
753
|
+
notice,
|
|
754
|
+
text: {
|
|
755
|
+
title: fillNotificationTemplate(text.title, { consentDate }),
|
|
756
|
+
body: fillNotificationTemplate(text.body, { consentDate }),
|
|
757
|
+
},
|
|
758
|
+
},
|
|
759
|
+
];
|
|
760
|
+
});
|
|
761
|
+
}
|