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
19 changes: 13 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ notifications switch.
those, and Notify shows each as a published name/avatar (falling back to
the full address) resolved through `RESOLVE_IDENTITIES`, with the address
always available as accessible text and a copy action.
- A public English **Developers** workspace (`?view=developers`) with the manager contract, privacy boundaries, revision-safe examples, section links and accessible copy controls. It is available before permission and in standalone browsers.
- Deep-linkable selection (`?app=<appKey>`), kept in sync with the browser
History API so Home's address bar and Back/Forward track the open app
detail view.
Expand All @@ -48,11 +49,11 @@ list/detail layout that stacks on narrow hosts.
## Runtime and QAVS

Qortium Home supplies the `qdnRequest` bridge and the manager/Home-settings
actions above. Notify has no useful standalone-browser mode: opening it
outside Home shows an explanatory card, since every real feature requires
Home's device-local manager state.
actions above. The Developers reference works in standalone browsers; the
App notifications workspace explains that managing device-local settings
requires Home. Notify remains Qortium QDN only, with no Qortal app integration.

Notify is at QAVS `1.5.3`: `1.5` is the minimum Qortium platform level this
Notify is at QAVS `1.5.6`: `1.5` is the minimum Qortium platform level this
first release is built against, and the patch number is the app's own free
running release counter from here on. `vite.config.ts` reads `package.json`,
injects the visible version, and emits `dist/qortium-app.json` with the name
Expand Down Expand Up @@ -103,7 +104,13 @@ to report `READY`.

## Contract reference

See [`docs/NOTIFICATION_MANAGER.md`](docs/NOTIFICATION_MANAGER.md) for the
exact bridge actions Notify uses and how each maps to a UI affordance. The
Open **Developers** in Notify (`qdn://APP/Notify/Notify?view=developers`) for
the in-app reference. `?view=developer` and `?view=reference` normalize to the
same workspace. Switching tabs preserves `?app=...`; section links also
preserve Home parameters and fragments, and support Back/Forward. The body
remains English/LTR while the tab label follows Home language.

See [`docs/NOTIFICATION_MANAGER.md`](docs/NOTIFICATION_MANAGER.md) for source
pointers and how each action maps to a UI affordance. The
authoritative source is Qortium Home's own `docs/HOME_DATA_MANAGERS.md` and
`docs/APP_NOTIFICATIONS.md`.
33 changes: 28 additions & 5 deletions docs/NOTIFICATION_MANAGER.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ capability. This document maps each `qdnRequest` action to where it's used in

`SHOW_ACTIONS` must include every action in
`NOTIFICATION_MANAGER_ACTIONS` (`src/notificationManager.ts`) before Notify
shows anything but the "needs a newer Home" card:
enables the manager workspace. Developers remains accessible on older hosts
and outside Home:

- `NOTIFICATION_MANAGER_HAS_PERMISSION`
- `NOTIFICATION_MANAGER_GET`
Expand Down Expand Up @@ -56,7 +57,7 @@ user must retry the action once the fresh data is visible.
the desktop `qortiumNotificationManagerChanged` window event. Android sends
the equivalent `{ type: 'qortium:notification-manager-changed', detail }`
message; Notify source-checks it and forwards only its revision into the same
handler. Both forms carry a version number, not data. Notify rejects
handler. Both forms carry a revision number, not data. Notify rejects
out-of-order responses and treats a newer revision as "go refetch", not as
something to diff or merge itself.

Expand All @@ -77,9 +78,10 @@ implying the array is complete.

## Address filter identity resolution

The four address filter keys above are the only filter values Notify ever
receives unmasked, and only once Home has validated them as Qortal
addresses. `src/identity.ts` gathers every such address across the current
The four address filter keys above may remain visible once Home validates
them as Q-addresses (the shared address format). Non-sensitive filters such
as resource service/name and coin can also remain visible; this does not
imply any Qortal app integration. `src/identity.ts` gathers every such address across the current
summary (`extractAddressesFromSummary`), deduplicates, and resolves them
through `RESOLVE_IDENTITIES` in batches of at most 500
(`resolveIdentities`/`chunkAddresses`) — Home's existing action, shared with
Expand Down Expand Up @@ -107,3 +109,24 @@ through `GET_HOME_SETTINGS` / `UPDATE_HOME_SETTINGS`. The read supplies the
initial theme, accent, language, text size, and UI style; desktop
`qortiumHomeSettingsChanged`, Android `qortium:home-settings-changed`, and
legacy display messages keep those host-owned values current.


## Developers workspace (1.5.6)

The primary user-facing reference is `src/Reference.tsx`, linked from the
always-available Developers tab. It imports the adapter action/event/version
constants and the identity allowlist/batch size to reduce documentation drift.
Examples describe sanitized manager summaries, not producer registrations.
Manager support does not establish availability of producer/delivery backends.

`src/routes.ts` handles the independent workspace and app-detail queries;
`src/ReferenceNavigation.tsx` uses a `section` query under Core's injected base,
preserving app selection, repeated/unknown parameters, fragments and host
history state. Switching workspaces does not remount App's manager state.
The English/LTR reference follows Home appearance, and copy buttons announce
success or manual-copy fallback without executing examples.

