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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .changeset/6757-global-search-notifications-renderers.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
---
'@object-ui/app-shell': minor
---

Renderers for the `global:search` and `global:notifications` page blocks
(objectui#6757). A page that declared either member drew the literal "Component
Placeholder" scaffold: both are first-class `PageComponentType` members that the
2026-08-26 maintainer ruling on objectstack#12183 kept declared once the
readiness read in objectstack#13117 evidenced both data sources shipped, and
the renderer was the remaining half.

Neither block adds a data layer — each is a new mount point on plumbing that was
already live:

- `global:search` mounts `useRecordSearch` (`@object-ui/react`), the same hook
the ⌘K command palette and the full-page search results already use. It
prefers the adapter's `searchAll` (`GET /api/v1/search` — cross-object hits
with title/snippet/record) and inherits that hook's fanout fallback for
adapters without it. Scope is the metadata provider's searchable object set,
which is the hook's documented default when no `objectNames` whitelist is
given.
- `global:notifications` mounts `InboxPopover` — the bell ADR-0012/ADR-0030
defines ("the bell reads `sys_inbox_message`") — over the shared inbox feed.

To keep that second one honest, the header bell's inbox wiring (rows, badge
addends, and the three mark-read paths) moves out of `AppHeader` into a new
`useInboxBell` hook that both surfaces mount. Copying it would have re-opened
the two defects `sharedUserFeeds` closed — #4225 (two owners of one read issuing
it twice per page) and #4316 (two derivations of read-state disagreeing) — so a
bell in the header and a bell an author declared on a page now have no
representable state in which they disagree about a row.

Both registrations publish **no** `inputs`: `ComponentPropsMap` declares an
empty shape for each ("declares no props at all" is the recorded intent), and
both use `skipFallback: true` so neither claims the bare `search` /
`notifications` keys. This does not change the Studio page palette —
`global:notifications` remains recorded there as a shell singleton, which is a
palette decision independent of whether a declared type renders.
4 changes: 4 additions & 0 deletions packages/app-shell/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,8 @@
"./dist/console/home/CloudOnboardingNext.js",
"./dist/console/marketplace/InstalledListWidget.js",
"./dist/services/builtinComponents.js",
"./dist/views/global-notifications-renderer.js",
"./dist/views/global-search-renderer.js",
"./dist/views/metadata-admin/index.js",
"./dist/views/record-approvals-renderer.js",
"./dist/views/record-attachments-renderer.js",
Expand All@@ -21,6 +23,8 @@
"./src/console/home/CloudOnboardingNext.tsx",
"./src/console/marketplace/InstalledListWidget.tsx",
"./src/services/builtinComponents.tsx",
"./src/views/global-notifications-renderer.tsx",
"./src/views/global-search-renderer.tsx",
"./src/views/metadata-admin/index.ts",
"./src/views/record-approvals-renderer.tsx",
"./src/views/record-attachments-renderer.tsx",
Expand Down
164 changes: 164 additions & 0 deletions packages/app-shell/src/hooks/useInboxBell.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* useInboxBell — everything an {@link InboxPopover} needs, from the shared feeds.
*
* The rows, the badge number and the three mark-read paths used to live inline
* in `AppHeader`, which was fine while the header held the only bell. It no
* longer does: `global:notifications` is a spec `PageComponentType` member an
* author may declare on a page (objectui#6757), and its renderer has to reach
* the SAME inbox — the one ADR-0012/ADR-0030 defines and `sharedUserFeeds`
* already serves to the header and to Home's action centre.
*
* Copying the wiring into the renderer was the alternative, and this repo has
* measured what that costs twice already: #4225 (two owners of one read issued
* it twice per page) and #4316 (two derivations of read-state disagreed, so the
* bell showed zero unread while Home listed five of the same rows as waiting).
* One hook, two consumers — the two surfaces have no representable state in
* which they disagree, because there is no second read and no second overlay.
*
* NOTHING is fetched here: `useSharedInboxFeed` / `useSharedPendingApprovalsCount`
* own the polling, its cadence, its hidden-tab throttle and its failure backoff.
* This hook adds only the optimistic read overlay and the mark-read writes.
*
* @module
*/
import { useCallback, useMemo, useState } from 'react';
import { bearerAuthHeaders } from '../utils/authToken.js';
import { useSharedInboxFeed, useSharedPendingApprovalsCount } from './sharedUserFeeds.js';
import type { InboxNotification } from '../layout/inboxGrouping.js';

/**
* Same stable-reference rule the header used: a fresh empty Set per render
* would re-run every memo that depends on the overlay.
*/
const EMPTY_READ_IDS: ReadonlySet<string> = new Set<string>();

export interface InboxBell {
/** The shared inbox rows, with this surface's optimistic read flips applied. */
notifications: InboxNotification[];
/** Raw unread ROW count (the popover folds it into topics itself). */
unreadCount: number;
/** The badge's second addend — pending approvals waiting on this user. */
pendingApprovalsCount: number;
markAllRead: () => Promise<void>;
markRead: (id: string) => Promise<void>;
markManyRead: (ids: string[]) => Promise<void>;
}

