cursedbelt 4.7.0 β 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -0
- package/dist/react/chat/index.d.ts +1 -0
- package/dist/react/chat/index.d.ts.map +1 -1
- package/dist/react/chat/index.js +1 -0
- package/dist/react/chat/index.js.map +1 -1
- package/dist/react/chat/usePresence.d.ts +75 -0
- package/dist/react/chat/usePresence.d.ts.map +1 -0
- package/dist/react/chat/usePresence.js +109 -0
- package/dist/react/chat/usePresence.js.map +1 -0
- package/dist/react/components/SecretInput.d.ts +7 -2
- package/dist/react/components/SecretInput.d.ts.map +1 -1
- package/dist/react/components/SecretInput.js +5 -2
- package/dist/react/components/SecretInput.js.map +1 -1
- package/dist/react/components/TooltipImpl.d.ts.map +1 -1
- package/dist/react/components/TooltipImpl.js +52 -3
- package/dist/react/components/TooltipImpl.js.map +1 -1
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +1 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/master-lock/MasterLockDialog.d.ts.map +1 -1
- package/dist/react/master-lock/MasterLockDialog.js +2 -1
- package/dist/react/master-lock/MasterLockDialog.js.map +1 -1
- package/dist/react/master-lock/MasterLockProvider.d.ts +12 -18
- package/dist/react/master-lock/MasterLockProvider.d.ts.map +1 -1
- package/dist/react/master-lock/MasterLockProvider.js +14 -20
- package/dist/react/master-lock/MasterLockProvider.js.map +1 -1
- package/dist/react/media/MediaUploadControl.d.ts +3 -7
- package/dist/react/media/MediaUploadControl.d.ts.map +1 -1
- package/dist/react/media/MediaUploadControl.js +4 -13
- package/dist/react/media/MediaUploadControl.js.map +1 -1
- package/dist/react/media/fileUploadClient.d.ts +0 -8
- package/dist/react/media/fileUploadClient.d.ts.map +1 -1
- package/dist/react/media/fileUploadClient.js +0 -8
- package/dist/react/media/fileUploadClient.js.map +1 -1
- package/dist/react/media/hlsSource.d.ts.map +1 -1
- package/dist/react/media/hlsSource.js +4 -2
- package/dist/react/media/hlsSource.js.map +1 -1
- package/dist/react/media/index.d.ts +0 -4
- package/dist/react/media/index.d.ts.map +1 -1
- package/dist/react/media/index.js +0 -4
- package/dist/react/media/index.js.map +1 -1
- package/dist/react/sessionAuth.d.ts +1 -2
- package/dist/react/sessionAuth.d.ts.map +1 -1
- package/dist/react/sessionAuth.js +2 -3
- package/dist/react/sessionAuth.js.map +1 -1
- package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
- package/dist/styles-areas/activity-bar.css +1 -1
- package/dist/styles-areas/auth.css +1 -1
- package/dist/styles-areas/charts.css +1 -1
- package/dist/styles-areas/chat.css +1 -1
- package/dist/styles-areas/core.css +1 -1
- package/dist/styles-areas/data-table.css +1 -1
- package/dist/styles-areas/disk-usage.css +1 -1
- package/dist/styles-areas/fields.css +1 -1
- package/dist/styles-areas/master-detail.css +1 -1
- package/dist/styles-areas/master-lock.css +108 -2
- package/dist/styles-areas/media-gallery.css +1 -1
- package/dist/styles-areas/media.css +1 -17
- package/dist/styles-areas/nav.css +1 -1
- package/dist/styles-areas/notifications.css +1 -1
- package/dist/styles-areas/spreadsheet.css +1 -1
- package/dist/styles-areas/stats.css +1 -1
- package/dist/styles-areas/wizard.css +1 -1
- package/dist/styles-areas/workbook-viewer.css +1 -1
- package/dist/styles-utilities.css +0 -16
- package/dist/testing/_noBun.d.ts +2 -0
- package/dist/testing/_noBun.d.ts.map +1 -0
- package/dist/testing/_noBun.js +10 -0
- package/dist/testing/_noBun.js.map +1 -0
- package/package.json +42 -19
- package/scripts/checkAreaStyles.spec.ts +71 -0
- package/scripts/checkAreaStyles.ts +150 -0
- package/scripts/guardrailsEnforce.spec.ts +43 -0
- package/scripts/guardrailsEnforce.ts +46 -1
- package/scripts/styleAreas.ts +1 -2
- package/src/barrelsReachNoOptionalPeer.spec.ts +6 -3
- package/src/bundlerOnlyEntries.spec.ts +101 -0
- package/src/declaredImports.spec.ts +4 -0
- package/src/optionalPeersTable.spec.ts +127 -0
- package/src/react/chat/index.ts +8 -0
- package/src/react/chat/usePresence.spec.tsx +138 -0
- package/src/react/chat/usePresence.ts +185 -0
- package/src/react/components/SecretInput.tsx +12 -1
- package/src/react/components/TooltipImpl.tsx +54 -5
- package/src/react/index.ts +8 -0
- package/src/react/master-lock/MasterLockDialog.tsx +13 -9
- package/src/react/master-lock/MasterLockProvider.spec.tsx +17 -0
- package/src/react/master-lock/MasterLockProvider.tsx +16 -19
- package/src/react/media/MediaUploadControl.tsx +8 -29
- package/src/react/media/fileUploadClient.ts +7 -17
- package/src/react/media/hlsSource.ts +4 -1
- package/src/react/media/index.ts +0 -16
- package/src/react/sessionAuth.ts +2 -3
- package/src/scriptsResolve.spec.ts +66 -0
- package/src/styles-areas/activity-bar.css +1 -1
- package/src/styles-areas/auth.css +1 -1
- package/src/styles-areas/charts.css +1 -1
- package/src/styles-areas/chat.css +1 -1
- package/src/styles-areas/core.css +1 -1
- package/src/styles-areas/data-table.css +1 -1
- package/src/styles-areas/disk-usage.css +1 -1
- package/src/styles-areas/fields.css +1 -1
- package/src/styles-areas/master-detail.css +1 -1
- package/src/styles-areas/master-lock.css +108 -2
- package/src/styles-areas/media-gallery.css +1 -1
- package/src/styles-areas/media.css +1 -17
- package/src/styles-areas/nav.css +1 -1
- package/src/styles-areas/notifications.css +1 -1
- package/src/styles-areas/spreadsheet.css +1 -1
- package/src/styles-areas/stats.css +1 -1
- package/src/styles-areas/wizard.css +1 -1
- package/src/styles-areas/workbook-viewer.css +1 -1
- package/src/styles-utilities.css +0 -16
- package/src/testing/_noBun.ts +12 -0
- package/src/verifyGraph.spec.ts +11 -0
- package/dist/react/media/PrivateIndexView.d.ts +0 -6
- package/dist/react/media/PrivateIndexView.d.ts.map +0 -1
- package/dist/react/media/PrivateIndexView.js +0 -44
- package/dist/react/media/PrivateIndexView.js.map +0 -1
- package/dist/react/media/PrivateModeBadge.d.ts +0 -17
- package/dist/react/media/PrivateModeBadge.d.ts.map +0 -1
- package/dist/react/media/PrivateModeBadge.js +0 -24
- package/dist/react/media/PrivateModeBadge.js.map +0 -1
- package/dist/react/media/privateClient.d.ts +0 -34
- package/dist/react/media/privateClient.d.ts.map +0 -1
- package/dist/react/media/privateClient.js +0 -36
- package/dist/react/media/privateClient.js.map +0 -1
- package/dist/react/media/usePrivateUnlock.d.ts +0 -25
- package/dist/react/media/usePrivateUnlock.d.ts.map +0 -1
- package/dist/react/media/usePrivateUnlock.js +0 -68
- package/dist/react/media/usePrivateUnlock.js.map +0 -1
- package/src/react/media/PrivateIndexView.tsx +0 -113
- package/src/react/media/PrivateModeBadge.tsx +0 -50
- package/src/react/media/privateClient.spec.ts +0 -113
- package/src/react/media/privateClient.ts +0 -82
- package/src/react/media/usePrivateUnlock.ts +0 -132
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { act, render } from '@testing-library/react';
|
|
3
|
+
import { PresenceRoster } from './PresenceRoster.js';
|
|
4
|
+
import { presenceUsers, usePresence } from './usePresence.js';
|
|
5
|
+
|
|
6
|
+
/** A socket the test drives by hand: `open()`, `snapshot(rows)`, `drop()`. */
|
|
7
|
+
class FakeSocket extends EventTarget {
|
|
8
|
+
static made: FakeSocket[] = [];
|
|
9
|
+
closed = false;
|
|
10
|
+
constructor(readonly url: string) {
|
|
11
|
+
super();
|
|
12
|
+
FakeSocket.made.push(this);
|
|
13
|
+
}
|
|
14
|
+
open() {
|
|
15
|
+
this.dispatchEvent(new Event('open'));
|
|
16
|
+
}
|
|
17
|
+
snapshot(rows: unknown, event = 'presence') {
|
|
18
|
+
const message = new MessageEvent('message', { data: JSON.stringify({ event, data: rows }) });
|
|
19
|
+
this.dispatchEvent(message);
|
|
20
|
+
}
|
|
21
|
+
drop() {
|
|
22
|
+
this.dispatchEvent(new Event('close'));
|
|
23
|
+
}
|
|
24
|
+
close() {
|
|
25
|
+
if (this.closed) return;
|
|
26
|
+
this.closed = true;
|
|
27
|
+
this.dispatchEvent(new Event('close'));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const NAMES: Record<string, string> = { u1: 'Ada', u2: 'Brian', me: 'Me' };
|
|
32
|
+
|
|
33
|
+
function Harness({ enabled = true }: { enabled?: boolean }) {
|
|
34
|
+
const { users, connected } = usePresence({
|
|
35
|
+
channel: 'rooms',
|
|
36
|
+
enabled,
|
|
37
|
+
selfId: 'me',
|
|
38
|
+
resolveUser: (id) => ({ name: NAMES[id] }),
|
|
39
|
+
retryMs: [5],
|
|
40
|
+
location: { protocol: 'https:', host: 'roms.example' },
|
|
41
|
+
createSocket: (url) => new FakeSocket(url) as unknown as WebSocket,
|
|
42
|
+
});
|
|
43
|
+
return (
|
|
44
|
+
<div>
|
|
45
|
+
<span data-testid='state'>{connected ? 'on' : 'off'}</span>
|
|
46
|
+
<span data-testid='names'>{users.map((u) => u.name).join(',')}</span>
|
|
47
|
+
<PresenceRoster users={users} />
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const last = () => FakeSocket.made[FakeSocket.made.length - 1] as FakeSocket;
|
|
53
|
+
|
|
54
|
+
describe('presenceUsers', () => {
|
|
55
|
+
test('one row per user (the earliest tab), arrival order, without the viewer', () => {
|
|
56
|
+
const users = presenceUsers(
|
|
57
|
+
[
|
|
58
|
+
{ userId: 'u2', connectedAt: 30 },
|
|
59
|
+
{ userId: 'me', connectedAt: 1 },
|
|
60
|
+
{ userId: 'u1', connectedAt: 20 },
|
|
61
|
+
{ userId: 'u2', connectedAt: 10 },
|
|
62
|
+
],
|
|
63
|
+
'me',
|
|
64
|
+
(id) => ({ name: NAMES[id] }),
|
|
65
|
+
);
|
|
66
|
+
expect(users).toEqual([
|
|
67
|
+
{ id: 'u2', isOnline: true, name: 'Brian' },
|
|
68
|
+
{ id: 'u1', isOnline: true, name: 'Ada' },
|
|
69
|
+
]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("a row's own name wins over the resolver, and a malformed row is skipped", () => {
|
|
73
|
+
const users = presenceUsers([{ userId: 'u1', name: 'Ada L.' }, { userId: '' } as never, null as never], null, () => ({ name: 'x' }));
|
|
74
|
+
expect(users).toEqual([{ id: 'u1', isOnline: true, name: 'Ada L.' }]);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('usePresence', () => {
|
|
79
|
+
test('connects to /ws/<channel> over wss, and a join and a leave redraw the roster', () => {
|
|
80
|
+
FakeSocket.made = [];
|
|
81
|
+
const { getByTestId } = render(<Harness />);
|
|
82
|
+
expect(last().url).toBe('wss://roms.example/ws/rooms');
|
|
83
|
+
expect(getByTestId('state').textContent).toBe('off');
|
|
84
|
+
|
|
85
|
+
act(() => last().open());
|
|
86
|
+
expect(getByTestId('state').textContent).toBe('on');
|
|
87
|
+
|
|
88
|
+
act(() => last().snapshot([{ userId: 'me', connectedAt: 1 }, { userId: 'u1', connectedAt: 2 }]));
|
|
89
|
+
expect(getByTestId('names').textContent).toBe('Ada');
|
|
90
|
+
|
|
91
|
+
act(() => last().snapshot([{ userId: 'me', connectedAt: 1 }, { userId: 'u1', connectedAt: 2 }, { userId: 'u2', connectedAt: 3 }]));
|
|
92
|
+
expect(getByTestId('names').textContent).toBe('Ada,Brian');
|
|
93
|
+
|
|
94
|
+
act(() => last().snapshot([{ userId: 'me', connectedAt: 1 }, { userId: 'u2', connectedAt: 3 }]));
|
|
95
|
+
expect(getByTestId('names').textContent).toBe('Brian');
|
|
96
|
+
|
|
97
|
+
// Another channel's frame is not a presence snapshot.
|
|
98
|
+
act(() => last().snapshot([{ userId: 'u9' }], 'chat:message'));
|
|
99
|
+
expect(getByTestId('names').textContent).toBe('Brian');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('π΄ a dropped socket empties the roster, then reconnects and refills it', async () => {
|
|
103
|
+
FakeSocket.made = [];
|
|
104
|
+
const { getByTestId } = render(<Harness />);
|
|
105
|
+
act(() => last().open());
|
|
106
|
+
act(() => last().snapshot([{ userId: 'u1', connectedAt: 2 }]));
|
|
107
|
+
expect(getByTestId('names').textContent).toBe('Ada');
|
|
108
|
+
|
|
109
|
+
const first = last();
|
|
110
|
+
act(() => first.drop());
|
|
111
|
+
expect(getByTestId('state').textContent).toBe('off');
|
|
112
|
+
expect(getByTestId('names').textContent).toBe('');
|
|
113
|
+
|
|
114
|
+
await act(async () => {
|
|
115
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
116
|
+
});
|
|
117
|
+
expect(FakeSocket.made.length).toBe(2);
|
|
118
|
+
expect(last()).not.toBe(first);
|
|
119
|
+
act(() => last().open());
|
|
120
|
+
act(() => last().snapshot([{ userId: 'u2', connectedAt: 5 }]));
|
|
121
|
+
expect(getByTestId('state').textContent).toBe('on');
|
|
122
|
+
expect(getByTestId('names').textContent).toBe('Brian');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('disabled opens no socket; unmount closes it and does not reconnect', async () => {
|
|
126
|
+
FakeSocket.made = [];
|
|
127
|
+
const off = render(<Harness enabled={false} />);
|
|
128
|
+
expect(FakeSocket.made.length).toBe(0);
|
|
129
|
+
off.unmount();
|
|
130
|
+
|
|
131
|
+
const on = render(<Harness />);
|
|
132
|
+
act(() => last().open());
|
|
133
|
+
on.unmount();
|
|
134
|
+
expect(last().closed).toBe(true);
|
|
135
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
136
|
+
expect(FakeSocket.made.length).toBe(1);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { buildChannelSocketUrl, parseWsMessage, type WsLocation } from 'cursedbelt-core/ws-wire';
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
+
import type { PresenceUser } from './types.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Who else is here β the CLIENT half of presence, so `PresenceRoster` has something to render.
|
|
7
|
+
*
|
|
8
|
+
* Task 078 (owner, 2026-09-19: *"show which other users are on apps"*). The library shipped the
|
|
9
|
+
* view (`PresenceRoster`) and the server (`cursedbelt-server`'s `createWsHub` with
|
|
10
|
+
* `presence: true`, whose `getPresence(channel)` is one `{ userId, connectedAt }` row per live
|
|
11
|
+
* client) and nothing in between β so the roster sat exported with zero consumers, and the one
|
|
12
|
+
* app that shows presence (`apps/family`, `src/live.ts`) hand-rolled its own socket. This is that
|
|
13
|
+
* socket, generalised: open `/ws/<channel>`, take every `presence` snapshot the channel
|
|
14
|
+
* broadcasts, and reconnect with a capped backoff when the socket drops.
|
|
15
|
+
*
|
|
16
|
+
* The server side a channel needs is one line in its lifecycle hooks:
|
|
17
|
+
*
|
|
18
|
+
* onConnect: (_c, hub) => hub.broadcast('rooms', 'presence', hub.getPresence('rooms')),
|
|
19
|
+
* onDisconnect: (_c, hub) => hub.broadcast('rooms', 'presence', hub.getPresence('rooms')),
|
|
20
|
+
*
|
|
21
|
+
* ββ What it does with a snapshot βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
22
|
+
* Β· ONE row per user, not per tab β the same person in three tabs is one avatar β keeping the
|
|
23
|
+
* earliest `connectedAt`, so the roster's order is "who arrived first" and stable.
|
|
24
|
+
* Β· `selfId` drops the viewer: the roster is who ELSE is here.
|
|
25
|
+
* Β· `resolveUser` turns an id into a name/avatar; the hub deliberately ships only the id (its
|
|
26
|
+
* `PresenceEntry` is the "business-safe view" of a client). A row may also carry `name` /
|
|
27
|
+
* `avatarUrl` itself, for a channel that broadcasts richer rows.
|
|
28
|
+
*
|
|
29
|
+
* ββ A dropped socket is an EMPTY roster, not a stale one βββββββββββββββββββββββββββββββββ
|
|
30
|
+
* On close the users go to `[]` and `connected` to false until the next snapshot. Showing the
|
|
31
|
+
* last list while disconnected would tell the viewer someone is here who may have left an hour
|
|
32
|
+
* ago; an empty strip is merely less informative. Same choice as family's `useLiveTree`.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** One presence row as a hub channel broadcasts it (`cursedbelt-server`'s `PresenceEntry`). */
|
|
36
|
+
export interface PresenceEntryLike {
|
|
37
|
+
userId: string;
|
|
38
|
+
connectedAt?: number;
|
|
39
|
+
name?: string;
|
|
40
|
+
avatarUrl?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface UsePresenceOptions {
|
|
44
|
+
/** The hub channel (`/ws/<channel>`) whose snapshots to follow. */
|
|
45
|
+
channel: string;
|
|
46
|
+
/** Room within the channel (`?room=`), when the channel is roomed. */
|
|
47
|
+
roomId?: string;
|
|
48
|
+
/** Off β no socket at all (e.g. signed out). Default true. */
|
|
49
|
+
enabled?: boolean;
|
|
50
|
+
/** The viewer's own id β left out of the roster. */
|
|
51
|
+
selfId?: string | null;
|
|
52
|
+
/** Name and avatar for an id the snapshot does not carry them for. */
|
|
53
|
+
resolveUser?: (userId: string) => { name?: string; avatarUrl?: string } | undefined;
|
|
54
|
+
/** The frame event carrying the snapshot. Default `'presence'`. */
|
|
55
|
+
event?: string;
|
|
56
|
+
/** Reconnect backoff, capped at its last entry. */
|
|
57
|
+
retryMs?: readonly number[];
|
|
58
|
+
/** Test/SSR seams. Default `window.location` and the global `WebSocket`. */
|
|
59
|
+
location?: WsLocation;
|
|
60
|
+
createSocket?: (url: string) => WebSocket;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface PresenceSubscription {
|
|
64
|
+
/** Everybody else on the channel now β pass straight to `<PresenceRoster users={β¦}>`. */
|
|
65
|
+
users: PresenceUser[];
|
|
66
|
+
/** Is the socket open? A closed one also means `users` is `[]`. */
|
|
67
|
+
connected: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Backoff, capped. A tab left open overnight must not hammer the box. */
|
|
71
|
+
export const PRESENCE_RETRY_MS = [500, 1_000, 2_000, 5_000, 10_000, 30_000] as const;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Collapse a snapshot to one {@link PresenceUser} per user, earliest arrival first, without
|
|
75
|
+
* `selfId`. Pure β the hook's whole judgement, exported so it is tested without a socket.
|
|
76
|
+
*/
|
|
77
|
+
export function presenceUsers(
|
|
78
|
+
rows: readonly PresenceEntryLike[],
|
|
79
|
+
selfId?: string | null,
|
|
80
|
+
resolveUser?: UsePresenceOptions['resolveUser'],
|
|
81
|
+
): PresenceUser[] {
|
|
82
|
+
const byUser = new Map<string, PresenceEntryLike>();
|
|
83
|
+
for (const row of rows) {
|
|
84
|
+
if (!row || typeof row.userId !== 'string' || row.userId === '') continue;
|
|
85
|
+
if (selfId != null && row.userId === selfId) continue;
|
|
86
|
+
const seen = byUser.get(row.userId);
|
|
87
|
+
if (!seen || (row.connectedAt ?? Infinity) < (seen.connectedAt ?? Infinity)) byUser.set(row.userId, row);
|
|
88
|
+
}
|
|
89
|
+
return [...byUser.values()]
|
|
90
|
+
.sort((a, b) => (a.connectedAt ?? Infinity) - (b.connectedAt ?? Infinity))
|
|
91
|
+
.map((row) => {
|
|
92
|
+
const resolved = resolveUser?.(row.userId);
|
|
93
|
+
const name = row.name ?? resolved?.name;
|
|
94
|
+
const avatarUrl = row.avatarUrl ?? resolved?.avatarUrl;
|
|
95
|
+
return {
|
|
96
|
+
id: row.userId,
|
|
97
|
+
isOnline: true,
|
|
98
|
+
...(name !== undefined ? { name } : {}),
|
|
99
|
+
...(avatarUrl !== undefined ? { avatarUrl } : {}),
|
|
100
|
+
};
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The rows in a snapshot frame: the hub's bare array, or `{ users }` / `{ peers }` / `{ presence }`. */
|
|
105
|
+
function rowsOf(data: unknown): PresenceEntryLike[] | null {
|
|
106
|
+
if (Array.isArray(data)) return data as PresenceEntryLike[];
|
|
107
|
+
if (data && typeof data === 'object') {
|
|
108
|
+
for (const key of ['users', 'peers', 'presence'] as const) {
|
|
109
|
+
const value = (data as Record<string, unknown>)[key];
|
|
110
|
+
if (Array.isArray(value)) return value as PresenceEntryLike[];
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function usePresence(options: UsePresenceOptions): PresenceSubscription {
|
|
117
|
+
const {
|
|
118
|
+
channel,
|
|
119
|
+
roomId,
|
|
120
|
+
enabled = true,
|
|
121
|
+
selfId = null,
|
|
122
|
+
resolveUser,
|
|
123
|
+
event = 'presence',
|
|
124
|
+
retryMs = PRESENCE_RETRY_MS,
|
|
125
|
+
location,
|
|
126
|
+
createSocket,
|
|
127
|
+
} = options;
|
|
128
|
+
const [rows, setRows] = useState<PresenceEntryLike[]>([]);
|
|
129
|
+
const [connected, setConnected] = useState(false);
|
|
130
|
+
// The seams and backoff are read through a ref so an inline lambda or array literal in the
|
|
131
|
+
// caller does not tear the socket down on every render.
|
|
132
|
+
const seams = useRef({ location, createSocket, retryMs });
|
|
133
|
+
seams.current = { location, createSocket, retryMs };
|
|
134
|
+
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
const loc = seams.current.location ?? (typeof window !== 'undefined' ? window.location : undefined);
|
|
137
|
+
if (!enabled || !loc) return;
|
|
138
|
+
let closed = false;
|
|
139
|
+
let attempt = 0;
|
|
140
|
+
let retry: ReturnType<typeof setTimeout> | null = null;
|
|
141
|
+
let socket: WebSocket | null = null;
|
|
142
|
+
|
|
143
|
+
const open = () => {
|
|
144
|
+
if (closed) return;
|
|
145
|
+
const url = buildChannelSocketUrl(channel, roomId, loc);
|
|
146
|
+
const make = seams.current.createSocket ?? ((u: string) => new WebSocket(u));
|
|
147
|
+
const ws = make(url);
|
|
148
|
+
socket = ws;
|
|
149
|
+
ws.addEventListener('open', () => {
|
|
150
|
+
attempt = 0;
|
|
151
|
+
setConnected(true);
|
|
152
|
+
});
|
|
153
|
+
ws.addEventListener('message', (message) => {
|
|
154
|
+
const frame = parseWsMessage(String((message as MessageEvent).data));
|
|
155
|
+
if (!frame || frame.event !== event) return;
|
|
156
|
+
const next = rowsOf(frame.data);
|
|
157
|
+
if (next) setRows(next);
|
|
158
|
+
});
|
|
159
|
+
ws.addEventListener('close', () => {
|
|
160
|
+
setConnected(false);
|
|
161
|
+
setRows([]);
|
|
162
|
+
if (closed) return;
|
|
163
|
+
const steps = seams.current.retryMs;
|
|
164
|
+
const wait = steps[Math.min(attempt, steps.length - 1)] ?? 30_000;
|
|
165
|
+
attempt += 1;
|
|
166
|
+
retry = setTimeout(open, wait);
|
|
167
|
+
});
|
|
168
|
+
// An `error` is always followed by a `close`; reconnecting from both would schedule two.
|
|
169
|
+
ws.addEventListener('error', () => ws.close());
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
open();
|
|
173
|
+
return () => {
|
|
174
|
+
closed = true;
|
|
175
|
+
if (retry) clearTimeout(retry);
|
|
176
|
+
socket?.close();
|
|
177
|
+
socket = null;
|
|
178
|
+
setConnected(false);
|
|
179
|
+
setRows([]);
|
|
180
|
+
};
|
|
181
|
+
}, [enabled, channel, roomId, event]);
|
|
182
|
+
|
|
183
|
+
const users = useMemo(() => presenceUsers(rows, selfId, resolveUser), [rows, selfId, resolveUser]);
|
|
184
|
+
return { users, connected };
|
|
185
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type CSSProperties, type ReactNode, useState } from 'react';
|
|
1
|
+
import { type CSSProperties, type KeyboardEvent, type ReactNode, useState } from 'react';
|
|
2
2
|
import { useCopyToClipboard } from '../hooks/useCopyToClipboard.js';
|
|
3
3
|
import { CONTROL_HEIGHT_CLASS, type ControlSize } from '../lib/field.js';
|
|
4
4
|
import { resolveClass, resolveStyle, type StyleableProps } from '../styling.js';
|
|
@@ -118,6 +118,11 @@ export interface SecretInputProps extends StyleableProps<SecretInputSlot> {
|
|
|
118
118
|
'aria-invalid'?: boolean | 'true' | 'false';
|
|
119
119
|
'aria-describedby'?: string;
|
|
120
120
|
autoFocus?: boolean;
|
|
121
|
+
/** Disable typing while a form submits (the eye stays usable β seeing what you typed is
|
|
122
|
+
* harmless while it is in flight). */
|
|
123
|
+
disabled?: boolean;
|
|
124
|
+
/** Fires on Enter in THIS field β the login/rotation forms submit from their last field. */
|
|
125
|
+
onEnter?: (event: KeyboardEvent<HTMLInputElement>) => void;
|
|
121
126
|
className?: string;
|
|
122
127
|
style?: CSSProperties;
|
|
123
128
|
/** Container height on the shared control-height scale (`--control-h-*`, task
|
|
@@ -149,6 +154,8 @@ export const SecretInput = ({
|
|
|
149
154
|
'aria-invalid': ariaInvalid,
|
|
150
155
|
'aria-describedby': ariaDescribedBy,
|
|
151
156
|
autoFocus = false,
|
|
157
|
+
disabled,
|
|
158
|
+
onEnter,
|
|
152
159
|
className,
|
|
153
160
|
style,
|
|
154
161
|
classNames,
|
|
@@ -199,6 +206,10 @@ export const SecretInput = ({
|
|
|
199
206
|
value={value}
|
|
200
207
|
readOnly={readOnly}
|
|
201
208
|
onChange={onChange ? (e) => onChange(e.target.value) : undefined}
|
|
209
|
+
onKeyDown={onEnter ? (e) => {
|
|
210
|
+
if (e.key === 'Enter') onEnter(e);
|
|
211
|
+
} : undefined}
|
|
212
|
+
disabled={disabled}
|
|
202
213
|
placeholder={placeholder}
|
|
203
214
|
aria-label={label}
|
|
204
215
|
aria-invalid={ariaInvalid}
|
|
@@ -44,6 +44,10 @@ export default function TooltipImpl({
|
|
|
44
44
|
const resolvedSide = placement ?? side ?? 'top';
|
|
45
45
|
const triggerRef = useRef<HTMLElement | null>(null);
|
|
46
46
|
const wasFocusedRef = useRef(false);
|
|
47
|
+
/** The node a TOUCH disclosure's taps land on β the β, or the `tap` branch's span. */
|
|
48
|
+
const touchTriggerRef = useRef<HTMLButtonElement | null>(null);
|
|
49
|
+
/** True while one of that trigger's own events is being dispatched β see `onTouchOpenChange`. */
|
|
50
|
+
const inTriggerEventRef = useRef(false);
|
|
47
51
|
|
|
48
52
|
// Replay the arming gesture. Radix opens on `pointermove` over the trigger and on
|
|
49
53
|
// focus; both already happened before this module existed, so re-dispatch one on
|
|
@@ -215,7 +219,16 @@ export default function TooltipImpl({
|
|
|
215
219
|
sideOffset={6}
|
|
216
220
|
collisionPadding={FLOATING_COLLISION_PADDING}
|
|
217
221
|
style={maxWidth != null ? { maxWidth } : undefined}
|
|
218
|
-
onPointerDownOutside={() =>
|
|
222
|
+
onPointerDownOutside={(e) => {
|
|
223
|
+
// A touch disclosure's OWN trigger is outside the bubble, but a tap on it is the
|
|
224
|
+
// toggle's business (it closes an open bubble on its click). Letting the layer
|
|
225
|
+
// dismiss here as well would close on this pointerdown and re-open on the click.
|
|
226
|
+
if (!hasHover && touchTriggerRef.current?.contains(e.target as Node)) {
|
|
227
|
+
e.preventDefault();
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
setOpen(false);
|
|
231
|
+
}}
|
|
219
232
|
className={cn(
|
|
220
233
|
// NOTE: the bubble is a transient hint and must NEVER be a click target β
|
|
221
234
|
// a focus-opened tooltip (e.g. a prompt auto-focusing its safe default
|
|
@@ -276,6 +289,42 @@ export default function TooltipImpl({
|
|
|
276
289
|
|
|
277
290
|
const toggle = () => setOpen((o) => !o);
|
|
278
291
|
|
|
292
|
+
/*
|
|
293
|
+
* π΄ On touch, the TAP owns `open`, and Radix's trigger handlers are told nothing they say.
|
|
294
|
+
*
|
|
295
|
+
* Tasks 2082 + 2093, measured in a Pixel 7 context on station's Console: the first tap on
|
|
296
|
+
* every β did nothing and the second opened it. A real tap fires `pointerdown β pointerup β
|
|
297
|
+
* focus β click`. Radix's Trigger clears its pointer-down flag on `pointerup`, so the
|
|
298
|
+
* `focus` that follows reads as KEYBOARD focus and opens; then this branch's click `toggle`
|
|
299
|
+
* flips that fresh `true` back to `false`. (Radix also composes `onClose` onto every click
|
|
300
|
+
* and every `pointerdown` on an open trigger, which is the half the tasks first suspected.)
|
|
301
|
+
*
|
|
302
|
+
* So while one of the trigger's own events is being dispatched, `onOpenChange` from Radix
|
|
303
|
+
* is ignored β the flag is set by the handlers below, which `composeEventHandlers` runs
|
|
304
|
+
* BEFORE Radix's, and cleared in a microtask, which runs once the whole dispatch returns.
|
|
305
|
+
* Nothing is `preventDefault`ed, so a `mobile="tap"` trigger that is a link still
|
|
306
|
+
* navigates. Every OTHER close Radix makes still lands: Escape, scroll, and another
|
|
307
|
+
* tooltip opening. `e2e/tooltip-tap.spec.ts` is the proof, in the `touch` project β it
|
|
308
|
+
* cannot be a happy-dom case, because happy-dom fires whatever order a spec writes.
|
|
309
|
+
*/
|
|
310
|
+
const markTriggerEvent = () => {
|
|
311
|
+
inTriggerEventRef.current = true;
|
|
312
|
+
queueMicrotask(() => {
|
|
313
|
+
inTriggerEventRef.current = false;
|
|
314
|
+
});
|
|
315
|
+
};
|
|
316
|
+
const onTouchOpenChange = (next: boolean) => {
|
|
317
|
+
if (inTriggerEventRef.current) return;
|
|
318
|
+
setOpen(next);
|
|
319
|
+
};
|
|
320
|
+
const touchTriggerProps = {
|
|
321
|
+
ref: touchTriggerRef,
|
|
322
|
+
onPointerDown: markTriggerEvent,
|
|
323
|
+
onFocus: markTriggerEvent,
|
|
324
|
+
onBlur: markTriggerEvent,
|
|
325
|
+
onClick: markTriggerEvent,
|
|
326
|
+
};
|
|
327
|
+
|
|
279
328
|
// `icon`: render the trigger as-is plus a tappable β disclosure that owns the
|
|
280
329
|
// tooltip (so a tap on the trigger still does its own action).
|
|
281
330
|
if (mobile === 'icon') {
|
|
@@ -287,8 +336,8 @@ export default function TooltipImpl({
|
|
|
287
336
|
accidental hover to protect against on a touch device β see
|
|
288
337
|
`lib/hoverIntent.ts`, "delay the accident, never the request". */}
|
|
289
338
|
<RTooltip.Provider delayDuration={HOVER_HINT_INSTANT_MS}>
|
|
290
|
-
<RTooltip.Root open={open} onOpenChange={
|
|
291
|
-
<RTooltip.Trigger asChild>
|
|
339
|
+
<RTooltip.Root open={open} onOpenChange={onTouchOpenChange}>
|
|
340
|
+
<RTooltip.Trigger asChild {...touchTriggerProps}>
|
|
292
341
|
<button
|
|
293
342
|
// guardrails-ignore no-raw-action-button: this IS the Tooltip primitive's
|
|
294
343
|
// own mobile disclosure trigger β Button/IconButton wrap every button in a
|
|
@@ -321,8 +370,8 @@ export default function TooltipImpl({
|
|
|
321
370
|
// as the `icon` branch above β the tap IS the request.
|
|
322
371
|
return (
|
|
323
372
|
<RTooltip.Provider delayDuration={HOVER_HINT_INSTANT_MS}>
|
|
324
|
-
<RTooltip.Root open={open} onOpenChange={
|
|
325
|
-
<RTooltip.Trigger asChild>
|
|
373
|
+
<RTooltip.Root open={open} onOpenChange={onTouchOpenChange}>
|
|
374
|
+
<RTooltip.Trigger asChild {...touchTriggerProps}>
|
|
326
375
|
{/* On touch, tapping the trigger toggles the tooltip. Radix's Trigger
|
|
327
376
|
(asChild) merges the real keyboard + ARIA interaction onto this element;
|
|
328
377
|
the bare onClick here is only the tap affordance. */}
|
package/src/react/index.ts
CHANGED
|
@@ -47,6 +47,14 @@ export { MessageScroller, type MessageScrollerProps } from './chat/MessageScroll
|
|
|
47
47
|
export { MessageThread, type MessageThreadProps } from './chat/MessageThread.js';
|
|
48
48
|
export { PresenceRoster, type PresenceRosterProps } from './chat/PresenceRoster.js';
|
|
49
49
|
export { StatusMarker, type StatusMarkerProps } from './chat/StatusMarker.js';
|
|
50
|
+
export {
|
|
51
|
+
PRESENCE_RETRY_MS,
|
|
52
|
+
type PresenceEntryLike,
|
|
53
|
+
type PresenceSubscription,
|
|
54
|
+
presenceUsers,
|
|
55
|
+
type UsePresenceOptions,
|
|
56
|
+
usePresence,
|
|
57
|
+
} from './chat/usePresence.js';
|
|
50
58
|
export { Markdown, type MarkdownProps } from './markdown/Markdown.js';
|
|
51
59
|
export type {
|
|
52
60
|
ChatAttachment,
|
|
@@ -28,6 +28,7 @@ import { Button } from "../components/Button.js";
|
|
|
28
28
|
import { FieldLabel } from "../components/FieldLabel.js";
|
|
29
29
|
import { Input } from "../components/Input.js";
|
|
30
30
|
import { Modal } from "../components/Modal.js";
|
|
31
|
+
import { SecretInput } from "../components/SecretInput.js";
|
|
31
32
|
|
|
32
33
|
export interface MasterLockDialogProps {
|
|
33
34
|
open: boolean;
|
|
@@ -201,39 +202,42 @@ export function MasterLockDialog({
|
|
|
201
202
|
<label htmlFor="ml-current">
|
|
202
203
|
<FieldLabel>Current master password</FieldLabel>
|
|
203
204
|
</label>
|
|
204
|
-
<
|
|
205
|
+
<SecretInput
|
|
205
206
|
id="ml-current"
|
|
206
|
-
|
|
207
|
+
label="current master password"
|
|
207
208
|
autoComplete="current-password"
|
|
209
|
+
showCopy={false}
|
|
208
210
|
value={current}
|
|
209
211
|
disabled={busy}
|
|
210
|
-
onChange={
|
|
212
|
+
onChange={setCurrent}
|
|
211
213
|
/>
|
|
212
214
|
</div>
|
|
213
215
|
<div className="flex flex-col gap-1.5">
|
|
214
216
|
<label htmlFor="ml-next">
|
|
215
217
|
<FieldLabel>New master password</FieldLabel>
|
|
216
218
|
</label>
|
|
217
|
-
<
|
|
219
|
+
<SecretInput
|
|
218
220
|
id="ml-next"
|
|
219
|
-
|
|
221
|
+
label="new master password"
|
|
220
222
|
autoComplete="new-password"
|
|
223
|
+
showCopy={false}
|
|
221
224
|
value={next}
|
|
222
225
|
disabled={busy}
|
|
223
|
-
onChange={
|
|
226
|
+
onChange={setNext}
|
|
224
227
|
/>
|
|
225
228
|
</div>
|
|
226
229
|
<div className="flex flex-col gap-1.5">
|
|
227
230
|
<label htmlFor="ml-confirm">
|
|
228
231
|
<FieldLabel>Repeat the new one</FieldLabel>
|
|
229
232
|
</label>
|
|
230
|
-
<
|
|
233
|
+
<SecretInput
|
|
231
234
|
id="ml-confirm"
|
|
232
|
-
|
|
235
|
+
label="repeated new master password"
|
|
233
236
|
autoComplete="new-password"
|
|
237
|
+
showCopy={false}
|
|
234
238
|
value={confirm}
|
|
235
239
|
disabled={busy}
|
|
236
|
-
onChange={
|
|
240
|
+
onChange={setConfirm}
|
|
237
241
|
onEnter={() => void changePassword()}
|
|
238
242
|
/>
|
|
239
243
|
</div>
|
|
@@ -126,3 +126,20 @@ describe("π΄ the rotation never mints a KDF", () => {
|
|
|
126
126
|
expect(code).toContain("kdf: MasterLockKdfParams;");
|
|
127
127
|
});
|
|
128
128
|
});
|
|
129
|
+
|
|
130
|
+
describe("the account-menu entries (task 106)", () => {
|
|
131
|
+
test("π΄ 'Master passwordsβ¦' is a LINK to the accounts page, from the wire, not a string here", async () => {
|
|
132
|
+
const { MasterLockMenuItems } = await import("./MasterLockProvider.js");
|
|
133
|
+
const { findByText, getByText } = render(
|
|
134
|
+
<MasterLockProvider onLocked={() => {}}>
|
|
135
|
+
<MasterLockMenuItems itemClassName="sam-item" />
|
|
136
|
+
</MasterLockProvider>,
|
|
137
|
+
);
|
|
138
|
+
const link = await findByText("Master passwordsβ¦");
|
|
139
|
+
expect(link.tagName).toBe("A");
|
|
140
|
+
expect(link.getAttribute("href")).toBe(MASTER_LOCK_PATHS.accounts);
|
|
141
|
+
expect(link.className).toBe("sam-item");
|
|
142
|
+
// The idle timeout lives only in the dialog, so its entry stays.
|
|
143
|
+
expect(getByText("Lock settingsβ¦").tagName).toBe("BUTTON");
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -237,31 +237,25 @@ export interface MasterLockMenuItemsProps {
|
|
|
237
237
|
}
|
|
238
238
|
|
|
239
239
|
/**
|
|
240
|
-
* The
|
|
240
|
+
* The entries for the account menu β *"a new option in the user dropdown β¦ which is
|
|
241
241
|
* located near the sign out and switch user options"*. The owner chose that placement
|
|
242
242
|
* deliberately: *"this placement will help make sure it isn't confused with the private
|
|
243
243
|
* collections lock or other locks like that."*
|
|
244
244
|
*
|
|
245
245
|
* Renders nothing when no master password is configured for the app.
|
|
246
246
|
*
|
|
247
|
-
* ββ
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
* password, because it describes a broken CLIENT. So the entry did not merely look wrong; it
|
|
255
|
-
* refused, and the owner could lock a site and enroll a first password but could not CHANGE one.
|
|
247
|
+
* ββ The second entry is a LINK to the accounts page (task 106, 2026-09-23) βββββββββββββββββ
|
|
248
|
+
* `cursedbelt-server` serves `GET /__lock/accounts` from the same guard that serves the lock
|
|
249
|
+
* page: server-rendered, listing the accounts and adding, renaming and rotating them. So the
|
|
250
|
+
* entry is an `<a href>`, not a button β the target is a same-origin page, so there is nothing
|
|
251
|
+
* for this bundle to open and nothing for it to keep in step β and the path is
|
|
252
|
+
* `MASTER_LOCK_PATHS.accounts` from `cursedbelt-core`'s wire, never a string here, because a
|
|
253
|
+
* second copy of the wire in the design system is what `wire.ts`'s header exists to prevent.
|
|
256
254
|
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
* link is a task, not a one-liner (`tasks/Not_Started`, filed 2026-09-17). What IS fixed here
|
|
262
|
-
* is the dialog itself: `MasterLockDialog` no longer mints a salt, so this entry works again
|
|
263
|
-
* rather than 409ing. `apps/collections` links to the page directly from its own rail in the
|
|
264
|
-
* meantime, and says why in a comment there.
|
|
255
|
+
* The THIRD entry keeps the dialog, because the dialog also owns the idle timeout, which the
|
|
256
|
+
* accounts page does not. (History: until 2026-09-17 the dialog minted a fresh `kdf` salt on
|
|
257
|
+
* every rotation and the server refused it `409 stale-client`; it derives under the app's
|
|
258
|
+
* existing descriptor now, so both surfaces rotate correctly.)
|
|
265
259
|
*/
|
|
266
260
|
export function MasterLockMenuItems({ close, itemClassName }: MasterLockMenuItemsProps) {
|
|
267
261
|
const lock = useMasterLock();
|
|
@@ -283,6 +277,9 @@ export function MasterLockMenuItems({ close, itemClassName }: MasterLockMenuItem
|
|
|
283
277
|
>
|
|
284
278
|
π Lock this site
|
|
285
279
|
</button>
|
|
280
|
+
<a href={MASTER_LOCK_PATHS.accounts} className={cls} onClick={() => close?.()}>
|
|
281
|
+
Master passwordsβ¦
|
|
282
|
+
</a>
|
|
286
283
|
<button
|
|
287
284
|
// guardrails-ignore no-raw-action-button: see the sibling above β it must match
|
|
288
285
|
// the host menu's own rows, not this design system's buttons.
|
|
@@ -293,7 +290,7 @@ export function MasterLockMenuItems({ close, itemClassName }: MasterLockMenuItem
|
|
|
293
290
|
lock.openSettings();
|
|
294
291
|
}}
|
|
295
292
|
>
|
|
296
|
-
|
|
293
|
+
Lock settingsβ¦
|
|
297
294
|
</button>
|
|
298
295
|
</>
|
|
299
296
|
);
|