Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <body> tag, but before you use any Appwrite services:

```html
<script src="https://cdn.jsdelivr.net/npm/appwrite@28.2.0-rc.3"></script>
<script src="https://cdn.jsdelivr.net/npm/appwrite@28.2.0-rc.6"></script>
```

## Getting Started
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};

Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export type {
export type { RealtimeSubscription } from './services/realtime';
export type {
PushMessage,
PushNotificationOpened,
PushSubscription,
SubscribeOptions,
MessageCallback,
Expand Down
108 changes: 94 additions & 14 deletions src/services/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@ export interface PushMessage {

export type MessageCallback = (message: PushMessage) => void | Promise<void>;

/** 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<string, unknown>;
}

// Taps on the notifications this page shows, reported to every onNotificationOpened.
const openedListeners = new Set<(opened: PushNotificationOpened) => void>();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scope notification-open listeners to their Push instance

With two Push instances on the page, tapping a notification from one invokes the other instance's listeners too, potentially routing data from a different project to the wrong handler. Please keep the listener set on the instance that creates the notification.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/services/push.ts
Line: 32

Comment:
**Scope notification-open listeners to their Push instance**

With two Push instances on the page, tapping a notification from one invokes the other instance's listeners too, potentially routing data from a different project to the wrong handler. Please keep the listener set on the instance that creates the notification.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

🟡 Minor · bug · Reply if this doesn't apply.


/** Per-subscription options passed to `subscribe` and `PushSubscription.update`. */
export interface SubscribeOptions {
/**
Expand Down Expand Up @@ -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}`;
Expand Down Expand Up @@ -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<PushNotificationOpened | null> {
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) {
Expand Down Expand Up @@ -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.',
);
}

Expand Down Expand Up @@ -818,20 +858,19 @@ export class Push {
qos: packet.qos,
};
const content = notificationContent(message);
const titles = new Set<string>();
const postedTitles = new Set<string>();
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 {
Expand Down Expand Up @@ -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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isolate failures in notification-open listeners

If one listener throws, forEach stops and the remaining registered listeners never receive the tap; the exception also escapes onto the host page. Please catch and report errors per listener so one handler cannot block the others.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/services/push.ts
Line: 912

Comment:
**Isolate failures in notification-open listeners**

If one listener throws, `forEach` stops and the remaining registered listeners never receive the tap; the exception also escapes onto the host page. Please catch and report errors per listener so one handler cannot block the others.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

🟡 Minor · error-handling · Reply if this doesn't apply.

};
} 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<string, unknown>)
: {},
};
}

class SupersededError extends Error {
constructor() {
super('The push connection was superseded');
Expand Down Expand Up @@ -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<string, unknown>;
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": <userId>, "secret": ...}`.
* Returns '' when the value is not such a secret.
Expand Down