From 496da960722c261a55d8a2834df0395fe2aa6c30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaqu=C3=ADn=20S=C3=A1nchez?= Date: Sun, 1 Jan 2023 20:38:05 +0100 Subject: [PATCH] feat(pwa): allow access elk users from service worker (#662) Co-authored-by: patak --- .../push-notifications/usePushManager.ts | 3 +- composables/users.ts | 28 +- package.json | 1 + pnpm-lock.yaml | 39 ++- service-worker/notification.ts | 106 ++++++++ service-worker/types.ts | 241 +++++++++++++++++- service-worker/web-push-notifications.ts | 34 +-- 7 files changed, 413 insertions(+), 39 deletions(-) create mode 100644 service-worker/notification.ts diff --git a/composables/push-notifications/usePushManager.ts b/composables/push-notifications/usePushManager.ts index defa87e7..c1c54a07 100644 --- a/composables/push-notifications/usePushManager.ts +++ b/composables/push-notifications/usePushManager.ts @@ -35,7 +35,8 @@ export const usePushManager = () => { poll: currentUser.value?.pushSubscription?.alerts.poll ?? true, policy: configuredPolicy.value[currentUser.value?.account?.acct ?? ''] ?? 'all', }) - const { history, commit, clear } = useManualRefHistory(pushNotificationData, { clone: true }) + // don't clone, we're using indexeddb + const { history, commit, clear } = useManualRefHistory(pushNotificationData) const saveEnabled = computed(() => { const current = pushNotificationData.value const previous = history.value?.[0]?.snapshot diff --git a/composables/users.ts b/composables/users.ts index 4af51e1f..67282c5c 100644 --- a/composables/users.ts +++ b/composables/users.ts @@ -1,6 +1,8 @@ import { login as loginMasto } from 'masto' +import { useIDBKeyval } from '@vueuse/integrations/useIDBKeyval' import type { Account, AccountCredentials, Instance, MastoClient, WsEvents } from 'masto' import type { Ref } from 'vue' +import type { RemovableRef } from '@vueuse/core' import type { ElkMasto, UserLogin } from '~/types' import { DEFAULT_POST_CHARS_LIMIT, @@ -14,7 +16,31 @@ import { import type { PushNotificationPolicy, PushNotificationRequest } from '~/composables/push-notifications/types' const mock = process.mock -const users = useLocalStorage(STORAGE_KEY_USERS, mock ? [mock.user] : [], { deep: true }) + +const initializeUsers = (): Ref | RemovableRef => { + let defaultUsers = mock ? [mock.user] : [] + + // Backward compatibility with localStorage + let removeUsersOnLocalStorage = false + if (globalThis?.localStorage) { + const usersOnLocalStorageString = globalThis.localStorage.getItem(STORAGE_KEY_USERS) + if (usersOnLocalStorageString) { + defaultUsers = JSON.parse(usersOnLocalStorageString) + removeUsersOnLocalStorage = true + } + } + + const users = process.server + ? ref(defaultUsers) + : useIDBKeyval(STORAGE_KEY_USERS, defaultUsers, { deep: true }) + + if (removeUsersOnLocalStorage) + globalThis.localStorage.removeItem(STORAGE_KEY_USERS) + + return users +} + +const users = initializeUsers() const instances = useLocalStorage>(STORAGE_KEY_SERVERS, mock ? mock.server : {}, { deep: true }) const currentUserId = useLocalStorage(STORAGE_KEY_CURRENT_USER, mock ? mock.user.account.id : '') diff --git a/package.json b/package.json index 9a8ab610..696cdb39 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "focus-trap": "^7.2.0", "form-data": "^4.0.0", "fuse.js": "^6.6.2", + "idb-keyval": "^6.2.0", "js-yaml": "^4.1.0", "lru-cache": "^7.14.1", "masto": "^4.11.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c170fd48..7275f5c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,6 +48,7 @@ specifiers: form-data: ^4.0.0 fs-extra: ^11.1.0 fuse.js: ^6.6.2 + idb-keyval: ^6.2.0 js-yaml: ^4.1.0 jsdom: ^20.0.3 lint-staged: ^13.1.0 @@ -93,13 +94,14 @@ dependencies: '@tiptap/suggestion': 2.0.0-beta.204 '@tiptap/vue-3': 2.0.0-beta.204 '@vueuse/core': 9.9.0 - '@vueuse/integrations': 9.9.0_7zhv6s73i5wtygx2wkeytrmn7q + '@vueuse/integrations': 9.9.0_ha7ivgav6uqpoo2b5thfugqwjq blurhash: 2.0.4 browser-fs-access: 0.31.1 floating-vue: 2.0.0-beta.20 focus-trap: 7.2.0 form-data: 4.0.0 fuse.js: 6.6.2 + idb-keyval: 6.2.0 js-yaml: 4.1.0 lru-cache: 7.14.1 masto: 4.11.1 @@ -157,7 +159,7 @@ devDependencies: typescript: 4.9.4 unplugin-auto-import: 0.12.1_@vueuse+core@9.9.0 vite-plugin-inspect: 0.7.11 - vite-plugin-pwa: 0.13.3_workbox-window@6.5.4 + vite-plugin-pwa: 0.13.3 vitest: 0.26.2_jsdom@20.0.3 vue-tsc: 1.0.16_typescript@4.9.4 workbox-window: 6.5.4 @@ -1621,8 +1623,8 @@ packages: vue-i18n: optional: true dependencies: - '@intlify/message-compiler': 9.3.0-beta.11 - '@intlify/shared': 9.3.0-beta.11 + '@intlify/message-compiler': 9.3.0-beta.12 + '@intlify/shared': 9.3.0-beta.12 jsonc-eslint-parser: 1.4.1 source-map: 0.6.1 vue-i18n: 9.3.0-beta.10 @@ -1654,8 +1656,8 @@ packages: source-map: 0.6.1 dev: true - /@intlify/message-compiler/9.3.0-beta.11: - resolution: {integrity: sha512-gGGfBGzM7JBXp1Q9gbDAy5jELz9ho3ILqnpxp2yp64+gkqohrqc2YXIvCdwZoc6AtKIh/Zmv4sWVqxkvMsBWtQ==} + /@intlify/message-compiler/9.3.0-beta.12: + resolution: {integrity: sha512-A8/s7pb3v8nf6HG77qFPJntxgQKI9GXxGnkn7aO+b03/X/GkF/4WceDSAIk3i+yLeIgszeBn9GZ23tSg4sTEHA==} engines: {node: '>= 14'} dependencies: '@intlify/shared': 9.3.0-beta.11 @@ -1672,6 +1674,11 @@ packages: engines: {node: '>= 14'} dev: true + /@intlify/shared/9.3.0-beta.12: + resolution: {integrity: sha512-WsmaS54sA8xuwezPKpa/OMoaX1v2VF2fCgAmYS6prDr2ir0CkUFWPm9A8ilmxzv4nkS61/v8+vf4lGGkn5uBdA==} + engines: {node: '>= 14'} + dev: true + /@intlify/unplugin-vue-i18n/0.8.0_vue-i18n@9.3.0-beta.10: resolution: {integrity: sha512-bqMDYrbmV0oMLGHTdYMUXfcEsy2rPwQnGrQAg4gvw5FimvJfTQt3RliLVayT5ldOfeT2g0IUc/0t7LPeGrFUag==} engines: {node: '>= 14.16'} @@ -1688,7 +1695,7 @@ packages: optional: true dependencies: '@intlify/bundle-utils': 3.4.0_vue-i18n@9.3.0-beta.10 - '@intlify/shared': 9.3.0-beta.11 + '@intlify/shared': 9.3.0-beta.12 '@rollup/pluginutils': 4.2.1 '@vue/compiler-sfc': 3.2.45 debug: 4.3.4 @@ -3514,7 +3521,7 @@ packages: vue: 3.2.45 dev: true - /@vueuse/integrations/9.9.0_7zhv6s73i5wtygx2wkeytrmn7q: + /@vueuse/integrations/9.9.0_ha7ivgav6uqpoo2b5thfugqwjq: resolution: {integrity: sha512-/wr3jrMlzbPNd38dO85NOT4j7vga9+eQewEZFXHJAFEvKnRxBy/Ytp1pt4Sz8dVOLLYMBHfSaVAra91ftfIh0w==} peerDependencies: async-validator: '*' @@ -3556,6 +3563,7 @@ packages: '@vueuse/shared': 9.9.0 focus-trap: 7.2.0 fuse.js: 6.6.2 + idb-keyval: 6.2.0 vue-demi: 0.13.11 transitivePeerDependencies: - '@vue/composition-api' @@ -6186,6 +6194,12 @@ packages: safer-buffer: 2.1.2 dev: true + /idb-keyval/6.2.0: + resolution: {integrity: sha512-uw+MIyQn2jl3+hroD7hF8J7PUviBU7BPKWw4f/ISf32D4LoGu98yHjrzWWJDASu9QNrX10tCJqk9YY0ClWm8Ng==} + dependencies: + safari-14-idb-fix: 3.0.0 + dev: false + /idb/7.1.1: resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==} dev: true @@ -8590,6 +8604,10 @@ packages: tslib: 2.4.1 dev: true + /safari-14-idb-fix/3.0.0: + resolution: {integrity: sha512-eBNFLob4PMq8JA1dGyFn6G97q3/WzNtFK4RnzT1fnLq+9RyrGknzYiM/9B12MnKAxuj1IXr7UKYtTNtjyKMBog==} + dev: false + /safe-buffer/5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} @@ -9817,11 +9835,10 @@ packages: - supports-color dev: true - /vite-plugin-pwa/0.13.3_workbox-window@6.5.4: + /vite-plugin-pwa/0.13.3: resolution: {integrity: sha512-cjWXpZ7slAY14OKz7M8XdgTIi9wjf6OD6NkhiMAc+ogxnbUrecUwLdRtfGPCPsN2ftut5gaN1jTghb11p6IQAA==} peerDependencies: vite: ^3.1.0 - workbox-window: ^6.5.4 dependencies: '@rollup/plugin-replace': 4.0.0_rollup@2.79.1 debug: 4.3.4 @@ -10069,7 +10086,7 @@ packages: vue-router: optional: true dependencies: - '@intlify/shared': 9.3.0-beta.11 + '@intlify/shared': 9.3.0-beta.12 '@intlify/vue-i18n-bridge': 0.8.0_vue-i18n@9.3.0-beta.10 '@intlify/vue-router-bridge': 0.8.0 ufo: 1.0.1 diff --git a/service-worker/notification.ts b/service-worker/notification.ts new file mode 100644 index 00000000..c585ac8d --- /dev/null +++ b/service-worker/notification.ts @@ -0,0 +1,106 @@ +import { get } from 'idb-keyval' +import type { MastoNotification, NotificationInfo, PushPayload, UserLogin } from './types' + +export const findNotification = async ( + { access_token, notification_id/* , notification_type */ }: PushPayload, +): Promise => { + const users = await get('elk-users') + if (!users) + return undefined + + const filteredUsers = users.filter(user => user.token === access_token) + if (!filteredUsers || filteredUsers.length === 0) + return undefined + + for (const user of filteredUsers) { + try { + const response = await fetch(`https://${user.server}/api/v1/notifications/${notification_id}`, { + method: 'get', + headers: { + 'Authorization': `Bearer ${user.token}`, + 'Content-Type': 'application/json', + }, + }) + // assume it is ok to return the first notification: backend should return 404 if not found + if (response && response.ok) { + const notification: MastoNotification = await response.json() + return { user, notification } + } + } + catch { + // just ignore + } + } + + return undefined +} + +export function createNotificationOptions( + pushPayload: PushPayload, + notificationInfo?: NotificationInfo, +): NotificationOptions { + const { + access_token, + body, + icon, + notification_id, + notification_type, + preferred_locale, + } = pushPayload + + const url = notification_type === 'mention' ? 'notifications/mention' : 'notifications' + + const notificationOptions: NotificationOptions = { + badge: '/pwa-192x192.png', + body, + data: { + access_token, + preferred_locale, + url: `/${url}`, + }, + dir: 'auto', + icon, + lang: preferred_locale, + tag: notification_id, + timestamp: new Date().getTime(), + } + + if (notificationInfo) { + const { user, notification } = notificationInfo + notificationOptions.tag = notification.id + /* + if (notification.account.avatar_static) + notificationOptions.icon = notification.account.avatar_static +*/ + if (notification.created_at) + notificationOptions.timestamp = new Date(notification.created_at).getTime() + + /* TODO: add spolier when actions available, checking also notification type + if (notification.status && (notification.status.spoilerText || notification.status.sensitive)) { + if (notification.status.spoilerText) + notificationOptions.body = notification.status.spoilerText + + notificationOptions.image = undefined + } + */ + if (notification.status) { + // notificationOptions.body = htmlToPlainText(notification.status.content) + if (notification.status.media_attachments && notification.status.media_attachments.length > 0 && notification.status.media_attachments[0].preview_url) + notificationOptions.image = notification.status.media_attachments[0].preview_url + + if (notification.type === 'favourite' || notification.type === 'reblog' || notification.type === 'mention') + notificationOptions.data.url = `${user.server}/@${user.account.username}/${notification.status.id}` + } + else if (notification.type === 'follow') { + notificationOptions.data.url = `${user.server}/@${notification.account.acct}` + } + } + + return notificationOptions +} + +/* +function htmlToPlainText(html: string) { + return decodeURIComponent(html.replace(//g, '\n').replace(/<\/p>

/g, '\n\n').replace(/<[^>]*>/g, '')) +} +*/ diff --git a/service-worker/types.ts b/service-worker/types.ts index 7c6aec3a..27ab8c24 100644 --- a/service-worker/types.ts +++ b/service-worker/types.ts @@ -1,9 +1,248 @@ +// masto types and notification types differs +// Any type used from masto api retrieving notification from push notification id is no camel case, it is snake case +// I just copy/paste any entry from masto api and convert it to snake case, reusing types not including camel case props +import type { + AccountCredentials, + AttachmentMeta, + AttachmentType, + Card, + Mention, + StatusVisibility, + Tag, +} from 'masto' + +export type NotificationType = 'mention' | 'status' | 'reblog' | 'follow' | 'follow_request' | 'favourite' | 'poll' | 'update' | 'admin.sign_up' | 'admin.report' + export interface PushPayload { access_token: string notification_id: string - notification_type: 'follow' | 'favourite' | 'reblog' | 'mention' | 'poll' + notification_type: NotificationType preferred_locale: string title: string body: string icon: string } + +export interface UserLogin { + server: string + token?: string + account: AccountCredentials +} + +export interface NotificationInfo { + user: UserLogin + notification: MastoNotification +} + +interface PollOption { + /** The text value of the poll option. String. */ + title: string + /** The number of received votes for this option. Number, or null if results are not published yet. */ + votes_count?: number + /** Custom emoji to be used for rendering poll options. */ + emojis: Emoji[] +} +/** + * Represents a poll attached to a status. + * @see https://docs.joinmastodon.org/entities/poll/ + */ +interface Poll { + /** The ID of the poll in the database. */ + id: string + /** When the poll ends. */ + expires_at?: string | null + /** Is the poll currently expired? */ + expired: boolean + /** Does the poll allow multiple-choice answers? */ + multiple: boolean + /** How many votes have been received. */ + votes_count: number + /** How many unique accounts have voted on a multiple-choice poll. */ + voters_count?: number | null + /** When called with a user token, has the authorized user voted? */ + voted?: boolean + /** + * When called with a user token, which options has the authorized user chosen? + * Contains an array of index values for options. + */ + own_votes?: number[] | null + /** Possible answers for the poll. */ + options: PollOption[] +} + +export interface Attachment { + /** The ID of the attachment in the database. */ + id: string + /** The type of the attachment. */ + type: AttachmentType + /** The location of the original full-size attachment. */ + url?: string | null + /** The location of a scaled-down preview of the attachment. */ + preview_url: string + /** The location of the full-size original attachment on the remote website. */ + remote_url?: string | null + /** Remote version of previewUrl */ + preview_remote_url?: string | null + /** A shorter URL for the attachment. */ + text_url?: string | null + /** Metadata returned by Paperclip. */ + meta?: AttachmentMeta | null + /** + * Alternate text that describes what is in the media attachment, + * to be used for the visually impaired or when media attachments do not load. + */ + description?: string | null + /** + * A hash computed by the BlurHash algorithm, + * for generating colorful preview thumbnails when media has not been downloaded yet. + */ + blurhash?: string | null +} + +export interface Emoji { + /** The name of the custom emoji. */ + shortcode: string + /** A link to the custom emoji. */ + url: string + /** A link to a static copy of the custom emoji. */ + static_url: string + /** Whether this Emoji should be visible in the picker or unlisted. */ + visible_in_picker: boolean + /** Used for sorting custom emoji in the picker. */ + category?: string | null +} + +export interface Status { + /** ID of the status in the database. */ + id: string + /** URI of the status used for federation. */ + uri: string + /** The date when this status was created. */ + created_at: string + /** Timestamp of when the status was last edited. */ + edited_at: string | null + /** The account that authored this status. */ + account: MastoAccount + /** HTML-encoded status content. */ + content: string + /** Visibility of this status. */ + visibility: StatusVisibility + /** Is this status marked as sensitive content? */ + sensitive: boolean + /** Subject or summary line, below which status content is collapsed until expanded. */ + spoiler_text: string + /** Media that is attached to this status. */ + media_attachments: Attachment[] + /** The application used to post this status. */ + // application: Application + /** Mentions of users within the status content. */ + mentions: Mention[] + /** Hashtags used within the status content. */ + tags: Tag[] + /** Custom emoji to be used when rendering status content. */ + emojis: Emoji[] + /** How many boosts this status has received. */ + reblogs_count: number + /** How many favourites this status has received. */ + favourites_count: number + /** How many replies this status has received. */ + replies_count: number + /** A link to the status's HTML representation. */ + url?: string | null + /** ID of the status being replied. */ + in_reply_to_id?: string | null + /** ID of the account being replied to. */ + in_reply_to_account_id?: string | null + /** The status being reblogged. */ + reblog?: Status | null + /** The poll attached to the status. */ + poll?: Poll | null + /** Preview card for links included within status content. */ + card?: Card | null + /** Primary language of this status. */ + language?: string | null + /** + * Plain-text source of a status. Returned instead of `content` when status is deleted, + * so the user may redraft from the source text without the client having + * to reverse-engineer the original text from the HTML content. + */ + text?: string | null + /** Have you favourited this status? */ + favourited?: boolean | null + /** Have you boosted this status? */ + reblogged?: boolean | null + /** Have you muted notifications for this status's conversation? */ + muted?: boolean | null + /** Have you bookmarked this status? */ + bookmarked?: boolean | null + /** Have you pinned this status? Only appears if the status is pin-able. */ + pinned?: boolean | null +} + +export interface Field { + /** The key of a given field's key-value pair. */ + name: string + /** The value associated with the `name` key. */ + value: string + /** Timestamp of when the server verified a URL value for a rel="me” link. */ + verified_at?: string | null +} +export interface MastoAccount { + /** The account id */ + id: string + /** The username of the account, not including domain */ + username: string + /** The WebFinger account URI. Equal to `username` for local users, or `username@domain` for remote users. */ + acct: string + /** The location of the user's profile page. */ + url: string + /** The profile's display name. */ + display_name: string + /** The profile's bio / description. */ + note: string + /** An image icon that is shown next to statuses and in the profile. */ + avatar: string + /** A static version of the `avatar`. Equal to avatar if its value is a static image; different if `avatar` is an animated GIF. */ + avatar_static: string + /** An image banner that is shown above the profile and in profile cards. */ + header: string + /** A static version of the header. Equal to `header` if its value is a static image; different if `header` is an animated GIF. */ + header_static: string + /** Whether the account manually approves follow requests. */ + locked: boolean + /** Custom emoji entities to be used when rendering the profile. If none, an empty array will be returned. */ + emojis: Emoji[] + /** Whether the account has opted into discovery features such as the profile directory. */ + discoverable: boolean + /** When the account was created. */ + created_at: string + /** How many statuses are attached to this account. */ + statuses_count: number + /** The reported followers of this profile. */ + followers_count: number + /** The reported follows of this profile. */ + following_count: number + /** Time of the last status posted */ + last_status_at: string + /** Indicates that the profile is currently inactive and that its user has moved to a new account. */ + moved?: boolean | null + /** An extra entity returned when an account is suspended. **/ + suspended?: boolean | null + /** Additional metadata attached to a profile as name-value pairs. */ + fields?: Field[] | null + /** Boolean to indicate that the account performs automated actions */ + bot?: boolean | null +} + +export interface MastoNotification { + /** The id of the notification in the database. */ + id: string + /** The type of event that resulted in the notification. */ + type: NotificationType + /** The timestamp of the notification. */ + created_at: string + /** The account that performed the action that generated the notification. */ + account: MastoAccount + /** Status that was the object of the notification, e.g. in mentions, reblogs, favourites, or polls. */ + status?: Status | null +} diff --git a/service-worker/web-push-notifications.ts b/service-worker/web-push-notifications.ts index 5c37efdc..43a48429 100644 --- a/service-worker/web-push-notifications.ts +++ b/service-worker/web-push-notifications.ts @@ -1,5 +1,6 @@ /// /// +import { createNotificationOptions, findNotification } from './notification' import type { PushPayload } from '~/service-worker/types' declare const self: ServiceWorkerGlobalScope @@ -10,32 +11,15 @@ export const onPush = (event: PushEvent) => { return Promise.resolve() const options: PushPayload = event.data!.json() - const { - access_token, - body, - icon, - notification_id, - notification_type, - preferred_locale, - } = options - const url = notification_type === 'mention' ? 'notifications/mention' : 'notifications' - - const notificationOptions: NotificationOptions = { - badge: '/pwa-192x192.png', - body, - data: { - access_token, - preferred_locale, - url: `/${url}`, - }, - dir: 'auto', - icon, - lang: preferred_locale, - tag: notification_id, - timestamp: new Date().getTime(), - } - return self.registration.showNotification(options.title, notificationOptions) + return findNotification(options) + .catch((e) => { + console.error('unhandled error finding notification', e) + return Promise.resolve(undefined) + }) + .then((notificationInfo) => { + return self.registration.showNotification(options.title, createNotificationOptions(options, notificationInfo)) + }) }) event.waitUntil(promise)