feat: push notifs button

Co-authored-by: Tamania <tamaina@hotmail.co.jp>
This commit is contained in:
ThatOneCalculator 2023-04-03 20:36:59 -07:00
parent 419c84a39d
commit 3e7d64ac4b
14 changed files with 359 additions and 62 deletions

View File

@ -12,6 +12,7 @@ fetchingAsApObject: "Fetching from the Fediverse"
ok: "OK" ok: "OK"
gotIt: "Got it!" gotIt: "Got it!"
cancel: "Cancel" cancel: "Cancel"
noThankYou: "No thank you"
enterUsername: "Enter username" enterUsername: "Enter username"
renotedBy: "Boosted by {user}" renotedBy: "Boosted by {user}"
noNotes: "No posts" noNotes: "No posts"

View File

@ -12,6 +12,7 @@ fetchingAsApObject: "連合宇宙から取得中"
ok: "OK" ok: "OK"
gotIt: "わかった!" gotIt: "わかった!"
cancel: "キャンセル" cancel: "キャンセル"
noThankYou: "やめておく"
enterUsername: "ユーザー名を入力" enterUsername: "ユーザー名を入力"
renotedBy: "{user}がブースト" renotedBy: "{user}がブースト"
noNotes: "投稿はありません" noNotes: "投稿はありません"

View File

@ -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"`);
}
}

View File

@ -41,4 +41,9 @@ export class SwSubscription {
length: 128, length: 128,
}) })
public publickey: string; public publickey: string;
@Column('boolean', {
default: false,
})
public sendReadMessage: boolean;
} }

View File

