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.