diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 3fba36a754406a647826583b6d517fcbd5644ec8..3a53f470e33d876549ae0d50e5cda4f04c037423 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -13,6 +13,7 @@ fetchingAsApObject: "連åˆã«ç…§ä¼šä¸" ok: "OK" gotIt: "ã‚ã‹ã£ãŸ" cancel: "ã‚ャンセル" +noThankYou: "ã‚„ã‚ã¦ãŠã" enterUsername: "ユーザーåを入力" renotedBy: "{user}ãŒRenote" noNotes: "ノートã¯ã‚ã‚Šã¾ã›ã‚“" @@ -898,6 +899,13 @@ navbar: "ナビゲーションãƒãƒ¼" shuffle: "シャッフル" account: "アカウント" move: "移動" +pushNotification: "プッシュ通知" +subscribePushNotification: "プッシュ通知を有効化" +unsubscribePushNotification: "プッシュ通知をåœæ¢ã™ã‚‹" +pushNotificationAlreadySubscribed: "プッシュ通知ã¯æœ‰åŠ¹ã§ã™" +pushNotificationNotSupported: "ブラウザã‹ã‚¤ãƒ³ã‚¹ã‚¿ãƒ³ã‚¹ãŒãƒ—ッシュ通知ã«éžå¯¾å¿œ" +sendPushNotificationReadMessage: "通知やメッセージãŒæ—¢èªã«ãªã£ãŸã‚‰ãƒ—ッシュ通知を削除ã™ã‚‹" +sendPushNotificationReadMessageCaption: "「{emptyPushNotificationMessage}ã€ã¨ã„ã†é€šçŸ¥ãŒä¸€çž¬è¡¨ç¤ºã•ã‚Œã‚‹ã‚ˆã†ã«ãªã‚Šã¾ã™ã€‚端末ã®é›»æ± 消費é‡ãŒå¢—åŠ ã™ã‚‹å¯èƒ½æ€§ãŒã‚ã‚Šã¾ã™ã€‚" _sensitiveMediaDetection: description: "機械å¦ç¿’を使ã£ã¦è‡ªå‹•ã§ã‚»ãƒ³ã‚·ãƒ†ã‚£ãƒ–ãªãƒ¡ãƒ‡ã‚£ã‚¢ã‚’検出ã—ã€ãƒ¢ãƒ‡ãƒ¬ãƒ¼ã‚·ãƒ§ãƒ³ã«å½¹ç«‹ã¦ã‚‹ã“ã¨ãŒã§ãã¾ã™ã€‚サーãƒãƒ¼ã®è² è·ãŒå°‘ã—増ãˆã¾ã™ã€‚" @@ -1235,6 +1243,9 @@ _tutorial: step7_1: "ã“ã‚Œã§ã€Misskeyã®åŸºæœ¬çš„ãªä½¿ã„æ–¹ã®èª¬æ˜Žã¯çµ‚ã‚ã‚Šã¾ã—ãŸã€‚ãŠç–²ã‚Œæ§˜ã§ã—ãŸã€‚" step7_2: "ã‚‚ã£ã¨Misskeyã«ã¤ã„ã¦çŸ¥ã‚ŠãŸã„ã¨ãã¯ã€{help}を見ã¦ã¿ã¦ãã ã•ã„。" step7_3: "ã§ã¯ã€Misskeyã‚’ãŠæ¥½ã—ã¿ãã ã•ã„🚀" + step8_1: "最後ã«ã€ãƒ—ッシュ通知を有効化ã—ã¦ã¿ã¾ã›ã‚“ã‹ï¼Ÿ" + step8_2: "プッシュ通知をå—ã‘å–ã‚‹ã“ã¨ã§ã€Misskeyã‚’é–‹ã„ã¦ã„ãªã„時ã«ã‚‚リアクションやフォãƒãƒ¼ã€ãƒ¡ãƒ³ã‚·ãƒ§ãƒ³ãªã©ã«æ°—ã¥ã‘ã¾ã™ã€‚" + step8_3: "通知ã®è¨å®šã¯å¾Œã‹ã‚‰å¤‰æ›´ã§ãã¾ã™ã€‚" _2fa: alreadyRegistered: "æ—¢ã«è¨å®šã¯å®Œäº†ã—ã¦ã„ã¾ã™ã€‚" diff --git a/packages/backend/migration/1669138716634-whetherPushNotifyToSendReadMessage.js b/packages/backend/migration/1669138716634-whetherPushNotifyToSendReadMessage.js new file mode 100644 index 0000000000000000000000000000000000000000..2265b006170deabafe129b9f5a69d66d8acb3f5e --- /dev/null +++ b/packages/backend/migration/1669138716634-whetherPushNotifyToSendReadMessage.js @@ -0,0 +1,11 @@ +export class whetherPushNotifyToSendReadMessage1669138716634 { + name = 'whetherPushNotifyToSendReadMessage1669138716634' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "sw_subscription" ADD "sendReadMessage" boolean NOT NULL DEFAULT false`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "sw_subscription" DROP COLUMN "sendReadMessage"`); + } +} diff --git a/packages/backend/src/core/PushNotificationService.ts b/packages/backend/src/core/PushNotificationService.ts index df5284de4be4ed2111ba7ae130dda0861cf1f8d6..842cd1a9f8e8a765e307526cde7c7785aa7b2464 100644 --- a/packages/backend/src/core/PushNotificationService.ts +++ b/packages/backend/src/core/PushNotificationService.ts @@ -69,6 +69,14 @@ export class PushNotificationService { }); for (const subscription of subscriptions) { + // Continue if sendReadMessage is false + if ([ + 'readNotifications', + 'readAllNotifications', + 'readAllMessagingMessages', + 'readAllMessagingMessagesOfARoom', + ].includes(type) && !subscription.sendReadMessage) continue; + const pushSubscription = { endpoint: subscription.endpoint, keys: { diff --git a/packages/backend/src/models/entities/SwSubscription.ts b/packages/backend/src/models/entities/SwSubscription.ts index 51b9786e96f8512c31545e9cdd84be660e1d767f..0658294983eeb8a4d5a6234bbfe008009ea5cefe 100644 --- a/packages/backend/src/models/entities/SwSubscription.ts +++ b/packages/backend/src/models/entities/SwSubscription.ts @@ -34,4 +34,9 @@ export class SwSubscription { length: 128, }) public publickey: string; + + @Column('boolean', { + default: false, + }) + public sendReadMessage: boolean; } diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index e41ed388b43a82bbbdfa127dea4874bf0a8e36cd..647f60317a38c46c7d65e94290ed6cd87ec0971f 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -272,6 +272,8 @@ import * as ep___resetDb from './endpoints/reset-db.js'; import * as ep___resetPassword from './endpoints/reset-password.js'; import * as ep___serverInfo from './endpoints/server-info.js'; import * as ep___stats from './endpoints/stats.js'; +import * as ep___sw_show_registration from './endpoints/sw/show-registration.js'; +import * as ep___sw_update_registration from './endpoints/sw/update-registration.js'; import * as ep___sw_register from './endpoints/sw/register.js'; import * as ep___sw_unregister from './endpoints/sw/unregister.js'; import * as ep___test from './endpoints/test.js'; @@ -588,6 +590,8 @@ const $resetDb: Provider = { provide: 'ep:reset-db', useClass: ep___resetDb.defa const $resetPassword: Provider = { provide: 'ep:reset-password', useClass: ep___resetPassword.default }; const $serverInfo: Provider = { provide: 'ep:server-info', useClass: ep___serverInfo.default }; const $stats: Provider = { provide: 'ep:stats', useClass: ep___stats.default }; +const $sw_show_registration: Provider = { provide: 'ep:sw/show-registration', useClass: ep___sw_show_registration.default }; +const $sw_update_registration: Provider = { provide: 'ep:sw/update-registration', useClass: ep___sw_update_registration.default }; const $sw_register: Provider = { provide: 'ep:sw/register', useClass: ep___sw_register.default }; const $sw_unregister: Provider = { provide: 'ep:sw/unregister', useClass: ep___sw_unregister.default }; const $test: Provider = { provide: 'ep:test', useClass: ep___test.default }; @@ -908,6 +912,8 @@ const $fetchRss: Provider = { provide: 'ep:fetch-rss', useClass: ep___fetchRss.d $resetPassword, $serverInfo, $stats, + $sw_show_registration, + $sw_update_registration, $sw_register, $sw_unregister, $test, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index b2ab36e070b1a1741d80a36eec4c8a49e281ac63..6d10cb8f35ade54c034b07a99edbc892150c28b1 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -271,6 +271,8 @@ import * as ep___resetDb from './endpoints/reset-db.js'; import * as ep___resetPassword from './endpoints/reset-password.js'; import * as ep___serverInfo from './endpoints/server-info.js'; import * as ep___stats from './endpoints/stats.js'; +import * as ep___sw_show_registration from './endpoints/sw/show-registration.js'; +import * as ep___sw_update_registration from './endpoints/sw/update-registration.js'; import * as ep___sw_register from './endpoints/sw/register.js'; import * as ep___sw_unregister from './endpoints/sw/unregister.js'; import * as ep___test from './endpoints/test.js'; @@ -585,6 +587,8 @@ const eps = [ ['reset-password', ep___resetPassword], ['server-info', ep___serverInfo], ['stats', ep___stats], + ['sw/show-registration', ep___sw_show_registration], + ['sw/update-registration', ep___sw_update_registration], ['sw/register', ep___sw_register], ['sw/unregister', ep___sw_unregister], ['test', ep___test], diff --git a/packages/backend/src/server/api/endpoints/sw/register.ts b/packages/backend/src/server/api/endpoints/sw/register.ts index ddec877dd4246274660224bc22a36815abfebdf9..bfd5de7b007f102cbd96e370820bc9066c16c9a4 100644 --- a/packages/backend/src/server/api/endpoints/sw/register.ts +++ b/packages/backend/src/server/api/endpoints/sw/register.ts @@ -25,6 +25,18 @@ export const meta = { type: 'string', optional: false, nullable: true, }, + userId: { + type: 'string', + optional: false, nullable: false, + }, + endpoint: { + type: 'string', + optional: false, nullable: false, + }, + sendReadMessage: { + type: 'boolean', + optional: false, nullable: false, + }, }, }, } as const; @@ -35,6 +47,7 @@ export const paramDef = { endpoint: { type: 'string' }, auth: { type: 'string' }, publickey: { type: 'string' }, + sendReadMessage: { type: 'boolean', default: false }, }, required: ['endpoint', 'auth', 'publickey'], } as const; @@ -64,6 +77,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { return { state: 'already-subscribed' as const, key: instance.swPublicKey, + userId: me.id, + endpoint: exist.endpoint, + sendReadMessage: exist.sendReadMessage, }; } @@ -74,11 +90,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { endpoint: ps.endpoint, auth: ps.auth, publickey: ps.publickey, + sendReadMessage: ps.sendReadMessage, }); return { state: 'subscribed' as const, key: instance.swPublicKey, + userId: me.id, + endpoint: ps.endpoint, + sendReadMessage: ps.sendReadMessage, }; }); } diff --git a/packages/backend/src/server/api/endpoints/sw/show-registration.ts b/packages/backend/src/server/api/endpoints/sw/show-registration.ts new file mode 100644 index 0000000000000000000000000000000000000000..bede10be5cf3be4f35c964eec04b5eb2ccec9114 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/sw/show-registration.ts @@ -0,0 +1,66 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { SwSubscriptionsRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; + +export const meta = { + tags: ['account'], + + requireCredential: true, + + description: 'Check push notification registration exists.', + + res: { + type: 'object', + optional: false, nullable: true, + properties: { + userId: { + type: 'string', + optional: false, nullable: false, + }, + endpoint: { + type: 'string', + optional: false, nullable: false, + }, + sendReadMessage: { + type: 'boolean', + optional: false, nullable: false, + }, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + endpoint: { type: 'string' }, + }, + required: ['endpoint'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint<typeof meta, typeof paramDef> { + constructor( + @Inject(DI.swSubscriptionsRepository) + private swSubscriptionsRepository: SwSubscriptionsRepository, + ) { + super(meta, paramDef, async (ps, me) => { + // if already subscribed + const exist = await this.swSubscriptionsRepository.findOneBy({ + userId: me.id, + endpoint: ps.endpoint, + }); + + if (exist != null) { + return { + userId: exist.userId, + endpoint: exist.endpoint, + sendReadMessage: exist.sendReadMessage, + }; + } + + return null; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/sw/unregister.ts b/packages/backend/src/server/api/endpoints/sw/unregister.ts index 5772eeee266a8b490fb668917817593e388040df..f12b98617d33c03ae96d4c3824d90807d726ad1d 100644 --- a/packages/backend/src/server/api/endpoints/sw/unregister.ts +++ b/packages/backend/src/server/api/endpoints/sw/unregister.ts @@ -6,7 +6,7 @@ import { DI } from '@/di-symbols.js'; export const meta = { tags: ['account'], - requireCredential: true, + requireCredential: false, description: 'Unregister from receiving push notifications.', } as const; @@ -28,7 +28,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { ) { super(meta, paramDef, async (ps, me) => { await this.swSubscriptionsRepository.delete({ - userId: me.id, + ...(me ? { userId: me.id } : {}), endpoint: ps.endpoint, }); }); diff --git a/packages/backend/src/server/api/endpoints/sw/update-registration.ts b/packages/backend/src/server/api/endpoints/sw/update-registration.ts new file mode 100644 index 0000000000000000000000000000000000000000..9f08c8148d3e0c25e8a48664c7289e1b10ced845 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/sw/update-registration.ts @@ -0,0 +1,82 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { SwSubscriptionsRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['account'], + + requireCredential: true, + + description: 'Update push notification registration.', + + res: { + type: 'object', + optional: false, nullable: false, + properties: { + userId: { + type: 'string', + optional: false, nullable: false, + }, + endpoint: { + type: 'string', + optional: false, nullable: false, + }, + sendReadMessage: { + type: 'boolean', + optional: false, nullable: false, + }, + }, + }, + errors: { + noSuchRegistration: { + message: 'No such registration.', + code: 'NO_SUCH_REGISTRATION', + id: ' b09d8066-8064-5613-efb6-0e963b21d012', + }, + } +} as const; + +export const paramDef = { + type: 'object', + properties: { + endpoint: { type: 'string' }, + sendReadMessage: { type: 'boolean' }, + }, + required: ['endpoint'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint<typeof meta, typeof paramDef> { + constructor( + @Inject(DI.swSubscriptionsRepository) + private swSubscriptionsRepository: SwSubscriptionsRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const swSubscription = await this.swSubscriptionsRepository.findOneBy({ + userId: me.id, + endpoint: ps.endpoint, + }); + + if (swSubscription === null) { + throw new ApiError(meta.errors.noSuchRegistration); + } + + if (ps.sendReadMessage !== undefined) { + swSubscription.sendReadMessage = ps.sendReadMessage; + } + + await this.swSubscriptionsRepository.update(swSubscription.id, { + sendReadMessage: swSubscription.sendReadMessage, + }); + + return { + userId: swSubscription.userId, + endpoint: swSubscription.endpoint, + sendReadMessage: swSubscription.sendReadMessage, + }; + }); + } +} diff --git a/packages/client/src/components/MkPushNotificationAllowButton.vue b/packages/client/src/components/MkPushNotificationAllowButton.vue new file mode 100644 index 0000000000000000000000000000000000000000..a762914e64e4b611efbad13b9493b7b8ffd02d4b --- /dev/null +++ b/packages/client/src/components/MkPushNotificationAllowButton.vue @@ -0,0 +1,167 @@ +<template> +<MkButton + v-if="supported && !pushRegistrationInServer" + type="button" + primary + :gradate="gradate" + :rounded="rounded" + :inline="inline" + :autofocus="autofocus" + :wait="wait" + :full="full" + @click="subscribe" +> + {{ i18n.ts.subscribePushNotification }} +</MkButton> +<MkButton + v-else-if="!showOnlyToRegister && ($i ? pushRegistrationInServer : pushSubscription)" + type="button" + :primary="false" + :gradate="gradate" + :rounded="rounded" + :inline="inline" + :autofocus="autofocus" + :wait="wait" + :full="full" + @click="unsubscribe" +> + {{ i18n.ts.unsubscribePushNotification }} +</MkButton> +<MkButton v-else-if="$i && pushRegistrationInServer" disabled :rounded="rounded" :inline="inline" :wait="wait" :full="full"> + {{ i18n.ts.pushNotificationAlreadySubscribed }} +</MkButton> +<MkButton v-else-if="!supported" disabled :rounded="rounded" :inline="inline" :wait="wait" :full="full"> + {{ i18n.ts.pushNotificationNotSupported }} +</MkButton> +</template> + +<script setup lang="ts"> +import { $i, getAccounts } from '@/account'; +import MkButton from '@/components/MkButton.vue'; +import { instance } from '@/instance'; +import { api, apiWithDialog, promiseDialog } from '@/os'; +import { i18n } from '@/i18n'; + +defineProps<{ + primary?: boolean; + gradate?: boolean; + rounded?: boolean; + inline?: boolean; + link?: boolean; + to?: string; + autofocus?: boolean; + wait?: boolean; + danger?: boolean; + full?: boolean; + showOnlyToRegister?: boolean; +}>(); + +// ServiceWorker registration +let registration = $ref<ServiceWorkerRegistration | undefined>(); +// If this browser supports push notification +let supported = $ref(false); +// If this browser has already subscribed to push notification +let pushSubscription = $ref<PushSubscription | null>(null); +let pushRegistrationInServer = $ref<{ state?: string; key?: string; userId: string; endpoint: string; sendReadMessage: boolean; } | undefined>(); + +function subscribe() { + if (!registration || !supported || !instance.swPublickey) return; + + // SEE: https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe#Parameters + return promiseDialog(registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(instance.swPublickey) + }) + .then(async subscription => { + pushSubscription = subscription; + + // Register + pushRegistrationInServer = await api('sw/register', { + endpoint: subscription.endpoint, + auth: encode(subscription.getKey('auth')), + publickey: encode(subscription.getKey('p256dh')) + }); + }, async err => { // When subscribe failed + // 通知ãŒè¨±å¯ã•ã‚Œã¦ã„ãªã‹ã£ãŸã¨ã + if (err?.name === 'NotAllowedError') { + console.info('User denied the notification permission request.'); + return; + } + + // é•ã†applicationServerKey (ã¾ãŸã¯ gcm_sender_id)ã®ã‚µãƒ–スクリプション㌠+ // æ—¢ã«å˜åœ¨ã—ã¦ã„ã‚‹ã“ã¨ãŒåŽŸå› ã§ã‚¨ãƒ©ãƒ¼ã«ãªã£ãŸå¯èƒ½æ€§ãŒã‚ã‚‹ã®ã§ã€ + // ãã®ã‚µãƒ–スクリプションを解除ã—ã¦ãŠã + // (ã“ã‚Œã¯å®Ÿè¡Œã•ã‚Œãªã•ãã†ã ã‘ã©ã€ãŠã¾ã˜ãªã„çš„ã«å¤ã„実装ã‹ã‚‰æ®‹ã—ã¦ã‚る) + await unsubscribe(); + }), null, null); +} + +async function unsubscribe() { + if (!pushSubscription) return; + + const endpoint = pushSubscription.endpoint; + const accounts = await getAccounts(); + + pushRegistrationInServer = undefined; + + if ($i && accounts.length >= 2) { + apiWithDialog('sw/unregister', { + i: $i.token, + endpoint, + }); + } else { + pushSubscription.unsubscribe(); + apiWithDialog('sw/unregister', { + endpoint, + }); + pushSubscription = null; + } +} + +function encode(buffer: ArrayBuffer | null) { + return btoa(String.fromCharCode.apply(null, new Uint8Array(buffer))); +} + +/** + * Convert the URL safe base64 string to a Uint8Array + * @param base64String base64 string + */ + function urlBase64ToUint8Array(base64String: string): Uint8Array { + const padding = '='.repeat((4 - base64String.length % 4) % 4); + const base64 = (base64String + padding) + .replace(/-/g, '+') + .replace(/_/g, '/'); + + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +} + +navigator.serviceWorker.ready.then(async swr => { + registration = swr; + + pushSubscription = await registration.pushManager.getSubscription(); + + if (instance.swPublickey && ('PushManager' in window) && $i && $i.token) { + supported = true; + + if (pushSubscription) { + const res = await api('sw/show-registration', { + endpoint: pushSubscription.endpoint, + }); + + if (res) { + pushRegistrationInServer = res; + } + } + } +}); + +defineExpose({ + pushRegistrationInServer: $$(pushRegistrationInServer), +}); +</script> diff --git a/packages/client/src/pages/settings/notifications.vue b/packages/client/src/pages/settings/notifications.vue index 5703e0c6b6df88e7960766b0854bc9bf11125bd9..77ec567da41cc34d50e6d09000ae7b28ff2a6b59 100644 --- a/packages/client/src/pages/settings/notifications.vue +++ b/packages/client/src/pages/settings/notifications.vue @@ -6,6 +6,18 @@ <FormLink class="_formBlock" @click="readAllUnreadNotes">{{ i18n.ts.markAsReadAllUnreadNotes }}</FormLink> <FormLink class="_formBlock" @click="readAllMessagingMessages">{{ i18n.ts.markAsReadAllTalkMessages }}</FormLink> </FormSection> + <FormSection> + <template #label>{{ i18n.ts.pushNotification }}</template> + <MkPushNotificationAllowButton ref="allowButton" /> + <FormSwitch class="_formBlock" :disabled="!pushRegistrationInServer" :model-value="sendReadMessage" @update:modelValue="onChangeSendReadMessage"> + <template #label>{{ i18n.ts.sendPushNotificationReadMessage }}</template> + <template #caption> + <I18n :src="i18n.ts.sendPushNotificationReadMessageCaption"> + <template #emptyPushNotificationMessage>{{ i18n.ts._notification.emptyPushNotificationMessage }}</template> + </I18n> + </template> + </FormSwitch> + </FormSection> </div> </template> @@ -15,10 +27,16 @@ import { notificationTypes } from 'misskey-js'; import FormButton from '@/components/MkButton.vue'; import FormLink from '@/components/form/link.vue'; import FormSection from '@/components/form/section.vue'; +import FormSwitch from '@/components/form/switch.vue'; import * as os from '@/os'; import { $i } from '@/account'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; +import MkPushNotificationAllowButton from '@/components/MkPushNotificationAllowButton.vue'; + +let allowButton = $ref<InstanceType<typeof MkPushNotificationAllowButton>>(); +let pushRegistrationInServer = $computed(() => allowButton?.pushRegistrationInServer); +let sendReadMessage = $computed(() => pushRegistrationInServer?.sendReadMessage || false); async function readAllUnreadNotes() { await os.api('i/read-all-unread-notes'); @@ -49,6 +67,18 @@ function configure() { }, 'closed'); } +function onChangeSendReadMessage(v: boolean) { + if (!pushRegistrationInServer) return; + + os.apiWithDialog('sw/update-registration', { + endpoint: pushRegistrationInServer.endpoint, + sendReadMessage: v, + }).then(res => { + if (!allowButton) return; + allowButton.pushRegistrationInServer = res; + }); +} + const headerActions = $computed(() => []); const headerTabs = $computed(() => []); diff --git a/packages/client/src/pages/timeline.tutorial.vue b/packages/client/src/pages/timeline.tutorial.vue index 7f08ccc2a1aea96a96cab49cbc75909a360c001d..9683cc22a5b4502f73614a822c444aa9ba326097 100644 --- a/packages/client/src/pages/timeline.tutorial.vue +++ b/packages/client/src/pages/timeline.tutorial.vue @@ -1,6 +1,17 @@ <template> -<div class="_card tbkwesmv"> - <div class="_title"><i class="fas fa-info-circle"></i> {{ i18n.ts._tutorial.title }}</div> +<div class="_card"> + <div :class="$style.title" class="_title"> + <div :class="$style.titleText"><i class="fas fa-info-circle"></i> {{ i18n.ts._tutorial.title }}</div> + <div :class="$style.step"> + <button class="_button" :class="$style.stepArrow" :disabled="tutorial === 0" @click="tutorial--"> + <i class="fas fa-chevron-left"></i> + </button> + <span :class="$style.stepNumber">{{ tutorial + 1 }} / {{ tutorialsNumber }}</span> + <button class="_button" :class="$style.stepArrow" :disabled="tutorial === tutorialsNumber - 1" @click="tutorial++"> + <i class="fas fa-chevron-right"></i> + </button> + </div> + </div> <div v-if="tutorial === 0" class="_content"> <div>{{ i18n.ts._tutorial.step1_1 }}</div> <div>{{ i18n.ts._tutorial.step1_2 }}</div> @@ -15,7 +26,7 @@ <div>{{ i18n.ts._tutorial.step3_1 }}</div> <div>{{ i18n.ts._tutorial.step3_2 }}</div> <div>{{ i18n.ts._tutorial.step3_3 }}</div> - <small>{{ i18n.ts._tutorial.step3_4 }}</small> + <small :class="$style.small">{{ i18n.ts._tutorial.step3_4 }}</small> </div> <div v-else-if="tutorial === 3" class="_content"> <div>{{ i18n.ts._tutorial.step4_1 }}</div> @@ -32,7 +43,7 @@ </template> </I18n> <div>{{ i18n.ts._tutorial.step5_3 }}</div> - <small>{{ i18n.ts._tutorial.step5_4 }}</small> + <small :class="$style.small">{{ i18n.ts._tutorial.step5_4 }}</small> </div> <div v-else-if="tutorial === 5" class="_content"> <div>{{ i18n.ts._tutorial.step6_1 }}</div> @@ -48,19 +59,20 @@ </I18n> <div>{{ i18n.ts._tutorial.step7_3 }}</div> </div> + <div v-else-if="tutorial === 7" class="_content"> + <div>{{ i18n.ts._tutorial.step8_1 }}</div> + <div>{{ i18n.ts._tutorial.step8_2 }}</div> + <small :class="$style.small">{{ i18n.ts._tutorial.step8_3 }}</small> + </div> - <div class="_footer navigation"> - <div class="step"> - <button class="arrow _button" :disabled="tutorial === 0" @click="tutorial--"> - <i class="fas fa-chevron-left"></i> - </button> - <span>{{ tutorial + 1 }} / 7</span> - <button class="arrow _button" :disabled="tutorial === 6" @click="tutorial++"> - <i class="fas fa-chevron-right"></i> - </button> - </div> - <MkButton v-if="tutorial === 6" class="ok" primary @click="tutorial = -1"><i class="fas fa-check"></i> {{ i18n.ts.gotIt }}</MkButton> - <MkButton v-else class="ok" primary @click="tutorial++"><i class="fas fa-check"></i> {{ i18n.ts.next }}</MkButton> + <div class="_footer" :class="$style.footer"> + <template v-if="tutorial === tutorialsNumber - 1"> + <MkPushNotificationAllowButton :class="$style.footerItem" primary show-only-to-register @click="tutorial = -1" /> + <MkButton :class="$style.footerItem" :primary="false" @click="tutorial = -1">{{ i18n.ts.noThankYou }}</MkButton> + </template> + <template v-else> + <MkButton :class="$style.footerItem" primary @click="tutorial++"><i class="fas fa-check"></i> {{ i18n.ts.next }}</MkButton> + </template> </div> </div> </template> @@ -68,53 +80,63 @@ <script lang="ts" setup> import { computed } from 'vue'; import MkButton from '@/components/MkButton.vue'; +import MkPushNotificationAllowButton from '@/components/MkPushNotificationAllowButton.vue'; import { defaultStore } from '@/store'; import { i18n } from '@/i18n'; +const tutorialsNumber = 8; + const tutorial = computed({ get() { return defaultStore.reactiveState.tutorial.value || 0; }, set(value) { defaultStore.set('tutorial', value); }, }); </script> -<style lang="scss" scoped> -.tbkwesmv { - > ._content { - > small { - opacity: 0.7; - } - } +<style lang="scss" module> +.small { + opacity: 0.7; +} - > .navigation { - display: flex; - flex-direction: row; - align-items: baseline; +.title { + display: flex; + flex-wrap: wrap; - > .step { - > .arrow { - padding: 4px; + &Text { + margin: 4px 0; + padding-right: 4px; + } +} - &:disabled { - opacity: 0.5; - } +.step { + margin-left: auto; - &:first-child { - padding-right: 8px; - } + &Arrow { + padding: 4px; + &:disabled { + opacity: 0.5; + } + &:first-child { + padding-right: 8px; + } + &:last-child { + padding-left: 8px; + } + } - &:last-child { - padding-left: 8px; - } - } + &Number { + font-weight: normal; + margin: 4px; + } +} - > span { - margin: 0 4px; - } - } +.footer { + display: flex; + flex-wrap: wrap; + flex-direction: row; + justify-content: right; - > .ok { - margin-left: auto; - } + &Item { + margin: 4px; } } </style> diff --git a/packages/client/src/scripts/initialize-sw.ts b/packages/client/src/scripts/initialize-sw.ts index 7bacfbdf00ccccfcc8e05d0a0f836e28bd745938..de52f30523aedccb510e12cca573b090aa9a1294 100644 --- a/packages/client/src/scripts/initialize-sw.ts +++ b/packages/client/src/scripts/initialize-sw.ts @@ -1,6 +1,3 @@ -import { instance } from '@/instance'; -import { $i } from '@/account'; -import { api } from '@/os'; import { lang } from '@/config'; export async function initializeSw() { @@ -12,57 +9,5 @@ export async function initializeSw() { msg: 'initialize', lang, }); - - if (instance.swPublickey && ('PushManager' in window) && $i && $i.token) { - // SEE: https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe#Parameters - registration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: urlBase64ToUint8Array(instance.swPublickey) - }) - .then(subscription => { - function encode(buffer: ArrayBuffer | null) { - return btoa(String.fromCharCode.apply(null, new Uint8Array(buffer))); - } - - // Register - api('sw/register', { - endpoint: subscription.endpoint, - auth: encode(subscription.getKey('auth')), - publickey: encode(subscription.getKey('p256dh')) - }); - }) - // When subscribe failed - .catch(async (err: Error) => { - // 通知ãŒè¨±å¯ã•ã‚Œã¦ã„ãªã‹ã£ãŸã¨ã - if (err.name === 'NotAllowedError') { - return; - } - - // é•ã†applicationServerKey (ã¾ãŸã¯ gcm_sender_id)ã®ã‚µãƒ–スクリプション㌠- // æ—¢ã«å˜åœ¨ã—ã¦ã„ã‚‹ã“ã¨ãŒåŽŸå› ã§ã‚¨ãƒ©ãƒ¼ã«ãªã£ãŸå¯èƒ½æ€§ãŒã‚ã‚‹ã®ã§ã€ - // ãã®ã‚µãƒ–スクリプションを解除ã—ã¦ãŠã - const subscription = await registration.pushManager.getSubscription(); - if (subscription) subscription.unsubscribe(); - }); - } }); } - -/** - * Convert the URL safe base64 string to a Uint8Array - * @param base64String base64 string - */ -function urlBase64ToUint8Array(base64String: string): Uint8Array { - const padding = '='.repeat((4 - base64String.length % 4) % 4); - const base64 = (base64String + padding) - .replace(/-/g, '+') - .replace(/_/g, '/'); - - const rawData = window.atob(base64); - const outputArray = new Uint8Array(rawData.length); - - for (let i = 0; i < rawData.length; ++i) { - outputArray[i] = rawData.charCodeAt(i); - } - return outputArray; -}