@hanzo/rooms 0.1.35 → 0.1.37
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/dist/Chat.js +10 -7
- package/dist/Column.js +1 -1
- package/dist/Recents.d.ts +3 -1
- package/dist/Recents.js +32 -33
- package/dist/Share.d.ts +10 -0
- package/dist/Share.js +37 -22
- package/dist/Shell.js +19 -11
- package/dist/chord.d.ts +21 -0
- package/dist/chord.js +51 -0
- package/dist/pending.d.ts +41 -0
- package/dist/pending.js +181 -0
- package/dist/recent.d.ts +12 -5
- package/dist/recent.js +24 -11
- package/dist/stars.d.ts +8 -0
- package/dist/stars.js +32 -0
- package/dist/thread.d.ts +57 -0
- package/dist/thread.js +408 -0
- package/package.json +3 -3
package/dist/thread.js
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// A conversation's own actions, kept where the conversation is: PATCH and DELETE
|
|
4
|
+
// /v1/agents/chat/conversations/{id} (hanzoai/agent v1.0.12), the member's own
|
|
5
|
+
// only — and the ones that need nothing of the store: its link, a new tab, the
|
|
6
|
+
// Share panel.
|
|
7
|
+
//
|
|
8
|
+
// ONE MENU A LIST. `ConversationMenu` wraps a list of rows and mounts, once for
|
|
9
|
+
// all of them, gui's ContextMenu (a right-click at the cursor, a long-press on
|
|
10
|
+
// touch), gui's Menu hung from whichever row's ⋯ was pressed (a gui Menu takes
|
|
11
|
+
// many triggers and anchors to the one pressed), and the Rename, Delete and
|
|
12
|
+
// Share dialogs. A row (`ThreadRow`) is its markup and a trigger. The column's
|
|
13
|
+
// Recents and the shell's /chat list both wrap theirs in it. Not
|
|
14
|
+
// @hanzo/ui/product/menu's ContextMenu: it portals its panel under gui's
|
|
15
|
+
// `pointer-events: none` host, so it drew and took no click (measured: every
|
|
16
|
+
// ancestor of its panel reads `none`).
|
|
17
|
+
//
|
|
18
|
+
// A CHANGE SHOWS AT ONCE, everywhere, and is taken back with a toast if the
|
|
19
|
+
// store refuses it (pending.ts): every list on the page draws the changes in
|
|
20
|
+
// flight over what it last read, and reads the store again once it answers.
|
|
21
|
+
// A pin is the server's, never this browser's: it follows the person to every
|
|
22
|
+
// device they sign in on.
|
|
23
|
+
import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore, } from 'react';
|
|
24
|
+
import { useAi } from '@hanzo/ai/react';
|
|
25
|
+
import { Archive, ArchiveRestore, ExternalLink, Link2, MoreHorizontal, Pencil, Pin, PinOff, Share2, Trash2 } from 'lucide-react';
|
|
26
|
+
import { Button, ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuShortcut, ContextMenuTrigger, Dialog, DialogContent, DialogTitle, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuTrigger, Input, SizableText, Text, XStack, YStack, toast, } from '@hanzo/ui';
|
|
27
|
+
import { empty } from './open.js';
|
|
28
|
+
import { say } from './lib/org.js';
|
|
29
|
+
import { talk } from './lib/host.js';
|
|
30
|
+
import { BAD } from './lib/mix.js';
|
|
31
|
+
import { site } from './where.js';
|
|
32
|
+
import { attempt, bind, enlist, reread, snapshot, subscribe, sweep, tick, view } from './pending.js';
|
|
33
|
+
import { chord, named, said } from './chord.js';
|
|
34
|
+
import { carry } from './stars.js';
|
|
35
|
+
import { ShareDialog } from './Share.js';
|
|
36
|
+
/**
|
|
37
|
+
* The member's conversations: pinned first, then most recently spoken in, the
|
|
38
|
+
* archived ones left out — or, with `archived`, those alone — with every change
|
|
39
|
+
* in flight drawn over them. It reads again whenever a conversation changes
|
|
40
|
+
* here, and once for `seek` when it does not hold it: a conversation it has not
|
|
41
|
+
* seen is one just begun.
|
|
42
|
+
*/
|
|
43
|
+
export function useThreadList(archived = false, seek = null) {
|
|
44
|
+
const ai = useAi();
|
|
45
|
+
// What was read, when, and from which shelf: the other shelf's rows are never drawn as this one's.
|
|
46
|
+
const [read, setRead] = useState({ threads: [], at: 0, archived });
|
|
47
|
+
const [loading, setLoading] = useState(true);
|
|
48
|
+
const [error, setError] = useState(null);
|
|
49
|
+
const [asks, ask] = useState(0);
|
|
50
|
+
const reload = useCallback(() => ask((n) => n + 1), []);
|
|
51
|
+
const me = useRef({ at: 0, reload });
|
|
52
|
+
useEffect(() => enlist(me.current), []);
|
|
53
|
+
useEffect(() => bind(ai), [ai]);
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
const control = new AbortController();
|
|
56
|
+
const at = tick();
|
|
57
|
+
setLoading(true);
|
|
58
|
+
ai.threads.list({ archived, signal: control.signal }).then((threads) => {
|
|
59
|
+
if (control.signal.aborted)
|
|
60
|
+
return;
|
|
61
|
+
me.current.at = at;
|
|
62
|
+
setRead({ threads, at, archived });
|
|
63
|
+
setError(null);
|
|
64
|
+
setLoading(false);
|
|
65
|
+
sweep();
|
|
66
|
+
// A read that answers is a client the store takes: the browser's old stars can become pins.
|
|
67
|
+
stars(ai);
|
|
68
|
+
}, (e) => {
|
|
69
|
+
if (control.signal.aborted)
|
|
70
|
+
return;
|
|
71
|
+
setError(e instanceof Error ? e : new Error(String(e)));
|
|
72
|
+
setLoading(false);
|
|
73
|
+
});
|
|
74
|
+
return () => control.abort();
|
|
75
|
+
}, [ai, archived, asks]);
|
|
76
|
+
const changes = useSyncExternalStore(subscribe, snapshot, snapshot);
|
|
77
|
+
const threads = useMemo(() => (read.archived === archived ? view(read.threads, read.at, archived) : view([], 0, archived)), [read, archived, changes]);
|
|
78
|
+
const asked = useRef(new Set());
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (archived || !seek || loading || asked.current.has(seek) || threads.some((t) => t.id === seek))
|
|
81
|
+
return;
|
|
82
|
+
asked.current.add(seek);
|
|
83
|
+
reload();
|
|
84
|
+
}, [archived, seek, loading, threads, reload]);
|
|
85
|
+
return { threads, loading, error, reload };
|
|
86
|
+
}
|
|
87
|
+
/** How long a change waits for the store before it is taken back. */
|
|
88
|
+
const WAIT = 15_000;
|
|
89
|
+
/** A refusal in the store's words, or that the store did not answer at all. */
|
|
90
|
+
const refused = (e) => e instanceof DOMException && e.name === 'TimeoutError' ? 'The conversation store did not answer. Try again.' : say(e);
|
|
91
|
+
let carried = false;
|
|
92
|
+
/** The browser's stars from before pins were the server's, made pins once a page (stars.ts `carry`). */
|
|
93
|
+
function stars(ai) {
|
|
94
|
+
if (carried)
|
|
95
|
+
return;
|
|
96
|
+
carried = true;
|
|
97
|
+
void carry((id) => ai.threads.update(id, { pinned: true }, { signal: AbortSignal.timeout(WAIT) })).then((done) => {
|
|
98
|
+
if (done)
|
|
99
|
+
reread();
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
/** What a conversation can be asked to do. Each shows at once; a refusal takes it back and says so in a toast. */
|
|
103
|
+
export function useThreadActions() {
|
|
104
|
+
const ai = useAi();
|
|
105
|
+
return useMemo(() => {
|
|
106
|
+
const run = async (t, change, send) => {
|
|
107
|
+
try {
|
|
108
|
+
// The clock starts when the change is asked, after the ones before it are answered.
|
|
109
|
+
await attempt(t, change, () => send(AbortSignal.timeout(WAIT)));
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
catch (e) {
|
|
113
|
+
toast.error(refused(e));
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
const change = (t, to) => run(t, to, (signal) => ai.threads.update(t.id, to, { signal }));
|
|
118
|
+
return {
|
|
119
|
+
pin: (t) => change(t, { pinned: !t.pinned }),
|
|
120
|
+
archive: (t) => change(t, { archived: !t.archived }),
|
|
121
|
+
rename: (t, title) => change(t, { title }),
|
|
122
|
+
remove: (t) => run(t, null, (signal) => ai.threads.remove(t.id, { signal })),
|
|
123
|
+
};
|
|
124
|
+
}, [ai]);
|
|
125
|
+
}
|
|
126
|
+
const titleOf = (t) => t.title || 'Untitled';
|
|
127
|
+
/** A conversation's own address, whole: the app at `/?chat=<id>` (lib/host `talk`), wherever the rooms are drawn. */
|
|
128
|
+
export const href = (id) => new URL(site(talk(id)), window.location.href).href;
|
|
129
|
+
/** Opens a conversation in a tab of its own. */
|
|
130
|
+
const away = (id) => {
|
|
131
|
+
window.open(href(id), '_blank', 'noopener');
|
|
132
|
+
};
|
|
133
|
+
/** Puts a conversation's address on the clipboard. */
|
|
134
|
+
async function copy(id) {
|
|
135
|
+
try {
|
|
136
|
+
await navigator.clipboard.writeText(href(id));
|
|
137
|
+
toast.success('Link copied');
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
toast.error('The browser would not copy the link.');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/** The one colour in the menu: Delete's — design's destructive mixed toward the theme's ink, 4.5:1 on either theme's panel. */
|
|
144
|
+
const DANGER = BAD;
|
|
145
|
+
const mac = () => typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent);
|
|
146
|
+
const Rows = createContext(null);
|
|
147
|
+
const keyed = new Set();
|
|
148
|
+
/**
|
|
149
|
+
* A keydown anywhere on the page, before anything inside it handles it (an
|
|
150
|
+
* open menu takes its keys): the list whose menu is open, or else the first
|
|
151
|
+
* that holds the open conversation, acts.
|
|
152
|
+
*/
|
|
153
|
+
function hear(e) {
|
|
154
|
+
const act = chord(e);
|
|
155
|
+
if (!act)
|
|
156
|
+
return;
|
|
157
|
+
const all = [...keyed].map((one) => one.current).filter((one) => one !== null);
|
|
158
|
+
if (all.some((one) => one.held))
|
|
159
|
+
return;
|
|
160
|
+
const list = all.find((one) => one.open) ?? all.find((one) => one.target() !== null);
|
|
161
|
+
const t = list?.target();
|
|
162
|
+
if (!list || !t)
|
|
163
|
+
return;
|
|
164
|
+
e.preventDefault();
|
|
165
|
+
list.act(act, t);
|
|
166
|
+
}
|
|
167
|
+
function listen(keys) {
|
|
168
|
+
if (!keyed.size)
|
|
169
|
+
window.addEventListener('keydown', hear, true);
|
|
170
|
+
keyed.add(keys);
|
|
171
|
+
return () => {
|
|
172
|
+
keyed.delete(keys);
|
|
173
|
+
if (!keyed.size)
|
|
174
|
+
window.removeEventListener('keydown', hear, true);
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
/** The element of a row that takes focus: the row itself, a button. */
|
|
178
|
+
const own = (row) => row?.querySelector('[data-slot="sidebar-item"], [role="button"]') ?? null;
|
|
179
|
+
/**
|
|
180
|
+
* A list of conversations with their menu: a right-click on a row or a
|
|
181
|
+
* long-press opens it at the point, a row's ⋯ hangs it from itself. Its one
|
|
182
|
+
* child holds the rows (`ThreadRow`). `threads` are the list's conversations by
|
|
183
|
+
* id, `active` the one the pane holds (what the keys act on with no menu open),
|
|
184
|
+
* and `onGone` runs after that one is deleted — the room moves to a new chat —
|
|
185
|
+
* defaulting to emptying the room.
|
|
186
|
+
*/
|
|
187
|
+
export function ConversationMenu({ threads, active = null, onGone = empty, children, }) {
|
|
188
|
+
const act = useThreadActions();
|
|
189
|
+
// The conversation the pane holds NOW: a delete answered after the reader moved on leaves the room they moved to.
|
|
190
|
+
const holds = useRef(active);
|
|
191
|
+
holds.current = active;
|
|
192
|
+
// The list's own element, for the row after one deleted.
|
|
193
|
+
const list = useId();
|
|
194
|
+
const [menu, setMenu] = useState(null);
|
|
195
|
+
const [dialogue, setDialogue] = useState(null);
|
|
196
|
+
// The conversation the menu or a dialog acts on, by id, read from the list so
|
|
197
|
+
// it is current; the last one read stands in once the list lets it go (a
|
|
198
|
+
// closing Delete dialog still names it).
|
|
199
|
+
const [aimed, setAimed] = useState(null);
|
|
200
|
+
const last = useRef(null);
|
|
201
|
+
const listed = aimed ? (threads.get(aimed) ?? null) : null;
|
|
202
|
+
if (listed)
|
|
203
|
+
last.current = listed;
|
|
204
|
+
const subject = aimed ? (listed ?? (last.current?.id === aimed ? last.current : null)) : null;
|
|
205
|
+
// Where focus goes back to: the ⋯ pressed, or the row right-clicked.
|
|
206
|
+
const from = useRef(null);
|
|
207
|
+
// What a right-click or a long-press landed on, before the menu decides to open.
|
|
208
|
+
const pointed = useRef(null);
|
|
209
|
+
const ask = useCallback((d, t) => {
|
|
210
|
+
setAimed(t.id);
|
|
211
|
+
setMenu(null);
|
|
212
|
+
setDialogue(d);
|
|
213
|
+
}, []);
|
|
214
|
+
const aim = useCallback((t, el) => {
|
|
215
|
+
from.current = el;
|
|
216
|
+
setAimed(t.id);
|
|
217
|
+
}, []);
|
|
218
|
+
const point = (e) => {
|
|
219
|
+
const row = e.target?.closest?.('[data-thread]') ?? null;
|
|
220
|
+
pointed.current = row ? (threads.get(row.getAttribute('data-thread') ?? '') ?? null) : null;
|
|
221
|
+
if (pointed.current)
|
|
222
|
+
from.current = own(row);
|
|
223
|
+
};
|
|
224
|
+
// A menu shut by Escape, a choice or a press outside gives focus back where it
|
|
225
|
+
// was opened from — unless what was chosen opened a dialog, which takes it.
|
|
226
|
+
const back = (e) => {
|
|
227
|
+
e.preventDefault();
|
|
228
|
+
requestAnimationFrame(() => {
|
|
229
|
+
const now = document.activeElement;
|
|
230
|
+
if (!now || now === document.body)
|
|
231
|
+
from.current?.focus();
|
|
232
|
+
});
|
|
233
|
+
};
|
|
234
|
+
const shut = (open) => {
|
|
235
|
+
if (!open)
|
|
236
|
+
setDialogue(null);
|
|
237
|
+
};
|
|
238
|
+
const after = (e) => {
|
|
239
|
+
e.preventDefault();
|
|
240
|
+
if (from.current?.isConnected)
|
|
241
|
+
from.current.focus();
|
|
242
|
+
};
|
|
243
|
+
const keys = useRef(null);
|
|
244
|
+
keys.current = {
|
|
245
|
+
open: menu !== null,
|
|
246
|
+
held: dialogue !== null,
|
|
247
|
+
target: () => (menu !== null ? subject : active ? (threads.get(active) ?? null) : null),
|
|
248
|
+
act: (a, t) => {
|
|
249
|
+
// With no menu open, focus goes back to wherever the keys were pressed.
|
|
250
|
+
if (menu === null && document.activeElement instanceof HTMLElement)
|
|
251
|
+
from.current = document.activeElement;
|
|
252
|
+
if (a === 'rename')
|
|
253
|
+
return ask('rename', t);
|
|
254
|
+
setMenu(null);
|
|
255
|
+
void act.pin(t);
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
useEffect(() => listen(keys), []);
|
|
259
|
+
const rows = useMemo(() => ({ aim, open: menu ? aimed : null }), [aim, menu, aimed]);
|
|
260
|
+
// The menu is named by its conversation: gui would name it by its trigger, the
|
|
261
|
+
// list itself for a right-click, and the ⋯ for the other.
|
|
262
|
+
const title = subject ? titleOf(subject) : undefined;
|
|
263
|
+
const apple = mac();
|
|
264
|
+
const entries = subject
|
|
265
|
+
? [
|
|
266
|
+
{
|
|
267
|
+
key: 'rename',
|
|
268
|
+
label: 'Rename',
|
|
269
|
+
icon: _jsx(Pencil, { size: 16, "aria-hidden": true }),
|
|
270
|
+
shortcut: said('rename', apple),
|
|
271
|
+
keys: named('rename'),
|
|
272
|
+
run: () => ask('rename', subject),
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
key: 'pin',
|
|
276
|
+
label: subject.pinned ? 'Unpin' : 'Pin',
|
|
277
|
+
icon: subject.pinned ? _jsx(PinOff, { size: 16, "aria-hidden": true }) : _jsx(Pin, { size: 16, "aria-hidden": true }),
|
|
278
|
+
shortcut: said('pin', apple),
|
|
279
|
+
keys: named('pin'),
|
|
280
|
+
run: () => void act.pin(subject),
|
|
281
|
+
},
|
|
282
|
+
{ key: 'share', label: 'Share…', icon: _jsx(Share2, { size: 16, "aria-hidden": true }), run: () => ask('share', subject) },
|
|
283
|
+
{ key: 'copy', label: 'Copy link', icon: _jsx(Link2, { size: 16, "aria-hidden": true }), run: () => void copy(subject.id) },
|
|
284
|
+
{ key: 'tab', label: 'Open in new tab', icon: _jsx(ExternalLink, { size: 16, "aria-hidden": true }), run: () => away(subject.id) },
|
|
285
|
+
{
|
|
286
|
+
key: 'archive',
|
|
287
|
+
label: subject.archived ? 'Unarchive' : 'Archive',
|
|
288
|
+
icon: subject.archived ? _jsx(ArchiveRestore, { size: 16, "aria-hidden": true }) : _jsx(Archive, { size: 16, "aria-hidden": true }),
|
|
289
|
+
run: () => void act.archive(subject),
|
|
290
|
+
},
|
|
291
|
+
{ key: 'gone', line: true },
|
|
292
|
+
{ key: 'delete', label: 'Delete', icon: _jsx(Trash2, { size: 16, color: DANGER, "aria-hidden": true }), destructive: true, run: () => ask('delete', subject) },
|
|
293
|
+
]
|
|
294
|
+
: [];
|
|
295
|
+
return (_jsxs(Rows.Provider, { value: rows, children: [_jsxs(DropdownMenu, { open: menu === 'dots', onOpenChange: (open) => setMenu(open ? 'dots' : null), children: [_jsxs(ContextMenu
|
|
296
|
+
// Held here as well as inside gui's ContextMenu, so the keys can shut it.
|
|
297
|
+
, {
|
|
298
|
+
// Held here as well as inside gui's ContextMenu, so the keys can shut it.
|
|
299
|
+
open: menu === 'point', onOpenChange: (open, e) => {
|
|
300
|
+
if (!open)
|
|
301
|
+
return setMenu((m) => (m === 'point' ? null : m));
|
|
302
|
+
// Not on a conversation: the browser's own menu answers.
|
|
303
|
+
if (!pointed.current)
|
|
304
|
+
return e?.preventDefault();
|
|
305
|
+
setAimed(pointed.current.id);
|
|
306
|
+
setMenu('point');
|
|
307
|
+
}, children: [_jsx(ContextMenuTrigger, { asChild: true, "data-menu": list, onContextMenu: point, onPointerDown: (e) => {
|
|
308
|
+
if (e.pointerType !== 'mouse')
|
|
309
|
+
point(e);
|
|
310
|
+
}, children: children }), _jsx(ContextMenuContent, { minW: 220, onCloseAutoFocus: back, "aria-labelledby": undefined, "aria-label": title, children: entries.map((one) => 'line' in one ? (_jsx(ContextMenuSeparator, {}, one.key)) : (_jsx(ContextMenuItem, { variant: one.destructive ? 'destructive' : 'default', "aria-keyshortcuts": one.keys, onSelect: one.run, children: _jsx(Face, { entry: one, Shortcut: ContextMenuShortcut }) }, one.key))) })] }), _jsx(DropdownMenuContent, { align: "end", minW: 220, onCloseAutoFocus: back, "aria-labelledby": undefined, "aria-label": title, children: entries.map((one) => 'line' in one ? (_jsx(DropdownMenuSeparator, {}, one.key)) : (_jsx(DropdownMenuItem, { variant: one.destructive ? 'destructive' : 'default', "aria-keyshortcuts": one.keys, onSelect: one.run, children: _jsx(Face, { entry: one, Shortcut: DropdownMenuShortcut }) }, one.key))) })] }), _jsx(Rename, { t: subject, open: dialogue === 'rename', onOpenChange: shut, onCloseAutoFocus: after, save: (t, words) => {
|
|
311
|
+
if (words !== titleOf(t))
|
|
312
|
+
void act.rename(t, words);
|
|
313
|
+
} }), _jsx(Delete, { t: subject, open: dialogue === 'delete', onOpenChange: shut, onCloseAutoFocus: after, remove: (t) => {
|
|
314
|
+
// Focus goes on to the row after it, or the one before, not to the page.
|
|
315
|
+
const rows = [...(document.querySelector(`[data-menu="${list}"]`)?.querySelectorAll('[data-thread]') ?? [])];
|
|
316
|
+
const at = rows.findIndex((row) => row.getAttribute('data-thread') === t.id);
|
|
317
|
+
if (at >= 0)
|
|
318
|
+
from.current = own(rows[at + 1] ?? rows[at - 1] ?? null);
|
|
319
|
+
void act.remove(t).then((ok) => {
|
|
320
|
+
if (ok && holds.current === t.id)
|
|
321
|
+
onGone();
|
|
322
|
+
});
|
|
323
|
+
} }), _jsx(ShareDialog, { thread: subject?.id ?? null, open: dialogue === 'share', onOpenChange: shut, onCloseAutoFocus: after })] }));
|
|
324
|
+
}
|
|
325
|
+
/** One entry's line: its icon in a 16px well, its name, and its keys at the end. */
|
|
326
|
+
function Face({ entry, Shortcut, }) {
|
|
327
|
+
return (_jsxs(_Fragment, { children: [_jsx(XStack, { width: 16, height: 16, items: "center", justify: "center", shrink: 0, children: entry.icon }), _jsx(Text, { flex: 1, minW: 0, fontSize: "$2", color: entry.destructive ? DANGER : '$ink', numberOfLines: 1, children: entry.label }), entry.shortcut ? _jsx(Shortcut, { "aria-hidden": true, children: entry.shortcut }) : null] }));
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* One conversation's row, inside a `ConversationMenu`: its menu opens by a
|
|
331
|
+
* right-click anywhere on it, a long-press, or its ⋯; a middle-click or a
|
|
332
|
+
* ⌘/Ctrl-click opens it in a new tab.
|
|
333
|
+
*
|
|
334
|
+
* The ⋯ stands where `aside` (the row's age) stands, and takes its place while
|
|
335
|
+
* the row is hovered, focused, open in the pane or has its menu open, and
|
|
336
|
+
* always on a touch screen, which has no hover to find it by; the rest of the
|
|
337
|
+
* time the age reads there.
|
|
338
|
+
*/
|
|
339
|
+
export function ThreadRow({ t, active = false, aside, listed = true, children, }) {
|
|
340
|
+
const menus = useContext(Rows);
|
|
341
|
+
if (!menus)
|
|
342
|
+
throw new Error('ThreadRow is drawn inside a ConversationMenu, which holds its menu.');
|
|
343
|
+
const [focused, setFocused] = useState(false);
|
|
344
|
+
// Its own id: gui gives every trigger of one menu the menu's one id.
|
|
345
|
+
const id = useId();
|
|
346
|
+
// What the ⋯ is for, said after its name: the row's title.
|
|
347
|
+
const about = useId();
|
|
348
|
+
// The ⋯ and the age share one place: whichever shows, the other gives way.
|
|
349
|
+
const shown = active || menus.open === t.id || focused;
|
|
350
|
+
// gui types these handlers by React Native's event; on the web they carry the DOM's.
|
|
351
|
+
const aim = (e) => menus.aim(t, e.currentTarget);
|
|
352
|
+
// A new tab for the row itself, never for its ⋯.
|
|
353
|
+
const onDots = (target) => Boolean(target?.closest?.('[data-slot="thread-options"]'));
|
|
354
|
+
return (_jsxs(YStack, { ...(listed ? { role: 'listitem' } : null), position: "relative", group: true, "data-thread": t.id, "data-pinned": t.pinned ? '' : undefined, onClickCapture: (press) => {
|
|
355
|
+
const e = press;
|
|
356
|
+
if (!(e.metaKey || e.ctrlKey) || onDots(e.target))
|
|
357
|
+
return;
|
|
358
|
+
e.preventDefault();
|
|
359
|
+
e.stopPropagation();
|
|
360
|
+
away(t.id);
|
|
361
|
+
}, onMouseDown: (e) => {
|
|
362
|
+
// The middle button's own autoscroll would start here.
|
|
363
|
+
if (e.button === 1)
|
|
364
|
+
e.preventDefault();
|
|
365
|
+
}, onMouseUp: (e) => {
|
|
366
|
+
if (e.button !== 1 || onDots(e.target))
|
|
367
|
+
return;
|
|
368
|
+
e.preventDefault();
|
|
369
|
+
away(t.id);
|
|
370
|
+
}, children: [children, _jsx(Text, { id: about, display: "none", children: titleOf(t) }), aside ? (_jsx(XStack, { position: "absolute", r: "$2", t: 0, b: 0, items: "center", pointerEvents: "none", opacity: shown ? 0 : 1, "$group-hover": { opacity: 0 }, $touchable: { opacity: 0 }, children: aside })) : null, _jsx(XStack, { position: "absolute", r: "$1", t: 0, b: 0, items: "center", children: _jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(XStack, { render: "button", "aria-label": "Chat options", "aria-describedby": about, id: id, "data-slot": "thread-options", width: 26, height: 26, rounded: "$2", items: "center", justify: "center", cursor: "pointer", bg: "transparent", opacity: shown ? 1 : 0, "$group-hover": { opacity: 1 },
|
|
371
|
+
// A thumb's 44px on a touch screen (lib/tap.ts), centred on the row and over its edges.
|
|
372
|
+
$touchable: { opacity: 1, minW: 44, minH: 44 }, hoverStyle: { bg: '$hover' },
|
|
373
|
+
// Before gui's own press handler, which opens the menu and stops there.
|
|
374
|
+
onPointerDownCapture: aim, onKeyDownCapture: aim, onFocus: () => setFocused(true), onBlur: () => setFocused(false), children: _jsx(MoreHorizontal, { size: 15, "aria-hidden": true }) }) }) })] }));
|
|
375
|
+
}
|
|
376
|
+
/** A conversation's title, written again: one line, never empty. */
|
|
377
|
+
function Rename({ t, open, onOpenChange, onCloseAutoFocus, save, }) {
|
|
378
|
+
const [words, setWords] = useState('');
|
|
379
|
+
// The title as it stood when the dialog opened; a list read again meanwhile does not overwrite the typing.
|
|
380
|
+
const id = t?.id;
|
|
381
|
+
useEffect(() => {
|
|
382
|
+
if (open && t)
|
|
383
|
+
setWords(titleOf(t));
|
|
384
|
+
}, [open, id]);
|
|
385
|
+
const submit = () => {
|
|
386
|
+
const title = words.trim();
|
|
387
|
+
// Once: a second Enter while the dialog closes asks nothing.
|
|
388
|
+
if (!open || !title || !t)
|
|
389
|
+
return;
|
|
390
|
+
onOpenChange(false);
|
|
391
|
+
save(t, title);
|
|
392
|
+
};
|
|
393
|
+
return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { maxW: 440, rounded: "$6", gap: "$3", onCloseAutoFocus: onCloseAutoFocus, children: [_jsx(DialogTitle, { children: "Rename chat" }), _jsx(Input, { autoFocus: true, value: words, onChangeText: setWords, "aria-label": "Chat title", maxLength: 80, onKeyDown: (e) => {
|
|
394
|
+
// An Enter that ends an IME composition writes the word; it does not save.
|
|
395
|
+
if (e.key === 'Enter' && !e.nativeEvent?.isComposing && e.keyCode !== 229)
|
|
396
|
+
submit();
|
|
397
|
+
} }), _jsxs(XStack, { gap: "$2", justify: "flex-end", children: [_jsx(Button, { size: "sm", variant: "outline", rounded: 999, onPress: () => onOpenChange(false), children: "Cancel" }), _jsx(Button, { size: "sm", rounded: 999, disabled: !words.trim(), onPress: submit, children: "Save" })] })] }) }));
|
|
398
|
+
}
|
|
399
|
+
/** Deleting asks first, and says what goes with it: every turn, and every link it was shared by. */
|
|
400
|
+
function Delete({ t, open, onOpenChange, onCloseAutoFocus, remove, }) {
|
|
401
|
+
return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { maxW: 440, rounded: "$6", gap: "$3", onCloseAutoFocus: onCloseAutoFocus, children: [_jsx(DialogTitle, { children: "Delete chat?" }), _jsx(SizableText, { size: "$2", color: "$soft", children: `“${t ? titleOf(t) : ''}” is deleted for good, with every link it was shared by. To keep it out of the list instead, archive it.` }), _jsxs(XStack, { gap: "$2", justify: "flex-end", children: [_jsx(Button, { size: "sm", variant: "outline", rounded: 999, onPress: () => onOpenChange(false), children: "Cancel" }), _jsx(Button, { size: "sm", variant: "destructive", rounded: 999, disabled: !t, onPress: () => {
|
|
402
|
+
// Once: a second press while the dialog closes would ask the store to delete it again.
|
|
403
|
+
if (!open || !t)
|
|
404
|
+
return;
|
|
405
|
+
onOpenChange(false);
|
|
406
|
+
remove(t);
|
|
407
|
+
}, children: "Delete" })] })] }) }));
|
|
408
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanzo/rooms",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.37",
|
|
4
4
|
"description": "The workspace rooms — Home, Chat, Inbox, Contacts, Meet, Cal, Drive, Board, Work, Bots — and the frame that holds them, once, for every Hanzo surface.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"simple-icons": "16.30.0"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@hanzo/ai": ">=0.6.
|
|
50
|
+
"@hanzo/ai": ">=0.6.23",
|
|
51
51
|
"@hanzo/appearance": ">=0.2.6",
|
|
52
52
|
"@hanzo/build": ">=0.2.34",
|
|
53
53
|
"@hanzo/composer": ">=0.2.0",
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
"react-dom": ">=19.2.8"
|
|
66
66
|
},
|
|
67
67
|
"devDependencies": {
|
|
68
|
-
"@hanzo/ai": "0.6.
|
|
68
|
+
"@hanzo/ai": "0.6.23",
|
|
69
69
|
"@hanzo/appearance": "0.2.8",
|
|
70
70
|
"@hanzo/build": "0.2.34",
|
|
71
71
|
"@hanzo/composer": "0.2.0",
|