Home 2 validates the revision before mutations and fails closed for corrupt
or unavailable notification stores. Home settings approval is independent of
`notifications.manage`. Sanitization hides account bindings and sensitive
filters; optional free-text title/text/link fields are still visible.
4 changes: 2 additions & 2 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "qortium-notify",
"version": "1.5.5",
"version": "1.5.6",
"private": true,
"license": "0BSD",
"description": "A QDN notification settings and subscription manager for Qortium Home.",
Expand Down
33 changes: 28 additions & 5 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
Check,
ChevronLeft,
Copy,
Code2,
Loader2,
RefreshCw,
ShieldCheck,
Expand Down Expand Up @@ -34,6 +35,7 @@ import {
getChangedRevisionFromMessage,
isCurrentNotificationManagerResponse,
NOTIFICATION_MANAGER_ACTIONS,
HOME_SETTINGS_ACTIONS_FOR_NOTIFY,
removeAppRules,
revokeApp,
setAppMuted,
Expand All @@ -43,7 +45,8 @@ import {
type NotificationEvent,
} from './notificationManager';
import { getBridgeState, hasAction, isStaleRevisionError, type BridgeState } from './qdnRequest';
import { readSelectedAppFromLocation, subscribeToPopState, writeSelectedAppToLocation } from './routes';
import { readSelectedAppFromLocation, readWorkspaceFromLocation, subscribeToPopState, writeSelectedAppToLocation, writeWorkspaceToLocation, type NotifyWorkspace } from './routes';
import { Reference } from './Reference';
import {
canMuteApp,
countGrantedApps,
Expand Down Expand Up @@ -448,6 +451,7 @@ function RuleCard({

export default function App() {
const [initialAppKey] = useState(readSelectedAppFromLocation);
const [workspace, setWorkspace] = useState<NotifyWorkspace>(readWorkspaceFromLocation);
const [displaySettings, setDisplaySettings] = useState<QdnDisplaySettings>(getInitialDisplaySettings);
const t = useMemo(() => createTranslator(displaySettings.language), [displaySettings.language]);
const [bridgeState, setBridgeState] = useState<BridgeState>(emptyBridgeState);
Expand Down Expand Up @@ -480,7 +484,7 @@ export default function App() {
[bridgeState.actions],
);
const homeSettingsSupported = useMemo(
() => hasAction(bridgeState.actions, 'GET_HOME_SETTINGS') && hasAction(bridgeState.actions, 'UPDATE_HOME_SETTINGS'),
() => hasEveryAction(bridgeState.actions, HOME_SETTINGS_ACTIONS_FOR_NOTIFY),
[bridgeState.actions],
);
const identityResolutionSupported = useMemo(
Expand Down Expand Up @@ -541,7 +545,11 @@ export default function App() {
}, []);

useEffect(() => {
return subscribeToPopState(setSelectedAppKey);
if (readWorkspaceFromLocation() === 'developers') writeWorkspaceToLocation('developers', false);
return subscribeToPopState((appKey) => {
setSelectedAppKey(appKey);
setWorkspace(readWorkspaceFromLocation());
});
}, []);

useEffect(() => {
Expand Down Expand Up @@ -840,7 +848,7 @@ export default function App() {
</div>
</div>
<div className="topbar__actions">
{homeSettingsSupported && globalEnabled !== null ? (
{workspace === 'manager' && homeSettingsSupported && globalEnabled !== null ? (
<div className="toggle-row">
<span className="toggle-row__label">{t('global.title')}</span>
<Switch
Expand All @@ -851,7 +859,7 @@ export default function App() {
/>
</div>
) : null}
{permissionGranted ? (
{workspace === 'manager' && permissionGranted ? (
<IconButton
busy={summaryLoading}
icon={<RefreshCw aria-hidden />}
Expand All @@ -862,6 +870,19 @@ export default function App() {
</div>
</header>

<nav className="workspace-tabs" aria-label={t('app.title')}>
{([
['manager', t('global.title'), Bell],
['developers', t('nav.developers'), Code2],
] as const).map(([id, label, Icon]) => <button key={id} type="button"
aria-current={workspace === id ? 'page' : undefined}
className={`workspace-tab${workspace === id ? ' workspace-tab--active' : ''}`}
onClick={() => { writeWorkspaceToLocation(id, true); setWorkspace(id); }}>
<Icon aria-hidden /><span>{label}</span>
</button>)}
</nav>
{workspace === 'developers' ? <Reference /> : <>

{!bridgeLoaded ? (
<div className="empty-state empty-state--loading">
<Loader2 aria-hidden className="spinner" />
Expand Down Expand Up @@ -1054,6 +1075,8 @@ export default function App() {
</>
) : null}

</>}

{confirmRevokeTarget ? (
<ConfirmDialog
body={translatePlural(t, 'confirm.revoke.body', confirmRevokeTarget.rules.length, {
Expand Down
54 changes: 54 additions & 0 deletions src/Reference.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, it, vi } from 'vitest';
import { renderToStaticMarkup } from 'react-dom/server';
import { Reference, REFERENCE_SNIPPETS, RULE_EXAMPLES, SUMMARY_EXAMPLE } from './Reference';
import { NOTIFICATION_EVENTS, NOTIFICATION_MANAGER_ACTIONS } from './notificationManager';
import { referenceSectionUrl } from './ReferenceNavigation';

const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor;
describe('developer reference contract', () => {
it('has complete sanitized event examples without private account bindings', () => {
expect(Object.keys(RULE_EXAMPLES)).toEqual([...NOTIFICATION_EVENTS]);
expect(JSON.parse(REFERENCE_SNIPPETS.summary)).toEqual(SUMMARY_EXAMPLE);
expect(REFERENCE_SNIPPETS.summary).not.toContain('accountAddress');
expect(RULE_EXAMPLES.FOREIGN_PAYMENT_RECEIVED.filters).not.toHaveProperty('xpub');
});
it('renders an English public reference with selectable code and accessible copying', () => {
const html = renderToStaticMarkup(<Reference />);
expect(html).toContain('lang="en" dir="ltr"');
expect(html).toContain('aria-live="polite"');
for (const key of Object.keys(REFERENCE_SNIPPETS)) expect(html).toContain(`aria-label="Copy ${key} example"`);
for (const action of NOTIFICATION_MANAGER_ACTIONS) expect(html).toContain(action);
});
it('keeps section links under the current Core path and preserves repeated parameters and fragments', () => {
expect(referenceSectionUrl('/render/APP/Notify/Notify?view=reference&app=example&host=a&host=b#fragment', 'summary'))
.toBe('/render/APP/Notify/Notify?view=developers&app=example&host=a&host=b&section=summary#fragment');
});
it('runs capability discovery without requesting a permission-prompting read when ungranted', async () => {
const request = vi.fn(async ({ action }) => action === 'SHOW_ACTIONS' ? NOTIFICATION_MANAGER_ACTIONS : { granted: false });
await new AsyncFunction('qdnRequest', REFERENCE_SNIPPETS.capabilities)(request);
expect(request.mock.calls.map(([r]) => r.action)).toEqual(['SHOW_ACTIONS', 'NOTIFICATION_MANAGER_HAS_PERMISSION']);
});
it('checks the returned envelope when granted and stops on unsupported hosts', async () => {
const request = vi.fn(async ({ action }) => action === 'SHOW_ACTIONS' ? NOTIFICATION_MANAGER_ACTIONS : action.endsWith('HAS_PERMISSION') ? { granted: true } : { version: 2, revision: 0, apps: [] });
await expect(new AsyncFunction('qdnRequest', REFERENCE_SNIPPETS.capabilities)(request)).rejects.toThrow('Unsupported');
const unavailable = vi.fn(async () => []);
await expect(new AsyncFunction('qdnRequest', REFERENCE_SNIPPETS.capabilities)(unavailable)).rejects.toThrow('unavailable');
expect(unavailable).toHaveBeenCalledOnce();
});
it('uses the reviewed revision for mute and never silently retries stale requests', async () => {
const request = vi.fn(async () => { throw Object.assign(new Error('stale'), { code: 'HOME_DATA_STALE' }); });
const setMuted = await new AsyncFunction('qdnRequest', REFERENCE_SNIPPETS.mute + '\nreturn setMuted;')(request);
await expect(setMuted({ revision: 42 }, 'qdn://APP/Example/Example', true)).rejects.toMatchObject({ code: 'HOME_DATA_STALE' });
expect(request).toHaveBeenCalledExactlyOnceWith({ action: 'NOTIFICATION_MANAGER_SET_MUTED', expectedRevision: 42, appKey: 'qdn://APP/Example/Example', muted: true });
});
it('writes only the Home notifications setting and resolves deduplicated bounded identity batches', async () => {
const request = vi.fn(async ({ action, addresses }) => action === 'SHOW_ACTIONS' ? ['GET_HOME_SETTINGS', 'UPDATE_HOME_SETTINGS', 'RESOLVE_IDENTITIES'] : action === 'GET_HOME_SETTINGS' ? { appNotifications: true, theme: 'dark' } : addresses ? addresses.map((address: string) => ({ address, name: null, avatarSrc: null })) : { appNotifications: false });
await new AsyncFunction('qdnRequest', REFERENCE_SNIPPETS.settings)(request);
expect(request.mock.calls[2][0]).toEqual({ action: 'UPDATE_HOME_SETTINGS', patch: { appNotifications: false } });
request.mockClear();
const resolve = await new AsyncFunction('qdnRequest', REFERENCE_SNIPPETS.identities + '\nreturn resolveVisibleAddresses;')(request);
const values = Array.from({ length: 501 }, (_, i) => `fixture-${i}`);
expect(await resolve([...values, values[0]])).toHaveLength(501);
expect(request.mock.calls.slice(1).map(([r]) => r.addresses.length)).toEqual([500, 1]);
});
});
Loading