From 97e995c0002600f102e7e2a9e493ed591609f7be Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 6 Oct 2026 17:13:43 +0530 Subject: [PATCH 1/3] fix: post each push notification as its callback returns, collapsing only server titles --- src/services/push.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/services/push.ts b/src/services/push.ts index fb731dc..474b105 100644 --- a/src/services/push.ts +++ b/src/services/push.ts @@ -818,20 +818,22 @@ export class Push { qos: packet.qos, }; const content = notificationContent(message); - const titles = new Set(); + let postedServerTitle = false; 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); + // with its own title. A title the server sent replaces theirs, so it posts once. + if (sub.background && !postedServerTitle) { + postedServerTitle = content.title !== undefined; + this.notify( + message, + content.title ?? sub.title ?? message.topic, + content, + ); } } } - for (const title of titles) { - this.notify(message, title, content); - } } private requestNotificationPermission(): void { From 95eb9b487b33bdcba26f74afb0d6f816fac4cdf3 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 7 Oct 2026 06:22:01 +0000 Subject: [PATCH 2/3] chore: update Web SDK to 28.2.0-rc.5 --- CHANGELOG.md | 4 + README.md | 2 +- package-lock.json | 4 +- package.json | 2 +- src/client.ts | 2 +- src/index.ts | 8 + src/services/analytics-tracking.ts | 799 +++++++++++++++++++++++++++++ src/services/push.ts | 16 +- 8 files changed, 825 insertions(+), 12 deletions(-) create mode 100644 src/services/analytics-tracking.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ee9ccc..2b0d714 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Change Log +## 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..18b7d2e 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..96b31a6 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.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "appwrite", - "version": "28.2.0-rc.3", + "version": "28.2.0-rc.5", "license": "BSD-3-Clause", "dependencies": { "buffer": "6.0.3", diff --git a/package.json b/package.json index ced404a..315a479 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.5", "license": "BSD-3-Clause", "main": "dist/cjs/sdk.js", "exports": { diff --git a/src/client.ts b/src/client.ts index 6ed4147..5126230 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.5', 'X-Appwrite-Response-Format': '2.3.0', }; diff --git a/src/index.ts b/src/index.ts index dbf2afb..30d905d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,6 +26,7 @@ export { Teams } from './services/teams'; export { VectorsDB } from './services/vectors-db'; export { Realtime } from './services/realtime'; export { Push } from './services/push'; +export { AnalyticsTracking } from './services/analytics-tracking'; export type { Models, Payload, @@ -39,6 +40,13 @@ export type { SubscribeOptions, MessageCallback, } from './services/push'; +export type { + AnalyticsEventEmitter, + AnalyticsEventOptions, + AnalyticsTrackingOptions, + DownloadTrackingOptions, + OutboundTrackingOptions, +} from './services/analytics-tracking'; export type { QueryTypes, QueryTypesList } from './query'; export { Permission } from './permission'; export { Role } from './role'; diff --git a/src/services/analytics-tracking.ts b/src/services/analytics-tracking.ts new file mode 100644 index 0000000..b78d2e3 --- /dev/null +++ b/src/services/analytics-tracking.ts @@ -0,0 +1,799 @@ +/** + * Analytics auto-tracking helpers. + * + * These are hand-written companions to the generated `Analytics` service. + * The generated service exposes `createEvent(params)` for arbitrary events; + * this module wires up common auto-tracking behaviours (pageviews, outbound + * links, downloads, scroll depth, active engagement) on top of it. + * + * The tracking helpers accept a plain event-emitter function so they do not + * hard-couple to the generated `Analytics` class. The emitter adapts this + * module's option shape to `createEvent`: `url` is required there, and `props` + * is a flat alternating key/value list rather than an object. + * + * ```ts + * const analytics = new Analytics(client); + * const tracking = new AnalyticsTracking( + * (name, options) => analytics.createEvent({ + * propertyId: options?.propertyId ?? '', + * name, + * url: options?.url ?? window.location.href, + * referrer: options?.referrer, + * scrollDepth: options?.scrollDepth, + * engagementTime: options?.engagementTime, + * props: Object.entries(options?.props ?? {}).flatMap( + * ([key, value]) => [key, String(value)], + * ), + * }), + * { propertyId: '' } + * ); + * tracking.enableAllAutoTracking(); + * ``` + * + * `propertyId` is optional: when set it is merged into the options of every + * emitted event, so an emitter that forwards `options.propertyId` (as above) + * picks it up instead of every caller hand-rolling it into the closure. + * Per-event `propertyId` always wins over the configured default. + * + * All helpers respect `navigator.doNotTrack === '1'` by default; opt out with + * `respectDoNotTrack: false` in the constructor options. + * + * Engagement time follows Plausible's model: rather than one total at unload, + * a delta is emitted at every natural checkpoint of the visit (tab hidden, SPA + * route change, unload). See `enableAutoEngagementTime` for details. + * + * Auto-emitted event names follow `snake_case` + lowercase (`pageview`, + * `outbound_link`, `file_download`, `screen_view`, etc.). Any prop keys use + * camelCase to match the endpoint's parameter naming. Callers can pass any + * custom event name; the auto-track methods only own the events they emit + * themselves. + */ + +export type AnalyticsEventOptions = { + props?: Record; + url?: string; + referrer?: string; + propertyId?: string; // analytics property (or snippet) ID; forwarded to cloud endpoint as top-level param + scrollDepth?: number; // 0-100 percentage; forwarded to cloud endpoint as top-level param + engagementTime?: number; // seconds since the previous flush; forwarded to cloud endpoint as top-level param +}; + +export type AnalyticsEventEmitter = ( + name: string, + options?: AnalyticsEventOptions, +) => unknown; + +export type AnalyticsTrackingOptions = { + /** + * When true (default) the tracker becomes a no-op if `navigator.doNotTrack` + * is set to `'1'`. Set to false to always fire regardless of the DNT signal. + */ + respectDoNotTrack?: boolean; + + /** + * Analytics property (or snippet) ID to attach to every emitted event. + * Optional — omit it when the emitter closure already supplies one. A + * `propertyId` passed on an individual event takes precedence. + */ + propertyId?: string; +}; + +export type OutboundTrackingOptions = { + /** + * When true, links pointing at the current host's subdomains are treated + * as outbound. Defaults to false (subdomains are same-origin for tracking). + */ + includeSubdomains?: boolean; +}; + +export type DownloadTrackingOptions = { + /** + * File extensions (without the leading dot) that mark an anchor as a + * download. Defaults cover the common set. + */ + extensions?: string[]; +}; + +const DEFAULT_DOWNLOAD_EXTENSIONS = [ + 'pdf', + 'zip', + 'rar', + '7z', + 'tar', + 'gz', + 'csv', + 'tsv', + 'xls', + 'xlsx', + 'doc', + 'docx', + 'ppt', + 'pptx', + 'dmg', + 'exe', + 'msi', + 'pkg', + 'deb', + 'rpm', + 'apk', + 'mp3', + 'wav', + 'flac', + 'mp4', + 'mov', + 'avi', + 'mkv', + 'webm', +]; + +/** + * Two-part public suffixes common enough to matter for outbound-link detection. + * See `rootDomain()` for why this is a short list rather than the Public Suffix + * List. + */ +const MULTI_PART_SUFFIXES = new Set([ + 'co.uk', + 'org.uk', + 'ac.uk', + 'gov.uk', + 'me.uk', + 'net.uk', + 'sch.uk', + 'co.jp', + 'ne.jp', + 'or.jp', + 'ac.jp', + 'go.jp', + 'com.au', + 'net.au', + 'org.au', + 'edu.au', + 'gov.au', + 'com.br', + 'net.br', + 'org.br', + 'com.cn', + 'net.cn', + 'org.cn', + 'gov.cn', + 'co.in', + 'net.in', + 'org.in', + 'co.nz', + 'net.nz', + 'org.nz', + 'co.za', + 'org.za', + 'com.mx', + 'com.ar', + 'com.co', + 'com.pe', + 'com.tr', + 'com.tw', + 'com.sg', + 'com.hk', + 'com.my', + 'com.ph', + 'com.vn', + 'co.kr', + 'co.il', + 'co.id', + 'co.th', + 'com.ua', + 'com.pl', + 'com.es', +]); + +const SCROLL_DEPTH_THROTTLE_MS = 200; + +type Cleanup = () => void; + +export class AnalyticsTracking { + private readonly emit: AnalyticsEventEmitter; + private readonly respectDoNotTrack: boolean; + private readonly propertyId?: string; + + private pageviewCleanups: Cleanup[] = []; + private outboundCleanup?: Cleanup; + private downloadCleanup?: Cleanup; + private scrollCleanups: Cleanup[] = []; + private engagementCleanups: Cleanup[] = []; + + private currentPath?: string; + private maxScrollDepth = 0; + private scrollTicking = false; + private lastScrollReport = 0; + + private engagementStart = 0; + private engagementAccumulatedMs = 0; + private engagementActive = false; + + constructor( + emit: AnalyticsEventEmitter, + options: AnalyticsTrackingOptions = {}, + ) { + this.emit = emit; + this.respectDoNotTrack = options.respectDoNotTrack !== false; + this.propertyId = options.propertyId; + } + + /** + * Enable the opinionated default auto-tracking set: pageviews, outbound + * links, scroll depth and active engagement time. Downloads must be opted + * into separately via `enableAutoDownloadTracking()` because the extension + * list is application-specific. + */ + public enableAllAutoTracking(): void { + this.enableAutoPageviews(); + this.enableAutoOutboundTracking(); + this.enableAutoScrollDepth(); + this.enableAutoEngagementTime(); + } + + /** + * Track an event manually. Respects the DNT setting and swallows emitter + * exceptions so tracking failures never surface to the host page. + */ + public track(name: string, options?: AnalyticsEventOptions): void { + if (!this.canTrack()) { + return; + } + try { + this.emit(name, this.withPropertyId(options)); + } catch (error) { + console.warn('AnalyticsTracking: emitter threw', error); + } + } + + /** + * Convenience wrapper for pageview-shaped events. + */ + public pageview(url?: string, referrer?: string): void { + this.track('pageview', { + url: url ?? this.currentPageUrl(), + referrer: + referrer ?? + (typeof document !== 'undefined' + ? document.referrer + : undefined), + }); + } + + /** + * Enable SPA-aware pageview tracking. Fires an initial pageview on + * enable, then again on every `pushState`, `replaceState`, `popstate` + * or hashchange. Idempotent — repeat calls no-op. + */ + public enableAutoPageviews(): void { + if (this.pageviewCleanups.length > 0) { + return; + } + if (typeof window === 'undefined' || typeof history === 'undefined') { + return; + } + + this.currentPath = this.currentPageUrl(); + this.pageview(); + + const handleNavigation = (): void => { + const nextPath = this.currentPageUrl(); + if (nextPath === this.currentPath) { + return; + } + const previous = this.currentPath; + this.currentPath = nextPath; + this.flushScrollDepth(previous); + this.flushEngagement(previous); + this.pageview(nextPath); + // flushEngagement no longer auto-restarts the timer (see its + // implementation note re: bfcache). SPA navigation stays on the + // same document, so restart engagement tracking for the new + // route as long as the tab is visible. + if ( + this.engagementCleanups.length > 0 && + (typeof document === 'undefined' || + document.visibilityState !== 'hidden') + ) { + this.startEngagement(); + } + }; + + const originalPushState = history.pushState.bind(history); + const originalReplaceState = history.replaceState.bind(history); + + // Our wrappers stay reachable after cleanup whenever another library + // patched on top of ours, so they have to be switchable off rather than + // merely unreferenced. + let patchActive = true; + + const patchedPushState = ( + data: unknown, + unused: string, + url?: string | URL | null, + ): void => { + originalPushState(data, unused, url); + if (patchActive) { + handleNavigation(); + } + }; + const patchedReplaceState = ( + data: unknown, + unused: string, + url?: string | URL | null, + ): void => { + originalReplaceState(data, unused, url); + if (patchActive) { + handleNavigation(); + } + }; + + history.pushState = patchedPushState; + history.replaceState = patchedReplaceState; + + const popstateHandler = (): void => handleNavigation(); + const hashchangeHandler = (): void => handleNavigation(); + + window.addEventListener('popstate', popstateHandler); + window.addEventListener('hashchange', hashchangeHandler); + + this.pageviewCleanups.push(() => { + // Only unwind our own patch. A router (Next.js App Router, React + // Router, …) that patched these after us owns the current value, + // and assigning our saved reference back would silently throw its + // wrapper away. In that case ours stays in the chain, which is why + // `patchActive` exists: the wrapper keeps delegating to the original + // but stops reporting. + patchActive = false; + + if (history.pushState === patchedPushState) { + history.pushState = originalPushState; + } + if (history.replaceState === patchedReplaceState) { + history.replaceState = originalReplaceState; + } + window.removeEventListener('popstate', popstateHandler); + window.removeEventListener('hashchange', hashchangeHandler); + }); + } + + /** + * Disable auto-pageview tracking installed by `enableAutoPageviews`. + * Safe to call when auto-pageviews were never enabled. + */ + public disableAutoPageviews(): void { + for (const cleanup of this.pageviewCleanups) { + cleanup(); + } + this.pageviewCleanups = []; + } + + /** + * Enable click-based outbound link tracking. Detects anchor clicks that + * navigate to a different origin and fires an `outbound_link` event + * (with `hostname` and `href` props) before the browser navigates. + */ + public enableAutoOutboundTracking( + options: OutboundTrackingOptions = {}, + ): void { + if (this.outboundCleanup) { + return; + } + if (typeof document === 'undefined') { + return; + } + + const includeSubdomains = options.includeSubdomains === true; + + const clickHandler = (event: MouseEvent): void => { + const anchor = this.findAnchor(event.target); + if (!anchor || !anchor.href) { + return; + } + const target = this.parseUrl(anchor.href); + if (!target || !this.isExternal(target, includeSubdomains)) { + return; + } + this.track('outbound_link', { + url: target.href, + props: { hostname: target.hostname, href: target.href }, + }); + }; + + document.addEventListener('click', clickHandler, { capture: true }); + this.outboundCleanup = () => + document.removeEventListener('click', clickHandler, { + capture: true, + }); + } + + /** + * Disable outbound-link tracking installed by `enableAutoOutboundTracking`. + * Safe to call when auto-outbound was never enabled. + */ + public disableAutoOutboundTracking(): void { + if (this.outboundCleanup) { + this.outboundCleanup(); + this.outboundCleanup = undefined; + } + } + + /** + * Enable click-based download tracking. Detects anchor clicks that point + * at a file whose extension appears in the configured list and fires a + * `file_download` event with `filename`, `extension` and `href` props. + */ + public enableAutoDownloadTracking( + options: DownloadTrackingOptions = {}, + ): void { + if (this.downloadCleanup) { + return; + } + if (typeof document === 'undefined') { + return; + } + + const extensions = ( + options.extensions ?? DEFAULT_DOWNLOAD_EXTENSIONS + ).map((ext) => ext.toLowerCase().replace(/^\./, '')); + const extensionSet = new Set(extensions); + + const clickHandler = (event: MouseEvent): void => { + const anchor = this.findAnchor(event.target); + if (!anchor || !anchor.href) { + return; + } + const url = this.parseUrl(anchor.href); + if (!url) { + return; + } + const filename = url.pathname.split('/').pop() ?? ''; + const extension = filename.includes('.') + ? filename.split('.').pop()?.toLowerCase() + : undefined; + if (!extension || !extensionSet.has(extension)) { + return; + } + this.track('file_download', { + url: url.href, + props: { filename, extension, href: url.href }, + }); + }; + + document.addEventListener('click', clickHandler, { capture: true }); + this.downloadCleanup = () => + document.removeEventListener('click', clickHandler, { + capture: true, + }); + } + + /** + * Disable file-download tracking installed by `enableAutoDownloadTracking`. + * Safe to call when auto-download was never enabled. + */ + public disableAutoDownloadTracking(): void { + if (this.downloadCleanup) { + this.downloadCleanup(); + this.downloadCleanup = undefined; + } + } + + /** + * Enable passive scroll-depth tracking. Records the maximum percent of the + * document scrolled and reports it on `beforeunload` and on SPA route + * changes. Uses a throttled passive listener so it is safe on hot paths. + */ + public enableAutoScrollDepth(): void { + if (this.scrollCleanups.length > 0) { + return; + } + if (typeof window === 'undefined' || typeof document === 'undefined') { + return; + } + + this.maxScrollDepth = 0; + this.lastScrollReport = 0; + + const scrollHandler = (): void => { + if (this.scrollTicking) { + return; + } + this.scrollTicking = true; + window.requestAnimationFrame(() => { + const now = Date.now(); + if (now - this.lastScrollReport < SCROLL_DEPTH_THROTTLE_MS) { + this.scrollTicking = false; + return; + } + this.lastScrollReport = now; + const depth = this.computeScrollDepth(); + if (depth > this.maxScrollDepth) { + this.maxScrollDepth = depth; + } + this.scrollTicking = false; + }); + }; + + const unloadHandler = (): void => { + this.flushScrollDepth(this.currentPageUrl()); + }; + + window.addEventListener('scroll', scrollHandler, { passive: true }); + window.addEventListener('beforeunload', unloadHandler); + window.addEventListener('pagehide', unloadHandler); + + this.scrollCleanups.push(() => { + window.removeEventListener('scroll', scrollHandler); + window.removeEventListener('beforeunload', unloadHandler); + window.removeEventListener('pagehide', unloadHandler); + }); + } + + /** + * Disable scroll-depth tracking installed by `enableAutoScrollDepth`. Safe + * to call when auto-scroll-depth was never enabled. Any accumulated depth + * for the current page is dropped. + */ + public disableAutoScrollDepth(): void { + for (const cleanup of this.scrollCleanups) { + cleanup(); + } + this.scrollCleanups = []; + this.maxScrollDepth = 0; + this.scrollTicking = false; + this.lastScrollReport = 0; + } + + /** + * Enable active engagement-time tracking. Counts time the tab is visible + * and uses `visibilitychange` to exclude background time. + * + * Delivery follows Plausible's model rather than a single total at the + * end of the visit: an `engagement_time` event is emitted at every natural + * checkpoint — the tab going hidden, an SPA route change, and finally + * `beforeunload`/`pagehide`. Unload is kept only as a best-effort last + * flush; it is the least reliable moment to send anything (browsers + * routinely cancel in-flight requests there, and `sendBeacon` cannot set + * the `X-Appwrite-Project` header the endpoint requires), so it must never + * be the only delivery. + * + * Each event carries a **delta** — the seconds accrued since the previous + * flush, not a running total — so the values are additive per session and + * a lost event costs only its own slice. + * + * There is deliberately no periodic ping. The cadence is driven by + * navigation and tab switches, so request volume tracks what the visitor + * actually does instead of multiplying with time on page. + */ + public enableAutoEngagementTime(): void { + if (this.engagementCleanups.length > 0) { + return; + } + if (typeof window === 'undefined' || typeof document === 'undefined') { + return; + } + + this.engagementAccumulatedMs = 0; + // Do not start the clock for a tab that is already hidden (prerender, + // background tab restore); `visibilitychange` starts it when it is + // actually looked at. + if (document.visibilityState !== 'hidden') { + this.startEngagement(); + } + + const visibilityHandler = (): void => { + if (document.visibilityState === 'hidden') { + // Flush, don't just pause. The page is still fully alive here, + // which makes this the most reliable delivery point in the + // whole visit — and it is the same moment Plausible reports on. + this.flushEngagement(this.currentPageUrl()); + } else if (document.visibilityState === 'visible') { + this.startEngagement(); + } + }; + + const unloadHandler = (): void => { + this.flushEngagement(this.currentPageUrl()); + }; + + const pageshowHandler = (event: PageTransitionEvent): void => { + // When the page is restored from the back-forward cache the + // engagement timer is in a stopped state (flushEngagement paused + // it on pagehide and does not auto-restart). Kick off a fresh + // engagementStart so bfcache dwell time is not billed as + // engagement. + if (event.persisted) { + this.startEngagement(); + } + }; + + document.addEventListener('visibilitychange', visibilityHandler); + window.addEventListener('beforeunload', unloadHandler); + window.addEventListener('pagehide', unloadHandler); + window.addEventListener('pageshow', pageshowHandler); + + this.engagementCleanups.push(() => { + document.removeEventListener('visibilitychange', visibilityHandler); + window.removeEventListener('beforeunload', unloadHandler); + window.removeEventListener('pagehide', unloadHandler); + window.removeEventListener('pageshow', pageshowHandler); + }); + } + + /** + * Disable engagement-time tracking installed by `enableAutoEngagementTime`. + * Safe to call when auto-engagement was never enabled. Any accumulated + * engagement time is dropped without being emitted. + */ + public disableAutoEngagementTime(): void { + for (const cleanup of this.engagementCleanups) { + cleanup(); + } + this.engagementCleanups = []; + this.engagementActive = false; + this.engagementAccumulatedMs = 0; + this.engagementStart = 0; + } + + /** + * Merge the configured `propertyId` into an event's options. Returns the + * caller's object untouched when no default is configured (or the event + * already carries one), so emitters written against the pre-`propertyId` + * shape keep receiving exactly what they received before. + */ + private withPropertyId( + options?: AnalyticsEventOptions, + ): AnalyticsEventOptions | undefined { + if ( + this.propertyId === undefined || + options?.propertyId !== undefined + ) { + return options; + } + return { ...(options ?? {}), propertyId: this.propertyId }; + } + + private canTrack(): boolean { + if (!this.respectDoNotTrack) { + return true; + } + if (typeof navigator === 'undefined') { + return true; + } + return navigator.doNotTrack !== '1'; + } + + private currentPageUrl(): string { + if (typeof window === 'undefined' || !window.location) { + return ''; + } + return window.location.href; + } + + private findAnchor(target: EventTarget | null): HTMLAnchorElement | null { + let node = target as Node | null; + while (node) { + if ((node as HTMLElement).tagName === 'A') { + return node as HTMLAnchorElement; + } + node = (node as Node).parentNode; + } + return null; + } + + private parseUrl(href: string): URL | null { + try { + return new URL(href, window.location.href); + } catch { + return null; + } + } + + private isExternal(target: URL, includeSubdomains: boolean): boolean { + if (target.protocol !== 'http:' && target.protocol !== 'https:') { + return false; + } + if (target.hostname === window.location.hostname) { + return false; + } + if (includeSubdomains) { + return true; + } + const root = this.rootDomain(window.location.hostname); + return this.rootDomain(target.hostname) !== root; + } + + private rootDomain(hostname: string): string { + const parts = hostname.split('.'); + if (parts.length <= 2) { + return hostname; + } + // Taking the last two labels is wrong wherever the registry sells names + // one level down: `shop.example.co.uk` and `google.co.uk` both reduce to + // `co.uk`, so every outbound click between two `.co.uk` sites looks + // internal and is dropped. Take three labels after a known two-part + // suffix instead. + // + // This is a heuristic, not the Public Suffix List — the full list is + // orders of magnitude larger than this whole file, and a tracking + // snippet cannot carry it. Sites on a suffix that is missing here can + // pass `includeSubdomains: true` to treat every other host as outbound. + const suffix = parts.slice(-2).join('.'); + if (parts.length > 2 && MULTI_PART_SUFFIXES.has(suffix)) { + return parts.slice(-3).join('.'); + } + return suffix; + } + + private computeScrollDepth(): number { + const scrollTop = window.scrollY || document.documentElement.scrollTop; + const viewport = + window.innerHeight || document.documentElement.clientHeight; + const total = document.documentElement.scrollHeight; + if (total <= viewport) { + return 100; + } + const depth = Math.round(((scrollTop + viewport) / total) * 100); + return Math.max(0, Math.min(100, depth)); + } + + private flushScrollDepth(url: string | undefined): void { + if (this.scrollCleanups.length === 0) { + return; + } + if (this.maxScrollDepth <= 0) { + return; + } + this.track('scroll_depth', { + url, + scrollDepth: this.maxScrollDepth, + }); + this.maxScrollDepth = 0; + } + + private startEngagement(): void { + if (this.engagementActive) { + return; + } + this.engagementActive = true; + this.engagementStart = Date.now(); + } + + private pauseEngagement(): void { + if (!this.engagementActive) { + return; + } + this.engagementActive = false; + this.engagementAccumulatedMs += Date.now() - this.engagementStart; + } + + private flushEngagement(url: string | undefined): void { + if (this.engagementCleanups.length === 0) { + return; + } + this.pauseEngagement(); + // Deltas, not a running total: emit only what accrued since the last + // flush. `floor` plus keeping the sub-second remainder in the + // accumulator is what makes repeated flushes safe — nothing is emitted + // twice, and a visit chopped into many short segments does not lose a + // fraction of a second to rounding on every one of them. + const seconds = Math.floor(this.engagementAccumulatedMs / 1000); + if (seconds > 0) { + // Deduct before emitting so a synchronous emitter that re-enters + // (or throws) can never bill the same seconds again. + this.engagementAccumulatedMs -= seconds * 1000; + this.track('engagement_time', { + url, + engagementTime: seconds, + }); + } + // NOTE: intentionally do NOT call startEngagement() here. flushEngagement + // runs on `pagehide`, which is also the last hook before the browser + // may freeze the page into the back-forward cache. Auto-restarting + // would snapshot engagementStart just before the freeze; the next + // pauseEngagement (after bfcache restore) would then bill the entire + // bfcache dwell as active engagement. Restart is driven by explicit + // resume signals: `pageshow` (with `persisted === true`), + // `visibilitychange` back to visible, or the SPA navigation flow in + // `enableAutoPageviews`. + } +} diff --git a/src/services/push.ts b/src/services/push.ts index fb731dc..474b105 100644 --- a/src/services/push.ts +++ b/src/services/push.ts @@ -818,20 +818,22 @@ export class Push { qos: packet.qos, }; const content = notificationContent(message); - const titles = new Set(); + let postedServerTitle = false; 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); + // with its own title. A title the server sent replaces theirs, so it posts once. + if (sub.background && !postedServerTitle) { + postedServerTitle = content.title !== undefined; + this.notify( + message, + content.title ?? sub.title ?? message.topic, + content, + ); } } } - for (const title of titles) { - this.notify(message, title, content); - } } private requestNotificationPermission(): void { From b5239835a4cc9d97468f2fa214c3c6db759e87f5 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 7 Oct 2026 09:51:37 +0000 Subject: [PATCH 3/3] chore: update Web SDK to 28.2.0-rc.6 --- CHANGELOG.md | 13 ++++++ README.md | 2 +- package-lock.json | 4 +- package.json | 2 +- src/client.ts | 2 +- src/index.ts | 1 + src/services/push.ts | 108 +++++++++++++++++++++++++++++++++++++------ 7 files changed, 113 insertions(+), 19 deletions(-) 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.