From d38ebdda3d2d1071f467f27b6891ef8992cbf1da Mon Sep 17 00:00:00 2001
From: Bitcoin Error Log <18273620+BitcoinErrorLog@users.noreply.github.com>
Date: Thu, 24 Sep 2026 17:21:48 +0100
Subject: [PATCH 1/5] fix(shop): badge only activity and orders that still need
you, cleared per account
Activity rows badge only while their order or offer still waits on the signed-in account; informational rows never count, and one subject counts once on the marketplace icon. Opening Activity or Orders saves the checkpoint in the account's private homeserver document so every browser clears.
---
.../Marketplace/MarketplaceSectionNav.tsx | 2 +-
.../Marketplace/MarketplaceNotifications.tsx | 12 +-
.../Marketplace/MarketplaceOrders.tsx | 7 +-
.../commerce/attention-seen.test.ts | 161 +++++++++++++++
.../application/commerce/attention-seen.ts | 137 +++++++++++++
src/core/application/commerce/commerce.ts | 11 +-
src/core/controllers/commerce/commerce.ts | 25 ++-
.../pipes/commerce/commerce.normalizer.ts | 5 +
.../useMarkMarketplaceOrdersSeen.ts | 20 ++
.../useMarketplaceActivityAttentionKeys.ts | 155 +++++++++++++++
.../useMarketplaceActivityUnread.test.ts | 73 +++++++
.../useMarketplaceActivityUnread.ts | 125 +-----------
.../useMarketplaceNavAttention.test.tsx | 45 +++++
.../useMarketplaceNavAttention.ts | 14 +-
.../useMarketplaceOrdersAttention.ts | 96 +--------
.../useMarketplaceOrdersAttentionKeys.ts | 109 ++++++++++
.../commerce/marketplace-attention.test.ts | 188 +++++++++++++++---
src/libs/commerce/marketplace-attention.ts | 182 +++++++++++++++--
src/test/mocks/marketplace-vrt.ts | 3 +
.../MarketplaceNavBadges.vrt.test.tsx | 2 +-
20 files changed, 1091 insertions(+), 281 deletions(-)
create mode 100644 src/core/application/commerce/attention-seen.test.ts
create mode 100644 src/core/application/commerce/attention-seen.ts
create mode 100644 src/hooks/useMarkMarketplaceOrdersSeen/useMarkMarketplaceOrdersSeen.ts
create mode 100644 src/hooks/useMarketplaceActivityAttentionKeys/useMarketplaceActivityAttentionKeys.ts
create mode 100644 src/hooks/useMarketplaceOrdersAttentionKeys/useMarketplaceOrdersAttentionKeys.ts
diff --git a/src/components/organisms/Marketplace/MarketplaceSectionNav.tsx b/src/components/organisms/Marketplace/MarketplaceSectionNav.tsx
index fe69d4b88d..7142fe5c52 100644
--- a/src/components/organisms/Marketplace/MarketplaceSectionNav.tsx
+++ b/src/components/organisms/Marketplace/MarketplaceSectionNav.tsx
@@ -93,7 +93,7 @@ export function MarketplaceSectionNav({
? ordersAttentionCount
: 0;
const badgeNoun =
- badge === 'cart' ? 'cart items' : badge === 'orders' ? 'orders needing you' : 'unread activity';
+ badge === 'cart' ? 'cart items' : badge === 'orders' ? 'orders needing you' : 'activity needing you';
return (
state.session !== null);
- // Visiting this surface clears the device-local read state behind the
- // marketplace Activity badge: watch alerts get their real local `seen_at`
- // (the mount-frozen highlights above stay visible), and the activity read
- // checkpoint advances to now — the honest, device-local substitute for the
- // read state the durable service does not store. Sandbox service rows keep
- // their REAL read state and clear only via the Mark all read button.
+ // Visiting this surface clears the marketplace Activity badge: watch alerts
+ // get their real local `seen_at` (the mount-frozen highlights above stay
+ // visible), and the account's activity checkpoint advances to now on every
+ // browser — the substitute for the read state the durable service does not
+ // store. Sandbox service rows keep their REAL read state and clear only via
+ // the Mark all read button.
const markAllWatchAlertsSeen = watchAlerts.markAllSeen;
useEffect(() => {
if (!isAuthenticated) return;
diff --git a/src/components/templates/Marketplace/MarketplaceOrders.tsx b/src/components/templates/Marketplace/MarketplaceOrders.tsx
index 7685719b9f..f5c54e96c6 100644
--- a/src/components/templates/Marketplace/MarketplaceOrders.tsx
+++ b/src/components/templates/Marketplace/MarketplaceOrders.tsx
@@ -14,6 +14,7 @@ import { Skeleton } from '@/atoms/Skeleton/Skeleton';
import { Typography } from '@/atoms/Typography/Typography';
import { type CommerceAdapterMode, isDurableCommerceMode, isTransactionalCommerceMode } from '@/config/commerce';
import { type MarketplaceOrderView, useMarketplaceOrders } from '@/hooks/useMarketplaceOrders/useMarketplaceOrders';
+import { useMarkMarketplaceOrdersSeen } from '@/hooks/useMarkMarketplaceOrdersSeen/useMarkMarketplaceOrdersSeen';
import { orderAnchorId, readOrderAnchorId } from '@/libs/commerce/activity-links';
import { buildCarrierTrackingUrl } from '@/libs/commerce/carriers';
import { CHECKOUT_HOLD_COPY, isHoldExpiredNoLateMoney } from '@/libs/commerce/checkout-hold';
@@ -32,7 +33,6 @@ import {
} from '@/libs/commerce/checkout-phase';
import { formatCommerceMoney } from '@/libs/commerce/format';
import { buyerVisiblePaymentStatus } from '@/libs/commerce/locks-payment';
-import { markOrdersAttentionSeen } from '@/libs/commerce/marketplace-attention';
import { listingIdFromOrder, marketplaceConversationHref } from '@/libs/commerce/marketplace-conversation-query';
import { MESSAGING_COPY } from '@/libs/commerce/messaging-copy';
import { partialRefundLabel } from '@/libs/commerce/partial-refund';
@@ -86,10 +86,7 @@ export function MarketplaceOrders() {
const orderCounts = getOrderTabCounts(historyOrders, currentUserPubky);
const visibleOrders = historyOrders.filter((view) => isOrderInTab(view, activeTab, currentUserPubky));
- useEffect(() => {
- if (!currentUserPubky || isLoading || error || needsSession) return;
- markOrdersAttentionSeen(currentUserPubky);
- }, [currentUserPubky, error, isLoading, needsSession, orders]);
+ useMarkMarketplaceOrdersSeen(!isLoading && !error && !needsSession, orders);
useEffect(() => {
if (isLoading) return;
diff --git a/src/core/application/commerce/attention-seen.test.ts b/src/core/application/commerce/attention-seen.test.ts
new file mode 100644
index 0000000000..5a81e14016
--- /dev/null
+++ b/src/core/application/commerce/attention-seen.test.ts
@@ -0,0 +1,161 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { readOrdersSeenAt } from '@/libs/commerce/marketplace-attention';
+import { AppError } from '@/libs/error/error';
+import { ClientErrorCode } from '@/libs/error/error.codes';
+import { ErrorCategory, ErrorService } from '@/libs/error/error.types';
+import { CommerceActivityCheckpointModel } from '@/models/commerce/commerce.models';
+import { CommerceHomeserverService } from '@/services/homeserver/commerce/commerce';
+import { HomeserverService } from '@/services/homeserver/homeserver';
+import { LocalCommerceService } from '@/services/local/commerce/commerce';
+import { useAuthStore } from '@/stores/auth/auth.store';
+import { CommerceAttentionSeenApplication } from './attention-seen';
+
+const state = vi.hoisted(() => ({ mode: 'transaction-service' as string }));
+
+vi.mock('@/config/commerce', async () => {
+ const actual = await vi.importActual('@/config/commerce');
+ return { ...actual, getCommerceAdapterMode: () => state.mode };
+});
+
+const OWNER = 'o'.repeat(52);
+const OTHER = 'p'.repeat(52);
+const DOC_URL = `pubky://${OWNER}/priv/pubky.app/marketplace/v1/attention_seen.json`;
+const T_ACTIVITY = Date.parse('2026-09-24T14:00:00.000Z');
+const T_ORDERS = Date.parse('2026-09-24T14:05:00.000Z');
+
+const notFound = () =>
+ new AppError({
+ category: ErrorCategory.Client,
+ code: ClientErrorCode.NOT_FOUND,
+ message: 'HTTP 404',
+ service: ErrorService.Homeserver,
+ operation: 'test',
+ context: { statusCode: 404 },
+ });
+
+/** One homeserver document shared by every "browser" in a test. */
+function homeserverDocument(initial: unknown = null) {
+ let doc: unknown = initial;
+ vi.spyOn(CommerceHomeserverService, 'fetchJson').mockImplementation(async (url) => {
+ expect(url).toBe(DOC_URL);
+ if (doc === null) throw notFound();
+ return structuredClone(doc);
+ });
+ const put = vi.spyOn(CommerceHomeserverService, 'putJson').mockImplementation(async (url, body) => {
+ expect(url).toBe(DOC_URL);
+ doc = structuredClone(body);
+ });
+ return { put, read: () => doc };
+}
+
+/** A fresh browser for the same account: nothing in Dexie or local storage. */
+async function switchToFreshBrowser() {
+ await CommerceActivityCheckpointModel.table.clear();
+ window.localStorage.clear();
+}
+
+describe('CommerceAttentionSeenApplication (per-account badge checkpoints)', () => {
+ beforeEach(async () => {
+ state.mode = 'transaction-service';
+ useAuthStore.setState({ currentUserPubky: OWNER });
+ vi.spyOn(HomeserverService, 'hasActiveSession').mockReturnValue(true);
+ vi.spyOn(HomeserverService, 'canCurrentSessionWrite').mockReturnValue(true);
+ await switchToFreshBrowser();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('clears Activity and Orders in a second browser after the first browser opened them', async () => {
+ const remote = homeserverDocument();
+
+ await CommerceAttentionSeenApplication.markSeen(OWNER, 'activity', T_ACTIVITY);
+ await CommerceAttentionSeenApplication.markSeen(OWNER, 'orders', T_ORDERS);
+ expect(remote.read()).toEqual({ version: 1, activitySeenAt: T_ACTIVITY, ordersSeenAt: T_ORDERS });
+
+ await switchToFreshBrowser();
+ expect(await LocalCommerceService.getActivityReadCheckpoint(OWNER)).toBe(0);
+ expect(readOrdersSeenAt(OWNER, window.localStorage)).toBe(0);
+
+ await CommerceAttentionSeenApplication.pull(OWNER);
+
+ expect(await LocalCommerceService.getActivityReadCheckpoint(OWNER)).toBe(T_ACTIVITY);
+ expect(readOrdersSeenAt(OWNER, window.localStorage)).toBe(T_ORDERS);
+ });
+
+ it('tells mounted Orders badges to re-read when a pull raises the checkpoint', async () => {
+ homeserverDocument({ version: 1, activitySeenAt: 0, ordersSeenAt: T_ORDERS });
+ const listener = vi.fn();
+ window.addEventListener('marketplace-orders-seen', listener);
+
+ await CommerceAttentionSeenApplication.pull(OWNER);
+
+ window.removeEventListener('marketplace-orders-seen', listener);
+ expect(listener).toHaveBeenCalledOnce();
+ });
+
+ it('merges into the saved document and never moves a side backward', async () => {
+ const remote = homeserverDocument({ version: 1, activitySeenAt: T_ACTIVITY, ordersSeenAt: T_ORDERS });
+
+ await CommerceAttentionSeenApplication.markSeen(OWNER, 'activity', T_ACTIVITY - 60_000);
+ expect(remote.put).not.toHaveBeenCalled();
+
+ await CommerceAttentionSeenApplication.markSeen(OWNER, 'activity', T_ORDERS + 60_000);
+ expect(remote.read()).toEqual({ version: 1, activitySeenAt: T_ORDERS + 60_000, ordersSeenAt: T_ORDERS });
+ });
+
+ it('caps a checkpoint saved by a device whose clock runs ahead', async () => {
+ const future = Date.now() + 24 * 60 * 60 * 1000;
+ homeserverDocument({ version: 1, activitySeenAt: future, ordersSeenAt: 0 });
+
+ const before = Date.now();
+ await CommerceAttentionSeenApplication.pull(OWNER);
+ const local = await LocalCommerceService.getActivityReadCheckpoint(OWNER);
+
+ expect(local).toBeGreaterThanOrEqual(before);
+ expect(local).toBeLessThan(future);
+ });
+
+ it('never replaces a document it cannot read', async () => {
+ const remote = homeserverDocument({ version: 2, somethingElse: true });
+
+ await CommerceAttentionSeenApplication.markSeen(OWNER, 'orders', T_ORDERS);
+
+ expect(remote.put).not.toHaveBeenCalled();
+ expect(readOrdersSeenAt(OWNER, window.localStorage)).toBe(T_ORDERS);
+ });
+
+ it('keeps the checkpoint in this browser only when the session cannot write /priv', async () => {
+ vi.mocked(HomeserverService.canCurrentSessionWrite).mockReturnValue(false);
+ const remote = homeserverDocument();
+
+ await CommerceAttentionSeenApplication.markSeen(OWNER, 'activity', T_ACTIVITY);
+ await CommerceAttentionSeenApplication.pull(OWNER);
+
+ expect(CommerceHomeserverService.fetchJson).not.toHaveBeenCalled();
+ expect(remote.put).not.toHaveBeenCalled();
+ expect(await LocalCommerceService.getActivityReadCheckpoint(OWNER)).toBe(T_ACTIVITY);
+ });
+
+ it('never reads or writes another account’s document', async () => {
+ useAuthStore.setState({ currentUserPubky: OTHER });
+ const remote = homeserverDocument();
+
+ await CommerceAttentionSeenApplication.markSeen(OWNER, 'activity', T_ACTIVITY);
+ await CommerceAttentionSeenApplication.pull(OWNER);
+
+ expect(CommerceHomeserverService.fetchJson).not.toHaveBeenCalled();
+ expect(remote.put).not.toHaveBeenCalled();
+ });
+
+ it('stays local in the sandbox', async () => {
+ state.mode = 'sandbox';
+ const remote = homeserverDocument();
+
+ await CommerceAttentionSeenApplication.markSeen(OWNER, 'orders', T_ORDERS);
+
+ expect(CommerceHomeserverService.fetchJson).not.toHaveBeenCalled();
+ expect(remote.put).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/core/application/commerce/attention-seen.ts b/src/core/application/commerce/attention-seen.ts
new file mode 100644
index 0000000000..3c38b2bfbd
--- /dev/null
+++ b/src/core/application/commerce/attention-seen.ts
@@ -0,0 +1,137 @@
+import { z } from 'zod';
+import { getCommerceAdapterMode, isDurableCommerceMode } from '@/config/commerce';
+import { raiseLocalOrdersSeenAt, readLocalOrdersSeenAt } from '@/libs/commerce/marketplace-attention';
+import { hasHttpStatus, isAppError, isNotFound } from '@/libs/error/error.utils';
+import { HttpStatusCode } from '@/libs/http/http.types';
+import { Logger } from '@/libs/logger/logger';
+import { CommerceRecordNormalizer } from '@/pipes/commerce/commerce.normalizer';
+import { CommerceHomeserverService } from '@/services/homeserver/commerce/commerce';
+import { HomeserverService, PRIVATE_APP_DATA_PATH } from '@/services/homeserver/homeserver';
+import { LocalCommerceService } from '@/services/local/commerce/commerce';
+import { useAuthStore } from '@/stores/auth/auth.store';
+
+export type MarketplaceAttentionSide = 'activity' | 'orders';
+
+const attentionSeenRecordSchema = z.object({
+ version: z.literal(1),
+ activitySeenAt: z.number().int().nonnegative(),
+ ordersSeenAt: z.number().int().nonnegative(),
+});
+
+type AttentionSeenRecord = z.infer;
+
+type RemoteRead = { kind: 'present'; record: AttentionSeenRecord } | { kind: 'absent' } | { kind: 'unavailable' };
+
+const SIDE_FIELD = {
+ activity: 'activitySeenAt',
+ orders: 'ordersSeenAt',
+} as const satisfies Record;
+
+/**
+ * The account's badge checkpoints: when this account last opened Activity
+ * and Orders, on any browser.
+ *
+ * The durable marketplace service stores no read state, so the checkpoints
+ * live in the owner's private homeserver document
+ * `/priv/pubky.app/marketplace/v1/attention_seen.json` (ms epoch per side).
+ * Each browser keeps a local copy (Dexie for Activity, local storage for
+ * Orders) that the badge hooks read live. Opening a view raises the local
+ * copy, then merges into the document (GET, per-side max, PUT). Mounting a
+ * badge pulls the document and raises the local copies, so a view cleared
+ * in one browser clears in every other.
+ *
+ * Checkpoints only move forward. A value from a device clock ahead of this
+ * one is capped at this device's now. Without a session that can write
+ * `/priv/pubky.app/` (or in the sandbox) the local copy is all there is,
+ * and the badge behaves per browser.
+ */
+export class CommerceAttentionSeenApplication {
+ private constructor() {}
+
+ private static pullsInFlight = new Map>();
+
+ static async markSeen(ownerPubky: string, side: MarketplaceAttentionSide, now = Date.now()): Promise {
+ await this.raiseLocal(ownerPubky, side, now);
+ if (!this.canUseRemote(ownerPubky)) return;
+ try {
+ const remote = await this.readRemote(ownerPubky);
+ if (remote.kind === 'unavailable') return;
+ const localActivity = await LocalCommerceService.getActivityReadCheckpoint(ownerPubky);
+ const localOrders = readLocalOrdersSeenAt(ownerPubky);
+ const current: AttentionSeenRecord =
+ remote.kind === 'present' ? remote.record : { version: 1, activitySeenAt: 0, ordersSeenAt: 0 };
+ const next: AttentionSeenRecord = {
+ version: 1,
+ activitySeenAt: Math.max(current.activitySeenAt, Math.min(localActivity, now)),
+ ordersSeenAt: Math.max(current.ordersSeenAt, Math.min(localOrders, now)),
+ };
+ next[SIDE_FIELD[side]] = Math.max(next[SIDE_FIELD[side]], now);
+ if (next.activitySeenAt === current.activitySeenAt && next.ordersSeenAt === current.ordersSeenAt) return;
+ await CommerceHomeserverService.putJson(CommerceRecordNormalizer.attentionSeenUri(ownerPubky), next);
+ } catch (error) {
+ Logger.warn('Failed to save the marketplace badge checkpoint', { error });
+ }
+ }
+
+ /** Raises this browser's checkpoints to the account's. One read per owner at a time. */
+ static async pull(ownerPubky: string): Promise {
+ if (!this.canUseRemote(ownerPubky)) return;
+ const inFlight = this.pullsInFlight.get(ownerPubky);
+ if (inFlight) return await inFlight;
+ const run = this.runPull(ownerPubky).finally(() => {
+ this.pullsInFlight.delete(ownerPubky);
+ });
+ this.pullsInFlight.set(ownerPubky, run);
+ return await run;
+ }
+
+ private static async runPull(ownerPubky: string): Promise {
+ try {
+ const remote = await this.readRemote(ownerPubky);
+ if (remote.kind !== 'present') return;
+ const now = Date.now();
+ await this.raiseLocal(ownerPubky, 'activity', Math.min(remote.record.activitySeenAt, now));
+ await this.raiseLocal(ownerPubky, 'orders', Math.min(remote.record.ordersSeenAt, now));
+ } catch (error) {
+ Logger.warn('Failed to load the marketplace badge checkpoint', { error });
+ }
+ }
+
+ private static async raiseLocal(ownerPubky: string, side: MarketplaceAttentionSide, at: number): Promise {
+ if (!(at > 0)) return;
+ if (side === 'activity') {
+ await LocalCommerceService.markActivityRead(ownerPubky, at);
+ return;
+ }
+ raiseLocalOrdersSeenAt(ownerPubky, at);
+ }
+
+ private static canUseRemote(ownerPubky: string): boolean {
+ if (!isDurableCommerceMode(getCommerceAdapterMode())) return false;
+ if (useAuthStore.getState().currentUserPubky !== ownerPubky) return false;
+ return HomeserverService.hasActiveSession() && HomeserverService.canCurrentSessionWrite(PRIVATE_APP_DATA_PATH);
+ }
+
+ /**
+ * A document that fails its schema is left alone: never replaced
+ * wholesale, never trusted.
+ */
+ private static async readRemote(ownerPubky: string): Promise {
+ let payload: unknown;
+ try {
+ payload = await CommerceHomeserverService.fetchJson(CommerceRecordNormalizer.attentionSeenUri(ownerPubky));
+ } catch (error) {
+ if (isAppError(error) && isNotFound(error)) return { kind: 'absent' };
+ if (hasHttpStatus(error, HttpStatusCode.FORBIDDEN) || hasHttpStatus(error, HttpStatusCode.UNAUTHORIZED)) {
+ return { kind: 'unavailable' };
+ }
+ throw error;
+ }
+ const parsed = attentionSeenRecordSchema.safeParse(payload);
+ if (!parsed.success) {
+ Logger.warn('Ignoring an unreadable marketplace badge checkpoint');
+ return { kind: 'unavailable' };
+ }
+ return { kind: 'present', record: parsed.data };
+ }
+}
diff --git a/src/core/application/commerce/commerce.ts b/src/core/application/commerce/commerce.ts
index 01982cdad4..83708f0448 100644
--- a/src/core/application/commerce/commerce.ts
+++ b/src/core/application/commerce/commerce.ts
@@ -1,6 +1,7 @@
import { blake3 } from '@noble/hashes/blake3.js';
import { bytesToHex } from '@noble/hashes/utils.js';
import { z } from 'zod';
+import { CommerceAttentionSeenApplication } from '@/application/commerce/attention-seen';
import { CommerceInventoryApplication } from '@/application/commerce/inventory';
import { TagKind } from '@/application/tag/tag.types';
import {
@@ -1682,7 +1683,15 @@ export class CommerceApplication {
}
static async markActivityRead(ownerPubky: string): Promise {
- await LocalCommerceService.markActivityRead(ownerPubky, Date.now());
+ await CommerceAttentionSeenApplication.markSeen(ownerPubky, 'activity');
+ }
+
+ static async markOrdersAttentionSeen(ownerPubky: string): Promise {
+ await CommerceAttentionSeenApplication.markSeen(ownerPubky, 'orders');
+ }
+
+ static async syncAttentionSeen(ownerPubky: string): Promise {
+ await CommerceAttentionSeenApplication.pull(ownerPubky);
}
/**
diff --git a/src/core/controllers/commerce/commerce.ts b/src/core/controllers/commerce/commerce.ts
index 2519ddcd1c..6992f24dae 100644
--- a/src/core/controllers/commerce/commerce.ts
+++ b/src/core/controllers/commerce/commerce.ts
@@ -1304,9 +1304,9 @@ export class CommerceController {
}
/**
- * The signed-in user's device-local activity read checkpoint (ms epoch;
- * `0` when signed out or never visited). Service notifications created
- * after it count as new on the marketplace Activity badge.
+ * This browser's copy of the signed-in account's activity checkpoint (ms
+ * epoch; `0` when signed out or never visited). Service notifications
+ * created after it can count on the marketplace Activity badge.
*/
static async getActivityReadCheckpoint(): Promise {
if (!useAuthStore.getState().currentUserPubky) return 0;
@@ -1314,16 +1314,27 @@ export class CommerceController {
}
/**
- * Advances the signed-in user's device-local activity read checkpoint to
- * now. Deliberately NOT service-side read state — the durable service has
- * none — it only records that THIS device showed an activity surface, the
- * same doctrine as the Messages read checkpoint.
+ * Advances the signed-in account's activity checkpoint to now, in this
+ * browser and in the account's private homeserver document. Not service
+ * read state — the durable service has none.
*/
static async markActivityRead(): Promise {
if (!useAuthStore.getState().currentUserPubky) return;
await CommerceApplication.markActivityRead(this.getCurrentUserPubky());
}
+ /** Advances the signed-in account's Orders checkpoint to now, here and on the homeserver. */
+ static async markOrdersAttentionSeen(): Promise {
+ if (!useAuthStore.getState().currentUserPubky) return;
+ await CommerceApplication.markOrdersAttentionSeen(this.getCurrentUserPubky());
+ }
+
+ /** Raises this browser's badge checkpoints to the ones the account saved from any browser. */
+ static async syncAttentionSeen(): Promise {
+ if (!useAuthStore.getState().currentUserPubky) return;
+ await CommerceApplication.syncAttentionSeen(this.getCurrentUserPubky());
+ }
+
static async getSavedSearches() {
if (!useAuthStore.getState().currentUserPubky) return [];
return await CommerceApplication.getSavedSearches(this.getCurrentUserPubky());
diff --git a/src/core/pipes/commerce/commerce.normalizer.ts b/src/core/pipes/commerce/commerce.normalizer.ts
index 2f19d2b324..88c016312b 100644
--- a/src/core/pipes/commerce/commerce.normalizer.ts
+++ b/src/core/pipes/commerce/commerce.normalizer.ts
@@ -542,6 +542,11 @@ export class CommerceRecordNormalizer {
return `pubky://${this.pubky(ownerPubky)}/priv/pubky.app/marketplace/v1/auction_reserves/${this.entityId(listingId)}.json`;
}
+ /** The owner's PRIVATE badge checkpoint document (last time Activity and Orders were opened). */
+ static attentionSeenUri(ownerPubky: unknown): string {
+ return `pubky://${this.pubky(ownerPubky)}/priv/pubky.app/marketplace/v1/attention_seen.json`;
+ }
+
static mediaUri(ownerPubky: unknown, mediaId: unknown): string {
const owner = this.pubky(ownerPubky);
const id = this.entityId(mediaId);
diff --git a/src/hooks/useMarkMarketplaceOrdersSeen/useMarkMarketplaceOrdersSeen.ts b/src/hooks/useMarkMarketplaceOrdersSeen/useMarkMarketplaceOrdersSeen.ts
new file mode 100644
index 0000000000..c7d0e523bb
--- /dev/null
+++ b/src/hooks/useMarkMarketplaceOrdersSeen/useMarkMarketplaceOrdersSeen.ts
@@ -0,0 +1,20 @@
+'use client';
+
+import { useEffect } from 'react';
+import { CommerceController } from '@/controllers/commerce/commerce';
+import { Logger } from '@/libs/logger/logger';
+import { useAuthStore } from '@/stores/auth/auth.store';
+
+/**
+ * Moves the signed-in account's Orders checkpoint to now whenever `shown`
+ * orders are on screen, so the Orders badge clears on every browser.
+ */
+export function useMarkMarketplaceOrdersSeen(isShowingOrders: boolean, shown: unknown): void {
+ const currentUserPubky = useAuthStore((state) => state.currentUserPubky);
+ useEffect(() => {
+ if (!currentUserPubky || !isShowingOrders) return;
+ CommerceController.markOrdersAttentionSeen().catch((error) => {
+ Logger.warn('Failed to advance the orders badge checkpoint', { error });
+ });
+ }, [currentUserPubky, isShowingOrders, shown]);
+}
diff --git a/src/hooks/useMarketplaceActivityAttentionKeys/useMarketplaceActivityAttentionKeys.ts b/src/hooks/useMarketplaceActivityAttentionKeys/useMarketplaceActivityAttentionKeys.ts
new file mode 100644
index 0000000000..1876e51a06
--- /dev/null
+++ b/src/hooks/useMarketplaceActivityAttentionKeys/useMarketplaceActivityAttentionKeys.ts
@@ -0,0 +1,155 @@
+'use client';
+
+import { useEffect, useLayoutEffect, useRef, useState } from 'react';
+import { useLiveQuery } from 'dexie-react-hooks';
+import { getCommerceAdapterMode } from '@/config/commerce';
+import { CommerceController } from '@/controllers/commerce/commerce';
+import { activityNeedingAttentionKeys } from '@/libs/commerce/marketplace-attention';
+import { Logger } from '@/libs/logger/logger';
+import { isRecognizedMarketplaceNotification } from '@/services/marketplace/marketplace-projections';
+import { useAuthStore } from '@/stores/auth/auth.store';
+import { useCommerceStore } from '@/stores/commerce/commerce.store';
+
+/**
+ * Activity that still needs the signed-in account, for the marketplace
+ * Activity entry point. One key per subject, without overlap:
+ *
+ * - `order:` / `offer:` / `notification:` — service rows of an
+ * action type whose subject still needs this account right now (the order
+ * waits on them, the offer waits on their answer, the award waits on their
+ * checkout; see `activityNeedingAttentionKeys`). Informational rows
+ * (checkout started, cancelled, shipped, completed) never count. Sandbox
+ * rows use their REAL read state (`readAt`); durable rows count only when
+ * created after the account's activity checkpoint, which opening Activity
+ * on any browser moves forward (see `CommerceAttentionSeenApplication`).
+ * - `alert:` — unseen watch alerts this device's own checks produced,
+ * whose `seen_at` read state is real because it is local.
+ *
+ * The local parts (alerts, this browser's checkpoint copy) are live Dexie
+ * reads; the service lists are fetched on mount and re-fetched when the
+ * session or checkpoint changes. A failed fetch contributes nothing — the
+ * badge may lag reality but can never invent it. Zero renders no badge.
+ *
+ * Every asynchronous result is tagged with the pubky it was read for. A
+ * result for any other pubky is dropped, and an identity change clears the
+ * displayed keys before paint so the previous account cannot badge the
+ * next one.
+ */
+type TaggedKeys = { pubky: string; keys: readonly string[] };
+
+type LocalActivityBadge = {
+ pubky: string | null;
+ unseenAlertKeys: readonly string[];
+ checkpoint: number | undefined;
+};
+
+const NO_KEYS: readonly string[] = [];
+
+export function useMarketplaceActivityAttentionKeys(): readonly string[] {
+ const currentUserPubky = useAuthStore((state) => state.currentUserPubky);
+ // Refetch trigger: connecting a session replaces this store object (the
+ // same wiring the activity page's own notifications hook relies on).
+ const marketplaceSession = useCommerceStore((state) => state.marketplaceSession);
+ const adapterMode = getCommerceAdapterMode();
+ const [trackedPubky, setTrackedPubky] = useState(currentUserPubky);
+ const [serviceKeys, setServiceKeys] = useState(null);
+ const pubkyRef = useRef(currentUserPubky);
+ useLayoutEffect(() => {
+ pubkyRef.current = currentUserPubky;
+ }, [currentUserPubky]);
+
+ // Identity changes before paint. React re-renders with cleared keys
+ // instead of committing the previous account's badge.
+ if (trackedPubky !== currentUserPubky) {
+ setTrackedPubky(currentUserPubky);
+ setServiceKeys(null);
+ }
+
+ useEffect(() => {
+ if (!currentUserPubky) return;
+ // Raises this browser's checkpoint copy (a live Dexie row) when another
+ // browser already opened Activity for this account.
+ Promise.resolve()
+ .then(() => CommerceController.syncAttentionSeen())
+ .catch((error) => {
+ Logger.warn('Failed to load the marketplace badge checkpoint', { error });
+ });
+ }, [currentUserPubky]);
+
+ const local = useLiveQuery(async (): Promise => {
+ if (!currentUserPubky) return { pubky: null, unseenAlertKeys: NO_KEYS, checkpoint: 0 };
+ try {
+ const [alerts, checkpoint] = await Promise.all([
+ CommerceController.getWatchAlerts(),
+ CommerceController.getActivityReadCheckpoint(),
+ ]);
+ return {
+ pubky: currentUserPubky,
+ unseenAlertKeys: alerts.filter(({ seen_at }) => seen_at === null).map(({ id }) => `alert:${id}`),
+ checkpoint,
+ };
+ } catch (error) {
+ // A stubbed controller throws before a promise exists. Leaving the
+ // checkpoint unset keeps the service count at zero instead of treating
+ // every row as new.
+ Logger.warn('Failed to load the marketplace activity badge count', { error });
+ return { pubky: currentUserPubky, unseenAlertKeys: NO_KEYS, checkpoint: undefined };
+ }
+ }, [currentUserPubky]);
+
+ const localForCurrent = currentUserPubky !== null && local?.pubky === currentUserPubky ? local : undefined;
+ const checkpoint = localForCurrent?.checkpoint;
+ const keysForCurrent = serviceKeys?.pubky === currentUserPubky ? serviceKeys.keys : NO_KEYS;
+
+ useEffect(() => {
+ if (!currentUserPubky || adapterMode === 'unavailable' || checkpoint === undefined) {
+ setServiceKeys(currentUserPubky ? { pubky: currentUserPubky, keys: NO_KEYS } : null);
+ return;
+ }
+ const fetchedFor = currentUserPubky;
+ const fetchedCheckpoint = checkpoint;
+ let active = true;
+ // A subject list that fails to load only drops the rows about it.
+ const subjects = (load: () => Promise, what: string): Promise =>
+ Promise.resolve()
+ .then(load)
+ .catch((error) => {
+ Logger.warn(`Failed to load marketplace ${what} for the activity badge`, { error });
+ return [];
+ });
+ // A stubbed controller throws before a promise exists. That is a failed
+ // load: the badge stays at zero.
+ Promise.resolve()
+ .then(() =>
+ Promise.all([
+ CommerceController.getMarketplaceNotifications(),
+ subjects(() => CommerceController.getMarketplaceOrders(), 'orders'),
+ subjects(() => CommerceController.getMarketplaceOffers(), 'offers'),
+ ]),
+ )
+ .then(([notifications, orders, offers]) => {
+ if (!active || pubkyRef.current !== fetchedFor) return;
+ setServiceKeys({
+ pubky: fetchedFor,
+ keys: activityNeedingAttentionKeys({
+ notifications: notifications.filter(isRecognizedMarketplaceNotification),
+ orders,
+ offers,
+ currentUserPubky: fetchedFor,
+ clearedBy: adapterMode === 'sandbox' ? { kind: 'read-state' } : { kind: 'seen', seenAt: fetchedCheckpoint },
+ }),
+ });
+ })
+ .catch((error) => {
+ if (!active || pubkyRef.current !== fetchedFor) return;
+ setServiceKeys({ pubky: fetchedFor, keys: NO_KEYS });
+ Logger.warn('Failed to load the marketplace activity badge count', { error });
+ });
+ return () => {
+ active = false;
+ };
+ }, [currentUserPubky, adapterMode, checkpoint, marketplaceSession]);
+
+ const alertKeys = localForCurrent?.unseenAlertKeys ?? NO_KEYS;
+ return alertKeys.length === 0 ? keysForCurrent : [...keysForCurrent, ...alertKeys];
+}
diff --git a/src/hooks/useMarketplaceActivityUnread/useMarketplaceActivityUnread.test.ts b/src/hooks/useMarketplaceActivityUnread/useMarketplaceActivityUnread.test.ts
index feb2e952a0..efed3053dc 100644
--- a/src/hooks/useMarketplaceActivityUnread/useMarketplaceActivityUnread.test.ts
+++ b/src/hooks/useMarketplaceActivityUnread/useMarketplaceActivityUnread.test.ts
@@ -51,9 +51,39 @@ vi.mock('@/controllers/commerce/commerce', () => ({
getWatchAlerts: vi.fn(),
getActivityReadCheckpoint: vi.fn(),
getMarketplaceNotifications: vi.fn(),
+ getMarketplaceOrders: vi.fn(),
+ getMarketplaceOffers: vi.fn(),
+ syncAttentionSeen: vi.fn(),
},
}));
+// Every offer row in these tests points at an offer that still waits on OWNER.
+const OPEN_OFFER_IDS = ['old', 'new-1', 'new-2', 'offer', 'unread', 'read', 'seen-1', 'seen-2'];
+
+function openOffer(id: string) {
+ return {
+ id,
+ buyerPubky: ACTOR,
+ sellerPubky: OWNER,
+ state: 'pending' as const,
+ offeredBy: ACTOR,
+ expiresAt: '2099-01-01T00:00:00.000Z',
+ award: null,
+ };
+}
+
+function orderRecord(id: string, state: string, nextActor: 'buyer' | 'seller' | 'none') {
+ return {
+ id,
+ state,
+ nextActor,
+ buyerPubky: ACTOR,
+ sellerPubky: OWNER,
+ updatedAt: '2026-08-20T01:00:00.000Z',
+ holdExpiresAt: null,
+ };
+}
+
function notification(id: string, createdAt: string, readAt: string | null = null) {
return {
id,
@@ -78,6 +108,49 @@ describe('useMarketplaceActivityUnread', () => {
vi.mocked(CommerceController.getWatchAlerts).mockResolvedValue([]);
vi.mocked(CommerceController.getActivityReadCheckpoint).mockResolvedValue(0);
vi.mocked(CommerceController.getMarketplaceNotifications).mockResolvedValue([]);
+ vi.mocked(CommerceController.getMarketplaceOrders).mockResolvedValue([]);
+ vi.mocked(CommerceController.getMarketplaceOffers).mockResolvedValue(OPEN_OFFER_IDS.map(openOffer) as never);
+ vi.mocked(CommerceController.syncAttentionSeen).mockResolvedValue();
+ });
+
+ it('pulls the account-wide checkpoint so a view cleared in another browser stays cleared', async () => {
+ renderHook(() => useMarketplaceActivityUnread());
+
+ await waitFor(() => expect(CommerceController.syncAttentionSeen).toHaveBeenCalled());
+ });
+
+ it('stops badging an offer row once the offer is answered', async () => {
+ vi.mocked(CommerceController.getMarketplaceNotifications).mockResolvedValue([
+ notification('offer', '2026-08-20T01:00:00.000Z'),
+ ]);
+ vi.mocked(CommerceController.getMarketplaceOffers).mockResolvedValue([
+ { ...openOffer('offer'), state: 'rejected' },
+ ] as never);
+
+ const { result } = renderHook(() => useMarketplaceActivityUnread());
+
+ await waitFor(() => expect(CommerceController.getMarketplaceOffers).toHaveBeenCalled());
+ await waitFor(() => expect(CommerceController.getMarketplaceNotifications).toHaveBeenCalled());
+ expect(result.current).toBe(0);
+ });
+
+ it('badges order rows only while the order waits on this account, never a started checkout or a cancellation', async () => {
+ vi.mocked(CommerceController.getMarketplaceNotifications).mockResolvedValue([
+ { ...notification('checkout', '2026-08-20T01:00:00.000Z'), type: 'order_created', aggregateId: 'order:o-new' },
+ { ...notification('cancel', '2026-08-20T02:00:00.000Z'), type: 'order_cancelled', aggregateId: 'order:o-gone' },
+ { ...notification('ret-open', '2026-08-20T03:00:00.000Z'), type: 'return_updated', aggregateId: 'order:o-ret' },
+ { ...notification('ret-done', '2026-08-20T04:00:00.000Z'), type: 'return_updated', aggregateId: 'order:o-done' },
+ ] as never);
+ vi.mocked(CommerceController.getMarketplaceOrders).mockResolvedValue([
+ orderRecord('o-new', 'pending_payment', 'buyer'),
+ orderRecord('o-gone', 'cancelled', 'none'),
+ orderRecord('o-ret', 'return_requested', 'seller'),
+ orderRecord('o-done', 'refunded', 'none'),
+ ] as never);
+
+ const { result } = renderHook(() => useMarketplaceActivityUnread());
+
+ await waitFor(() => expect(result.current).toBe(1));
});
it('counts only durable notifications newer than the device checkpoint', async () => {
diff --git a/src/hooks/useMarketplaceActivityUnread/useMarketplaceActivityUnread.ts b/src/hooks/useMarketplaceActivityUnread/useMarketplaceActivityUnread.ts
index ac2e2098e7..3274c59aeb 100644
--- a/src/hooks/useMarketplaceActivityUnread/useMarketplaceActivityUnread.ts
+++ b/src/hooks/useMarketplaceActivityUnread/useMarketplaceActivityUnread.ts
@@ -1,127 +1,8 @@
'use client';
-import { useEffect, useLayoutEffect, useRef, useState } from 'react';
-import { useLiveQuery } from 'dexie-react-hooks';
-import { getCommerceAdapterMode } from '@/config/commerce';
-import { CommerceController } from '@/controllers/commerce/commerce';
-import { isMarketplaceActionActivity } from '@/libs/commerce/marketplace-attention';
-import { Logger } from '@/libs/logger/logger';
-import { isRecognizedMarketplaceNotification } from '@/services/marketplace/marketplace-projections';
-import { useAuthStore } from '@/stores/auth/auth.store';
-import { useCommerceStore } from '@/stores/commerce/commerce.store';
-
-/**
- * Device-local unread count for the marketplace Activity entry point.
- *
- * HONESTY CONTRACT (same doctrine as the Messages badge): the durable
- * service stores NO notification read state, so this badge never claims
- * "unread" on the service's behalf. It counts, without overlap:
- *
- * - service notifications that still need the user (a return, an offer, a
- * message, a pickup, a refund, a bitcoin decision). Informational rows
- * stay in the history and do not count. Sandbox rows use their REAL read
- * state (`readAt`, clearable via `notification.mark_read`); durable rows
- * use a device-local read checkpoint — only rows created after the last
- * time THIS device opened an activity surface, cleared by visiting one.
- * - unseen watch alerts — rows this device's own checks produced, whose
- * `seen_at` read state is real because it is local.
- *
- * The local parts (alerts, checkpoint) are live Dexie reads; the service
- * list is fetched on mount and re-fetched when the session or checkpoint
- * changes. A failed fetch contributes zero — the badge may lag reality but
- * can never invent it. Zero renders no badge.
- *
- * Every asynchronous result is tagged with the pubky it was read for. A
- * result for any other pubky is dropped, and an identity change clears the
- * displayed counts before paint so the previous account cannot badge the
- * next one.
- */
-type TaggedCount = { pubky: string; count: number };
-
-type LocalActivityBadge = {
- pubky: string | null;
- unseenAlertCount: number;
- checkpoint: number | undefined;
-};
+import { useMarketplaceActivityAttentionKeys } from '@/hooks/useMarketplaceActivityAttentionKeys/useMarketplaceActivityAttentionKeys';
+/** Activity subjects that still need the signed-in account (see `useMarketplaceActivityAttentionKeys`). */
export function useMarketplaceActivityUnread(): number {
- const currentUserPubky = useAuthStore((state) => state.currentUserPubky);
- // Refetch trigger: connecting a session replaces this store object (the
- // same wiring the activity page's own notifications hook relies on).
- const marketplaceSession = useCommerceStore((state) => state.marketplaceSession);
- const adapterMode = getCommerceAdapterMode();
- const [trackedPubky, setTrackedPubky] = useState(currentUserPubky);
- const [notificationCount, setNotificationCount] = useState(null);
- const pubkyRef = useRef(currentUserPubky);
- useLayoutEffect(() => {
- pubkyRef.current = currentUserPubky;
- }, [currentUserPubky]);
-
- // Identity changes before paint. React re-renders with a cleared count
- // instead of committing the previous account's badge.
- if (trackedPubky !== currentUserPubky) {
- setTrackedPubky(currentUserPubky);
- setNotificationCount(null);
- }
-
- const local = useLiveQuery(async (): Promise => {
- if (!currentUserPubky) return { pubky: null, unseenAlertCount: 0, checkpoint: 0 };
- try {
- const [alerts, checkpoint] = await Promise.all([
- CommerceController.getWatchAlerts(),
- CommerceController.getActivityReadCheckpoint(),
- ]);
- return {
- pubky: currentUserPubky,
- unseenAlertCount: alerts.filter(({ seen_at }) => seen_at === null).length,
- checkpoint,
- };
- } catch (error) {
- // A stubbed controller throws before a promise exists. Leaving the
- // checkpoint unset keeps the service count at zero instead of treating
- // every row as new.
- Logger.warn('Failed to load the marketplace activity badge count', { error });
- return { pubky: currentUserPubky, unseenAlertCount: 0, checkpoint: undefined };
- }
- }, [currentUserPubky]);
-
- const localForCurrent = currentUserPubky !== null && local?.pubky === currentUserPubky ? local : undefined;
- const checkpoint = localForCurrent?.checkpoint;
- const serviceCount = notificationCount?.pubky === currentUserPubky ? notificationCount.count : 0;
-
- useEffect(() => {
- if (!currentUserPubky || adapterMode === 'unavailable' || checkpoint === undefined) {
- setNotificationCount(currentUserPubky ? { pubky: currentUserPubky, count: 0 } : null);
- return;
- }
- const fetchedFor = currentUserPubky;
- const fetchedCheckpoint = checkpoint;
- let active = true;
- // A stubbed controller throws before a promise exists. That is a failed
- // load: the badge stays at zero.
- Promise.resolve()
- .then(() => CommerceController.getMarketplaceNotifications())
- .then((notifications) => {
- if (!active || pubkyRef.current !== fetchedFor) return;
- setNotificationCount({
- pubky: fetchedFor,
- count: notifications.filter((notification) => {
- if (!isRecognizedMarketplaceNotification(notification)) return false;
- if (!isMarketplaceActionActivity(notification.type)) return false;
- if (adapterMode === 'sandbox') return !notification.readAt;
- return new Date(notification.createdAt).getTime() > fetchedCheckpoint;
- }).length,
- });
- })
- .catch((error) => {
- if (!active || pubkyRef.current !== fetchedFor) return;
- setNotificationCount({ pubky: fetchedFor, count: 0 });
- Logger.warn('Failed to load the marketplace activity badge count', { error });
- });
- return () => {
- active = false;
- };
- }, [currentUserPubky, adapterMode, checkpoint, marketplaceSession]);
-
- return serviceCount + (localForCurrent?.unseenAlertCount ?? 0);
+ return useMarketplaceActivityAttentionKeys().length;
}
diff --git a/src/hooks/useMarketplaceNavAttention/useMarketplaceNavAttention.test.tsx b/src/hooks/useMarketplaceNavAttention/useMarketplaceNavAttention.test.tsx
index 7e9243d8a7..9c78cf4916 100644
--- a/src/hooks/useMarketplaceNavAttention/useMarketplaceNavAttention.test.tsx
+++ b/src/hooks/useMarketplaceNavAttention/useMarketplaceNavAttention.test.tsx
@@ -63,6 +63,8 @@ vi.mock('@/controllers/commerce/commerce', () => ({
getActivityReadCheckpoint: vi.fn(),
getMarketplaceNotifications: vi.fn(),
getMarketplaceOrders: vi.fn(),
+ getMarketplaceOffers: vi.fn(),
+ syncAttentionSeen: vi.fn(),
},
}));
@@ -81,6 +83,7 @@ const reads = {
checkpoint: deferred(),
notifications: deferred>>(),
orders: deferred>>(),
+ offers: deferred>>(),
};
function armReads() {
@@ -88,6 +91,7 @@ function armReads() {
reads.checkpoint = deferred();
reads.notifications = deferred();
reads.orders = deferred();
+ reads.offers = deferred();
}
function notification(id: string, recipientPubky: string) {
@@ -102,9 +106,23 @@ function notification(id: string, recipientPubky: string) {
};
}
+function openOffer(id: string, sellerPubky: string) {
+ return {
+ id,
+ buyerPubky: SELLER,
+ sellerPubky,
+ state: 'pending',
+ offeredBy: SELLER,
+ expiresAt: '2099-01-01T00:00:00.000Z',
+ award: null,
+ };
+}
+
function order(id: string, buyerPubky: string) {
return {
id,
+ state: 'pending_payment',
+ holdExpiresAt: null,
nextActor: 'buyer' as const,
buyerPubky,
sellerPubky: SELLER,
@@ -142,6 +160,29 @@ describe('marketplace badge identity', () => {
() => reads.notifications.promise as never,
);
vi.mocked(CommerceController.getMarketplaceOrders).mockImplementation(() => reads.orders.promise as never);
+ vi.mocked(CommerceController.getMarketplaceOffers).mockImplementation(() => reads.offers.promise as never);
+ vi.mocked(CommerceController.syncAttentionSeen).mockResolvedValue();
+ });
+
+ it('counts an order once when both its return row and the order itself need the account', async () => {
+ render();
+
+ await act(async () => {
+ reads.alerts.resolve([]);
+ reads.checkpoint.resolve(0);
+ reads.notifications.resolve([
+ { ...notification('r1', ACCOUNT_A), type: 'return_updated', aggregateId: 'order:o1' },
+ notification('a1', ACCOUNT_A),
+ ]);
+ reads.orders.resolve([order('o1', ACCOUNT_A)]);
+ reads.offers.resolve([openOffer('a1', ACCOUNT_A)]);
+ });
+
+ await waitFor(() => {
+ expect(badge('activity-badge')).toBe('2');
+ expect(badge('orders-badge')).toBe('1');
+ expect(badge('marketplace-nav-badge')).toBe('2');
+ });
});
it('shows zero while account B is loading after account A had counts', async () => {
@@ -152,6 +193,7 @@ describe('marketplace badge identity', () => {
reads.checkpoint.resolve(0);
reads.notifications.resolve([notification('a1', ACCOUNT_A), notification('a2', ACCOUNT_A)]);
reads.orders.resolve([order('o1', ACCOUNT_A), order('o2', ACCOUNT_A)]);
+ reads.offers.resolve([openOffer('a1', ACCOUNT_A), openOffer('a2', ACCOUNT_A)]);
});
await waitFor(() => {
@@ -162,6 +204,7 @@ describe('marketplace badge identity', () => {
const staleNotifications = reads.notifications;
const staleOrders = reads.orders;
+ const staleOffers = reads.offers;
armReads();
await act(async () => {
@@ -176,6 +219,7 @@ describe('marketplace badge identity', () => {
await act(async () => {
staleNotifications.resolve([notification('late-a', ACCOUNT_A), notification('late-a2', ACCOUNT_A)]);
staleOrders.resolve([order('late-o', ACCOUNT_A), order('late-o2', ACCOUNT_A)]);
+ staleOffers.resolve([openOffer('late-a', ACCOUNT_A), openOffer('late-a2', ACCOUNT_A)]);
await Promise.resolve();
});
@@ -188,6 +232,7 @@ describe('marketplace badge identity', () => {
reads.checkpoint.resolve(0);
reads.notifications.resolve([notification('b1', ACCOUNT_B)]);
reads.orders.resolve([order('b-order', ACCOUNT_B)]);
+ reads.offers.resolve([openOffer('b1', ACCOUNT_B)]);
});
await waitFor(() => {
diff --git a/src/hooks/useMarketplaceNavAttention/useMarketplaceNavAttention.ts b/src/hooks/useMarketplaceNavAttention/useMarketplaceNavAttention.ts
index 6d6310454f..c11bbf4df3 100644
--- a/src/hooks/useMarketplaceNavAttention/useMarketplaceNavAttention.ts
+++ b/src/hooks/useMarketplaceNavAttention/useMarketplaceNavAttention.ts
@@ -1,9 +1,15 @@
'use client';
-import { useMarketplaceActivityUnread } from '@/hooks/useMarketplaceActivityUnread/useMarketplaceActivityUnread';
-import { useMarketplaceOrdersAttention } from '@/hooks/useMarketplaceOrdersAttention/useMarketplaceOrdersAttention';
+import { useMarketplaceActivityAttentionKeys } from '@/hooks/useMarketplaceActivityAttentionKeys/useMarketplaceActivityAttentionKeys';
+import { useMarketplaceOrdersAttentionKeys } from '@/hooks/useMarketplaceOrdersAttentionKeys/useMarketplaceOrdersAttentionKeys';
-/** Unread action on Activity plus orders that still need this identity. */
+/**
+ * Things that need this identity across Activity and Orders. An order that
+ * badges both tabs (its return row on Activity, the order itself on Orders)
+ * counts once.
+ */
export function useMarketplaceNavAttention(): number {
- return useMarketplaceActivityUnread() + useMarketplaceOrdersAttention();
+ const activity = useMarketplaceActivityAttentionKeys();
+ const orders = useMarketplaceOrdersAttentionKeys();
+ return new Set([...activity, ...orders]).size;
}
diff --git a/src/hooks/useMarketplaceOrdersAttention/useMarketplaceOrdersAttention.ts b/src/hooks/useMarketplaceOrdersAttention/useMarketplaceOrdersAttention.ts
index d1e8c48fda..2a5a1491f6 100644
--- a/src/hooks/useMarketplaceOrdersAttention/useMarketplaceOrdersAttention.ts
+++ b/src/hooks/useMarketplaceOrdersAttention/useMarketplaceOrdersAttention.ts
@@ -1,98 +1,8 @@
'use client';
-import { useEffect, useLayoutEffect, useRef, useState } from 'react';
-import { getCommerceAdapterMode, isTransactionalCommerceMode } from '@/config/commerce';
-import { CommerceController } from '@/controllers/commerce/commerce';
-import {
- countOrdersNeedingAttention,
- MARKETPLACE_ORDERS_SEEN_EVENT,
- readOrdersSeenAt,
-} from '@/libs/commerce/marketplace-attention';
-import { Logger } from '@/libs/logger/logger';
-import { useAuthStore } from '@/stores/auth/auth.store';
-import { useCommerceStore } from '@/stores/commerce/commerce.store';
-
-/**
- * Orders whose next move is the signed-in identity, newer than the last time
- * this browser opened the Orders tab for that identity. The durable service
- * has no read state for orders, so the checkpoint lives in local storage.
- * A failed fetch contributes zero.
- *
- * The count and the checkpoint are tagged with the pubky they were read for.
- * An identity change clears both before paint, and a result whose pubky is
- * no longer signed in is dropped.
- */
-type TaggedCount = { pubky: string; count: number };
-type TaggedSeen = { pubky: string; seenAt: number };
+import { useMarketplaceOrdersAttentionKeys } from '@/hooks/useMarketplaceOrdersAttentionKeys/useMarketplaceOrdersAttentionKeys';
+/** Orders that still need the signed-in account (see `useMarketplaceOrdersAttentionKeys`). */
export function useMarketplaceOrdersAttention(): number {
- const currentUserPubky = useAuthStore((state) => state.currentUserPubky);
- const marketplaceSession = useCommerceStore((state) => state.marketplaceSession);
- const adapterMode = getCommerceAdapterMode();
- const [trackedPubky, setTrackedPubky] = useState(currentUserPubky);
- const [seen, setSeen] = useState(null);
- const [taggedCount, setTaggedCount] = useState(null);
- const pubkyRef = useRef(currentUserPubky);
- useLayoutEffect(() => {
- pubkyRef.current = currentUserPubky;
- }, [currentUserPubky]);
-
- if (trackedPubky !== currentUserPubky) {
- setTrackedPubky(currentUserPubky);
- setSeen(null);
- setTaggedCount(null);
- }
-
- const seenAt = seen?.pubky === currentUserPubky ? seen.seenAt : 0;
- const count = taggedCount?.pubky === currentUserPubky ? taggedCount.count : 0;
-
- useEffect(() => {
- if (!currentUserPubky) {
- setSeen(null);
- return;
- }
- const pubky = currentUserPubky;
- const read = () => {
- if (pubkyRef.current !== pubky) return;
- setSeen({ pubky, seenAt: readOrdersSeenAt(pubky, window.localStorage) });
- };
- read();
- window.addEventListener(MARKETPLACE_ORDERS_SEEN_EVENT, read);
- window.addEventListener('storage', read);
- return () => {
- window.removeEventListener(MARKETPLACE_ORDERS_SEEN_EVENT, read);
- window.removeEventListener('storage', read);
- };
- }, [currentUserPubky]);
-
- useEffect(() => {
- if (!currentUserPubky || !isTransactionalCommerceMode(adapterMode)) {
- setTaggedCount(currentUserPubky ? { pubky: currentUserPubky, count: 0 } : null);
- return;
- }
- const fetchedFor = currentUserPubky;
- const fetchedSeenAt = seenAt;
- let active = true;
- // A stubbed controller (tests) throws before a promise exists. That is a
- // failed load: the badge stays at zero.
- Promise.resolve()
- .then(() => CommerceController.getMarketplaceOrders())
- .then((orders) => {
- if (!active || pubkyRef.current !== fetchedFor) return;
- setTaggedCount({
- pubky: fetchedFor,
- count: countOrdersNeedingAttention(orders, fetchedFor, fetchedSeenAt),
- });
- })
- .catch((error) => {
- if (!active || pubkyRef.current !== fetchedFor) return;
- setTaggedCount({ pubky: fetchedFor, count: 0 });
- Logger.warn('Failed to load the marketplace orders badge count', { error });
- });
- return () => {
- active = false;
- };
- }, [currentUserPubky, adapterMode, marketplaceSession, seenAt]);
-
- return count;
+ return useMarketplaceOrdersAttentionKeys().length;
}
diff --git a/src/hooks/useMarketplaceOrdersAttentionKeys/useMarketplaceOrdersAttentionKeys.ts b/src/hooks/useMarketplaceOrdersAttentionKeys/useMarketplaceOrdersAttentionKeys.ts
new file mode 100644
index 0000000000..1685f81092
--- /dev/null
+++ b/src/hooks/useMarketplaceOrdersAttentionKeys/useMarketplaceOrdersAttentionKeys.ts
@@ -0,0 +1,109 @@
+'use client';
+
+import { useEffect, useLayoutEffect, useRef, useState } from 'react';
+import { getCommerceAdapterMode, isTransactionalCommerceMode } from '@/config/commerce';
+import { CommerceController } from '@/controllers/commerce/commerce';
+import {
+ MARKETPLACE_ORDERS_SEEN_EVENT,
+ ordersNeedingAttentionKeys,
+ readOrdersSeenAt,
+} from '@/libs/commerce/marketplace-attention';
+import { Logger } from '@/libs/logger/logger';
+import { useAuthStore } from '@/stores/auth/auth.store';
+import { useCommerceStore } from '@/stores/commerce/commerce.store';
+
+/**
+ * Orders whose next move is the signed-in identity, newer than the last time
+ * this account opened the Orders tab. The durable service has no read state
+ * for orders; the checkpoint is the account's private homeserver document,
+ * mirrored in local storage (see `CommerceAttentionSeenApplication`). A
+ * failed fetch contributes nothing.
+ *
+ * The keys and the checkpoint are tagged with the pubky they were read for.
+ * An identity change clears both before paint, and a result whose pubky is
+ * no longer signed in is dropped.
+ */
+type TaggedKeys = { pubky: string; keys: readonly string[] };
+type TaggedSeen = { pubky: string; seenAt: number };
+
+const NO_KEYS: readonly string[] = [];
+
+export function useMarketplaceOrdersAttentionKeys(): readonly string[] {
+ const currentUserPubky = useAuthStore((state) => state.currentUserPubky);
+ const marketplaceSession = useCommerceStore((state) => state.marketplaceSession);
+ const adapterMode = getCommerceAdapterMode();
+ const [trackedPubky, setTrackedPubky] = useState(currentUserPubky);
+ const [seen, setSeen] = useState(null);
+ const [taggedKeys, setTaggedKeys] = useState(null);
+ const pubkyRef = useRef(currentUserPubky);
+ useLayoutEffect(() => {
+ pubkyRef.current = currentUserPubky;
+ }, [currentUserPubky]);
+
+ if (trackedPubky !== currentUserPubky) {
+ setTrackedPubky(currentUserPubky);
+ setSeen(null);
+ setTaggedKeys(null);
+ }
+
+ const seenAt = seen?.pubky === currentUserPubky ? seen.seenAt : undefined;
+ const keys = taggedKeys?.pubky === currentUserPubky ? taggedKeys.keys : NO_KEYS;
+
+ useEffect(() => {
+ if (!currentUserPubky) {
+ setSeen(null);
+ return;
+ }
+ const pubky = currentUserPubky;
+ const read = () => {
+ if (pubkyRef.current !== pubky) return;
+ setSeen({ pubky, seenAt: readOrdersSeenAt(pubky, window.localStorage) });
+ };
+ read();
+ window.addEventListener(MARKETPLACE_ORDERS_SEEN_EVENT, read);
+ window.addEventListener('storage', read);
+ // Raises the local copy (and fires the event above) when another browser
+ // already cleared Orders for this account.
+ Promise.resolve()
+ .then(() => CommerceController.syncAttentionSeen())
+ .catch((error) => {
+ Logger.warn('Failed to load the marketplace badge checkpoint', { error });
+ });
+ return () => {
+ window.removeEventListener(MARKETPLACE_ORDERS_SEEN_EVENT, read);
+ window.removeEventListener('storage', read);
+ };
+ }, [currentUserPubky]);
+
+ useEffect(() => {
+ if (!currentUserPubky || !isTransactionalCommerceMode(adapterMode)) {
+ setTaggedKeys(currentUserPubky ? { pubky: currentUserPubky, keys: NO_KEYS } : null);
+ return;
+ }
+ if (seenAt === undefined) return;
+ const fetchedFor = currentUserPubky;
+ const fetchedSeenAt = seenAt;
+ let active = true;
+ // A stubbed controller (tests) throws before a promise exists. That is a
+ // failed load: the badge stays at zero.
+ Promise.resolve()
+ .then(() => CommerceController.getMarketplaceOrders())
+ .then((orders) => {
+ if (!active || pubkyRef.current !== fetchedFor) return;
+ setTaggedKeys({
+ pubky: fetchedFor,
+ keys: ordersNeedingAttentionKeys(orders, fetchedFor, fetchedSeenAt),
+ });
+ })
+ .catch((error) => {
+ if (!active || pubkyRef.current !== fetchedFor) return;
+ setTaggedKeys({ pubky: fetchedFor, keys: NO_KEYS });
+ Logger.warn('Failed to load the marketplace orders badge count', { error });
+ });
+ return () => {
+ active = false;
+ };
+ }, [currentUserPubky, adapterMode, marketplaceSession, seenAt]);
+
+ return keys;
+}
diff --git a/src/libs/commerce/marketplace-attention.test.ts b/src/libs/commerce/marketplace-attention.test.ts
index 9c622fa6e0..0170b11721 100644
--- a/src/libs/commerce/marketplace-attention.test.ts
+++ b/src/libs/commerce/marketplace-attention.test.ts
@@ -1,13 +1,19 @@
import { describe, expect, it } from 'vitest';
+import type { MarketplaceNotification } from '@/services/marketplace/marketplace';
import {
- countOrdersNeedingAttention,
+ activityNeedingAttentionKeys,
+ type AttentionOffer,
isMarketplaceActionActivity,
+ offerNeedsCurrentUser,
+ orderNeedsCurrentUser,
+ ordersNeedingAttentionKeys,
readOrdersSeenAt,
writeOrdersSeenAt,
} from './marketplace-attention';
const ME = 'm'.repeat(52);
const THEM = 't'.repeat(52);
+const NOW = Date.parse('2026-09-24T12:00:00.000Z');
function memoryStorage() {
const values = new Map();
@@ -19,39 +25,175 @@ function memoryStorage() {
};
}
+type TestOrder = Parameters[0];
+
+function order(id: string, overrides: Partial = {}): TestOrder {
+ return {
+ id,
+ state: 'paid',
+ nextActor: 'seller',
+ buyerPubky: THEM,
+ sellerPubky: ME,
+ updatedAt: '2026-09-23T12:00:00.000Z',
+ holdExpiresAt: null,
+ ...overrides,
+ };
+}
+
+function offer(id: string, overrides: Partial = {}): AttentionOffer {
+ return {
+ id,
+ buyerPubky: THEM,
+ sellerPubky: ME,
+ state: 'pending',
+ offeredBy: THEM,
+ expiresAt: '2026-09-30T00:00:00.000Z',
+ award: null,
+ ...overrides,
+ };
+}
+
+function row(
+ id: string,
+ type: MarketplaceNotification['type'],
+ aggregateId: string,
+ createdAt = '2026-09-22T10:00:00.000Z',
+) {
+ return { id, type, aggregateId, createdAt, readAt: null };
+}
+
describe('marketplace attention', () => {
- it('badges action events and leaves informational payment rows alone', () => {
+ it('treats only rows that can ask something of the recipient as action types', () => {
expect(isMarketplaceActionActivity('return_updated')).toBe(true);
expect(isMarketplaceActionActivity('offer_received')).toBe(true);
+ expect(isMarketplaceActionActivity('offer_accepted')).toBe(true);
expect(isMarketplaceActionActivity('message_received')).toBe(true);
expect(isMarketplaceActionActivity('pickup_ready')).toBe(true);
+ expect(isMarketplaceActionActivity('order_created')).toBe(false);
+ expect(isMarketplaceActionActivity('order_cancelled')).toBe(false);
expect(isMarketplaceActionActivity('payment_confirmed')).toBe(false);
expect(isMarketplaceActionActivity('order_shipped')).toBe(false);
});
- it('counts orders that still need this identity and are newer than last seen', () => {
+ it('keys orders that still need this identity and are newer than last seen', () => {
const orders = [
- {
- nextActor: 'seller' as const,
- buyerPubky: THEM,
- sellerPubky: ME,
- updatedAt: '2026-09-23T12:00:00.000Z',
- },
- {
- nextActor: 'buyer' as const,
- buyerPubky: THEM,
- sellerPubky: ME,
- updatedAt: '2026-09-23T12:00:00.000Z',
- },
- {
- nextActor: 'seller' as const,
- buyerPubky: THEM,
- sellerPubky: ME,
- updatedAt: '2026-09-01T12:00:00.000Z',
- },
+ order('a'),
+ order('b', { nextActor: 'buyer' }),
+ order('c', { updatedAt: '2026-09-01T12:00:00.000Z' }),
+ order('d', { state: 'cancelled', nextActor: 'none' }),
];
- expect(countOrdersNeedingAttention(orders, ME, 0)).toBe(2);
- expect(countOrdersNeedingAttention(orders, ME, Date.parse('2026-09-20T00:00:00.000Z'))).toBe(1);
+ expect(ordersNeedingAttentionKeys(orders, ME, 0, NOW)).toEqual(['order:a', 'order:c']);
+ expect(ordersNeedingAttentionKeys(orders, ME, Date.parse('2026-09-20T00:00:00.000Z'), NOW)).toEqual(['order:a']);
+ });
+
+ it('does not ask anyone to pay a checkout whose hold already lapsed', () => {
+ const lapsed = order('x', {
+ state: 'pending_payment',
+ nextActor: 'buyer',
+ buyerPubky: ME,
+ sellerPubky: THEM,
+ holdExpiresAt: '2026-09-22T10:15:00.000Z',
+ });
+ expect(orderNeedsCurrentUser(lapsed, ME, NOW)).toBe(false);
+ expect(orderNeedsCurrentUser({ ...lapsed, holdExpiresAt: '2026-09-24T12:10:00.000Z' }, ME, NOW)).toBe(true);
+ });
+
+ it('asks the party who did not make the latest offer amount, until it expires', () => {
+ expect(offerNeedsCurrentUser(offer('o'), ME, NOW)).toBe(true);
+ expect(offerNeedsCurrentUser(offer('o'), THEM, NOW)).toBe(false);
+ expect(offerNeedsCurrentUser(offer('o', { state: 'countered', offeredBy: ME }), ME, NOW)).toBe(false);
+ expect(offerNeedsCurrentUser(offer('o', { state: 'countered', offeredBy: ME }), THEM, NOW)).toBe(true);
+ expect(offerNeedsCurrentUser(offer('o', { expiresAt: '2026-09-23T00:00:00.000Z' }), ME, NOW)).toBe(false);
+ expect(offerNeedsCurrentUser(offer('o', { state: 'rejected' }), ME, NOW)).toBe(false);
+ expect(offerNeedsCurrentUser(offer('o', { state: 'pending' }), 'x'.repeat(52), NOW)).toBe(false);
+ });
+
+ it('asks the buyer of an accepted offer while its award is open for checkout', () => {
+ const accepted = offer('o', { state: 'accepted', award: { state: 'active' } });
+ expect(offerNeedsCurrentUser(accepted, THEM, NOW)).toBe(true);
+ expect(offerNeedsCurrentUser(accepted, ME, NOW)).toBe(false);
+ expect(offerNeedsCurrentUser({ ...accepted, award: { state: 'converted' } }, THEM, NOW)).toBe(false);
+ });
+
+ it('never badges old informational rows such as a cancelled order or a started checkout', () => {
+ const keys = activityNeedingAttentionKeys({
+ notifications: [
+ row('n1', 'order_created', 'order:a'),
+ row('n2', 'order_cancelled', 'order:b'),
+ row('n3', 'payment_confirmed', 'order:c'),
+ row('n4', 'order_completed', 'order:d'),
+ ],
+ orders: [
+ order('a', { state: 'pending_payment', nextActor: 'seller' }),
+ order('b', { state: 'cancelled', nextActor: 'none' }),
+ order('c'),
+ order('d', { state: 'completed', nextActor: 'none' }),
+ ],
+ offers: [],
+ currentUserPubky: ME,
+ clearedBy: { kind: 'seen', seenAt: 0 },
+ now: NOW,
+ });
+ expect(keys).toEqual([]);
+ });
+
+ it('badges an action row only while its order or offer still waits on this identity', () => {
+ const keys = activityNeedingAttentionKeys({
+ notifications: [
+ row('r-open', 'return_updated', 'order:open'),
+ row('r-done', 'return_updated', 'order:refunded'),
+ row('r-missing', 'return_updated', 'order:not-loaded'),
+ row('o-open', 'offer_received', 'offer:pending'),
+ row('o-answered', 'offer_received', 'offer:rejected'),
+ row('m', 'message_received', 'conversation:abc'),
+ ],
+ orders: [
+ order('open', { state: 'return_requested', nextActor: 'seller' }),
+ order('refunded', { state: 'refunded', nextActor: 'none' }),
+ ],
+ offers: [offer('pending'), offer('rejected', { state: 'rejected' })],
+ currentUserPubky: ME,
+ clearedBy: { kind: 'seen', seenAt: 0 },
+ now: NOW,
+ });
+ expect(keys).toEqual(['order:open', 'offer:pending', 'notification:m']);
+ });
+
+ it('counts several rows about one subject once', () => {
+ const keys = activityNeedingAttentionKeys({
+ notifications: [
+ row('r1', 'return_updated', 'order:open', '2026-09-22T10:00:00.000Z'),
+ row('r2', 'return_updated', 'order:open', '2026-09-22T11:00:00.000Z'),
+ row('o1', 'offer_received', 'offer:p'),
+ row('o2', 'offer_countered', 'offer:p'),
+ ],
+ orders: [order('open', { state: 'return_requested' })],
+ offers: [offer('p')],
+ currentUserPubky: ME,
+ clearedBy: { kind: 'seen', seenAt: 0 },
+ now: NOW,
+ });
+ expect(keys).toEqual(['order:open', 'offer:p']);
+ });
+
+ it('leaves out rows created before the account last opened Activity, or already read in the sandbox', () => {
+ const input = {
+ notifications: [
+ row('old', 'offer_received', 'offer:p1', '2026-09-20T00:00:00.000Z'),
+ { ...row('new', 'offer_received', 'offer:p2', '2026-09-23T00:00:00.000Z'), readAt: '2026-09-23T01:00:00.000Z' },
+ ],
+ orders: [],
+ offers: [offer('p1'), offer('p2')],
+ currentUserPubky: ME,
+ now: NOW,
+ };
+ expect(
+ activityNeedingAttentionKeys({
+ ...input,
+ clearedBy: { kind: 'seen', seenAt: Date.parse('2026-09-21T00:00:00.000Z') },
+ }),
+ ).toEqual(['offer:p2']);
+ expect(activityNeedingAttentionKeys({ ...input, clearedBy: { kind: 'read-state' } })).toEqual(['offer:p1']);
});
it('stores last-seen per identity and never moves it backward', () => {
diff --git a/src/libs/commerce/marketplace-attention.ts b/src/libs/commerce/marketplace-attention.ts
index ea412a3858..c52d039ed4 100644
--- a/src/libs/commerce/marketplace-attention.ts
+++ b/src/libs/commerce/marketplace-attention.ts
@@ -1,14 +1,30 @@
import type { MarketplaceNotification } from '@/services/marketplace/marketplace';
/**
- * Activity rows that still need the person who received them. Informational
- * rows (payment confirmed, shipped, completed) stay in the history and do
- * not badge.
+ * Activity rows that can need the person who received them. A row of one of
+ * these types badges only while its subject still needs that person (see
+ * `activityNeedsCurrentUser`). Informational rows (checkout started, payment
+ * confirmed, shipped, cancelled, completed) stay in the history and never
+ * badge.
*/
const ACTION_ACTIVITY_TYPES = new Set([
'message_received',
'offer_received',
'offer_countered',
+ 'offer_accepted',
+ 'return_updated',
+ 'pickup_ready',
+ 'payment_refund_required',
+ 'bitcoin_manual_review',
+]);
+
+const OFFER_ACTION_TYPES = new Set([
+ 'offer_received',
+ 'offer_countered',
+ 'offer_accepted',
+]);
+
+const ORDER_ACTION_TYPES = new Set([
'return_updated',
'pickup_ready',
'payment_refund_required',
@@ -38,31 +54,80 @@ export function writeOrdersSeenAt(pubky: string, at: number, storage: Pick {
- if (!orderNeedsCurrentUser(order, currentUserPubky)) return false;
- const updated = Date.parse(order.updatedAt);
- if (!Number.isFinite(updated)) return seenAt === 0;
- return updated > seenAt;
- }).length;
+ now = Date.now(),
+): string[] {
+ return orders
+ .filter((order) => {
+ if (!orderNeedsCurrentUser(order, currentUserPubky, now)) return false;
+ const updated = Date.parse(order.updatedAt);
+ if (!Number.isFinite(updated)) return seenAt === 0;
+ return updated > seenAt;
+ })
+ .map((order) => orderAttentionKey(order.id));
+}
+
+type AttentionNotification = {
+ id: string;
+ type: MarketplaceNotification['type'];
+ aggregateId: string;
+ createdAt: string;
+ readAt?: string | null;
+};
+
+export type ActivityAttentionInput = {
+ notifications: readonly AttentionNotification[];
+ orders: readonly AttentionOrder[];
+ offers: readonly AttentionOffer[];
+ currentUserPubky: string;
+ /**
+ * `seen` compares each row's `createdAt` to the account's activity
+ * checkpoint; `read-state` uses the row's own `readAt` (sandbox).
+ */
+ clearedBy: { kind: 'seen'; seenAt: number } | { kind: 'read-state' };
+ now?: number;
+};
+
+/**
+ * Activity that still needs this identity, as one subject key per order,
+ * offer, or message row. Several rows about the same order or offer count
+ * once. A row about an order or offer the caller could not load does not
+ * count: the badge may lag reality but never invents it.
+ */
+export function activityNeedingAttentionKeys({
+ notifications,
+ orders,
+ offers,
+ currentUserPubky,
+ clearedBy,
+ now = Date.now(),
+}: ActivityAttentionInput): string[] {
+ const ordersById = new Map(orders.map((order) => [order.id, order]));
+ const offersById = new Map(offers.map((offer) => [offer.id, offer]));
+ const keys = new Set();
+ for (const notification of notifications) {
+ if (!isMarketplaceActionActivity(notification.type)) continue;
+ if (clearedBy.kind === 'read-state') {
+ if (notification.readAt) continue;
+ } else if (!(Date.parse(notification.createdAt) > clearedBy.seenAt)) {
+ continue;
+ }
+ if (ORDER_ACTION_TYPES.has(notification.type)) {
+ const orderId = aggregateSuffix(notification.aggregateId, 'order:');
+ const order = orderId ? ordersById.get(orderId) : undefined;
+ if (order && orderNeedsCurrentUser(order, currentUserPubky, now)) keys.add(orderAttentionKey(order.id));
+ continue;
+ }
+ if (OFFER_ACTION_TYPES.has(notification.type)) {
+ const offerId = aggregateSuffix(notification.aggregateId, 'offer:');
+ const offer = offerId ? offersById.get(offerId) : undefined;
+ if (offer && offerNeedsCurrentUser(offer, currentUserPubky, now)) keys.add(`offer:${offer.id}`);
+ continue;
+ }
+ keys.add(`notification:${notification.id}`);
+ }
+ return [...keys];
+}
+
+function aggregateSuffix(aggregateId: string, prefix: string): string | null {
+ if (!aggregateId.startsWith(prefix)) return null;
+ const id = aggregateId.slice(prefix.length);
+ return id.length > 0 ? id : null;
}
diff --git a/src/test/mocks/marketplace-vrt.ts b/src/test/mocks/marketplace-vrt.ts
index 518ece4056..68307e8800 100644
--- a/src/test/mocks/marketplace-vrt.ts
+++ b/src/test/mocks/marketplace-vrt.ts
@@ -26,5 +26,8 @@ export function createMarketplaceVrtCommerceController() {
getWatchAlerts: async () => [],
getActivityReadCheckpoint: async () => 0,
getMarketplaceNotifications: async () => [],
+ getMarketplaceOrders: async () => [],
+ getMarketplaceOffers: async () => [],
+ syncAttentionSeen: async () => {},
};
}
diff --git a/src/test/vrt/marketplace/MarketplaceNavBadges.vrt.test.tsx b/src/test/vrt/marketplace/MarketplaceNavBadges.vrt.test.tsx
index c6c2782fef..0321095790 100644
--- a/src/test/vrt/marketplace/MarketplaceNavBadges.vrt.test.tsx
+++ b/src/test/vrt/marketplace/MarketplaceNavBadges.vrt.test.tsx
@@ -124,7 +124,7 @@ describe('Marketplace nav badges — visual regression', () => {
const screen = await renderForVRT(, { viewport: VRT_VIEWPORT_DESKTOP, disableHover: true });
await expect.element(screen.getByLabelText('3 cart items')).toBeInTheDocument();
- await expect.element(screen.getByLabelText('5 unread activity')).toBeInTheDocument();
+ await expect.element(screen.getByLabelText('5 activity needing you')).toBeInTheDocument();
await expect(screen.getByTestId(VRT_ROOT_TESTID)).toMatchScreenshot('marketplace-nav-badges-desktop');
});
From 0ec4ca106e58b4013db20c07d890040a17d3e180 Mon Sep 17 00:00:00 2001
From: Bitcoin Error Log <18273620+BitcoinErrorLog@users.noreply.github.com>
Date: Thu, 24 Sep 2026 17:21:48 +0100
Subject: [PATCH 2/5] docs(changelog): actionable, per-account marketplace
badges
---
changelog.d/next/activity-badge-actionable.fixed.md | 1 +
1 file changed, 1 insertion(+)
create mode 100644 changelog.d/next/activity-badge-actionable.fixed.md
diff --git a/changelog.d/next/activity-badge-actionable.fixed.md b/changelog.d/next/activity-badge-actionable.fixed.md
new file mode 100644
index 0000000000..38166a71b5
--- /dev/null
+++ b/changelog.d/next/activity-badge-actionable.fixed.md
@@ -0,0 +1 @@
+Marketplace badges now count only things that still need you: an order waiting on your move, an offer waiting on your answer, an accepted offer waiting on your checkout, or a new message. Old rows such as Checkout started or Order cancelled no longer count, and one order counts once on the marketplace icon. Opening Activity or Orders clears that badge for your account on every browser, not only the one you used.
From 9e719431a7fc4885d617ffecf24fba580e469ed8 Mon Sep 17 00:00:00 2001
From: Bitcoin Error Log <18273620+BitcoinErrorLog@users.noreply.github.com>
Date: Thu, 24 Sep 2026 17:31:22 +0100
Subject: [PATCH 3/5] fix(shop): a stubbed orders checkpoint write fails
quietly instead of breaking render
---
.../useMarkMarketplaceOrdersSeen.test.ts | 50 +++++++++++++++++++
.../useMarkMarketplaceOrdersSeen.ts | 10 ++--
2 files changed, 57 insertions(+), 3 deletions(-)
create mode 100644 src/hooks/useMarkMarketplaceOrdersSeen/useMarkMarketplaceOrdersSeen.test.ts
diff --git a/src/hooks/useMarkMarketplaceOrdersSeen/useMarkMarketplaceOrdersSeen.test.ts b/src/hooks/useMarkMarketplaceOrdersSeen/useMarkMarketplaceOrdersSeen.test.ts
new file mode 100644
index 0000000000..3c752f24aa
--- /dev/null
+++ b/src/hooks/useMarkMarketplaceOrdersSeen/useMarkMarketplaceOrdersSeen.test.ts
@@ -0,0 +1,50 @@
+import { renderHook, waitFor } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { CommerceController } from '@/controllers/commerce/commerce';
+import { useMarkMarketplaceOrdersSeen } from './useMarkMarketplaceOrdersSeen';
+
+const state = vi.hoisted(() => ({ currentUserPubky: 'o'.repeat(52) as string | null }));
+
+vi.mock('@/stores/auth/auth.store', () => ({
+ useAuthStore: (selector: (store: { currentUserPubky: string | null }) => unknown) =>
+ selector({ currentUserPubky: state.currentUserPubky }),
+}));
+
+vi.mock('@/controllers/commerce/commerce', () => ({
+ CommerceController: { markOrdersAttentionSeen: vi.fn() },
+}));
+
+describe('useMarkMarketplaceOrdersSeen', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ state.currentUserPubky = 'o'.repeat(52);
+ vi.mocked(CommerceController.markOrdersAttentionSeen).mockResolvedValue();
+ });
+
+ it('saves the account checkpoint once orders are on screen', async () => {
+ const { rerender } = renderHook(({ showing }) => useMarkMarketplaceOrdersSeen(showing, []), {
+ initialProps: { showing: false },
+ });
+ expect(CommerceController.markOrdersAttentionSeen).not.toHaveBeenCalled();
+
+ rerender({ showing: true });
+
+ await waitFor(() => expect(CommerceController.markOrdersAttentionSeen).toHaveBeenCalledOnce());
+ });
+
+ it('does nothing when signed out', async () => {
+ state.currentUserPubky = null;
+ renderHook(() => useMarkMarketplaceOrdersSeen(true, []));
+ await Promise.resolve();
+ expect(CommerceController.markOrdersAttentionSeen).not.toHaveBeenCalled();
+ });
+
+ it('treats a controller that throws before returning a promise as a failed write, not a render error', async () => {
+ vi.mocked(CommerceController.markOrdersAttentionSeen).mockImplementation(() => {
+ throw new TypeError('CommerceController.markOrdersAttentionSeen is not a function');
+ });
+
+ expect(() => renderHook(() => useMarkMarketplaceOrdersSeen(true, []))).not.toThrow();
+ await waitFor(() => expect(CommerceController.markOrdersAttentionSeen).toHaveBeenCalled());
+ });
+});
diff --git a/src/hooks/useMarkMarketplaceOrdersSeen/useMarkMarketplaceOrdersSeen.ts b/src/hooks/useMarkMarketplaceOrdersSeen/useMarkMarketplaceOrdersSeen.ts
index c7d0e523bb..67e01d40f6 100644
--- a/src/hooks/useMarkMarketplaceOrdersSeen/useMarkMarketplaceOrdersSeen.ts
+++ b/src/hooks/useMarkMarketplaceOrdersSeen/useMarkMarketplaceOrdersSeen.ts
@@ -13,8 +13,12 @@ export function useMarkMarketplaceOrdersSeen(isShowingOrders: boolean, shown: un
const currentUserPubky = useAuthStore((state) => state.currentUserPubky);
useEffect(() => {
if (!currentUserPubky || !isShowingOrders) return;
- CommerceController.markOrdersAttentionSeen().catch((error) => {
- Logger.warn('Failed to advance the orders badge checkpoint', { error });
- });
+ // A stubbed controller throws before a promise exists; that is a failed
+ // write, not a render error.
+ Promise.resolve()
+ .then(() => CommerceController.markOrdersAttentionSeen())
+ .catch((error) => {
+ Logger.warn('Failed to advance the orders badge checkpoint', { error });
+ });
}, [currentUserPubky, isShowingOrders, shown]);
}
From 6aefa30491d28db04a7fb179a2c7c1515577899e Mon Sep 17 00:00:00 2001
From: Bitcoin Error Log <18273620+BitcoinErrorLog@users.noreply.github.com>
Date: Thu, 24 Sep 2026 17:47:53 +0100
Subject: [PATCH 4/5] fix(shop): badge checkpoints cannot move backward across
tabs and browsers
Each save adds an immutable entry named by its checkpoint under a per-side private directory, and the checkpoint is the largest name. Saves are debounced, skipped when the homeserver already holds a newer checkpoint, and prune only entries below the one just written.
---
.../commerce/attention-seen.test.ts | 225 +++++++++++++-----
.../application/commerce/attention-seen.ts | 187 +++++++++------
.../pipes/commerce/commerce.normalizer.ts | 9 +-
.../services/homeserver/commerce/commerce.ts | 4 +
4 files changed, 293 insertions(+), 132 deletions(-)
diff --git a/src/core/application/commerce/attention-seen.test.ts b/src/core/application/commerce/attention-seen.test.ts
index 5a81e14016..3ccb6d4ef5 100644
--- a/src/core/application/commerce/attention-seen.test.ts
+++ b/src/core/application/commerce/attention-seen.test.ts
@@ -8,7 +8,7 @@ import { CommerceHomeserverService } from '@/services/homeserver/commerce/commer
import { HomeserverService } from '@/services/homeserver/homeserver';
import { LocalCommerceService } from '@/services/local/commerce/commerce';
import { useAuthStore } from '@/stores/auth/auth.store';
-import { CommerceAttentionSeenApplication } from './attention-seen';
+import { ATTENTION_SEEN_WRITE_DEBOUNCE_MS, CommerceAttentionSeenApplication } from './attention-seen';
const state = vi.hoisted(() => ({ mode: 'transaction-service' as string }));
@@ -19,33 +19,58 @@ vi.mock('@/config/commerce', async () => {
const OWNER = 'o'.repeat(52);
const OTHER = 'p'.repeat(52);
-const DOC_URL = `pubky://${OWNER}/priv/pubky.app/marketplace/v1/attention_seen.json`;
-const T_ACTIVITY = Date.parse('2026-09-24T14:00:00.000Z');
-const T_ORDERS = Date.parse('2026-09-24T14:05:00.000Z');
+const BASE = `pubky://${OWNER}/priv/pubky.app/marketplace/v1/attention_seen`;
+const DIR = { activity: `${BASE}/activity/`, orders: `${BASE}/orders/` } as const;
+const T0 = Date.parse('2026-09-24T14:00:00.000Z');
+const NOW = Date.parse('2026-09-24T16:00:00.000Z');
-const notFound = () =>
+const forbidden = () =>
new AppError({
category: ErrorCategory.Client,
- code: ClientErrorCode.NOT_FOUND,
- message: 'HTTP 404',
+ code: ClientErrorCode.BAD_REQUEST,
+ message: 'HTTP 403',
service: ErrorService.Homeserver,
operation: 'test',
- context: { statusCode: 404 },
+ context: { statusCode: 403 },
});
-/** One homeserver document shared by every "browser" in a test. */
-function homeserverDocument(initial: unknown = null) {
- let doc: unknown = initial;
- vi.spyOn(CommerceHomeserverService, 'fetchJson').mockImplementation(async (url) => {
- expect(url).toBe(DOC_URL);
- if (doc === null) throw notFound();
- return structuredClone(doc);
+const entry = (side: keyof typeof DIR, at: number) => `${DIR[side]}${String(at).padStart(13, '0')}`;
+
+/**
+ * A homeserver private tree shared by every tab and browser in a test.
+ * `holdLists` parks each list call until `releaseLists` runs, so a test can
+ * line up two writers that both read before either writes.
+ */
+function homeserver(seed: string[] = []) {
+ const files = new Set(seed);
+ let held: Array<() => void> | null = null;
+ const snapshot = (directory: string) => [...files].filter((url) => url.startsWith(directory)).sort();
+ vi.spyOn(CommerceHomeserverService, 'list').mockImplementation(async (directory) => {
+ const result = snapshot(directory);
+ if (held) await new Promise((release) => held!.push(release));
+ return result;
});
- const put = vi.spyOn(CommerceHomeserverService, 'putJson').mockImplementation(async (url, body) => {
- expect(url).toBe(DOC_URL);
- doc = structuredClone(body);
+ const put = vi.spyOn(CommerceHomeserverService, 'putJson').mockImplementation(async (url) => {
+ files.add(url);
});
- return { put, read: () => doc };
+ const del = vi.spyOn(CommerceHomeserverService, 'delete').mockImplementation(async (url) => {
+ files.delete(url);
+ });
+ return {
+ files,
+ put,
+ del,
+ holdLists: () => {
+ held = [];
+ },
+ releaseLists: () => {
+ const waiting = held ?? [];
+ held = null;
+ for (const release of waiting) release();
+ },
+ latest: (side: keyof typeof DIR) =>
+ snapshot(DIR[side]).reduce((max, url) => Math.max(max, Number(url.slice(url.lastIndexOf('/') + 1))), 0),
+ };
}
/** A fresh browser for the same account: nothing in Dexie or local storage. */
@@ -54,8 +79,14 @@ async function switchToFreshBrowser() {
window.localStorage.clear();
}
+async function settleDebounce() {
+ await vi.advanceTimersByTimeAsync(ATTENTION_SEEN_WRITE_DEBOUNCE_MS + 1);
+}
+
describe('CommerceAttentionSeenApplication (per-account badge checkpoints)', () => {
beforeEach(async () => {
+ vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] });
+ vi.setSystemTime(NOW);
state.mode = 'transaction-service';
useAuthStore.setState({ currentUserPubky: OWNER });
vi.spyOn(HomeserverService, 'hasActiveSession').mockReturnValue(true);
@@ -64,98 +95,172 @@ describe('CommerceAttentionSeenApplication (per-account badge checkpoints)', ()
});
afterEach(() => {
+ CommerceAttentionSeenApplication.resetPendingWrites();
+ vi.useRealTimers();
vi.restoreAllMocks();
});
it('clears Activity and Orders in a second browser after the first browser opened them', async () => {
- const remote = homeserverDocument();
+ const remote = homeserver();
- await CommerceAttentionSeenApplication.markSeen(OWNER, 'activity', T_ACTIVITY);
- await CommerceAttentionSeenApplication.markSeen(OWNER, 'orders', T_ORDERS);
- expect(remote.read()).toEqual({ version: 1, activitySeenAt: T_ACTIVITY, ordersSeenAt: T_ORDERS });
+ const activity = CommerceAttentionSeenApplication.markSeen(OWNER, 'activity', T0);
+ const orders = CommerceAttentionSeenApplication.markSeen(OWNER, 'orders', T0 + 1_000);
+ await settleDebounce();
+ await Promise.all([activity, orders]);
+ expect(remote.latest('activity')).toBe(T0);
+ expect(remote.latest('orders')).toBe(T0 + 1_000);
await switchToFreshBrowser();
- expect(await LocalCommerceService.getActivityReadCheckpoint(OWNER)).toBe(0);
- expect(readOrdersSeenAt(OWNER, window.localStorage)).toBe(0);
-
await CommerceAttentionSeenApplication.pull(OWNER);
- expect(await LocalCommerceService.getActivityReadCheckpoint(OWNER)).toBe(T_ACTIVITY);
- expect(readOrdersSeenAt(OWNER, window.localStorage)).toBe(T_ORDERS);
+ expect(await LocalCommerceService.getActivityReadCheckpoint(OWNER)).toBe(T0);
+ expect(readOrdersSeenAt(OWNER, window.localStorage)).toBe(T0 + 1_000);
});
- it('tells mounted Orders badges to re-read when a pull raises the checkpoint', async () => {
- homeserverDocument({ version: 1, activitySeenAt: 0, ordersSeenAt: T_ORDERS });
- const listener = vi.fn();
- window.addEventListener('marketplace-orders-seen', listener);
+ it('never moves a side backward when two browsers read before either writes', async () => {
+ // Both browsers start from activity = orders = T0. Browser A saves Orders
+ // at T0+20s, browser B saves Activity at T0+30s. Both read first, then B
+ // writes, then A writes: a read-modify-write of one document would put
+ // Activity back to T0.
+ const remote = homeserver([entry('activity', T0), entry('orders', T0)]);
+ remote.holdLists();
+
+ const browserA = CommerceAttentionSeenApplication.markSeen(OWNER, 'orders', T0 + 20_000);
+ const browserB = CommerceAttentionSeenApplication.markSeen(OWNER, 'activity', T0 + 30_000);
+ await settleDebounce();
+ remote.releaseLists();
+ await Promise.all([browserA, browserB]);
+
+ expect(remote.latest('activity')).toBe(T0 + 30_000);
+ expect(remote.latest('orders')).toBe(T0 + 20_000);
+ });
+
+ it('keeps the newer checkpoint when a slower tab lands an older one on the same side', async () => {
+ const remote = homeserver([entry('orders', T0)]);
+ remote.holdLists();
+
+ // This tab read T0 and will write T0+15s. Meanwhile another tab or
+ // browser has already written T0+20s.
+ const slowTab = CommerceAttentionSeenApplication.markSeen(OWNER, 'orders', T0 + 15_000);
+ await settleDebounce();
+ remote.files.add(entry('orders', T0 + 20_000));
+ remote.releaseLists();
+ await slowTab;
+ expect(remote.latest('orders')).toBe(T0 + 20_000);
+ expect(remote.files.has(entry('orders', T0 + 20_000))).toBe(true);
+ await switchToFreshBrowser();
await CommerceAttentionSeenApplication.pull(OWNER);
+ expect(readOrdersSeenAt(OWNER, window.localStorage)).toBe(T0 + 20_000);
+ });
+
+ it('prunes only entries older than the one it wrote', async () => {
+ const remote = homeserver([entry('activity', T0), entry('activity', T0 + 1)]);
+
+ const write = CommerceAttentionSeenApplication.markSeen(OWNER, 'activity', T0 + 5_000);
+ await settleDebounce();
+ await write;
- window.removeEventListener('marketplace-orders-seen', listener);
- expect(listener).toHaveBeenCalledOnce();
+ expect([...remote.files]).toEqual([entry('activity', T0 + 5_000)]);
});
- it('merges into the saved document and never moves a side backward', async () => {
- const remote = homeserverDocument({ version: 1, activitySeenAt: T_ACTIVITY, ordersSeenAt: T_ORDERS });
+ it('turns a burst of seen moments into one write', async () => {
+ const remote = homeserver();
- await CommerceAttentionSeenApplication.markSeen(OWNER, 'activity', T_ACTIVITY - 60_000);
+ const writes = [
+ CommerceAttentionSeenApplication.markSeen(OWNER, 'orders', T0),
+ CommerceAttentionSeenApplication.markSeen(OWNER, 'orders', T0 + 1_000),
+ CommerceAttentionSeenApplication.markSeen(OWNER, 'orders', T0 + 2_000),
+ ];
+ await vi.advanceTimersByTimeAsync(ATTENTION_SEEN_WRITE_DEBOUNCE_MS - 1);
expect(remote.put).not.toHaveBeenCalled();
+ await settleDebounce();
+ await Promise.all(writes);
- await CommerceAttentionSeenApplication.markSeen(OWNER, 'activity', T_ORDERS + 60_000);
- expect(remote.read()).toEqual({ version: 1, activitySeenAt: T_ORDERS + 60_000, ordersSeenAt: T_ORDERS });
+ expect(remote.put).toHaveBeenCalledOnce();
+ expect(remote.latest('orders')).toBe(T0 + 2_000);
+ });
+
+ it('writes nothing when the homeserver already holds a checkpoint at least as new', async () => {
+ const remote = homeserver([entry('activity', T0 + 60_000)]);
+
+ const write = CommerceAttentionSeenApplication.markSeen(OWNER, 'activity', T0);
+ await settleDebounce();
+ await write;
+
+ expect(remote.put).not.toHaveBeenCalled();
+ expect(remote.del).not.toHaveBeenCalled();
});
it('caps a checkpoint saved by a device whose clock runs ahead', async () => {
- const future = Date.now() + 24 * 60 * 60 * 1000;
- homeserverDocument({ version: 1, activitySeenAt: future, ordersSeenAt: 0 });
+ homeserver([entry('activity', NOW + 24 * 60 * 60 * 1000)]);
+
+ await CommerceAttentionSeenApplication.pull(OWNER);
+
+ expect(await LocalCommerceService.getActivityReadCheckpoint(OWNER)).toBe(NOW);
+ });
+
+ it('ignores entries that are not checkpoint names', async () => {
+ homeserver([`${DIR.orders}notes.json`, entry('orders', T0)]);
- const before = Date.now();
await CommerceAttentionSeenApplication.pull(OWNER);
- const local = await LocalCommerceService.getActivityReadCheckpoint(OWNER);
- expect(local).toBeGreaterThanOrEqual(before);
- expect(local).toBeLessThan(future);
+ expect(readOrdersSeenAt(OWNER, window.localStorage)).toBe(T0);
});
- it('never replaces a document it cannot read', async () => {
- const remote = homeserverDocument({ version: 2, somethingElse: true });
+ it('keeps the checkpoint local when the homeserver refuses the private path', async () => {
+ const remote = homeserver();
+ vi.mocked(CommerceHomeserverService.list).mockRejectedValue(forbidden());
- await CommerceAttentionSeenApplication.markSeen(OWNER, 'orders', T_ORDERS);
+ const write = CommerceAttentionSeenApplication.markSeen(OWNER, 'orders', T0);
+ await settleDebounce();
+ await write;
expect(remote.put).not.toHaveBeenCalled();
- expect(readOrdersSeenAt(OWNER, window.localStorage)).toBe(T_ORDERS);
+ expect(readOrdersSeenAt(OWNER, window.localStorage)).toBe(T0);
});
it('keeps the checkpoint in this browser only when the session cannot write /priv', async () => {
vi.mocked(HomeserverService.canCurrentSessionWrite).mockReturnValue(false);
- const remote = homeserverDocument();
+ const remote = homeserver();
- await CommerceAttentionSeenApplication.markSeen(OWNER, 'activity', T_ACTIVITY);
+ await CommerceAttentionSeenApplication.markSeen(OWNER, 'activity', T0);
await CommerceAttentionSeenApplication.pull(OWNER);
- expect(CommerceHomeserverService.fetchJson).not.toHaveBeenCalled();
+ expect(CommerceHomeserverService.list).not.toHaveBeenCalled();
+ expect(remote.put).not.toHaveBeenCalled();
+ expect(await LocalCommerceService.getActivityReadCheckpoint(OWNER)).toBe(T0);
+ });
+
+ it('drops a scheduled write when the account changes before it runs', async () => {
+ const remote = homeserver();
+
+ const write = CommerceAttentionSeenApplication.markSeen(OWNER, 'orders', T0);
+ useAuthStore.setState({ currentUserPubky: OTHER });
+ await settleDebounce();
+ await write;
+
expect(remote.put).not.toHaveBeenCalled();
- expect(await LocalCommerceService.getActivityReadCheckpoint(OWNER)).toBe(T_ACTIVITY);
});
- it('never reads or writes another account’s document', async () => {
+ it('never reads or writes another account’s checkpoints', async () => {
useAuthStore.setState({ currentUserPubky: OTHER });
- const remote = homeserverDocument();
+ const remote = homeserver();
- await CommerceAttentionSeenApplication.markSeen(OWNER, 'activity', T_ACTIVITY);
+ await CommerceAttentionSeenApplication.markSeen(OWNER, 'activity', T0);
await CommerceAttentionSeenApplication.pull(OWNER);
- expect(CommerceHomeserverService.fetchJson).not.toHaveBeenCalled();
+ expect(CommerceHomeserverService.list).not.toHaveBeenCalled();
expect(remote.put).not.toHaveBeenCalled();
});
it('stays local in the sandbox', async () => {
state.mode = 'sandbox';
- const remote = homeserverDocument();
+ const remote = homeserver();
- await CommerceAttentionSeenApplication.markSeen(OWNER, 'orders', T_ORDERS);
+ await CommerceAttentionSeenApplication.markSeen(OWNER, 'orders', T0);
- expect(CommerceHomeserverService.fetchJson).not.toHaveBeenCalled();
+ expect(CommerceHomeserverService.list).not.toHaveBeenCalled();
expect(remote.put).not.toHaveBeenCalled();
});
});
diff --git a/src/core/application/commerce/attention-seen.ts b/src/core/application/commerce/attention-seen.ts
index 3c38b2bfbd..1991508e5f 100644
--- a/src/core/application/commerce/attention-seen.ts
+++ b/src/core/application/commerce/attention-seen.ts
@@ -1,7 +1,6 @@
-import { z } from 'zod';
import { getCommerceAdapterMode, isDurableCommerceMode } from '@/config/commerce';
import { raiseLocalOrdersSeenAt, readLocalOrdersSeenAt } from '@/libs/commerce/marketplace-attention';
-import { hasHttpStatus, isAppError, isNotFound } from '@/libs/error/error.utils';
+import { hasHttpStatus } from '@/libs/error/error.utils';
import { HttpStatusCode } from '@/libs/http/http.types';
import { Logger } from '@/libs/logger/logger';
import { CommerceRecordNormalizer } from '@/pipes/commerce/commerce.normalizer';
@@ -12,65 +11,58 @@ import { useAuthStore } from '@/stores/auth/auth.store';
export type MarketplaceAttentionSide = 'activity' | 'orders';
-const attentionSeenRecordSchema = z.object({
- version: z.literal(1),
- activitySeenAt: z.number().int().nonnegative(),
- ordersSeenAt: z.number().int().nonnegative(),
-});
+/** Quiet period before a burst of "seen" moments becomes one homeserver write. */
+export const ATTENTION_SEEN_WRITE_DEBOUNCE_MS = 2_000;
-type AttentionSeenRecord = z.infer;
+/** Entry names are ms epochs zero-padded to this width, so names sort by value. */
+const ENTRY_NAME_DIGITS = 13;
+const ENTRY_NAME = /^\d{13}$/;
+/** Pruning keeps each directory to a handful of entries; this bounds one read. */
+const ENTRY_LIST_LIMIT = 100;
-type RemoteRead = { kind: 'present'; record: AttentionSeenRecord } | { kind: 'absent' } | { kind: 'unavailable' };
-
-const SIDE_FIELD = {
- activity: 'activitySeenAt',
- orders: 'ordersSeenAt',
-} as const satisfies Record;
+type Entry = { url: string; at: number };
+type Listing = { kind: 'entries'; entries: Entry[] } | { kind: 'unavailable' };
+type PendingWrite = { timer: ReturnType; done: Promise; resolve: () => void };
/**
* The account's badge checkpoints: when this account last opened Activity
* and Orders, on any browser.
*
* The durable marketplace service stores no read state, so the checkpoints
- * live in the owner's private homeserver document
- * `/priv/pubky.app/marketplace/v1/attention_seen.json` (ms epoch per side).
- * Each browser keeps a local copy (Dexie for Activity, local storage for
- * Orders) that the badge hooks read live. Opening a view raises the local
- * copy, then merges into the document (GET, per-side max, PUT). Mounting a
- * badge pulls the document and raises the local copies, so a view cleared
- * in one browser clears in every other.
+ * live on the owner's homeserver under
+ * `/priv/pubky.app/marketplace/v1/attention_seen/{activity|orders}/`. Each
+ * write adds a new entry named by the checkpoint it records and never
+ * rewrites an existing one; the checkpoint is the largest entry name. The
+ * homeserver has no conditional write, and a read-modify-write of one
+ * document lets a slower writer put back an older value. A set of
+ * immutable entries whose maximum is the value cannot move backward under
+ * any interleaving of tabs or browsers. After a write, entries below the one
+ * just written are deleted; an entry is only deleted when a larger one
+ * exists, so the maximum survives concurrent pruning too.
*
- * Checkpoints only move forward. A value from a device clock ahead of this
- * one is capped at this device's now. Without a session that can write
- * `/priv/pubky.app/` (or in the sandbox) the local copy is all there is,
- * and the badge behaves per browser.
+ * Each browser keeps a local copy (Dexie for Activity, local storage for
+ * Orders) that the badge hooks read live. `markSeen` raises the local copy
+ * at once and schedules one debounced write, which is skipped when the
+ * homeserver already holds a checkpoint at least as new. `pull` raises the
+ * local copies to the homeserver's, capped at this device's now so a clock
+ * running ahead cannot hide future activity. Without a session that can
+ * write `/priv/pubky.app/` (or in the sandbox) the local copy is all there
+ * is, and the badge behaves per browser.
*/
export class CommerceAttentionSeenApplication {
private constructor() {}
private static pullsInFlight = new Map>();
+ private static pendingWrites = new Map();
+ /**
+ * Records that this account saw `side` at `now`. Resolves once the
+ * debounced homeserver write for this burst has settled.
+ */
static async markSeen(ownerPubky: string, side: MarketplaceAttentionSide, now = Date.now()): Promise {
await this.raiseLocal(ownerPubky, side, now);
if (!this.canUseRemote(ownerPubky)) return;
- try {
- const remote = await this.readRemote(ownerPubky);
- if (remote.kind === 'unavailable') return;
- const localActivity = await LocalCommerceService.getActivityReadCheckpoint(ownerPubky);
- const localOrders = readLocalOrdersSeenAt(ownerPubky);
- const current: AttentionSeenRecord =
- remote.kind === 'present' ? remote.record : { version: 1, activitySeenAt: 0, ordersSeenAt: 0 };
- const next: AttentionSeenRecord = {
- version: 1,
- activitySeenAt: Math.max(current.activitySeenAt, Math.min(localActivity, now)),
- ordersSeenAt: Math.max(current.ordersSeenAt, Math.min(localOrders, now)),
- };
- next[SIDE_FIELD[side]] = Math.max(next[SIDE_FIELD[side]], now);
- if (next.activitySeenAt === current.activitySeenAt && next.ordersSeenAt === current.ordersSeenAt) return;
- await CommerceHomeserverService.putJson(CommerceRecordNormalizer.attentionSeenUri(ownerPubky), next);
- } catch (error) {
- Logger.warn('Failed to save the marketplace badge checkpoint', { error });
- }
+ await this.scheduleWrite(ownerPubky, side);
}
/** Raises this browser's checkpoints to the account's. One read per owner at a time. */
@@ -85,18 +77,89 @@ export class CommerceAttentionSeenApplication {
return await run;
}
+ /** Test support: drops scheduled writes without running them. */
+ static resetPendingWrites(): void {
+ for (const pending of this.pendingWrites.values()) {
+ clearTimeout(pending.timer);
+ pending.resolve();
+ }
+ this.pendingWrites.clear();
+ }
+
+ private static scheduleWrite(ownerPubky: string, side: MarketplaceAttentionSide): Promise {
+ const key = `${ownerPubky}|${side}`;
+ const existing = this.pendingWrites.get(key);
+ if (existing) clearTimeout(existing.timer);
+ let resolve: () => void = () => {};
+ const done = existing?.done ?? new Promise((settle) => (resolve = settle));
+ const pending: PendingWrite = {
+ done,
+ resolve: existing?.resolve ?? resolve,
+ timer: setTimeout(() => {
+ this.pendingWrites.delete(key);
+ void this.writeCheckpoint(ownerPubky, side).finally(pending.resolve);
+ }, ATTENTION_SEEN_WRITE_DEBOUNCE_MS),
+ };
+ this.pendingWrites.set(key, pending);
+ return done;
+ }
+
+ private static async writeCheckpoint(ownerPubky: string, side: MarketplaceAttentionSide): Promise {
+ // The account may have changed or lost its grant during the quiet period.
+ if (!this.canUseRemote(ownerPubky)) return;
+ try {
+ const local =
+ side === 'activity'
+ ? await LocalCommerceService.getActivityReadCheckpoint(ownerPubky)
+ : readLocalOrdersSeenAt(ownerPubky);
+ const value = Math.min(local, Date.now());
+ if (!(value > 0)) return;
+ const listing = await this.listEntries(ownerPubky, side);
+ if (listing.kind === 'unavailable') return;
+ if (listing.entries.some(({ at }) => at >= value)) return;
+ const directory = CommerceRecordNormalizer.attentionSeenDirectoryUri(ownerPubky, side);
+ await CommerceHomeserverService.putJson(`${directory}${entryName(value)}`, { version: 1, seenAt: value });
+ await Promise.allSettled(listing.entries.map(({ url }) => CommerceHomeserverService.delete(url)));
+ } catch (error) {
+ Logger.warn('Failed to save the marketplace badge checkpoint', { error });
+ }
+ }
+
private static async runPull(ownerPubky: string): Promise {
try {
- const remote = await this.readRemote(ownerPubky);
- if (remote.kind !== 'present') return;
+ const [activity, orders] = await Promise.all([
+ this.listEntries(ownerPubky, 'activity'),
+ this.listEntries(ownerPubky, 'orders'),
+ ]);
const now = Date.now();
- await this.raiseLocal(ownerPubky, 'activity', Math.min(remote.record.activitySeenAt, now));
- await this.raiseLocal(ownerPubky, 'orders', Math.min(remote.record.ordersSeenAt, now));
+ await this.raiseLocal(ownerPubky, 'activity', Math.min(latest(activity), now));
+ await this.raiseLocal(ownerPubky, 'orders', Math.min(latest(orders), now));
} catch (error) {
Logger.warn('Failed to load the marketplace badge checkpoint', { error });
}
}
+ private static async listEntries(ownerPubky: string, side: MarketplaceAttentionSide): Promise {
+ let urls: string[];
+ try {
+ urls = await CommerceHomeserverService.list(
+ CommerceRecordNormalizer.attentionSeenDirectoryUri(ownerPubky, side),
+ ENTRY_LIST_LIMIT,
+ );
+ } catch (error) {
+ if (hasHttpStatus(error, HttpStatusCode.FORBIDDEN) || hasHttpStatus(error, HttpStatusCode.UNAUTHORIZED)) {
+ return { kind: 'unavailable' };
+ }
+ throw error;
+ }
+ const entries: Entry[] = [];
+ for (const url of urls) {
+ const name = url.slice(url.lastIndexOf('/') + 1);
+ if (ENTRY_NAME.test(name)) entries.push({ url, at: Number(name) });
+ }
+ return { kind: 'entries', entries };
+ }
+
private static async raiseLocal(ownerPubky: string, side: MarketplaceAttentionSide, at: number): Promise {
if (!(at > 0)) return;
if (side === 'activity') {
@@ -111,27 +174,13 @@ export class CommerceAttentionSeenApplication {
if (useAuthStore.getState().currentUserPubky !== ownerPubky) return false;
return HomeserverService.hasActiveSession() && HomeserverService.canCurrentSessionWrite(PRIVATE_APP_DATA_PATH);
}
+}
- /**
- * A document that fails its schema is left alone: never replaced
- * wholesale, never trusted.
- */
- private static async readRemote(ownerPubky: string): Promise {
- let payload: unknown;
- try {
- payload = await CommerceHomeserverService.fetchJson(CommerceRecordNormalizer.attentionSeenUri(ownerPubky));
- } catch (error) {
- if (isAppError(error) && isNotFound(error)) return { kind: 'absent' };
- if (hasHttpStatus(error, HttpStatusCode.FORBIDDEN) || hasHttpStatus(error, HttpStatusCode.UNAUTHORIZED)) {
- return { kind: 'unavailable' };
- }
- throw error;
- }
- const parsed = attentionSeenRecordSchema.safeParse(payload);
- if (!parsed.success) {
- Logger.warn('Ignoring an unreadable marketplace badge checkpoint');
- return { kind: 'unavailable' };
- }
- return { kind: 'present', record: parsed.data };
- }
+function entryName(at: number): string {
+ return String(Math.trunc(at)).padStart(ENTRY_NAME_DIGITS, '0');
+}
+
+function latest(listing: Listing): number {
+ if (listing.kind === 'unavailable') return 0;
+ return listing.entries.reduce((max, { at }) => Math.max(max, at), 0);
}
diff --git a/src/core/pipes/commerce/commerce.normalizer.ts b/src/core/pipes/commerce/commerce.normalizer.ts
index 88c016312b..77f8b94eea 100644
--- a/src/core/pipes/commerce/commerce.normalizer.ts
+++ b/src/core/pipes/commerce/commerce.normalizer.ts
@@ -542,9 +542,12 @@ export class CommerceRecordNormalizer {
return `pubky://${this.pubky(ownerPubky)}/priv/pubky.app/marketplace/v1/auction_reserves/${this.entityId(listingId)}.json`;
}
- /** The owner's PRIVATE badge checkpoint document (last time Activity and Orders were opened). */
- static attentionSeenUri(ownerPubky: unknown): string {
- return `pubky://${this.pubky(ownerPubky)}/priv/pubky.app/marketplace/v1/attention_seen.json`;
+ /**
+ * The owner's PRIVATE badge checkpoint directory for one side (`activity`
+ * or `orders`). Each entry is named by the checkpoint it records.
+ */
+ static attentionSeenDirectoryUri(ownerPubky: unknown, side: 'activity' | 'orders'): string {
+ return `pubky://${this.pubky(ownerPubky)}/priv/pubky.app/marketplace/v1/attention_seen/${side}/`;
}
static mediaUri(ownerPubky: unknown, mediaId: unknown): string {
diff --git a/src/core/services/homeserver/commerce/commerce.ts b/src/core/services/homeserver/commerce/commerce.ts
index 1922e8290d..2185cb16bc 100644
--- a/src/core/services/homeserver/commerce/commerce.ts
+++ b/src/core/services/homeserver/commerce/commerce.ts
@@ -20,6 +20,10 @@ export class CommerceHomeserverService {
await HomeserverService.request({ method: HttpMethod.DELETE, url });
}
+ static async list(directoryUrl: string, limit: number): Promise {
+ return await HomeserverService.list({ baseDirectory: directoryUrl, limit });
+ }
+
static async exists(url: string): Promise {
return await HomeserverService.exists(url);
}
From 7e24a33b319c4c7aa65ddc2dd349a726ea060c05 Mon Sep 17 00:00:00 2001
From: Bitcoin Error Log <18273620+BitcoinErrorLog@users.noreply.github.com>
Date: Thu, 24 Sep 2026 17:47:53 +0100
Subject: [PATCH 5/5] fix(shop): save the Orders checkpoint on opening, not on
every poll
The Orders page saves when the list becomes ready, when the account changes, and when the tab becomes visible again. A refreshed order list no longer triggers a homeserver write.
---
.../Marketplace/MarketplaceOrders.test.tsx | 23 ++++++++++++
.../Marketplace/MarketplaceOrders.tsx | 2 +-
.../useMarkMarketplaceOrdersSeen.test.ts | 21 +++++++++--
.../useMarkMarketplaceOrdersSeen.ts | 35 +++++++++++++------
4 files changed, 66 insertions(+), 15 deletions(-)
diff --git a/src/components/templates/Marketplace/MarketplaceOrders.test.tsx b/src/components/templates/Marketplace/MarketplaceOrders.test.tsx
index 3ed7e19a49..0ac9fb7686 100644
--- a/src/components/templates/Marketplace/MarketplaceOrders.test.tsx
+++ b/src/components/templates/Marketplace/MarketplaceOrders.test.tsx
@@ -1,6 +1,7 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { CommerceController } from '@/controllers/commerce/commerce';
import { MESSAGING_COPY } from '@/libs/commerce/messaging-copy';
import { useMarketplaceDisplayStore } from '@/stores/marketplace-display/marketplace-display.store';
import {
@@ -789,3 +790,25 @@ describe('MarketplaceOrders local pickup cards (Wave 7, §A3/§A6)', () => {
expect(within(card).queryByRole('button', { name: 'Add tracking' })).not.toBeInTheDocument();
});
});
+
+describe('MarketplaceOrders seen checkpoint', () => {
+ beforeEach(() => {
+ ordersState.currentUserPubky = CURRENT_USER;
+ ordersState.adapterMode = 'transaction-service';
+ ordersState.orders = [orderView('paid', 'Sold paid boots', 'seller')];
+ });
+
+ it('saves the Orders checkpoint once per opening, not on every polled order list', async () => {
+ const markSeen = vi.spyOn(CommerceController, 'markOrdersAttentionSeen').mockResolvedValue();
+ const { rerender } = render();
+ await waitFor(() => expect(markSeen).toHaveBeenCalledOnce());
+
+ for (let poll = 0; poll < 3; poll += 1) {
+ ordersState.orders = [orderView('paid', 'Sold paid boots', 'seller')];
+ rerender();
+ }
+ await Promise.resolve();
+
+ expect(markSeen).toHaveBeenCalledOnce();
+ });
+});
diff --git a/src/components/templates/Marketplace/MarketplaceOrders.tsx b/src/components/templates/Marketplace/MarketplaceOrders.tsx
index f5c54e96c6..b61e797eda 100644
--- a/src/components/templates/Marketplace/MarketplaceOrders.tsx
+++ b/src/components/templates/Marketplace/MarketplaceOrders.tsx
@@ -86,7 +86,7 @@ export function MarketplaceOrders() {
const orderCounts = getOrderTabCounts(historyOrders, currentUserPubky);
const visibleOrders = historyOrders.filter((view) => isOrderInTab(view, activeTab, currentUserPubky));
- useMarkMarketplaceOrdersSeen(!isLoading && !error && !needsSession, orders);
+ useMarkMarketplaceOrdersSeen(!isLoading && !error && !needsSession);
useEffect(() => {
if (isLoading) return;
diff --git a/src/hooks/useMarkMarketplaceOrdersSeen/useMarkMarketplaceOrdersSeen.test.ts b/src/hooks/useMarkMarketplaceOrdersSeen/useMarkMarketplaceOrdersSeen.test.ts
index 3c752f24aa..b1631db94f 100644
--- a/src/hooks/useMarkMarketplaceOrdersSeen/useMarkMarketplaceOrdersSeen.test.ts
+++ b/src/hooks/useMarkMarketplaceOrdersSeen/useMarkMarketplaceOrdersSeen.test.ts
@@ -22,7 +22,7 @@ describe('useMarkMarketplaceOrdersSeen', () => {
});
it('saves the account checkpoint once orders are on screen', async () => {
- const { rerender } = renderHook(({ showing }) => useMarkMarketplaceOrdersSeen(showing, []), {
+ const { rerender } = renderHook(({ showing }) => useMarkMarketplaceOrdersSeen(showing), {
initialProps: { showing: false },
});
expect(CommerceController.markOrdersAttentionSeen).not.toHaveBeenCalled();
@@ -34,7 +34,7 @@ describe('useMarkMarketplaceOrdersSeen', () => {
it('does nothing when signed out', async () => {
state.currentUserPubky = null;
- renderHook(() => useMarkMarketplaceOrdersSeen(true, []));
+ renderHook(() => useMarkMarketplaceOrdersSeen(true));
await Promise.resolve();
expect(CommerceController.markOrdersAttentionSeen).not.toHaveBeenCalled();
});
@@ -44,7 +44,22 @@ describe('useMarkMarketplaceOrdersSeen', () => {
throw new TypeError('CommerceController.markOrdersAttentionSeen is not a function');
});
- expect(() => renderHook(() => useMarkMarketplaceOrdersSeen(true, []))).not.toThrow();
+ expect(() => renderHook(() => useMarkMarketplaceOrdersSeen(true))).not.toThrow();
await waitFor(() => expect(CommerceController.markOrdersAttentionSeen).toHaveBeenCalled());
});
+
+ it('saves again when the tab comes back into view, and not while it is hidden', async () => {
+ renderHook(() => useMarkMarketplaceOrdersSeen(true));
+ await waitFor(() => expect(CommerceController.markOrdersAttentionSeen).toHaveBeenCalledOnce());
+
+ const visibility = vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden');
+ document.dispatchEvent(new Event('visibilitychange'));
+ await Promise.resolve();
+ expect(CommerceController.markOrdersAttentionSeen).toHaveBeenCalledOnce();
+
+ visibility.mockReturnValue('visible');
+ document.dispatchEvent(new Event('visibilitychange'));
+ await waitFor(() => expect(CommerceController.markOrdersAttentionSeen).toHaveBeenCalledTimes(2));
+ visibility.mockRestore();
+ });
});
diff --git a/src/hooks/useMarkMarketplaceOrdersSeen/useMarkMarketplaceOrdersSeen.ts b/src/hooks/useMarkMarketplaceOrdersSeen/useMarkMarketplaceOrdersSeen.ts
index 67e01d40f6..5d8b788543 100644
--- a/src/hooks/useMarkMarketplaceOrdersSeen/useMarkMarketplaceOrdersSeen.ts
+++ b/src/hooks/useMarkMarketplaceOrdersSeen/useMarkMarketplaceOrdersSeen.ts
@@ -5,20 +5,33 @@ import { CommerceController } from '@/controllers/commerce/commerce';
import { Logger } from '@/libs/logger/logger';
import { useAuthStore } from '@/stores/auth/auth.store';
+function markOrdersSeen() {
+ // A stubbed controller throws before a promise exists; that is a failed
+ // write, not a render error.
+ Promise.resolve()
+ .then(() => CommerceController.markOrdersAttentionSeen())
+ .catch((error) => {
+ Logger.warn('Failed to advance the orders badge checkpoint', { error });
+ });
+}
+
/**
- * Moves the signed-in account's Orders checkpoint to now whenever `shown`
- * orders are on screen, so the Orders badge clears on every browser.
+ * Moves the signed-in account's Orders checkpoint to now when the Orders
+ * list becomes ready, when the account changes, and when the tab comes back
+ * into view while the list is showing. A refreshed or polled order list is
+ * not a new "seen" moment and never writes.
*/
-export function useMarkMarketplaceOrdersSeen(isShowingOrders: boolean, shown: unknown): void {
+export function useMarkMarketplaceOrdersSeen(isShowingOrders: boolean): void {
const currentUserPubky = useAuthStore((state) => state.currentUserPubky);
useEffect(() => {
if (!currentUserPubky || !isShowingOrders) return;
- // A stubbed controller throws before a promise exists; that is a failed
- // write, not a render error.
- Promise.resolve()
- .then(() => CommerceController.markOrdersAttentionSeen())
- .catch((error) => {
- Logger.warn('Failed to advance the orders badge checkpoint', { error });
- });
- }, [currentUserPubky, isShowingOrders, shown]);
+ markOrdersSeen();
+ const onVisibilityChange = () => {
+ if (document.visibilityState === 'visible') markOrdersSeen();
+ };
+ document.addEventListener('visibilitychange', onVisibilityChange);
+ return () => {
+ document.removeEventListener('visibilitychange', onVisibilityChange);
+ };
+ }, [currentUserPubky, isShowingOrders]);
}