diff options
| author | tamaina <tamaina@hotmail.co.jp> | 2022-12-18 01:59:59 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2022-12-18 01:59:59 +0900 |
| commit | 4ecc42744c3c8b68e38f58bfe03919bf437f137a (patch) | |
| tree | 094230a6992c05cf39136913d02400fae27931d7 /packages/client/src | |
| parent | fix(server): GitHubログインしようとするとreply.setCookie is not a ... (diff) | |
| download | sharkey-4ecc42744c3c8b68e38f58bfe03919bf437f137a.tar.gz sharkey-4ecc42744c3c8b68e38f58bfe03919bf437f137a.tar.bz2 sharkey-4ecc42744c3c8b68e38f58bfe03919bf437f137a.zip | |
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
* :v:
* implement subscribe push notification button to tutorial
* check-exists→show-registration
* add column sendReadMessage
* fix migration file
* sw api
* change PushNotificationService
* wip
* :v:
* fix tutorial footer flex
Diffstat (limited to 'packages/client/src')
| -rw-r--r-- | packages/client/src/components/MkPushNotificationAllowButton.vue | 167 | ||||
| -rw-r--r-- | packages/client/src/pages/settings/notifications.vue | 30 | ||||
| -rw-r--r-- | packages/client/src/pages/timeline.tutorial.vue | 116 | ||||
| -rw-r--r-- | packages/client/src/scripts/initialize-sw.ts | 55 |
4 files changed, 266 insertions, 102 deletions
diff --git a/packages/client/src/components/MkPushNotificationAllowButton.vue b/packages/client/src/components/MkPushNotificationAllowButton.vue new file mode 100644 index 0000000000..a762914e64 --- /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 5703e0c6b6..77ec567da4 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 7f08ccc2a1..9683cc22a5 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 7bacfbdf00..de52f30523 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; -} |