feat: Improve Push Notification (#7667)
* clean up * ev => data * refactor * clean up * add type * antenna * channel * fix * add Packed type * add PackedRef * fix lint * add emoji schema * add reversiGame * add reversiMatching * remove signin schema (use Signin entity) * add schemas refs, fix Packed type * wip PackedHoge => Packed<'Hoge'> * add Packed type * note-reaction * user * user-group * user-list * note * app, messaging-message * notification * drive-file * drive-folder * following * muting * blocking * hashtag * page * app (with modifying schema) * import user? * channel * antenna * clip * gallery-post * emoji * Packed * reversi-matching * update stream.ts * https://github.com/misskey-dev/misskey/pull/7769#issuecomment-917542339 * fix lint * clean up? * add app * fix * nanka iroiro * wip * wip * fix lint * fix loginId * fix * refactor * refactor * remove follow action * clean up * Revert "remove follow action" This reverts commitdefbb41648. * Revert "clean up" This reverts commitf94919cb9c. * remove fetch specification * renoteの条件追加 * apiFetch => cli * bypass fetch? * fix * refactor: use path alias * temp: add submodule * remove submodule * enhane: unison-reloadに指定したパスに移動できるように * null * null * feat: ログインするアカウントのIDをクエリ文字列で指定する機能 * null * await? * rename * rename * Update read.ts * merge * get-note-summary * fix * swパッケージに * add missing packages * fix getNoteSummary * add webpack-cli * ✌️ * remove plugins * sw-inject分離したがテストしてない * fix notification.vue * remove a blank line * disconnect intersection observer * disconnect2 * fix notification.vue * remove a blank line * disconnect intersection observer * disconnect2 * fix * ✌️ * clean up config * typesを戻した * Update packages/client/src/components/notification.vue Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com> * disconnect * oops * Failed to load the script unexpectedly回避 sw.jsとlib.tsを分離してみた * truncate notification * Update packages/client/src/ui/_common_/common.vue Co-authored-by: syuilo <Syuilotan@yahoo.co.jp> * clean up * clean up * キャッシュ対策 * Truncate push notification message * クライアントがあったらストリームに接続しているということなので通知しない判定の位置を修正 * components/drive-file-thumbnail.vue * components/drive-select-dialog.vue * components/drive-window.vue * merge * fix * Service Workerのビルドにesbuildを使うようにする * return createEmptyNotification() * fix * i18n.ts * update * ✌️ * remove ts-loader * fix * fix * enhance: Service Workerを常に登録するように * pollEnded * URLをsw.jsに戻す * clean up Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com> Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
This commit is contained in:
		| @@ -72,7 +72,7 @@ | ||||
| </template> | ||||
|  | ||||
| <script lang="ts"> | ||||
| import { defineComponent, ref, onMounted, onUnmounted } from 'vue'; | ||||
| import { defineComponent, ref, onMounted, onUnmounted, watch } from 'vue'; | ||||
| import * as misskey from 'misskey-js'; | ||||
| import { getNoteSummary } from '@/scripts/get-note-summary'; | ||||
| import XReactionIcon from './reaction-icon.vue'; | ||||
| @@ -126,6 +126,10 @@ export default defineComponent({ | ||||
| 				const connection = stream.useChannel('main'); | ||||
| 				connection.on('readAllNotifications', () => readObserver.disconnect()); | ||||
|  | ||||
| 				watch(props.notification.isRead, () => { | ||||
| 					readObserver.disconnect(); | ||||
| 				}); | ||||
|  | ||||
| 				onUnmounted(() => { | ||||
| 					readObserver.disconnect(); | ||||
| 					connection.dispose(); | ||||
|   | ||||
| @@ -64,6 +64,31 @@ const onNotification = (notification) => { | ||||
| onMounted(() => { | ||||
| 	const connection = stream.useChannel('main'); | ||||
| 	connection.on('notification', onNotification); | ||||
| 	connection.on('readAllNotifications', () => { | ||||
| 		if (pagingComponent.value) { | ||||
| 			for (const item of pagingComponent.value.queue) { | ||||
| 				item.isRead = true; | ||||
| 			} | ||||
| 			for (const item of pagingComponent.value.items) { | ||||
| 				item.isRead = true; | ||||
| 			} | ||||
| 		} | ||||
| 	}); | ||||
| 	connection.on('readNotifications', notificationIds => { | ||||
| 		if (pagingComponent.value) { | ||||
| 			for (let i = 0; i < pagingComponent.value.queue.length; i++) { | ||||
| 				if (notificationIds.includes(pagingComponent.value.queue[i].id)) { | ||||
| 					pagingComponent.value.queue[i].isRead = true; | ||||
| 				} | ||||
| 			} | ||||
| 			for (let i = 0; i < (pagingComponent.value.items || []).length; i++) { | ||||
| 				if (notificationIds.includes(pagingComponent.value.items[i].id)) { | ||||
| 					pagingComponent.value.items[i].isRead = true; | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	}); | ||||
|  | ||||
| 	onUnmounted(() => { | ||||
| 		connection.dispose(); | ||||
| 	}); | ||||
|   | ||||
| @@ -270,6 +270,7 @@ onDeactivated(() => { | ||||
|  | ||||
| defineExpose({ | ||||
| 	items, | ||||
| 	queue, | ||||
| 	backed, | ||||
| 	reload, | ||||
| 	fetchMoreAhead, | ||||
|   | ||||
| @@ -146,7 +146,6 @@ if ($i && $i.token) { | ||||
| 		try { | ||||
| 			document.body.innerHTML = '<div>Please wait...</div>'; | ||||
| 			await login(i); | ||||
| 			location.reload(); | ||||
| 		} catch (e) { | ||||
| 			// Render the error screen | ||||
| 			// TODO: ちゃんとしたコンポーネントをレンダリングする(v10とかのトラブルシューティングゲーム付きのやつみたいな) | ||||
|   | ||||
							
								
								
									
										3
									
								
								packages/client/src/scripts/get-user-name.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								packages/client/src/scripts/get-user-name.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| export default function(user: { name?: string | null, username: string }): string { | ||||
| 	return user.name || user.username; | ||||
| } | ||||
| @@ -4,26 +4,26 @@ import { api } from '@/os'; | ||||
| import { lang } from '@/config'; | ||||
|  | ||||
| export async function initializeSw() { | ||||
| 	if (instance.swPublickey && | ||||
| 		('serviceWorker' in navigator) && | ||||
| 		('PushManager' in window) && | ||||
| 		$i && $i.token) { | ||||
| 		navigator.serviceWorker.register(`/sw.js`); | ||||
| 	if (!('serviceWorker' in navigator)) return; | ||||
|  | ||||
| 		navigator.serviceWorker.ready.then(registration => { | ||||
| 			registration.active?.postMessage({ | ||||
| 				msg: 'initialize', | ||||
| 				lang, | ||||
| 			}); | ||||
| 	navigator.serviceWorker.register(`/sw.js`, { scope: '/', type: 'classic' }); | ||||
| 	navigator.serviceWorker.ready.then(registration => { | ||||
| 		registration.active?.postMessage({ | ||||
| 			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 => { | ||||
| 			}) | ||||
| 			.then(subscription => { | ||||
| 				function encode(buffer: ArrayBuffer | null) { | ||||
| 					return btoa(String.fromCharCode.apply(null, new Uint8Array(buffer))); | ||||
| 				} | ||||
|  | ||||
| 		 | ||||
| 				// Register | ||||
| 				api('sw/register', { | ||||
| 					endpoint: subscription.endpoint, | ||||
| @@ -37,15 +37,15 @@ export async function initializeSw() { | ||||
| 				if (err.name === 'NotAllowedError') { | ||||
| 					return; | ||||
| 				} | ||||
|  | ||||
| 		 | ||||
| 				// 違うapplicationServerKey (または gcm_sender_id)のサブスクリプションが | ||||
| 				// 既に存在していることが原因でエラーになった可能性があるので、 | ||||
| 				// そのサブスクリプションを解除しておく | ||||
| 				const subscription = await registration.pushManager.getSubscription(); | ||||
| 				if (subscription) subscription.unsubscribe(); | ||||
| 			}); | ||||
| 		}); | ||||
| 	} | ||||
| 		} | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| /** | ||||
|   | ||||
| @@ -1,107 +0,0 @@ | ||||
| /** | ||||
|  * Notification composer of Service Worker | ||||
|  */ | ||||
| declare var self: ServiceWorkerGlobalScope; | ||||
|  | ||||
| import * as misskey from 'misskey-js'; | ||||
|  | ||||
| function getUserName(user: misskey.entities.User): string { | ||||
| 	return user.name || user.username; | ||||
| } | ||||
|  | ||||
| export default async function(type, data, i18n): Promise<[string, NotificationOptions] | null | undefined> { | ||||
| 	if (!i18n) { | ||||
| 		console.log('no i18n'); | ||||
| 		return; | ||||
| 	} | ||||
|  | ||||
| 	switch (type) { | ||||
| 		case 'driveFileCreated': // TODO (Server Side) | ||||
| 			return [i18n.t('_notification.fileUploaded'), { | ||||
| 				body: data.name, | ||||
| 				icon: data.url | ||||
| 			}]; | ||||
| 		case 'notification': | ||||
| 			switch (data.type) { | ||||
| 				case 'mention': | ||||
| 					return [i18n.t('_notification.youGotMention', { name: getUserName(data.user) }), { | ||||
| 						body: data.note.text, | ||||
| 						icon: data.user.avatarUrl | ||||
| 					}]; | ||||
|  | ||||
| 				case 'reply': | ||||
| 					return [i18n.t('_notification.youGotReply', { name: getUserName(data.user) }), { | ||||
| 						body: data.note.text, | ||||
| 						icon: data.user.avatarUrl | ||||
| 					}]; | ||||
|  | ||||
| 				case 'renote': | ||||
| 					return [i18n.t('_notification.youRenoted', { name: getUserName(data.user) }), { | ||||
| 						body: data.note.text, | ||||
| 						icon: data.user.avatarUrl | ||||
| 					}]; | ||||
|  | ||||
| 				case 'quote': | ||||
| 					return [i18n.t('_notification.youGotQuote', { name: getUserName(data.user) }), { | ||||
| 						body: data.note.text, | ||||
| 						icon: data.user.avatarUrl | ||||
| 					}]; | ||||
|  | ||||
| 				case 'reaction': | ||||
| 					return [`${data.reaction} ${getUserName(data.user)}`, { | ||||
| 						body: data.note.text, | ||||
| 						icon: data.user.avatarUrl | ||||
| 					}]; | ||||
|  | ||||
| 				case 'pollVote': | ||||
| 					return [i18n.t('_notification.youGotPoll', { name: getUserName(data.user) }), { | ||||
| 						body: data.note.text, | ||||
| 						icon: data.user.avatarUrl | ||||
| 					}]; | ||||
|  | ||||
| 				case 'pollEnded': | ||||
| 					return [i18n.t('_notification.pollEnded'), { | ||||
| 						body: data.note.text, | ||||
| 					}]; | ||||
|  | ||||
| 				case 'follow': | ||||
| 					return [i18n.t('_notification.youWereFollowed'), { | ||||
| 						body: getUserName(data.user), | ||||
| 						icon: data.user.avatarUrl | ||||
| 					}]; | ||||
|  | ||||
| 				case 'receiveFollowRequest': | ||||
| 					return [i18n.t('_notification.youReceivedFollowRequest'), { | ||||
| 						body: getUserName(data.user), | ||||
| 						icon: data.user.avatarUrl | ||||
| 					}]; | ||||
|  | ||||
| 				case 'followRequestAccepted': | ||||
| 					return [i18n.t('_notification.yourFollowRequestAccepted'), { | ||||
| 						body: getUserName(data.user), | ||||
| 						icon: data.user.avatarUrl | ||||
| 					}]; | ||||
|  | ||||
| 				case 'groupInvited': | ||||
| 					return [i18n.t('_notification.youWereInvitedToGroup'), { | ||||
| 						body: data.group.name | ||||
| 					}]; | ||||
|  | ||||
| 				default: | ||||
| 					return null; | ||||
| 			} | ||||
| 		case 'unreadMessagingMessage': | ||||
| 			if (data.groupId === null) { | ||||
| 				return [i18n.t('_notification.youGotMessagingMessageFromUser', { name: getUserName(data.user) }), { | ||||
| 					icon: data.user.avatarUrl, | ||||
| 					tag: `messaging:user:${data.user.id}` | ||||
| 				}]; | ||||
| 			} | ||||
| 			return [i18n.t('_notification.youGotMessagingMessageFromGroup', { name: data.group.name }), { | ||||
| 				icon: data.user.avatarUrl, | ||||
| 				tag: `messaging:group:${data.group.id}` | ||||
| 			}]; | ||||
| 		default: | ||||
| 			return null; | ||||
| 	} | ||||
| } | ||||
| @@ -1,123 +0,0 @@ | ||||
| /** | ||||
|  * Service Worker | ||||
|  */ | ||||
| declare var self: ServiceWorkerGlobalScope; | ||||
|  | ||||
| import { get, set } from 'idb-keyval'; | ||||
| import composeNotification from '@/sw/compose-notification'; | ||||
| import { I18n } from '@/scripts/i18n'; | ||||
|  | ||||
| //#region Variables | ||||
| const version = _VERSION_; | ||||
| const cacheName = `mk-cache-${version}`; | ||||
|  | ||||
| let lang: string; | ||||
| let i18n: I18n<any>; | ||||
| let pushesPool: any[] = []; | ||||
| //#endregion | ||||
|  | ||||
| //#region Startup | ||||
| get('lang').then(async prelang => { | ||||
| 	if (!prelang) return; | ||||
| 	lang = prelang; | ||||
| 	return fetchLocale(); | ||||
| }); | ||||
| //#endregion | ||||
|  | ||||
| //#region Lifecycle: Install | ||||
| self.addEventListener('install', ev => { | ||||
| 	self.skipWaiting(); | ||||
| }); | ||||
| //#endregion | ||||
|  | ||||
| //#region Lifecycle: Activate | ||||
| self.addEventListener('activate', ev => { | ||||
| 	ev.waitUntil( | ||||
| 		caches.keys() | ||||
| 			.then(cacheNames => Promise.all( | ||||
| 				cacheNames | ||||
| 					.filter((v) => v !== cacheName) | ||||
| 					.map(name => caches.delete(name)) | ||||
| 			)) | ||||
| 			.then(() => self.clients.claim()) | ||||
| 	); | ||||
| }); | ||||
| //#endregion | ||||
|  | ||||
| //#region When: Fetching | ||||
| self.addEventListener('fetch', ev => { | ||||
| 	// Nothing to do | ||||
| }); | ||||
| //#endregion | ||||
|  | ||||
| //#region When: Caught Notification | ||||
| self.addEventListener('push', ev => { | ||||
| 	// クライアント取得 | ||||
| 	ev.waitUntil(self.clients.matchAll({ | ||||
| 		includeUncontrolled: true | ||||
| 	}).then(async clients => { | ||||
| 		// クライアントがあったらストリームに接続しているということなので通知しない | ||||
| 		if (clients.length != 0) return; | ||||
|  | ||||
| 		const { type, body } = ev.data?.json(); | ||||
|  | ||||
| 		// localeを読み込めておらずi18nがundefinedだった場合はpushesPoolにためておく | ||||
| 		if (!i18n) return pushesPool.push({ type, body }); | ||||
|  | ||||
| 		const n = await composeNotification(type, body, i18n); | ||||
| 		if (n) return self.registration.showNotification(...n); | ||||
| 	})); | ||||
| }); | ||||
| //#endregion | ||||
|  | ||||
| //#region When: Caught a message from the client | ||||
| self.addEventListener('message', ev => { | ||||
| 	switch(ev.data) { | ||||
| 		case 'clear': | ||||
| 			return; // TODO | ||||
| 		default: | ||||
| 			break; | ||||
| 	} | ||||
|  | ||||
| 	if (typeof ev.data === 'object') { | ||||
| 		// E.g. '[object Array]' → 'array' | ||||
| 		const otype = Object.prototype.toString.call(ev.data).slice(8, -1).toLowerCase(); | ||||
|  | ||||
| 		if (otype === 'object') { | ||||
| 			if (ev.data.msg === 'initialize') { | ||||
| 				lang = ev.data.lang; | ||||
| 				set('lang', lang); | ||||
| 				fetchLocale(); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| }); | ||||
| //#endregion | ||||
|  | ||||
| //#region Function: (Re)Load i18n instance | ||||
| async function fetchLocale() { | ||||
| 	//#region localeファイルの読み込み | ||||
| 	// Service Workerは何度も起動しそのたびにlocaleを読み込むので、CacheStorageを使う | ||||
| 	const localeUrl = `/assets/locales/${lang}.${version}.json`; | ||||
| 	let localeRes = await caches.match(localeUrl); | ||||
|  | ||||
| 	if (!localeRes) { | ||||
| 		localeRes = await fetch(localeUrl); | ||||
| 		const clone = localeRes?.clone(); | ||||
| 		if (!clone?.clone().ok) return; | ||||
|  | ||||
| 		caches.open(cacheName).then(cache => cache.put(localeUrl, clone)); | ||||
| 	} | ||||
|  | ||||
| 	i18n = new I18n(await localeRes.json()); | ||||
| 	//#endregion | ||||
|  | ||||
| 	//#region i18nをきちんと読み込んだ後にやりたい処理 | ||||
| 	for (const { type, body } of pushesPool) { | ||||
| 		const n = await composeNotification(type, body, i18n); | ||||
| 		if (n) self.registration.showNotification(...n); | ||||
| 	} | ||||
| 	pushesPool = []; | ||||
| 	//#endregion | ||||
| } | ||||
| //#endregion | ||||
| @@ -21,6 +21,7 @@ import { popup, popups, pendingApiRequestsCount } from '@/os'; | ||||
| import { uploads } from '@/scripts/upload'; | ||||
| import * as sound from '@/scripts/sound'; | ||||
| import { $i } from '@/account'; | ||||
| import { swInject } from './sw-inject'; | ||||
| import { stream } from '@/stream'; | ||||
|  | ||||
| export default defineComponent({ | ||||
| @@ -49,6 +50,11 @@ export default defineComponent({ | ||||
| 		if ($i) { | ||||
| 			const connection = stream.useChannel('main', null, 'UI'); | ||||
| 			connection.on('notification', onNotification); | ||||
|  | ||||
| 			//#region Listen message from SW | ||||
| 			if ('serviceWorker' in navigator) { | ||||
| 				swInject(); | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		return { | ||||
|   | ||||
							
								
								
									
										45
									
								
								packages/client/src/ui/_common_/sw-inject.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								packages/client/src/ui/_common_/sw-inject.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,45 @@ | ||||
| import { inject } from 'vue'; | ||||
| import { post } from '@/os'; | ||||
| import { $i, login } from '@/account'; | ||||
| import { defaultStore } from '@/store'; | ||||
| import { getAccountFromId } from '@/scripts/get-account-from-id'; | ||||
| import { router } from '@/router'; | ||||
|  | ||||
| export function swInject() { | ||||
| 	const navHook = inject('navHook', null); | ||||
| 	const sideViewHook = inject('sideViewHook', null); | ||||
|  | ||||
| 	navigator.serviceWorker.addEventListener('message', ev => { | ||||
| 		if (_DEV_) { | ||||
| 			console.log('sw msg', ev.data); | ||||
| 		} | ||||
|  | ||||
| 		const data = ev.data; // as SwMessage | ||||
| 		if (data.type !== 'order') return; | ||||
|  | ||||
| 		if (data.loginId !== $i?.id) { | ||||
| 			return getAccountFromId(data.loginId).then(account => { | ||||
| 				if (!account) return; | ||||
| 				return login(account.token, data.url); | ||||
| 			}); | ||||
| 		} | ||||
|  | ||||
| 		switch (data.order) { | ||||
| 			case 'post': | ||||
| 				return post(data.options); | ||||
| 			case 'push': | ||||
| 				if (router.currentRoute.value.path === data.url) { | ||||
| 					return window.scroll({ top: 0, behavior: 'smooth' }); | ||||
| 				} | ||||
| 				if (navHook) { | ||||
| 					return navHook(data.url); | ||||
| 				} | ||||
| 				if (sideViewHook && defaultStore.state.defaultSideView && data.url !== '/') { | ||||
| 					return sideViewHook(data.url); | ||||
| 				} | ||||
| 				return router.push(data.url); | ||||
| 			default: | ||||
| 				return; | ||||
| 		} | ||||
| 	}); | ||||
| } | ||||
		Reference in New Issue
	
	Block a user
	 tamaina
					tamaina