diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ee9ccc..2b44df6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Change Log +## 28.2.0-rc.6 + +* Added: `Push.onNotificationOpened()` fires when a user taps a shown notification +* Added: `Push.getInitialNotification()` (returns `null` on web) and the `PushNotificationOpened` type +* Added: tapping a push notification focuses then closes the page +* Fixed: `subscribe()` resolves the signed-in user from `cookieFallback`, not just an explicit session +* Fixed: background notifications de-duplicate by title, so each distinct title posts once +* Updated: clearer error messages for missing credentials in push + +## 28.2.0-rc.5 + +* Updated: regenerated with sdk-generator 5.5.0; no SDK code changes + ## 28.2.0-rc.3 * Added: background push notifications render the server `notification` title, body, and image diff --git a/README.md b/README.md index a72dfb6..cc5c4d7 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ import { Client, Account } from 'appwrite'; To install with a CDN (content delivery network) add the following scripts to the bottom of your tag, but before you use any Appwrite services: ```html - + ``` ## Getting Started diff --git a/package-lock.json b/package-lock.json index 80ed902..b5e3fb1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "appwrite", - "version": "28.2.0-rc.3", + "version": "28.2.0-rc.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "appwrite", - "version": "28.2.0-rc.3", + "version": "28.2.0-rc.6", "license": "BSD-3-Clause", "dependencies": { "buffer": "6.0.3", diff --git a/package.json b/package.json index ced404a..2120e83 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "appwrite", "homepage": "https://appwrite.io/support", "description": "Appwrite is an open-source self-hosted backend server that abstracts and simplifies complex and repetitive development tasks behind a very simple REST API", - "version": "28.2.0-rc.3", + "version": "28.2.0-rc.6", "license": "BSD-3-Clause", "main": "dist/cjs/sdk.js", "exports": { diff --git a/src/client.ts b/src/client.ts index 6ed4147..4ee6a1e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -401,7 +401,7 @@ class Client { 'x-sdk-name': 'Web', 'x-sdk-platform': 'client', 'x-sdk-language': 'web', - 'x-sdk-version': '28.2.0-rc.3', + 'x-sdk-version': '28.2.0-rc.6', 'X-Appwrite-Response-Format': '2.3.0', }; diff --git a/src/index.ts b/src/index.ts index dbf2afb..915cf34 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,6 +35,7 @@ export type { export type { RealtimeSubscription } from './services/realtime'; export type { PushMessage, + PushNotificationOpened, PushSubscription, SubscribeOptions, MessageCallback, diff --git a/src/services/push.ts b/src/services/push.ts index fb731dc..1c3dd5b 100644 --- a/src/services/push.ts +++ b/src/services/push.ts @@ -20,6 +20,17 @@ export interface PushMessage { export type MessageCallback = (message: PushMessage) => void | Promise; +/** A background notification the user tapped. */ +export interface PushNotificationOpened { + /** The topic the message was published to. */ + topic: string; + /** The `data` sent with the message (e.g. `createPush`), parsed; `{}` when it had none. */ + data: Record; +} + +// Taps on the notifications this page shows, reported to every onNotificationOpened. +const openedListeners = new Set<(opened: PushNotificationOpened) => void>(); + /** Per-subscription options passed to `subscribe` and `PushSubscription.update`. */ export interface SubscribeOptions { /** @@ -189,11 +200,13 @@ export class Push { * the session, which could belong to a different user), else the session. */ private userTopic(): string { - const { jwt, session } = this.client.config; - const userId = jwt ? userIdFromJwt(jwt) : userIdFromSession(session); + const { jwt } = this.client.config; + const userId = jwt + ? userIdFromJwt(jwt) + : userIdFromSession(clientSession(this.client)); if (!userId) { throw new Error( - 'subscribe() without a topic needs a signed-in user: set a JWT or session on the client', + 'subscribe() without a topic needs a signed-in user: sign in with this client, or set a JWT or session on it', ); } return `users/${userId}`; @@ -471,6 +484,32 @@ export class Push { return { unsubscribe, update }; } + /** + * The background notification whose tap launched the app, or null. A page shows its + * notifications itself, so a tap only reaches it while it is open (see + * {@link onNotificationOpened}): on the web this always resolves null. + */ + async getInitialNotification(): Promise { + return null; + } + + /** + * Call `callback` each time the user taps a notification this page showed while it is open; + * the tap also focuses the page. Returns a function that stops listening. + * + * ```ts + * const stop = push.onNotificationOpened(({ data }) => openSale(data.saleId)); + * ``` + */ + onNotificationOpened( + callback: (opened: PushNotificationOpened) => void, + ): () => void { + openedListeners.add(callback); + return () => { + openedListeners.delete(callback); + }; + } + /** Tear down the connection and drop all active subscriptions. */ close(): void { if (this.mqtt) { @@ -552,14 +591,15 @@ export class Push { credential: this.client.config.jwt, }; } - if (this.client.config.session) { + const session = clientSession(this.client); + if (session) { return { authMethod: 'appwrite-session', - credential: this.client.config.session, + credential: session, }; } throw new Error( - 'No credential set on the client; call Client.setJWT() or Client.setSession() first.', + 'No credential: sign in with this client, or call Client.setJWT() or Client.setSession() first.', ); } @@ -818,20 +858,19 @@ export class Push { qos: packet.qos, }; const content = notificationContent(message); - const titles = new Set(); + const postedTitles = new Set(); for (const sub of this.subscriptions.values()) { if (matches(sub.topic, message.topic)) { await sub.callback(message); // Notification is per-subscription: only subs that opted in post one, each - // with its own title. A title the server sent replaces theirs, so one posts. - if (sub.background) { - titles.add(content.title ?? sub.title ?? message.topic); + // title once. A title the server sent replaces theirs, so it posts once. + const title = content.title ?? sub.title ?? message.topic; + if (sub.background && !postedTitles.has(title)) { + postedTitles.add(title); + this.notify(message, title, content); } } } - for (const title of titles) { - this.notify(message, title, content); - } } private requestNotificationPermission(): void { @@ -865,13 +904,35 @@ export class Push { data: { topic: message.topic, payload: message.data }, }; try { - new Notification(title, options); + const notification = new Notification(title, options); + notification.onclick = () => { + window.focus(); + notification.close(); + const opened = toOpened(message.topic, message.data); + openedListeners.forEach((listener) => listener(opened)); + }; } catch { // Some browsers only allow notifications from a service worker; ignore. } } } +function toOpened(topic: string, payload: string): PushNotificationOpened { + let data: unknown; + try { + data = (JSON.parse(payload) as { data?: unknown })?.data; + } catch { + data = undefined; + } + return { + topic, + data: + typeof data === 'object' && data !== null && !Array.isArray(data) + ? (data as Record) + : {}, + }; +} + class SupersededError extends Error { constructor() { super('The push connection was superseded'); @@ -939,6 +1000,25 @@ function userIdFromJwt(jwt?: string): string { } } +/** + * The session set on the client, else the one it signed in with when the browser keeps it in + * localStorage (`cookieFallback`), as realtime reads it. + */ +function clientSession(client: Client): string { + if (client.config.session) { + return client.config.session; + } + try { + const fallback = JSON.parse( + window.localStorage.getItem('cookieFallback') ?? '{}', + ) as Record; + const session = fallback[`a_session_${client.config.project}`]; + return typeof session === 'string' ? session : ''; + } catch { + return ''; + } +} + /** * The user id inside a session secret, which is base64 of JSON `{"id": , "secret": ...}`. * Returns '' when the value is not such a secret.