export function useInboxBell(): InboxBell {
/**
* In-header notifications (ADR-0030), from the shared user feed (#4225).
*
* The rows are `sys_inbox_message` (the L5 in-app materialization, `mine`
* scope) joined with `sys_notification_receipt` for read-state — the bell
* does not read the re-modeled `sys_notification` L2 event (which carries no
* recipient/read columns).
*/
const { value: inboxMessages } = useSharedInboxFeed();

/**
* Optimistic read-state, layered over the shared rows.
*
* Mark-read used to mutate the header's own `notifications` state; the rows
* are shared, so a consumer may not write to them — one surface's optimistic
* flip must not become another's fact before the server agrees. Holding the
* flipped ids locally keeps the click instant while the next poll (which
* reads the persisted receipt) supersedes it.
*/
const [locallyRead, setLocallyRead] = useState<ReadonlySet<string>>(EMPTY_READ_IDS);
const notifications = useMemo(
() =>
locallyRead.size === 0
? inboxMessages
: inboxMessages.map((n) => (locallyRead.has(n.id) ? { ...n, is_read: true } : n)),
[inboxMessages, locallyRead],
);

/**
* M11.C15: pending approvals count — the topbar shortcut, and the second
* addend of the bell badge (`unreadTopics + pendingApprovalsCount`).
*
* Shared with Home's To-do card (#4197): one polled request serves both, so
* the badge and the card can no longer disagree.
*/
const pendingApprovalsCount = useSharedPendingApprovalsCount();

const unreadCount = notifications.reduce((n, x) => n + (x.is_read ? 0 : 1), 0);

// Read-state lives in `sys_notification_receipt`, keyed
// (notification_id, user_id, channel) — ADR-0030. That object is
// engine-owned (ADR-0103: `enable.apiMethods` = get/list), so the generic
// data API REJECTS receipt writes — a direct create/update here silently
// failed and the next poll flipped rows back to unread. Mark-read goes
// through the framework's dedicated REST surface instead
// (`POST /api/v1/notifications/read[/all]`), which upserts the receipt
// server-side keyed by the notification EVENT id. Rows without a
// `notification_id` (legacy/synthetic) can't be keyed, so they update
// optimistically but don't persist.
const postMarkRead = useCallback(async (subPath: 'read' | 'read/all', ids?: string[]) => {
const serverUrl = (import.meta.env?.VITE_SERVER_URL || '').replace(/\/$/, '');
await fetch(`${serverUrl}/api/v1/notifications/${subPath}`, {
method: 'POST',
credentials: 'include',
// Bearer too — see utils/authToken (#2548 split-origin fix).
headers: { 'Content-Type': 'application/json', ...bearerAuthHeaders() },
body: JSON.stringify(ids ? { ids } : {}),
});
}, []);

/** Flip rows read in the local overlay — never in the shared feed's rows. */
const markLocallyRead = useCallback((ids: readonly string[]) => {
if (ids.length === 0) return;
setLocallyRead((prev) => {
const next = new Set(prev);
for (const id of ids) next.add(id);
return next;
});
}, []);

const markRead = useCallback(async (id: string) => {
const target = notifications.find(n => n.id === id);
markLocallyRead([id]);
if (!target?.notification_id) return;
try { await postMarkRead('read', [target.notification_id]); } catch { /* best-effort */ }
}, [notifications, markLocallyRead, postMarkRead]);

const markAllRead = useCallback(async () => {
const unread = notifications.filter(n => !n.is_read);
if (!unread.length) return;
markLocallyRead(notifications.map(n => n.id));
try { await postMarkRead('read/all'); } catch { /* best-effort */ }
}, [notifications, markLocallyRead, postMarkRead]);

// Per-group "mark all of this type read" (#2765): the inbox coalesces
// repeats of the same (topic, title) into one expandable row, and this marks
// every member read in a SINGLE request instead of one POST per row (a
// scheduled-digest group can hold 20). Rows without a `notification_id`
// (legacy/synthetic) still flip optimistically but can't be keyed server-side.
const markManyRead = useCallback(async (ids: string[]) => {
const idSet = new Set(ids);
const notifIds = notifications
.filter(n => idSet.has(n.id) && !n.is_read)
.map(n => n.notification_id)
.filter((v): v is string => !!v);
markLocallyRead(ids);
if (!notifIds.length) return;
try { await postMarkRead('read', notifIds); } catch { /* best-effort */ }
}, [notifications, markLocallyRead, postMarkRead]);

return {
notifications,
unreadCount,
pendingApprovalsCount,
markAllRead,
markRead,
markManyRead,
};
}
9 changes: 9 additions & 0 deletions packages/app-shell/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -300,6 +300,15 @@ import './views/record-attachments-renderer.js';
// `record:approvals` — schema-addressable approval panel referenced by
// synthesized record pages when the record has approval requests (#3461).
import './views/record-approvals-renderer.js';
// `global:search` / `global:notifications` — the two spec `PageComponentType`
// members the 2026-08-26 ruling on objectstack#12183 kept declared because both
// data sources shipped (objectui#6757). Registered here, not in
// `@object-ui/components`, because they read this package's providers and feeds;
// without these two imports an authored page draws the "Component Placeholder"
// scaffold for `global:search` and a red unknown-type panel for
// `global:notifications`.
import './views/global-search-renderer.js';
import './views/global-notifications-renderer.js';

// Phase 3c — generic metadata admin engine. Re-exported so plugins
// can call `registerMetadataResource()` to override the per-type
Expand Down
Loading
Loading