enhance: Implement the toggle to (or not to) close push notifications when notifications or messages are read (#9219)
* create file * wip * fix * wip * tabun dekita * ✌️ * implement subscribe push notification button to tutorial * check-exists→show-registration * add column sendReadMessage * fix migration file * sw api * change PushNotificationService * wip * ✌️ * fix tutorial footer flex
This commit is contained in:
		
							parent
							
								
									96dda253b0
								
							
						
					
					
						commit
						4ecc42744c
					
				
					 14 changed files with 483 additions and 106 deletions
				
			
		|  | @ -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: "既に設定は完了しています。" | ||||
|  |  | |||
|  | @ -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"`); | ||||
|     } | ||||
| } | ||||
|  | @ -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: { | ||||
|  |  | |||
|  | @ -34,4 +34,9 @@ export class SwSubscription { | |||
| 		length: 128, | ||||
| 	}) | ||||
| 	public publickey: string; | ||||
| 
 | ||||
| 	@Column('boolean', { | ||||
| 		default: false, | ||||
| 	}) | ||||
| 	public sendReadMessage: boolean; | ||||
| } | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
|  | @ -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], | ||||
|  |  | |||
|  | @ -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, | ||||
| 			}; | ||||
| 		}); | ||||
| 	} | ||||
|  |  | |||
|  | @ -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; | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|  | @ -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, | ||||
| 			}); | ||||
| 		}); | ||||
|  |  | |||
|  | @ -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, | ||||
| 			}; | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										167
									
								
								packages/client/src/components/MkPushNotificationAllowButton.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										167
									
								
								packages/client/src/components/MkPushNotificationAllowButton.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -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> | ||||
|  | @ -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(() => []); | ||||
|  |  | |||
|  | @ -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; | ||||
| } | ||||
| 
 | ||||
| .title { | ||||
| 	display: flex; | ||||
| 	flex-wrap: wrap; | ||||
| 
 | ||||
| 	&Text { | ||||
| 		margin: 4px 0; | ||||
| 		padding-right: 4px; | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| .step { | ||||
| 	margin-left: auto; | ||||
| 
 | ||||
| 	&Arrow { | ||||
| 		padding: 4px; | ||||
| 		&:disabled { | ||||
| 			opacity: 0.5; | ||||
| 		} | ||||
| 		&:first-child { | ||||
| 			padding-right: 8px; | ||||
| 		} | ||||
| 		&:last-child { | ||||
| 			padding-left: 8px; | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	> .navigation { | ||||
| 		display: flex; | ||||
| 		flex-direction: row; | ||||
| 		align-items: baseline; | ||||
| 	&Number { | ||||
| 		font-weight: normal; | ||||
| 		margin: 4px; | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| 		> .step { | ||||
| 			> .arrow { | ||||
| 				padding: 4px; | ||||
| .footer { | ||||
| 	display: flex; | ||||
| 	flex-wrap: wrap; | ||||
| 	flex-direction: row; | ||||
| 	justify-content: right; | ||||
| 
 | ||||
| 				&:disabled { | ||||
| 					opacity: 0.5; | ||||
| 				} | ||||
| 
 | ||||
| 				&:first-child { | ||||
| 					padding-right: 8px; | ||||
| 				} | ||||
| 
 | ||||
| 				&:last-child { | ||||
| 					padding-left: 8px; | ||||
| 				} | ||||
| 			} | ||||
| 
 | ||||
| 			> span { | ||||
| 				margin: 0 4px; | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		> .ok { | ||||
| 			margin-left: auto; | ||||
| 		} | ||||
| 	&Item { | ||||
| 		margin: 4px; | ||||
| 	} | ||||
| } | ||||
| </style> | ||||
|  |  | |||
|  | @ -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; | ||||
| } | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue