diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 3fba36a75..3a53f470e 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 000000000..2265b0061 --- /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 df5284de4..842cd1a9f 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 51b9786e9..065829498 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 e41ed388b..647f60317 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 b2ab36e07..6d10cb8f3 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 ddec877dd..bfd5de7b0 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 { 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 { 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 000000000..bede10be5 --- /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 { + 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 5772eeee2..f12b98617 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 { ) { 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 000000000..9f08c8148 --- /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 { + 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 000000000..a762914e6 --- /dev/null +++ b/packages/client/src/components/MkPushNotificationAllowButton.vue @@ -0,0 +1,167 @@ + + + diff --git a/packages/client/src/pages/settings/notifications.vue b/packages/client/src/pages/settings/notifications.vue index 5703e0c6b..77ec567da 100644 --- a/packages/client/src/pages/settings/notifications.vue +++ b/packages/client/src/pages/settings/notifications.vue @@ -6,6 +6,18 @@ {{ i18n.ts.markAsReadAllUnreadNotes }} {{ i18n.ts.markAsReadAllTalkMessages }} + + + + + + + + @@ -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>(); +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 7f08ccc2a..9683cc22a 100644 --- a/packages/client/src/pages/timeline.tutorial.vue +++ b/packages/client/src/pages/timeline.tutorial.vue @@ -1,6 +1,17 @@
{{ i18n.ts._tutorial.step5_3 }}
- {{ i18n.ts._tutorial.step5_4 }} + {{ i18n.ts._tutorial.step5_4 }}
{{ i18n.ts._tutorial.step6_1 }}
@@ -48,19 +59,20 @@
{{ i18n.ts._tutorial.step7_3 }}
+
+
{{ i18n.ts._tutorial.step8_1 }}
+
{{ i18n.ts._tutorial.step8_2 }}
+ {{ i18n.ts._tutorial.step8_3 }} +
- @@ -68,53 +80,63 @@ - diff --git a/packages/client/src/scripts/initialize-sw.ts b/packages/client/src/scripts/initialize-sw.ts index 7bacfbdf0..de52f3052 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; -}