@ -290,6 +290,8 @@ import * as ep___resetDb from "./endpoints/reset-db.js";
import * as ep___resetPassword from "./endpoints/reset-password.js"; import * as ep___resetPassword from "./endpoints/reset-password.js";
import * as ep___serverInfo from "./endpoints/server-info.js"; import * as ep___serverInfo from "./endpoints/server-info.js";
import * as ep___stats from "./endpoints/stats.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_register from "./endpoints/sw/register.js";
import * as ep___sw_unregister from "./endpoints/sw/unregister.js"; import * as ep___sw_unregister from "./endpoints/sw/unregister.js";
import * as ep___test from "./endpoints/test.js"; import * as ep___test from "./endpoints/test.js";
@ -637,6 +639,8 @@ const eps = [
["stats", ep___stats], ["stats", ep___stats],
["sw/register", ep___sw_register], ["sw/register", ep___sw_register],
["sw/unregister", ep___sw_unregister], ["sw/unregister", ep___sw_unregister],
['sw/show-registration', ep___sw_show_registration],
['sw/update-registration', ep___sw_update_registration],
["test", ep___test], ["test", ep___test],
["username/available", ep___username_available], ["username/available", ep___username_available],
["users", ep___users], ["users", ep___users],

View File

@ -26,6 +26,18 @@ export const meta = {
optional: false, optional: false,
nullable: true, 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; } as const;
@ -36,14 +48,15 @@ export const paramDef = {
endpoint: { type: "string" }, endpoint: { type: "string" },
auth: { type: "string" }, auth: { type: "string" },
publickey: { type: "string" }, publickey: { type: "string" },
sendReadMessage: { type: 'boolean', default: false },
}, },
required: ["endpoint", "auth", "publickey"], required: ["endpoint", "auth", "publickey"],
} as const; } as const;
export default define(meta, paramDef, async (ps, user) => { export default define(meta, paramDef, async (ps, me) => {
// if already subscribed // if already subscribed
const exist = await SwSubscriptions.findOneBy({ const exist = await SwSubscriptions.findOneBy({
userId: user.id, userId: me.id,
endpoint: ps.endpoint, endpoint: ps.endpoint,
auth: ps.auth, auth: ps.auth,
publickey: ps.publickey, publickey: ps.publickey,
@ -55,20 +68,27 @@ export default define(meta, paramDef, async (ps, user) => {
return { return {
state: "already-subscribed" as const, state: "already-subscribed" as const,
key: instance.swPublicKey, key: instance.swPublicKey,
userId: me.id,
endpoint: exist.endpoint,
sendReadMessage: exist.sendReadMessage,
}; };
} }
await SwSubscriptions.insert({ await SwSubscriptions.insert({
id: genId(), id: genId(),
createdAt: new Date(), createdAt: new Date(),
userId: user.id, userId: me.id,
endpoint: ps.endpoint, endpoint: ps.endpoint,
auth: ps.auth, auth: ps.auth,
publickey: ps.publickey, publickey: ps.publickey,
sendReadMessage: ps.sendReadMessage,
}); });
return { return {
state: "subscribed" as const, state: "subscribed" as const,
key: instance.swPublicKey, key: instance.swPublicKey,
userId: me.id,
endpoint: ps.endpoint,
sendReadMessage: ps.sendReadMessage,
}; };
}); });

View File

@ -0,0 +1,55 @@
import { SwSubscriptions } from '@/models/index.js';
import define from "../../define.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
export default define(meta, paramDef, async (ps, me) => {
const exist = await SwSubscriptions.findOneBy({
userId: me.id,
endpoint: ps.endpoint,
});
if (exist != null) {
return {
userId: exist.userId,
endpoint: exist.endpoint,
sendReadMessage: exist.sendReadMessage,
};
}
return null;
});

View File

@ -4,7 +4,7 @@ import define from "../../define.js";
export const meta = { export const meta = {
tags: ["account"], tags: ["account"],
requireCredential: true, requireCredential: false,
description: "Unregister from receiving push notifications.", description: "Unregister from receiving push notifications.",
} as const; } as const;
@ -17,9 +17,9 @@ export const paramDef = {
required: ["endpoint"], required: ["endpoint"],
} as const; } as const;
export default define(meta, paramDef, async (ps, user) => { export default define(meta, paramDef, async (ps, me) => {
await SwSubscriptions.delete({ await SwSubscriptions.delete({
userId: user.id, ...(me ? { userId: me.id } : {}),
endpoint: ps.endpoint, endpoint: ps.endpoint,
}); });
}); });

View File

@ -0,0 +1,44 @@
import { SwSubscriptions } from "@/models/index.js";
import define from "../../define.js";
export const meta = {
tags: ["account"],
requireCredential: true,
description: "Unregister from receiving push notifications.",
} as const;
export const paramDef = {
type: "object",
properties: {
endpoint: { type: "string" },
sendReadMessage: { type: 'boolean' },
},
required: ["endpoint"],
} as const;
export default define(meta, paramDef, async (ps, me) => {
const swSubscription = await SwSubscriptions.findOneBy({
userId: me.id,
endpoint: ps.endpoint,
});
if (swSubscription === null) {
throw new Error("No such registration");
}
if (ps.sendReadMessage !== undefined) {
swSubscription.sendReadMessage = ps.sendReadMessage;
}
await SwSubscriptions.update(swSubscription.id, {
sendReadMessage: swSubscription.sendReadMessage,
});
return {
userId: swSubscription.userId,
endpoint: swSubscription.endpoint,
sendReadMessage: swSubscription.sendReadMessage,
};
});

View File

@ -63,6 +63,13 @@ export async function pushNotification<T extends keyof pushNotificationsTypes>(
}); });
for (const subscription of subscriptions) { for (const subscription of subscriptions) {
if ([
'readNotifications',
'readAllNotifications',
'readAllMessagingMessages',
'readAllMessagingMessagesOfARoom',
].includes(type) && !subscription.sendReadMessage) continue;
const pushSubscription = { const pushSubscription = {
endpoint: subscription.endpoint, endpoint: subscription.endpoint,
keys: { keys: {

View File

@ -0,0 +1,171 @@
<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;
}
if (navigator.serviceWorker == null) {
// TODO:
} else {
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>

View File

@ -106,6 +106,7 @@
<MkSparkle> <MkSparkle>
<h3>{{ i18n.ts._tutorial.step6_4 }} <Mfm text="$[shake 🚀]"></Mfm></h3> <h3>{{ i18n.ts._tutorial.step6_4 }} <Mfm text="$[shake 🚀]"></Mfm></h3>
</MkSparkle> </MkSparkle>
<MkPushNotificationAllowButton primary show-only-to-register/>
</div> </div>
</Transition> </Transition>
</div> </div>
@ -122,6 +123,7 @@ import MkButton from '@/components/MkButton.vue';
import XFeaturedUsers from '@/pages/explore.users.vue'; import XFeaturedUsers from '@/pages/explore.users.vue';
import XPostForm from '@/components/MkPostForm.vue'; import XPostForm from '@/components/MkPostForm.vue';
import MkSparkle from '@/components/MkSparkle.vue'; import MkSparkle from '@/components/MkSparkle.vue';
import MkPushNotificationAllowButton from '@/components/MkPushNotificationAllowButton.vue';
import { defaultStore } from '@/store'; import { defaultStore } from '@/store';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { $i } from '@/account'; import { $i } from '@/account';

View File

@ -6,6 +6,21 @@
<FormButton class="_formBlock" @click="readAllUnreadNotes">{{ i18n.ts.markAsReadAllUnreadNotes }}</FormButton> <FormButton class="_formBlock" @click="readAllUnreadNotes">{{ i18n.ts.markAsReadAllUnreadNotes }}</FormButton>
<FormButton class="_formBlock" @click="readAllMessagingMessages">{{ i18n.ts.markAsReadAllTalkMessages }}</FormButton> <FormButton class="_formBlock" @click="readAllMessagingMessages">{{ i18n.ts.markAsReadAllTalkMessages }}</FormButton>
</FormSection> </FormSection>
<FormSection>
<template #label>{{ i18n.ts.pushNotification }}</template>
<div class="_gaps_m">
<MkPushNotificationAllowButton ref="allowButton"/>
<MkSwitch :disabled="!pushRegistrationInServer" :model-value="sendReadMessage" @update:model-value="onChangeSendReadMessage">
<template #label>{{ i18n.ts.sendPushNotificationReadMessage }}</template>
<template #caption>
<I18n :src="i18n.ts.sendPushNotificationReadMessageCaption">
<template #emptyPushNotificationMessage>{{ i18n.ts._notification.emptyPushNotificationMessage }}</template>
</I18n>
</template>
</MkSwitch>
</div>
</FormSection>
</div> </div>
</template> </template>
@ -19,6 +34,11 @@ import * as os from '@/os';
import { $i } from '@/account'; import { $i } from '@/account';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata'; import { definePageMetadata } from '@/scripts/page-metadata';
import MkPushNotificationAllowButton from '@/components/MkPushNotificationAllowButton.vue';
let allowButton = $shallowRef<InstanceType<typeof MkPushNotificationAllowButton>>();
let pushRegistrationInServer = $computed(() => allowButton?.pushRegistrationInServer);
let sendReadMessage = $computed(() => pushRegistrationInServer?.sendReadMessage || false);
async function readAllUnreadNotes() { async function readAllUnreadNotes() {
await os.api('i/read-all-unread-notes'); await os.api('i/read-all-unread-notes');
@ -49,6 +69,18 @@ function configure() {
}, 'closed'); }, '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 headerActions = $computed(() => []);
const headerTabs = $computed(() => []); const headerTabs = $computed(() => []);

View File

@ -1,6 +1,3 @@
import { instance } from "@/instance";
import { $i } from "@/account";
import { api } from "@/os";
import { lang } from "@/config"; import { lang } from "@/config";
export async function initializeSw() { export async function initializeSw() {
@ -12,58 +9,5 @@ export async function initializeSw() {
msg: "initialize", msg: "initialize",
lang, 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;
}