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.
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.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 7685719b9f..b61e797eda 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);
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..3ccb6d4ef5
--- /dev/null
+++ b/src/core/application/commerce/attention-seen.test.ts
@@ -0,0 +1,266 @@
+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 { ATTENTION_SEEN_WRITE_DEBOUNCE_MS, 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 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 forbidden = () =>
+ new AppError({
+ category: ErrorCategory.Client,
+ code: ClientErrorCode.BAD_REQUEST,
+ message: 'HTTP 403',
+ service: ErrorService.Homeserver,
+ operation: 'test',
+ context: { statusCode: 403 },
+ });
+
+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) => {
+ files.add(url);
+ });
+ 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. */
+async function switchToFreshBrowser() {
+ await CommerceActivityCheckpointModel.table.clear();
+ 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);
+ vi.spyOn(HomeserverService, 'canCurrentSessionWrite').mockReturnValue(true);
+ await switchToFreshBrowser();
+ });
+
+ 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 = homeserver();
+
+ 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();
+ await CommerceAttentionSeenApplication.pull(OWNER);
+
+ expect(await LocalCommerceService.getActivityReadCheckpoint(OWNER)).toBe(T0);
+ expect(readOrdersSeenAt(OWNER, window.localStorage)).toBe(T0 + 1_000);
+ });
+
+ 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;
+
+ expect([...remote.files]).toEqual([entry('activity', T0 + 5_000)]);
+ });
+
+ it('turns a burst of seen moments into one write', async () => {
+ const remote = homeserver();
+
+ 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);
+
+ 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 () => {
+ 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)]);
+
+ await CommerceAttentionSeenApplication.pull(OWNER);
+
+ expect(readOrdersSeenAt(OWNER, window.localStorage)).toBe(T0);
+ });
+
+ it('keeps the checkpoint local when the homeserver refuses the private path', async () => {
+ const remote = homeserver();
+ vi.mocked(CommerceHomeserverService.list).mockRejectedValue(forbidden());
+
+ const write = CommerceAttentionSeenApplication.markSeen(OWNER, 'orders', T0);
+ await settleDebounce();
+ await write;
+
+ expect(remote.put).not.toHaveBeenCalled();
+ 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 = homeserver();
+
+ await CommerceAttentionSeenApplication.markSeen(OWNER, 'activity', T0);
+ await CommerceAttentionSeenApplication.pull(OWNER);
+
+ 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();
+ });
+
+ it('never reads or writes another account’s checkpoints', async () => {
+ useAuthStore.setState({ currentUserPubky: OTHER });
+ const remote = homeserver();
+
+ await CommerceAttentionSeenApplication.markSeen(OWNER, 'activity', T0);
+ await CommerceAttentionSeenApplication.pull(OWNER);
+
+ expect(CommerceHomeserverService.list).not.toHaveBeenCalled();
+ expect(remote.put).not.toHaveBeenCalled();
+ });
+
+ it('stays local in the sandbox', async () => {
+ state.mode = 'sandbox';
+ const remote = homeserver();
+
+ await CommerceAttentionSeenApplication.markSeen(OWNER, 'orders', T0);
+
+ 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
new file mode 100644
index 0000000000..1991508e5f
--- /dev/null
+++ b/src/core/application/commerce/attention-seen.ts
@@ -0,0 +1,186 @@
+import { getCommerceAdapterMode, isDurableCommerceMode } from '@/config/commerce';
+import { raiseLocalOrdersSeenAt, readLocalOrdersSeenAt } from '@/libs/commerce/marketplace-attention';
+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';
+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';
+
+/** Quiet period before a burst of "seen" moments becomes one homeserver write. */
+export const ATTENTION_SEEN_WRITE_DEBOUNCE_MS = 2_000;
+
+/** 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 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 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.
+ *
+ * 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;
+ await this.scheduleWrite(ownerPubky, side);
+ }
+
+ /** 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;
+ }
+
+ /** 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 [activity, orders] = await Promise.all([
+ this.listEntries(ownerPubky, 'activity'),
+ this.listEntries(ownerPubky, 'orders'),
+ ]);
+ const now = Date.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') {
+ 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);
+ }
+}
+
+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/application/commerce/commerce.ts b/src/core/application/commerce/commerce.ts
index 123e481778..ae62aa9578 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 {
@@ -1689,7 +1690,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..77f8b94eea 100644
--- a/src/core/pipes/commerce/commerce.normalizer.ts
+++ b/src/core/pipes/commerce/commerce.normalizer.ts
@@ -542,6 +542,14 @@ 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 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 {
const owner = this.pubky(ownerPubky);
const id = this.entityId(mediaId);
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);
}
diff --git a/src/hooks/useMarkMarketplaceOrdersSeen/useMarkMarketplaceOrdersSeen.test.ts b/src/hooks/useMarkMarketplaceOrdersSeen/useMarkMarketplaceOrdersSeen.test.ts
new file mode 100644
index 0000000000..b1631db94f
--- /dev/null
+++ b/src/hooks/useMarkMarketplaceOrdersSeen/useMarkMarketplaceOrdersSeen.test.ts
@@ -0,0 +1,65 @@
+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());
+ });
+
+ 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
new file mode 100644
index 0000000000..5d8b788543
--- /dev/null
+++ b/src/hooks/useMarkMarketplaceOrdersSeen/useMarkMarketplaceOrdersSeen.ts
@@ -0,0 +1,37 @@
+'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';
+
+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 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): void {
+ const currentUserPubky = useAuthStore((state) => state.currentUserPubky);
+ useEffect(() => {
+ if (!currentUserPubky || !isShowingOrders) return;
+ markOrdersSeen();
+ const onVisibilityChange = () => {
+ if (document.visibilityState === 'visible') markOrdersSeen();
+ };
+ document.addEventListener('visibilitychange', onVisibilityChange);
+ return () => {
+ document.removeEventListener('visibilitychange', onVisibilityChange);
+ };
+ }, [currentUserPubky, isShowingOrders]);
+}
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');
});