This commit is contained in:
tamaina 2021-02-15 06:05:18 +09:00
parent a8c4c74954
commit bf1db27824
15 changed files with 435 additions and 169 deletions

View File

@ -1567,7 +1567,7 @@ _notification:
youWereFollowed: "フォローされました"
youReceivedFollowRequest: "フォローリクエストが来ました"
yourFollowRequestAccepted: "フォローリクエストが承認されました"
youWereInvitedToGroup: "グループに招待されました"
youWereInvitedToGroup: "{userName}があなたをグループに招待しました"
_types:
all: "すべて"
@ -1583,6 +1583,11 @@ _notification:
groupInvited: "グループに招待された"
app: "連携アプリからの通知"
_actions:
followBack: "フォローバック"
reply: "返信"
renote: "Renote"
_deck:
alwaysShowMainColumn: "常にメインカラムを表示"
columnAlign: "カラムの寄せ"

View File

@ -2,7 +2,7 @@ import { get, set } from 'idb-keyval';
import { reactive } from 'vue';
import { apiUrl } from '@/config';
import { waiting } from '@/os';
import { unisonReload } from '@/scripts/unison-reload';
import { unisonReload, reloadChannel } from '@/scripts/unison-reload';
// TODO: 他のタブと永続化されたstateを同期
@ -89,18 +89,23 @@ export function updateAccount(data) {
}
export function refreshAccount() {
fetchAccount($i.token).then(updateAccount);
return fetchAccount($i.token).then(updateAccount);
}
export async function login(token: Account['token'], showTimeline: boolean = false) {
export async function login(token: Account['token'], href?: string) {
waiting();
if (_DEV_) console.log('logging as token ', token);
const me = await fetchAccount(token);
localStorage.setItem('account', JSON.stringify(me));
await addAccount(me.id, token);
if (showTimeline) location.href = '/';
else unisonReload();
if (href) {
reloadChannel.postMessage('reload');
location.href = href;
return;
}
unisonReload();
}
// このファイルに書きたくないけどここに書かないと何故かVeturが認識しない

View File

@ -61,11 +61,14 @@ import * as sound from '@/scripts/sound';
import { $i, refreshAccount, login, updateAccount, signout } from '@/account';
import { defaultStore, ColdDeviceStorage } from '@/store';
import { fetchInstance, instance } from '@/instance';
import { makeHotkey } from './scripts/hotkey';
import { search } from './scripts/search';
import { getThemes } from './theme-store';
import { initializeSw } from './scripts/initialize-sw';
import { reloadChannel } from './scripts/unison-reload';
import { makeHotkey } from '@/scripts/hotkey';
import { search } from '@/scripts/search';
import { getThemes } from '@/theme-store';
import { initializeSw } from '@/scripts/initialize-sw';
import { reloadChannel } from '@/scripts/unison-reload';
import { deleteLoginId } from '@/scripts/login-id';
import { getAccountFromId } from '@/scripts/get-account-from-id';
import { SwMessage } from '@/sw/types';
console.info(`Misskey v${version}`);
@ -142,6 +145,25 @@ const html = document.documentElement;
html.setAttribute('lang', lang);
//#endregion
//#region loginId
const params = new URLSearchParams(location.href);
const loginId = params.get('loginId');
if (loginId) {
const target = deleteLoginId(location.toString());
if (!$i || $i.id !== loginId) {
const account = await getAccountFromId(loginId);
if (account) {
login(account.token, target)
}
}
history.replaceState({ misskey: 'loginId' }, '', target)
}
//#endregion
//#region Fetch user
if ($i && $i.token) {
if (_DEV_) {
@ -188,7 +210,7 @@ fetchInstance().then(() => {
stream.init($i);
const app = createApp(await (
window.location.search === '?zen' ? import('@/ui/zen.vue') :
location.search === '?zen' ? import('@/ui/zen.vue') :
!$i ? import('@/ui/visitor.vue') :
ui === 'deck' ? import('@/ui/deck.vue') :
ui === 'desktop' ? import('@/ui/desktop.vue') :
@ -217,6 +239,33 @@ components(app);
await router.isReady();
//#region Listen message from SW
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':
return router.push(data.url);
default:
return;
}
});
//#endregion
//document.body.innerHTML = '<div id="app"></div>';
app.mount('body');

View File

@ -0,0 +1,7 @@
import { get } from 'idb-keyval';
export async function getAccountFromId(id: string) {
const accounts = await get('accounts') as { token: string; id: string; }[];
if (!accounts) console.log('Accounts are not recorded');
return accounts.find(e => e.id === id)
}

View File

@ -1,8 +1,7 @@
import { instance } from '@/instance';
import { $i } from '@/account';
import { api, post } from '@/os';
import { api } from '@/os';
import { lang } from '@/config';
import { SwMessage } from '@/sw/types';
export async function initializeSw() {
if (instance.swPublickey &&
@ -50,18 +49,6 @@ export async function initializeSw() {
}
}
navigator.serviceWorker.addEventListener('message', ev => {
const data = ev.data as SwMessage;
if (data.type !== 'order') return;
switch (data.order) {
case 'post':
return post(data.options);
default:
return;
}
});
/**
* Convert the URL safe base64 string to a Uint8Array
* @param base64String base64 string

View File

@ -0,0 +1,11 @@
export function appendLoginId(url: string, loginId: string) {
const u = new URL(url, origin);
u.searchParams.append('loginId', loginId);
return u.toString();
}
export function deleteLoginId(url: string) {
const u = new URL(url);
u.searchParams.delete('loginId');
return u.toString();
}

View File

@ -6,7 +6,7 @@ declare var self: ServiceWorkerGlobalScope;
import { getNoteSummary } from '../../misc/get-note-summary';
import getUserName from '../../misc/get-user-name';
import { swLang } from '@/sw/lang';
import { I18n } from '@/scripts/i18n';
import { I18n } from '../../misc/i18n';
import { pushNotificationData } from '../../types';
export async function createNotification(data: pushNotificationData) {
@ -18,107 +18,162 @@ async function composeNotification(data: pushNotificationData): Promise<[string,
if (!swLang.i18n) swLang.fetchLocale();
const i18n = await swLang.i18n as I18n<any>;
const { t } = i18n;
const { body } = data;
switch (data.type) {
/*
case 'driveFileCreated': // TODO (Server Side)
return [t('_notification.fileUploaded'), {
body: data.body.name,
icon: data.body.url,
body: body.name,
icon: body.url,
data
}];
*/
case 'notification':
switch (data.body.type) {
switch (body.type) {
case 'follow':
return [t('_notification.youWereFollowed'), {
body: getUserName(body.user),
icon: body.user.avatarUrl,
data,
actions: [
{
action: 'follow',
title: t('_notification._actions.followBack')
}
],
}];
case 'mention':
return [t('_notification.youGotMention', { name: getUserName(data.body.user) }), {
body: getNoteSummary(data.body.note, i18n.locale),
icon: data.body.user.avatarUrl,
return [t('_notification.youGotMention', { name: getUserName(body.user) }), {
body: getNoteSummary(body.note, i18n.locale),
icon: body.user.avatarUrl,
data,
actions: [
{
action: 'reply',
title: t('_notification._actions.reply')
}
],
}];
case 'reply':
return [t('_notification.youGotReply', { name: getUserName(body.user) }), {
body: getNoteSummary(body.note, i18n.locale),
icon: body.user.avatarUrl,
data,
actions: [
{
action: 'reply',
title: t('_notification._actions.reply')
}
],
}];
case 'renote':
return [t('_notification.youRenoted', { name: getUserName(body.user) }), {
body: getNoteSummary(body.note.renote, i18n.locale),
icon: body.user.avatarUrl,
data,
actions: [
{
action: 'showUser',
title: 'showUser'
title: getUserName(body.user)
}
]
}];
case 'reply':
return [t('_notification.youGotReply', { name: getUserName(data.body.user) }), {
body: getNoteSummary(data.body.note, i18n.locale),
icon: data.body.user.avatarUrl,
data,
}];
case 'renote':
return [t('_notification.youRenoted', { name: getUserName(data.body.user) }), {
body: getNoteSummary(data.body.note, i18n.locale),
icon: data.body.user.avatarUrl,
data,
],
}];
case 'quote':
return [t('_notification.youGotQuote', { name: getUserName(data.body.user) }), {
body: getNoteSummary(data.body.note, i18n.locale),
icon: data.body.user.avatarUrl,
return [t('_notification.youGotQuote', { name: getUserName(body.user) }), {
body: getNoteSummary(body.note, i18n.locale),
icon: body.user.avatarUrl,
data,
actions: [
{
action: 'reply',
title: t('_notification._actions.reply')
},
{
action: 'renote',
title: t('_notification._actions.renote')
}
],
}];
case 'reaction':
return [`${data.body.reaction} ${getUserName(data.body.user)}`, {
body: getNoteSummary(data.body.note, i18n.locale),
icon: data.body.user.avatarUrl,
return [`${body.reaction} ${getUserName(body.user)}`, {
body: getNoteSummary(body.note, i18n.locale),
icon: body.user.avatarUrl,
data,
actions: [
{
action: 'showUser',
title: getUserName(body.user)
}
],
}];
case 'pollVote':
return [t('_notification.youGotPoll', { name: getUserName(data.body.user) }), {
body: getNoteSummary(data.body.note, i18n.locale),
icon: data.body.user.avatarUrl,
data,
}];
case 'follow':
return [t('_notification.youWereFollowed'), {
body: getUserName(data.body.user),
icon: data.body.user.avatarUrl,
return [t('_notification.youGotPoll', { name: getUserName(body.user) }), {
body: getNoteSummary(body.note, i18n.locale),
icon: body.user.avatarUrl,
data,
}];
case 'receiveFollowRequest':
return [t('_notification.youReceivedFollowRequest'), {
body: getUserName(data.body.user),
icon: data.body.user.avatarUrl,
body: getUserName(body.user),
icon: body.user.avatarUrl,
data,
actions: [
{
action: 'accept',
title: t('accept')
},
{
action: 'reject',
title: t('reject')
}
],
}];
case 'followRequestAccepted':
return [t('_notification.yourFollowRequestAccepted'), {
body: getUserName(data.body.user),
icon: data.body.user.avatarUrl,
body: getUserName(body.user),
icon: body.user.avatarUrl,
data,
}];
case 'groupInvited':
return [t('_notification.youWereInvitedToGroup'), {
body: data.body.group.name,
return [t('_notification.youWereInvitedToGroup', { userName: getUserName(body.user) }), {
body: body.invitation.group.name,
data,
actions: [
{
action: 'accept',
title: t('accept')
},
{
action: 'reject',
title: t('reject')
}
],
}];
default:
return null;
}
case 'unreadMessagingMessage':
if (data.body.groupId === null) {
return [t('_notification.youGotMessagingMessageFromUser', { name: getUserName(data.body.user) }), {
icon: data.body.user.avatarUrl,
tag: `messaging:user:${data.body.user.id}`,
if (body.groupId === null) {
return [t('_notification.youGotMessagingMessageFromUser', { name: getUserName(body.user) }), {
icon: body.user.avatarUrl,
tag: `messaging:user:${body.userId}`,
data,
}];
}
return [t('_notification.youGotMessagingMessageFromGroup', { name: data.body.group.name }), {
icon: data.body.user.avatarUrl,
tag: `messaging:group:${data.body.group.id}`,
return [t('_notification.youGotMessagingMessageFromGroup', { name: body.group.name }), {
icon: body.user.avatarUrl,
tag: `messaging:group:${body.groupId}`,
data,
}];
default:

View File

@ -2,12 +2,12 @@ declare var self: ServiceWorkerGlobalScope;
import { get } from 'idb-keyval';
import { pushNotificationData } from '../../types';
import { api } from './operations';
type Accounts = {
[x: string]: {
queue: string[],
timeout: number | null,
token: string,
timeout: number | null
}
};
@ -15,14 +15,13 @@ class SwNotificationRead {
private accounts: Accounts = {};
public async construct() {
const accounts = await get('accounts') as { token: string, id: string }[];
if (!accounts) Error('Account is not recorded');
const accounts = await get('accounts');
if (!accounts) Error('Accounts are not recorded');
this.accounts = accounts.reduce((acc, e) => {
acc[e.id] = {
queue: [],
timeout: null,
token: e.token,
timeout: null
};
return acc;
}, {} as Accounts);
@ -36,21 +35,14 @@ class SwNotificationRead {
const account = this.accounts[data.userId];
account.queue.push(data.body.id);
account.queue.push(data.body.id as string);
// 最後の呼び出しから200ms待ってまとめて処理する
if (account.timeout) clearTimeout(account.timeout);
account.timeout = setTimeout(() => {
account.timeout = null;
console.info(account.token, account.queue);
fetch(`${location.origin}/api/notifications/read`, {
method: 'POST',
body: JSON.stringify({
i: account.token,
notificationIds: account.queue
})
});
api('notifications/read', data.userId, { notificationIds: account.queue });
}, 200);
}
}

View File

@ -1,41 +0,0 @@
/*
* Openers
*
* 稿
*/
declare var self: ServiceWorkerGlobalScope;
import { SwMessage, swMessageOrderType } from './types';
// rendered acctからユーザーを開く
export async function openUser(acct: string, loginId: string) {
open('push-user', { acct }, `${origin}/@${acct}?loginId=${loginId}`, loginId)
}
// post-formのオプションから投稿フォームを開く
export async function openPost(options: any, loginId: string) {
// Build share queries from options
let url = `${origin}/?`;
if (options.initialText) url += `text=${options.initialText}&`;
if (options.reply) url += `replyId=${options.reply.id}&`;
if (options.renote) url += `renoteId=${options.renote.id}&`;
url += `loginId=${loginId}`;
open('post', { options }, url, loginId)
}
async function open(order: swMessageOrderType, query: any, url: string, loginId: string) {
const client = await self.clients.matchAll({
includeUncontrolled: true,
type: 'window'
}).then(clients => clients.length > 0 ? clients[0] : null);
if (client) {
client.postMessage({ type: 'order', ...query, order, loginId, url } as SwMessage);
if ('focus' in client) (client as any).focus();
return;
}
return self.clients.openWindow(url);
}

View File

@ -0,0 +1,72 @@
/*
* Operations
*
*/
declare var self: ServiceWorkerGlobalScope;
import { SwMessage, swMessageOrderType } from './types';
import renderAcct from '../../misc/acct/render';
import { getAccountFromId } from '@/scripts/get-account-from-id';
import { appendLoginId } from '@/scripts/login-id';
export async function api(endpoint: string, userId: string, options: any = {}) {
const account = await getAccountFromId(userId)
if (!account) return;
return fetch(`${origin}/api/${endpoint}`, {
method: 'POST',
body: JSON.stringify({
i: account.token,
...options
}),
credentials: 'omit',
cache: 'no-cache',
}).then(async res => {
if (!res.ok) Error(`Error while fetching: ${await res.text()}`);
if (res.status === 200) return res.json();
return;
})
}
// rendered acctからユーザーを開く
export function openUser(acct: string, loginId: string) {
return openClient('push', `/@${acct}`, loginId, { acct })
}
// noteIdからートを開く
export function openNote(noteId: string, loginId: string) {
return openClient('push', `/notes/${noteId}`, loginId, { noteId })
}
export async function openChat(body: any, loginId: string) {
if (body.groupId === null) {
return openClient('push', `/my/messaging/${renderAcct(body.user)}`, loginId, { body })
} else {
return openClient('push', `/my/messaging/group/${body.groupId}`, loginId, { body })
}
}
// post-formのオプションから投稿フォームを開く
export async function openPost(options: any, loginId: string) {
// クエリを作成しておく
let url = `/share?`;
if (options.initialText) url += `text=${options.initialText}&`;
if (options.reply) url += `replyId=${options.reply.id}&`;
if (options.renote) url += `renoteId=${options.renote.id}&`;
return openClient('post', url, loginId, { options })
}
export async function openClient(order: swMessageOrderType, url: string, loginId: string, query: any = {}) {
const client = await self.clients.matchAll({
type: 'window'
}).then(clients => clients.length > 0 ? clients[0] as WindowClient : null);
if (client) {
client.postMessage({ type: 'order', ...query, order, loginId, url } as SwMessage);
return client;
}
return self.clients.openWindow(appendLoginId(url, loginId));
}

View File

@ -7,7 +7,7 @@ import { createNotification } from '@/sw/create-notification';
import { swLang } from '@/sw/lang';
import { swNotificationRead } from '@/sw/notification-read';
import { pushNotificationData } from '../../types';
import { openUser } from './open-client';
import * as ope from './operations';
import renderAcct from '../../misc/acct/render';
//#region Lifecycle: Install
@ -55,51 +55,125 @@ self.addEventListener('push', ev => {
return createNotification(data);
case 'readAllNotifications':
for (const n of await self.registration.getNotifications()) {
n.close();
if (n.data.type === 'notification') n.close();
}
break;
case 'readAllMessagingMessages':
for (const n of await self.registration.getNotifications()) {
if (n.data.type === 'unreadMessagingMessage') n.close();
}
break;
case 'readNotifications':
for (const notification of await self.registration.getNotifications()) {
if (data.body.notificationIds.includes(notification.data.body.id)) {
notification.close();
for (const n of await self.registration.getNotifications()) {
if (data.body.notificationIds?.includes(n.data.body.id)) {
n.close();
}
}
break;
case 'readAllMessagingMessagesOfARoom':
for (const n of await self.registration.getNotifications()) {
if (n.data.type === 'unreadMessagingMessage'
&& ('userId' in data.body
? data.body.userId === n.data.body.userId
: data.body.groupId === n.data.body.groupId)
) {
n.close();
}
}
break;
}
}));
});
//#endregion
//#region Notification
self.addEventListener('notificationclick', async ev => {
const { action, notification } = ev;
const data: pushNotificationData = notification.data;
self.addEventListener('notificationclick', ev => {
ev.waitUntil((async () => {
switch (action) {
case 'showUser':
switch (data.body.type) {
case 'reaction':
return openUser(renderAcct(data.body.user), data.userId);
default:
if ('note' in data.body) {
return openUser(renderAcct(data.body.data.user), data.userId);
}
}
break;
default:
if (_DEV_) {
console.log('notificationclick', ev.action, ev.notification.data);
}
// notification.close();
const { action, notification } = ev;
const data: pushNotificationData = notification.data;
const { type, userId: id, body } = data;
let client: WindowClient | null = null;
let close = true;
switch (action) {
case 'follow':
client = await ope.api('following/create', id, { userId: body.userId });
break;
case 'showUser':
client = await ope.openUser(renderAcct(body.user), id);
if (body.type !== 'renote') close = false;
break;
case 'reply':
client = await ope.openPost({ reply: body.note }, id);
break;
case 'renote':
await ope.api('notes/create', id, { renoteId: body.note.id });
break;
case 'accept':
if (body.type === 'receiveFollowRequest') {
await ope.api('following/requests/accept', id, { userId: body.userId });
} else if (body.type === 'groupInvited') {
await ope.api('users/groups/invitations/accept', id, { invitationId: body.invitation.id });
}
break;
case 'reject':
if (body.type === 'receiveFollowRequest') {
await ope.api('following/requests/reject', id, { userId: body.userId });
} else if (body.type === 'groupInvited') {
await ope.api('users/groups/invitations/reject', id, { invitationId: body.invitation.id });
}
break;
case 'showFollowRequests':
client = await ope.openClient('push', '/my/follow-requests', id);
break;
default:
if (type === 'unreadMessagingMessage') {
client = await ope.openChat(body, id);
break;
}
switch (body.type) {
case 'receiveFollowRequest':
client = await ope.openClient('push', '/my/follow-requests', id);
break;
case 'groupInvited':
client = await ope.openClient('push', '/my/groups', id);
break;
case 'reaction':
client = await ope.openNote(body.note.id, id);
break;
default:
if ('note' in body) {
client = await ope.openNote(body.note.id, id);
break;
}
if ('user' in body) {
client = await ope.openUser(renderAcct(body.data.user), id);
break;
}
}
}
if (client) {
client.focus();
}
if (type === 'notification') {
swNotificationRead.then(that => that.read(data));
}
if (close) {
notification.close();
}
})())
});
self.addEventListener('notificationclose', ev => {
const { notification } = ev;
if (!notification.title.startsWith('notification')) {
self.registration.showNotification('notificationclose', { body: `${notification?.data?.body?.id}` });
}
const data: pushNotificationData = notification.data;
const data: pushNotificationData = ev.notification.data;
if (data.type === 'notification') {
swNotificationRead.then(that => that.read(data));
@ -108,12 +182,15 @@ self.addEventListener('notificationclose', ev => {
//#endregion
//#region When: Caught a message from the client
self.addEventListener('message', ev => {
self.addEventListener('message', async ev => {
switch (ev.data) {
case 'clear':
// Cache Storage全削除
await caches.keys()
.then(cacheNames => Promise.all(
cacheNames.map(name => caches.delete(name))
))
return; // TODO
default:
break;
}
if (typeof ev.data === 'object') {

View File

@ -1,4 +1,4 @@
export type swMessageOrderType = 'post' | 'push-user' | 'push-note' | 'push-messaging-room';
export type swMessageOrderType = 'post' | 'push';
export type SwMessage = {
type: 'order';

View File

@ -1,6 +1,7 @@
import { publishMainStream, publishGroupMessagingStream } from '../../../services/stream';
import { publishMessagingStream } from '../../../services/stream';
import { publishMessagingIndexStream } from '../../../services/stream';
import { pushNotification } from '../../../services/push-notification';
import { User, ILocalUser, IRemoteUser } from '../../../models/entities/user';
import { MessagingMessage } from '../../../models/entities/messaging-message';
import { MessagingMessages, UserGroupJoinings, Users } from '../../../models';
@ -12,6 +13,7 @@ import { renderReadActivity } from '../../../remote/activitypub/renderer/read';
import { renderActivity } from '../../../remote/activitypub/renderer';
import { deliver } from '../../../queue';
import orderedCollection from '../../../remote/activitypub/renderer/ordered-collection';
import { use } from 'matter-js';
/**
* Mark messages as read
@ -50,6 +52,23 @@ export async function readUserMessagingMessage(
if (!await Users.getHasUnreadMessagingMessage(userId)) {
// 全ての(いままで未読だった)自分宛てのメッセージを(これで)読みましたよというイベントを発行
publishMainStream(userId, 'readAllMessagingMessages');
pushNotification(userId, 'readAllMessagingMessages', undefined);
} else {
// そのユーザーとのメッセージで未読がなければイベント発行
const count = await MessagingMessages.count({
where: {
userId: otherpartyId,
recipientId: userId,
isRead: false,
},
take: 1
})
if (!count) {
pushNotification(userId, 'readAllMessagingMessagesOfARoom', { userId: otherpartyId });
} else {
console.log('count')
}
}
}
@ -104,6 +123,21 @@ export async function readGroupMessagingMessage(
if (!await Users.getHasUnreadMessagingMessage(userId)) {
// 全ての(いままで未読だった)自分宛てのメッセージを(これで)読みましたよというイベントを発行
publishMainStream(userId, 'readAllMessagingMessages');
pushNotification(userId, 'readAllMessagingMessages', undefined);
} else {
// そのグループにおいて未読がなければイベント発行
const unreadExist = await MessagingMessages.createQueryBuilder('message')
.where(`message.groupId = :groupId`, { groupId: groupId })
.andWhere('message.userId != :userId', { userId: userId })
.andWhere('NOT (:userId = ANY(message.reads))', { userId: userId })
.andWhere('message.createdAt > :joinedAt', { joinedAt: joining.createdAt }) // 自分が加入する前の会話については、未読扱いしない
.getOne().then(x => x != null)
if (!unreadExist) {
pushNotification(userId, 'readAllMessagingMessagesOfARoom', { groupId });
} else {
console.log('unread exist')
}
}
}

View File

@ -11,6 +11,8 @@ type pushNotificationsTypes = {
'unreadMessagingMessage': PackedMessagingMessage;
'readNotifications': { notificationIds: string[] };
'readAllNotifications': undefined;
'readAllMessagingMessages': undefined;
'readAllMessagingMessagesOfARoom': { userId: string } | { groupId: string };
};
export async function pushNotification<T extends keyof pushNotificationsTypes>(userId: string, type: T, body: pushNotificationsTypes[T]) {

View File

@ -5,7 +5,18 @@ export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as
export const mutedNoteReasons = ['word', 'manual', 'spam', 'other'] as const;
export type pushNotificationData = {
type: 'notification' | 'unreadMessagingMessage' | 'readNotifications' | 'readAllNotifications',
body: any,
userId: string
type: 'notification' | 'unreadMessagingMessage' | 'readNotifications' | 'readAllMessagingMessagesOfARoom' | 'readAllNotifications' | 'readAllMessagingMessages';
body: {
[x: string]: any;
id?: string;
type?: typeof notificationTypes[number];
notificationIds?: string[];
user?: any;
userId?: string | null;
note?: any;
choice?: number;
reaction?: string;
invitation?: any;
};
userId: string;
};