bom-pi 0.0.0-stage → 0.0.2
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 +33 -2
- package/apps/README.md +7 -0
- package/apps/web/README.md +21 -0
- package/apps/web/pocket-adapter.mjs +323 -0
- package/apps/web/public/LICENSE.pocket +21 -0
- package/apps/web/public/activity-fold.js +28 -0
- package/apps/web/public/agent-now.js +37 -0
- package/apps/web/public/agent-tile.js +79 -0
- package/apps/web/public/app.js +975 -0
- package/apps/web/public/apple-touch-icon.png +0 -0
- package/apps/web/public/artifact-bridge.js +687 -0
- package/apps/web/public/avatar.js +87 -0
- package/apps/web/public/back.js +554 -0
- package/apps/web/public/box-keys.js +216 -0
- package/apps/web/public/browser-panel.js +1097 -0
- package/apps/web/public/browser.js +68 -0
- package/apps/web/public/calls.js +139 -0
- package/apps/web/public/chat.js +919 -0
- package/apps/web/public/clock.js +34 -0
- package/apps/web/public/commands.js +578 -0
- package/apps/web/public/composer.js +2068 -0
- package/apps/web/public/diff-parse.js +672 -0
- package/apps/web/public/diff.js +1646 -0
- package/apps/web/public/dock.js +299 -0
- package/apps/web/public/extension-ui.js +2734 -0
- package/apps/web/public/files-panel.js +775 -0
- package/apps/web/public/files.js +368 -0
- package/apps/web/public/fonts/FiraCode-VF.woff2 +0 -0
- package/apps/web/public/fonts/Geist.woff2 +0 -0
- package/apps/web/public/fonts/IBMPlexMono-Variable-Italic.woff2 +0 -0
- package/apps/web/public/fonts/IBMPlexMono-Variable.woff2 +0 -0
- package/apps/web/public/fonts/InterVariable-Italic.woff2 +0 -0
- package/apps/web/public/fonts/InterVariable.woff2 +0 -0
- package/apps/web/public/fonts/Iosevka-Bold.woff2 +0 -0
- package/apps/web/public/fonts/Iosevka-BoldItalic.woff2 +0 -0
- package/apps/web/public/fonts/Iosevka-Italic.woff2 +0 -0
- package/apps/web/public/fonts/Iosevka-Regular.woff2 +0 -0
- package/apps/web/public/fonts/Iosevka-SemiBold.woff2 +0 -0
- package/apps/web/public/fonts/Iosevka-SemiBoldItalic.woff2 +0 -0
- package/apps/web/public/fonts/JetBrainsMono-Italic.woff2 +0 -0
- package/apps/web/public/fonts/JetBrainsMono.woff2 +0 -0
- package/apps/web/public/fonts/NerdFontsSymbolsMono.woff2 +0 -0
- package/apps/web/public/fonts/SOURCES.md +69 -0
- package/apps/web/public/gestures.js +610 -0
- package/apps/web/public/highlight.js +480 -0
- package/apps/web/public/history.js +70 -0
- package/apps/web/public/home.js +169 -0
- package/apps/web/public/icon-192.png +0 -0
- package/apps/web/public/icon-512.png +0 -0
- package/apps/web/public/icon-maskable-512.png +0 -0
- package/apps/web/public/icon.svg +16 -0
- package/apps/web/public/index.html +71 -0
- package/apps/web/public/launcher.js +1293 -0
- package/apps/web/public/linked-sessions.js +55 -0
- package/apps/web/public/live.js +39 -0
- package/apps/web/public/manifest.webmanifest +32 -0
- package/apps/web/public/model-refresh.js +73 -0
- package/apps/web/public/notify.js +251 -0
- package/apps/web/public/peeks.js +920 -0
- package/apps/web/public/pip-attachments.js +182 -0
- package/apps/web/public/pip-window.js +141 -0
- package/apps/web/public/pip.js +1217 -0
- package/apps/web/public/quote-reply.js +109 -0
- package/apps/web/public/quote-text.js +31 -0
- package/apps/web/public/rich.js +403 -0
- package/apps/web/public/run-frame.js +106 -0
- package/apps/web/public/safe-mode.js +71 -0
- package/apps/web/public/seen.js +169 -0
- package/apps/web/public/session-tree.js +117 -0
- package/apps/web/public/sessions.js +2695 -0
- package/apps/web/public/share.js +126 -0
- package/apps/web/public/sheets/appearance.js +820 -0
- package/apps/web/public/sheets/branch.js +353 -0
- package/apps/web/public/sheets/extensions.js +818 -0
- package/apps/web/public/sheets/file.js +318 -0
- package/apps/web/public/sheets/find.js +250 -0
- package/apps/web/public/sheets/folder.js +680 -0
- package/apps/web/public/sheets/gallery.js +2340 -0
- package/apps/web/public/sheets/hooks.js +350 -0
- package/apps/web/public/sheets/memory.js +531 -0
- package/apps/web/public/sheets/menu.js +580 -0
- package/apps/web/public/sheets/message.js +220 -0
- package/apps/web/public/sheets/model.js +412 -0
- package/apps/web/public/sheets/people.js +414 -0
- package/apps/web/public/sheets/pi-sessions.js +264 -0
- package/apps/web/public/sheets/providers.js +525 -0
- package/apps/web/public/sheets/running.js +130 -0
- package/apps/web/public/sheets/schedules.js +71 -0
- package/apps/web/public/sheets/session.js +249 -0
- package/apps/web/public/sheets/shortcuts.js +101 -0
- package/apps/web/public/sheets/spend.js +150 -0
- package/apps/web/public/sheets/text.js +53 -0
- package/apps/web/public/sheets/viewers.js +363 -0
- package/apps/web/public/sheets/worktree.js +50 -0
- package/apps/web/public/sheets.js +410 -0
- package/apps/web/public/signin.js +57 -0
- package/apps/web/public/slots.js +208 -0
- package/apps/web/public/store.js +1760 -0
- package/apps/web/public/style.css +18625 -0
- package/apps/web/public/subagents-board.js +879 -0
- package/apps/web/public/subagents.js +273 -0
- package/apps/web/public/sw.js +169 -0
- package/apps/web/public/taps.js +26 -0
- package/apps/web/public/theme-surface.d.ts +4 -0
- package/apps/web/public/theme-surface.js +266 -0
- package/apps/web/public/theme.js +845 -0
- package/apps/web/public/themes/lantern/colors.toml +25 -0
- package/apps/web/public/themes/lantern/direction.css +60 -0
- package/apps/web/public/themes/lantern/frame.css +254 -0
- package/apps/web/public/themes/lantern/theme.css +10 -0
- package/apps/web/public/themes/lantern/theme.json +7 -0
- package/apps/web/public/themes/pocket/theme.css +189 -0
- package/apps/web/public/themes/pocket/theme.json +6 -0
- package/apps/web/public/themes.js +556 -0
- package/apps/web/public/transcript.js +3348 -0
- package/apps/web/public/trust.js +295 -0
- package/apps/web/public/turns.js +507 -0
- package/apps/web/public/ui.js +1910 -0
- package/apps/web/public/voice.js +345 -0
- package/apps/web/server.mjs +774 -0
- package/package.json +43 -4
- package/packages/cli/src/bom-pi.mjs +452 -0
- package/packages/cli/templates/blank.factory.json +7 -0
- package/packages/cli/templates/dev.factory.json +11 -0
- package/packages/cli/templates/docs.factory.json +12 -0
- package/packages/cli/templates/ops.factory.json +14 -0
- package/packages/cli/templates/triage.factory.json +10 -0
- package/packages/core/src/daemon.mjs +2047 -0
- package/packages/core/src/limit-gate.mjs +192 -0
- package/packages/core/src/rpc-client.mjs +66 -0
- package/systemd/README.md +11 -0
|
@@ -0,0 +1,919 @@
|
|
|
1
|
+
// People working together beside Pi: who is here, who is typing, and a side panel Pi does not see, with the chat,
|
|
2
|
+
// pinned messages, and shared notes.
|
|
3
|
+
|
|
4
|
+
import { Component } from "preact";
|
|
5
|
+
import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks";
|
|
6
|
+
import { Avatar, initials, personColor } from "./avatar.js";
|
|
7
|
+
import {
|
|
8
|
+
actions,
|
|
9
|
+
attempt,
|
|
10
|
+
canSteer,
|
|
11
|
+
canToggleTurns,
|
|
12
|
+
chatLogShown,
|
|
13
|
+
closePeople,
|
|
14
|
+
closeSheet,
|
|
15
|
+
collab,
|
|
16
|
+
drafts,
|
|
17
|
+
insertIntoComposer,
|
|
18
|
+
isLoaded,
|
|
19
|
+
loadEarlier,
|
|
20
|
+
markChatRead,
|
|
21
|
+
notify,
|
|
22
|
+
openSheet,
|
|
23
|
+
peopleDocked,
|
|
24
|
+
revealEntry,
|
|
25
|
+
store,
|
|
26
|
+
typing,
|
|
27
|
+
} from "./store.js";
|
|
28
|
+
import { clockTime, copyText, html, Icon, Loader, Sheet, Spinner, timeAgo } from "./ui.js";
|
|
29
|
+
|
|
30
|
+
const coarse = matchMedia("(pointer: coarse)").matches;
|
|
31
|
+
|
|
32
|
+
/** Chat messages from others that this browser has not seen yet. Activity lines do not count. */
|
|
33
|
+
export function chatUnread(state = store.state) {
|
|
34
|
+
return state.chat.filter(
|
|
35
|
+
(message) =>
|
|
36
|
+
message.kind !== "event" &&
|
|
37
|
+
message.userId !== state.me?.id &&
|
|
38
|
+
message.at > state.chatRead,
|
|
39
|
+
).length;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The current name of whoever sent a message, or the name they had when they sent it. */
|
|
43
|
+
function senderName(message, users) {
|
|
44
|
+
return users.find((user) => user.id === message.userId)?.name ?? message.name;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Top bar: the other people in this session, and the chat with its unread count. Shows and hides the People panel. */
|
|
48
|
+
export function PeopleButton() {
|
|
49
|
+
const { presence, me, server } = store.state;
|
|
50
|
+
|
|
51
|
+
if (!server?.chat) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const others = presence.filter((person) => person.id !== me?.id);
|
|
56
|
+
// The one shown: someone looking now, if anyone is.
|
|
57
|
+
const shown = others.find((person) => !person.away) ?? others[0];
|
|
58
|
+
const unread = chatUnread();
|
|
59
|
+
const docked = peopleDocked();
|
|
60
|
+
const label =
|
|
61
|
+
others.length === 0
|
|
62
|
+
? "Chat"
|
|
63
|
+
: `Chat with ${others.map((person) => person.name).join(", ")}`;
|
|
64
|
+
|
|
65
|
+
// Quiet with no one else here and nothing unread, unless the panel is open: the menu has the chat.
|
|
66
|
+
return html`<button
|
|
67
|
+
class=${`people-button badge-host ${docked ? "on" : ""} ${others.length === 0 && unread === 0 && !docked ? "quiet" : ""}`}
|
|
68
|
+
aria-label=${label}
|
|
69
|
+
aria-pressed=${docked}
|
|
70
|
+
title=${label}
|
|
71
|
+
onClick=${() => (docked ? closePeople() : openSheet({ type: "chat" }))}
|
|
72
|
+
>
|
|
73
|
+
${
|
|
74
|
+
others.length === 0
|
|
75
|
+
? html`<${Icon} name="chat" />`
|
|
76
|
+
: html`<span class="people-here">
|
|
77
|
+
<${Avatar} person=${shown} size=${28} />
|
|
78
|
+
<span
|
|
79
|
+
class=${`online-mark ${shown.away ? "away" : ""}`}
|
|
80
|
+
aria-hidden="true"
|
|
81
|
+
></span>
|
|
82
|
+
</span>
|
|
83
|
+
${others.length > 1 && html`<span class="people-more">+${others.length - 1}</span>`}`
|
|
84
|
+
}
|
|
85
|
+
${unread > 0 && html`<span class="badge">${unread}</span>`}
|
|
86
|
+
</button>`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function typingText(people, where, me) {
|
|
90
|
+
const names = people
|
|
91
|
+
.filter((person) => person.typing === where && person.id !== me?.id)
|
|
92
|
+
.map((person) => person.name);
|
|
93
|
+
|
|
94
|
+
if (names.length === 0) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const who =
|
|
99
|
+
names.length === 1
|
|
100
|
+
? names[0]
|
|
101
|
+
: names.length === 2
|
|
102
|
+
? `${names[0]} and ${names[1]}`
|
|
103
|
+
: `${names.length} people`;
|
|
104
|
+
|
|
105
|
+
return `${who} ${names.length === 1 ? "is" : "are"} ${where === "pi" ? "writing to Pi" : "typing"}…`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** "Alex is writing to Pi…" above the message box, or "Alex is typing…" in the chat. */
|
|
109
|
+
export function TypingLine({ where }) {
|
|
110
|
+
const { presence, me } = store.state;
|
|
111
|
+
const text = typingText(presence, where, me);
|
|
112
|
+
|
|
113
|
+
if (!text) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return html`<div class="typing-line" aria-live="polite">
|
|
118
|
+
<span class="typing-dots"><span></span><span></span><span></span></span>
|
|
119
|
+
${text}
|
|
120
|
+
</div>`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const clock = (at) => clockTime(new Date(at));
|
|
124
|
+
|
|
125
|
+
/** The newest jump: an earlier one still loading gives way to it. */
|
|
126
|
+
let jumps = 0;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Close the sheet and scroll the transcript to a message, briefly highlighted. The docked People panel stays. Resolves
|
|
130
|
+
* true once it shows, false when it cannot: not in the session's transcript, the session changed, or a newer jump came.
|
|
131
|
+
* `quiet` leaves the "earlier history" notice to the caller (`pocket.reveal`).
|
|
132
|
+
*/
|
|
133
|
+
export function jumpToEntry(entryId, { quiet = false } = {}) {
|
|
134
|
+
closeSheet();
|
|
135
|
+
const jump = ++jumps;
|
|
136
|
+
const { view, conversationId } = store.state;
|
|
137
|
+
const current = () => jump === jumps && store.state.conversationId === conversationId;
|
|
138
|
+
|
|
139
|
+
// In the session but not loaded yet (a view brings only the newest): load back to it first.
|
|
140
|
+
if (view.order.includes(entryId) && !isLoaded(view, entryId)) {
|
|
141
|
+
return loadEarlier({ from: entryId }).then(
|
|
142
|
+
() => current() && isLoaded(store.state.view, entryId) && show(entryId, jump, quiet),
|
|
143
|
+
(error) => {
|
|
144
|
+
notify("error", error.message ?? String(error));
|
|
145
|
+
|
|
146
|
+
return false;
|
|
147
|
+
},
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return show(entryId, jump, quiet);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The scroll and flash of a jump, once the entry is loaded. */
|
|
155
|
+
function show(entryId, jump, quiet) {
|
|
156
|
+
// A message above the rows the transcript shows: show from it down. The render runs before the next frame.
|
|
157
|
+
revealEntry(entryId);
|
|
158
|
+
// An answer of only tool calls is a mark in a group of Pi's calls: the group opens (transcript.js).
|
|
159
|
+
document.dispatchEvent(new CustomEvent("pocket:reveal", { detail: entryId }));
|
|
160
|
+
|
|
161
|
+
return new Promise((resolve) =>
|
|
162
|
+
requestAnimationFrame(() => {
|
|
163
|
+
const found = document.getElementById(`entry-${entryId}`);
|
|
164
|
+
|
|
165
|
+
if (jump !== jumps) {
|
|
166
|
+
return resolve(false);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (!found) {
|
|
170
|
+
if (!quiet) {
|
|
171
|
+
notify(
|
|
172
|
+
"info",
|
|
173
|
+
"That message is in earlier history: use “Show earlier messages” at the top.",
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return resolve(false);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// The mark has no size: its group's line is what shows.
|
|
181
|
+
const element = found.classList.contains("entry-anchor")
|
|
182
|
+
? (found.closest(".activity")?.querySelector(".activity-head") ?? found)
|
|
183
|
+
: found;
|
|
184
|
+
|
|
185
|
+
// A long one from its top, where it starts: centered, its middle would show, and what was found may be above.
|
|
186
|
+
const tall = element.getBoundingClientRect().height > innerHeight / 2;
|
|
187
|
+
const still = document.documentElement.dataset.motion === "reduced";
|
|
188
|
+
|
|
189
|
+
element.scrollIntoView({
|
|
190
|
+
block: tall ? "start" : "center",
|
|
191
|
+
behavior: still ? "auto" : "smooth",
|
|
192
|
+
});
|
|
193
|
+
element.classList.remove("flash");
|
|
194
|
+
void element.offsetWidth;
|
|
195
|
+
element.classList.add("flash");
|
|
196
|
+
resolve(true);
|
|
197
|
+
}),
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Chat text with `@Name` mentions of known people highlighted (and the reader's own name more so). */
|
|
202
|
+
function MessageText({ text, mentions, users, me }) {
|
|
203
|
+
const named = users
|
|
204
|
+
.filter((user) => mentions?.includes(user.id))
|
|
205
|
+
.sort((a, b) => b.name.length - a.name.length);
|
|
206
|
+
|
|
207
|
+
if (named.length === 0) {
|
|
208
|
+
return text;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const parts = [];
|
|
212
|
+
let rest = text;
|
|
213
|
+
|
|
214
|
+
while (rest.length > 0) {
|
|
215
|
+
const lower = rest.toLowerCase();
|
|
216
|
+
let best = null;
|
|
217
|
+
|
|
218
|
+
for (const user of named) {
|
|
219
|
+
const at = lower.indexOf(`@${user.name.toLowerCase()}`);
|
|
220
|
+
|
|
221
|
+
if (at !== -1 && (best === null || at < best.at)) {
|
|
222
|
+
best = { at, user };
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (best === null) {
|
|
227
|
+
parts.push(rest);
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
parts.push(rest.slice(0, best.at));
|
|
232
|
+
const length = best.user.name.length + 1;
|
|
233
|
+
|
|
234
|
+
parts.push(
|
|
235
|
+
html`<span class=${`mention ${best.user.id === me?.id ? "me" : ""}`}>
|
|
236
|
+
${rest.slice(best.at, best.at + length)}
|
|
237
|
+
</span>`,
|
|
238
|
+
);
|
|
239
|
+
rest = rest.slice(best.at + length);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return parts;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* One line of the chat log. The whole app renders again on every update while Pi streams; a line renders again only
|
|
247
|
+
* when something it shows changed, so a long chat stays light.
|
|
248
|
+
*/
|
|
249
|
+
class ChatLine extends Component {
|
|
250
|
+
shouldComponentUpdate(next) {
|
|
251
|
+
const now = this.props;
|
|
252
|
+
|
|
253
|
+
return !["message", "grouped", "open", "pinned", "users", "me", "steer"].every(
|
|
254
|
+
(key) => now[key] === next[key],
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
render({ message, grouped, open, pinned, users, me, steer, onSelect }) {
|
|
259
|
+
if (message.kind === "event") {
|
|
260
|
+
return html`<div class="chat-event">
|
|
261
|
+
<span style=${`color:${personColor(message.userId)}`}>${message.userId === me?.id ? "You" : senderName(message, users)}</span> ${message.text} · ${clock(message.at)}
|
|
262
|
+
</div>`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const mine = message.userId === me?.id;
|
|
266
|
+
|
|
267
|
+
return html`<div
|
|
268
|
+
data-chat=${message.id}
|
|
269
|
+
class=${`chat-msg ${mine ? "mine" : ""} ${grouped ? "grouped" : ""} ${open ? "open" : ""} ${message.mentions?.includes(me?.id) ? "mentions-me" : ""}`}
|
|
270
|
+
style=${`--who:${personColor(message.userId)}`}
|
|
271
|
+
>
|
|
272
|
+
${
|
|
273
|
+
!grouped &&
|
|
274
|
+
html`<div class="chat-meta">
|
|
275
|
+
<span class="chat-name">${mine ? "You" : senderName(message, users)}</span>
|
|
276
|
+
<span class="muted">${clock(message.at)}</span>
|
|
277
|
+
${pinned && html`<${Icon} name="pin" size=${11} class="muted" />`}
|
|
278
|
+
</div>`
|
|
279
|
+
}
|
|
280
|
+
${
|
|
281
|
+
message.quote &&
|
|
282
|
+
html`<button class="chat-quote" onClick=${() => jumpToEntry(message.quote.entryId)}>
|
|
283
|
+
${message.quote.text}
|
|
284
|
+
</button>`
|
|
285
|
+
}
|
|
286
|
+
<div class="chat-text" onClick=${() => collab() && onSelect(open ? null : message.id)}>
|
|
287
|
+
<${MessageText}
|
|
288
|
+
text=${message.text}
|
|
289
|
+
mentions=${message.mentions}
|
|
290
|
+
users=${users}
|
|
291
|
+
me=${me}
|
|
292
|
+
/>
|
|
293
|
+
</div>
|
|
294
|
+
${
|
|
295
|
+
open &&
|
|
296
|
+
html`<div class="chat-actions">
|
|
297
|
+
${
|
|
298
|
+
steer &&
|
|
299
|
+
html`<button
|
|
300
|
+
class="link small"
|
|
301
|
+
onClick=${() => insertIntoComposer(`> ${senderName(message, users)}: ${message.text.replace(/\n/g, "\n> ")}\n\n`)}
|
|
302
|
+
>
|
|
303
|
+
Send to Pi
|
|
304
|
+
</button>`
|
|
305
|
+
}
|
|
306
|
+
<button
|
|
307
|
+
class="link small"
|
|
308
|
+
onClick=${() => attempt(() => actions.pin({ chatId: message.id }))}
|
|
309
|
+
>
|
|
310
|
+
${pinned ? "Unpin" : "Pin"}
|
|
311
|
+
</button>
|
|
312
|
+
<button
|
|
313
|
+
class="link small"
|
|
314
|
+
onClick=${() =>
|
|
315
|
+
copyText(message.text).then(
|
|
316
|
+
() => notify("info", "Copied."),
|
|
317
|
+
() => notify("error", "Could not copy."),
|
|
318
|
+
)}
|
|
319
|
+
>
|
|
320
|
+
Copy
|
|
321
|
+
</button>
|
|
322
|
+
</div>`
|
|
323
|
+
}
|
|
324
|
+
</div>`;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** The `@query` being typed right before the caret, if any. */
|
|
329
|
+
function mentionQuery(value, caret) {
|
|
330
|
+
const before = value.slice(0, caret);
|
|
331
|
+
const match = /(^|\s)@([^\s@]{0,30})$/.exec(before);
|
|
332
|
+
|
|
333
|
+
return match ? { query: match[2], start: caret - match[2].length - 1 } : null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** The chat. `focus` puts the caret in its box on desktops: not when a reload brings back the People panel. */
|
|
337
|
+
function ChatTab({ highlight, focus }) {
|
|
338
|
+
const { chat, me, users, conversationId, chatQuote, view } = store.state;
|
|
339
|
+
const draftKey = `chat-${conversationId}`;
|
|
340
|
+
const [text, setText] = useState(() => drafts.get(draftKey));
|
|
341
|
+
const [sending, setSending] = useState(false);
|
|
342
|
+
const [selected, setSelected] = useState(highlight ?? null);
|
|
343
|
+
const [mention, setMention] = useState(null);
|
|
344
|
+
const log = useRef(null);
|
|
345
|
+
const box = useRef(null);
|
|
346
|
+
const pinned = new Set((view.pins ?? []).map((pin) => pin.chatId).filter(Boolean));
|
|
347
|
+
|
|
348
|
+
// The newest message, not the count: the chat keeps its last 500, so the count stops changing.
|
|
349
|
+
const newest = chat.at(-1)?.id;
|
|
350
|
+
|
|
351
|
+
useEffect(() => markChatRead(), [newest]);
|
|
352
|
+
useEffect(() => chatLogShown(), []);
|
|
353
|
+
// Whether the log follows its newest message, and where it last put itself: a scroll to anywhere else is the reader's.
|
|
354
|
+
const follow = useRef({ on: !highlight, at: -1 });
|
|
355
|
+
|
|
356
|
+
const stick = () => {
|
|
357
|
+
const element = log.current;
|
|
358
|
+
|
|
359
|
+
if (element && follow.current.on) {
|
|
360
|
+
element.scrollTop = element.scrollHeight;
|
|
361
|
+
follow.current.at = element.scrollTop;
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
useLayoutEffect(() => {
|
|
366
|
+
const element = log.current;
|
|
367
|
+
|
|
368
|
+
if (!element) {
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const target = highlight && element.querySelector(`[data-chat="${CSS.escape(highlight)}"]`);
|
|
373
|
+
|
|
374
|
+
if (target) {
|
|
375
|
+
follow.current.on = false;
|
|
376
|
+
target.scrollIntoView({ block: "center" });
|
|
377
|
+
} else {
|
|
378
|
+
follow.current.on = true;
|
|
379
|
+
stick();
|
|
380
|
+
}
|
|
381
|
+
}, [newest]);
|
|
382
|
+
// Following, it stays at the bottom while things settle: the panel opening, a pin showing above it, the box under it
|
|
383
|
+
// growing, a message taking its room.
|
|
384
|
+
useEffect(() => {
|
|
385
|
+
const element = log.current;
|
|
386
|
+
|
|
387
|
+
if (!element) {
|
|
388
|
+
return undefined;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const onScroll = () => {
|
|
392
|
+
// Its own scroll, or the browser holding it at the end as the log grew: still following.
|
|
393
|
+
if (Math.abs(element.scrollTop - follow.current.at) <= 1) {
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
follow.current.on =
|
|
398
|
+
element.scrollHeight - element.scrollTop - element.clientHeight < 24;
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
const sizes = new ResizeObserver(stick);
|
|
402
|
+
|
|
403
|
+
const watch = () => {
|
|
404
|
+
for (const child of element.children) {
|
|
405
|
+
sizes.observe(child);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
stick();
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
const children = new MutationObserver(watch);
|
|
412
|
+
|
|
413
|
+
element.addEventListener("scroll", onScroll, { passive: true });
|
|
414
|
+
sizes.observe(element);
|
|
415
|
+
children.observe(element, { childList: true });
|
|
416
|
+
watch();
|
|
417
|
+
|
|
418
|
+
return () => {
|
|
419
|
+
element.removeEventListener("scroll", onScroll);
|
|
420
|
+
sizes.disconnect();
|
|
421
|
+
children.disconnect();
|
|
422
|
+
};
|
|
423
|
+
}, []);
|
|
424
|
+
useEffect(() => {
|
|
425
|
+
if (focus && !coarse) {
|
|
426
|
+
box.current?.focus();
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return () => typing(null);
|
|
430
|
+
}, []);
|
|
431
|
+
useEffect(() => {
|
|
432
|
+
const element = box.current;
|
|
433
|
+
|
|
434
|
+
if (!element) {
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
element.style.height = "auto";
|
|
439
|
+
element.style.height = `${Math.min(element.scrollHeight, innerHeight * 0.3)}px`;
|
|
440
|
+
}, [text]);
|
|
441
|
+
|
|
442
|
+
const update = (value, caret = value.length) => {
|
|
443
|
+
setText(value);
|
|
444
|
+
drafts.set(draftKey, value);
|
|
445
|
+
typing(value.trim() === "" ? null : "chat");
|
|
446
|
+
setMention(collab() ? mentionQuery(value, caret) : null);
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
const candidates = mention
|
|
450
|
+
? users
|
|
451
|
+
.filter(
|
|
452
|
+
(user) =>
|
|
453
|
+
user.id !== me?.id &&
|
|
454
|
+
user.name.toLowerCase().includes(mention.query.toLowerCase()),
|
|
455
|
+
)
|
|
456
|
+
.slice(0, 5)
|
|
457
|
+
: [];
|
|
458
|
+
|
|
459
|
+
const pick = (user) => {
|
|
460
|
+
const element = box.current;
|
|
461
|
+
const caret = element?.selectionStart ?? text.length;
|
|
462
|
+
const value = `${text.slice(0, mention.start)}@${user.name} ${text.slice(caret)}`;
|
|
463
|
+
|
|
464
|
+
update(value, mention.start + user.name.length + 2);
|
|
465
|
+
setMention(null);
|
|
466
|
+
requestAnimationFrame(() => {
|
|
467
|
+
element?.focus();
|
|
468
|
+
const at = mention.start + user.name.length + 2;
|
|
469
|
+
|
|
470
|
+
element?.setSelectionRange(at, at);
|
|
471
|
+
});
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
const send = async () => {
|
|
475
|
+
if (text.trim() === "" || sending) {
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
setSending(true);
|
|
480
|
+
const ok = await attempt(() => actions.chat(text, chatQuote));
|
|
481
|
+
|
|
482
|
+
setSending(false);
|
|
483
|
+
|
|
484
|
+
if (ok) {
|
|
485
|
+
update("");
|
|
486
|
+
store.set({ chatQuote: null });
|
|
487
|
+
|
|
488
|
+
if (!coarse) {
|
|
489
|
+
box.current?.focus();
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
return html`<div class="chat-label">
|
|
495
|
+
<${Icon} name="eye" size=${12} />Side chat · Pi does not see this chat.
|
|
496
|
+
</div>
|
|
497
|
+
<div class="chat-log" ref=${log}>
|
|
498
|
+
${chat.length === 0 && html`<div class="muted">No messages yet.</div>`}
|
|
499
|
+
${chat.map((message, index) => {
|
|
500
|
+
const previous = chat[index - 1];
|
|
501
|
+
const grouped =
|
|
502
|
+
message.kind !== "event" &&
|
|
503
|
+
previous?.kind !== "event" &&
|
|
504
|
+
previous?.userId === message.userId &&
|
|
505
|
+
message.at - previous.at < 5 * 60_000 &&
|
|
506
|
+
!message.quote;
|
|
507
|
+
|
|
508
|
+
return html`<${ChatLine}
|
|
509
|
+
key=${message.id}
|
|
510
|
+
message=${message}
|
|
511
|
+
grouped=${grouped}
|
|
512
|
+
open=${selected === message.id}
|
|
513
|
+
pinned=${pinned.has(message.id)}
|
|
514
|
+
users=${users}
|
|
515
|
+
me=${me}
|
|
516
|
+
steer=${canSteer()}
|
|
517
|
+
onSelect=${setSelected}
|
|
518
|
+
/>`;
|
|
519
|
+
})}
|
|
520
|
+
</div>
|
|
521
|
+
<${TypingLine} where="chat" />
|
|
522
|
+
${
|
|
523
|
+
chatQuote &&
|
|
524
|
+
html`<div class="quote-draft">
|
|
525
|
+
<span class="muted small">Discussing</span> <span class="quote-text">${chatQuote.text}</span>
|
|
526
|
+
<button
|
|
527
|
+
class="icon-button small"
|
|
528
|
+
aria-label="Stop quoting"
|
|
529
|
+
onClick=${() => store.set({ chatQuote: null })}
|
|
530
|
+
>
|
|
531
|
+
<${Icon} name="close" size=${12} />
|
|
532
|
+
</button>
|
|
533
|
+
</div>`
|
|
534
|
+
}
|
|
535
|
+
${
|
|
536
|
+
candidates.length > 0 &&
|
|
537
|
+
html`<div class="mention-list">
|
|
538
|
+
${candidates.map(
|
|
539
|
+
(user) =>
|
|
540
|
+
html`<button
|
|
541
|
+
class="list-item"
|
|
542
|
+
onMouseDown=${(event) => event.preventDefault()}
|
|
543
|
+
onClick=${() => pick(user)}
|
|
544
|
+
>
|
|
545
|
+
<span>
|
|
546
|
+
<span class="avatar" style=${`--who:${personColor(user.id)};width:20px;height:20px`}>${initials(user.name)}</span> ${user.name}
|
|
547
|
+
</span>
|
|
548
|
+
<span class="muted small">${user.online ? "online" : ""}</span>
|
|
549
|
+
</button>`,
|
|
550
|
+
)}
|
|
551
|
+
</div>`
|
|
552
|
+
}
|
|
553
|
+
<div class="chat-compose">
|
|
554
|
+
<textarea
|
|
555
|
+
ref=${box}
|
|
556
|
+
rows="1"
|
|
557
|
+
maxlength="4000"
|
|
558
|
+
value=${text}
|
|
559
|
+
placeholder=${collab() ? "Message… @ to mention" : "Message the people here…"}
|
|
560
|
+
onInput=${(event) => update(event.currentTarget.value, event.currentTarget.selectionStart)}
|
|
561
|
+
onKeyDown=${(event) => {
|
|
562
|
+
if (
|
|
563
|
+
candidates.length > 0 &&
|
|
564
|
+
(event.key === "Tab" || (event.key === "Enter" && !event.shiftKey))
|
|
565
|
+
) {
|
|
566
|
+
event.preventDefault();
|
|
567
|
+
pick(candidates[0]);
|
|
568
|
+
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (event.key === "Escape" && mention) {
|
|
573
|
+
event.stopPropagation();
|
|
574
|
+
setMention(null);
|
|
575
|
+
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (event.key === "Enter" && !event.shiftKey && !coarse && !event.isComposing) {
|
|
580
|
+
event.preventDefault();
|
|
581
|
+
send();
|
|
582
|
+
}
|
|
583
|
+
}}
|
|
584
|
+
enterkeyhint=${coarse ? "enter" : "send"}
|
|
585
|
+
></textarea>
|
|
586
|
+
<button
|
|
587
|
+
class="round send"
|
|
588
|
+
aria-label="Send"
|
|
589
|
+
disabled=${text.trim() === "" || sending}
|
|
590
|
+
onClick=${send}
|
|
591
|
+
>
|
|
592
|
+
${sending ? html`<${Spinner} />` : html`<${Icon} name="send" size=${18} />`}
|
|
593
|
+
</button>
|
|
594
|
+
</div>`;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function PinsTab({ onShowChat }) {
|
|
598
|
+
const { view, users } = store.state;
|
|
599
|
+
const pins = [...(view.pins ?? [])].reverse();
|
|
600
|
+
const by = (id) => users.find((user) => user.id === id)?.name ?? "someone";
|
|
601
|
+
|
|
602
|
+
if (pins.length === 0) {
|
|
603
|
+
return html`<p class="muted">
|
|
604
|
+
Nothing pinned yet. Pin a reply of Pi's or a chat message to keep it at hand for everyone here.
|
|
605
|
+
</p>`;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
return html`<div class="pin-list">
|
|
609
|
+
${pins.map(
|
|
610
|
+
(pin) => html`<div class="pin" key=${pin.id}>
|
|
611
|
+
<button
|
|
612
|
+
class="pin-main"
|
|
613
|
+
onClick=${() => (pin.entryId !== undefined ? jumpToEntry(pin.entryId) : onShowChat(pin.chatId))}
|
|
614
|
+
>
|
|
615
|
+
<div class="pin-meta">
|
|
616
|
+
<span
|
|
617
|
+
class="chat-name"
|
|
618
|
+
style=${`--who:${pin.author === "Pi" ? "var(--brand)" : personColor(users.find((user) => user.name === pin.author)?.id ?? pin.author)}`}
|
|
619
|
+
>
|
|
620
|
+
${pin.author}
|
|
621
|
+
</span>
|
|
622
|
+
<span class="muted">
|
|
623
|
+
${pin.chatId ? "in chat" : "in the conversation"} · pinned by ${by(pin.by)} ${timeAgo(pin.at)}
|
|
624
|
+
</span>
|
|
625
|
+
</div>
|
|
626
|
+
<div class="pin-text">${pin.text}</div>
|
|
627
|
+
</button>
|
|
628
|
+
<button
|
|
629
|
+
class="icon-button small"
|
|
630
|
+
title="Unpin"
|
|
631
|
+
aria-label="Unpin"
|
|
632
|
+
onClick=${() => attempt(() => actions.pin(pin.entryId !== undefined ? { entryId: pin.entryId } : { chatId: pin.chatId }))}
|
|
633
|
+
>
|
|
634
|
+
<${Icon} name="close" size=${12} />
|
|
635
|
+
</button>
|
|
636
|
+
</div>`,
|
|
637
|
+
)}
|
|
638
|
+
</div>`;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function NotesTab() {
|
|
642
|
+
const { notes, users } = store.state;
|
|
643
|
+
const [draft, setDraft] = useState(null);
|
|
644
|
+
const [base, setBase] = useState(notes?.rev ?? 0);
|
|
645
|
+
const [saving, setSaving] = useState(false);
|
|
646
|
+
|
|
647
|
+
if (notes === null) {
|
|
648
|
+
return html`<${Loader} label="Loading notes" />`;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const editing = draft !== null;
|
|
652
|
+
const changedMeanwhile = editing && notes.rev !== base;
|
|
653
|
+
const by = notes.by ? (users.find((user) => user.id === notes.by)?.name ?? "someone") : null;
|
|
654
|
+
|
|
655
|
+
const save = async (rev = base) => {
|
|
656
|
+
setSaving(true);
|
|
657
|
+
const saved = await attempt(() => actions.saveNotes(draft, rev));
|
|
658
|
+
|
|
659
|
+
setSaving(false);
|
|
660
|
+
|
|
661
|
+
if (saved) {
|
|
662
|
+
setDraft(null);
|
|
663
|
+
setBase(saved.rev);
|
|
664
|
+
}
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
return html`<p class="muted small">
|
|
668
|
+
One page of notes for everyone here: the plan, decisions, who does what. Pi does not see it unless you send it.
|
|
669
|
+
</p>
|
|
670
|
+
${
|
|
671
|
+
changedMeanwhile &&
|
|
672
|
+
html`<div class="error-box small">
|
|
673
|
+
${by ?? "Someone"} saved the notes while you were editing.
|
|
674
|
+
<button
|
|
675
|
+
class="link small"
|
|
676
|
+
onClick=${() => {
|
|
677
|
+
setDraft(null);
|
|
678
|
+
setBase(notes.rev);
|
|
679
|
+
}}
|
|
680
|
+
>
|
|
681
|
+
Use theirs
|
|
682
|
+
</button> · <button class="link small" onClick=${() => save(notes.rev)}>Keep mine</button>
|
|
683
|
+
</div>`
|
|
684
|
+
}
|
|
685
|
+
<textarea
|
|
686
|
+
class="notes"
|
|
687
|
+
rows="10"
|
|
688
|
+
value=${editing ? draft : notes.text}
|
|
689
|
+
placeholder="Write the plan here…"
|
|
690
|
+
onInput=${(event) => {
|
|
691
|
+
if (!editing) {
|
|
692
|
+
setBase(notes.rev);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
setDraft(event.currentTarget.value);
|
|
696
|
+
}}
|
|
697
|
+
></textarea>
|
|
698
|
+
<div class="row">
|
|
699
|
+
<span class="muted small grow">
|
|
700
|
+
${by ? `Saved by ${by} ${timeAgo(notes.at)}` : "Not saved yet"}
|
|
701
|
+
${editing ? " · unsaved changes" : ""}
|
|
702
|
+
</span>
|
|
703
|
+
${
|
|
704
|
+
canSteer() &&
|
|
705
|
+
notes.text &&
|
|
706
|
+
!editing &&
|
|
707
|
+
html`<button
|
|
708
|
+
class="button small"
|
|
709
|
+
onClick=${() => insertIntoComposer(`Shared notes:\n\n${notes.text}\n\n`)}
|
|
710
|
+
>
|
|
711
|
+
Send to Pi
|
|
712
|
+
</button>`
|
|
713
|
+
}
|
|
714
|
+
${
|
|
715
|
+
editing &&
|
|
716
|
+
html`<button class="button small ghost" onClick=${() => setDraft(null)}>Cancel</button>`
|
|
717
|
+
}
|
|
718
|
+
<button
|
|
719
|
+
class="button small primary"
|
|
720
|
+
disabled=${!editing || saving || changedMeanwhile}
|
|
721
|
+
onClick=${() => save()}
|
|
722
|
+
>
|
|
723
|
+
${saving ? "Saving…" : "Save"}
|
|
724
|
+
</button>
|
|
725
|
+
</div>`;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/** The session's people and the same take-turns shortcut as the menu, with its driver permissions. */
|
|
729
|
+
function PeopleRoster() {
|
|
730
|
+
const { presence, users, me, view } = store.state;
|
|
731
|
+
const turns = view.turns;
|
|
732
|
+
const driver = users.find((person) => person.id === turns?.driver)?.name ?? "No one";
|
|
733
|
+
const canToggle = canToggleTurns();
|
|
734
|
+
|
|
735
|
+
return html`<div class="people-roster">
|
|
736
|
+
${presence.map((person) => {
|
|
737
|
+
const role = users.find((user) => user.id === person.id)?.role;
|
|
738
|
+
const writing = person.typing && person.id !== me?.id;
|
|
739
|
+
const state = writing
|
|
740
|
+
? person.typing === "pi"
|
|
741
|
+
? "writing to Pi…"
|
|
742
|
+
: "typing…"
|
|
743
|
+
: person.away
|
|
744
|
+
? "away"
|
|
745
|
+
: person.id === me?.id
|
|
746
|
+
? "here · you"
|
|
747
|
+
: "here";
|
|
748
|
+
const roleLabel =
|
|
749
|
+
role === "owner" ? "owner" : role === "viewer" ? "view only" : "can steer";
|
|
750
|
+
|
|
751
|
+
return html`<div class="people-person" key=${person.id}>
|
|
752
|
+
<span class="people-here">
|
|
753
|
+
<${Avatar} person=${person} size=${28} />
|
|
754
|
+
<span
|
|
755
|
+
class=${`online-mark ${person.away ? "away" : ""}`}
|
|
756
|
+
aria-hidden="true"
|
|
757
|
+
></span>
|
|
758
|
+
</span>
|
|
759
|
+
<span class="people-person-info">
|
|
760
|
+
<span class="people-person-name">${person.name}</span>
|
|
761
|
+
<span class=${`people-person-state ${writing ? "typing" : ""}`}>
|
|
762
|
+
${state}
|
|
763
|
+
</span>
|
|
764
|
+
</span>
|
|
765
|
+
<span class="people-role">${roleLabel}</span>
|
|
766
|
+
</div>`;
|
|
767
|
+
})}
|
|
768
|
+
</div>
|
|
769
|
+
${
|
|
770
|
+
collab() &&
|
|
771
|
+
html`<div class="people-turns">
|
|
772
|
+
<span class="grow">
|
|
773
|
+
<span class="people-turns-title">
|
|
774
|
+
${turns?.on ? `${driver} is driving` : "Take turns"}
|
|
775
|
+
</span>
|
|
776
|
+
<span class="people-turns-hint">
|
|
777
|
+
${turns?.on ? "Only the driver can send to Pi" : "One person drives Pi at a time"}
|
|
778
|
+
</span>
|
|
779
|
+
</span>
|
|
780
|
+
${
|
|
781
|
+
canToggle &&
|
|
782
|
+
html`<button
|
|
783
|
+
class=${`button small ${turns?.on ? "" : "primary"}`}
|
|
784
|
+
onClick=${() => attempt(() => actions.turns(turns?.on ? "off" : "on"))}
|
|
785
|
+
>
|
|
786
|
+
${turns?.on ? "Turn off" : "Start"}
|
|
787
|
+
</button>`
|
|
788
|
+
}
|
|
789
|
+
</div>`
|
|
790
|
+
}`;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/** Chat, pinned, and notes: the People sheet's on phones, the People panel's on wide screens. `ask` picks what shows first. */
|
|
794
|
+
function PeopleTabs({ ask, focus }) {
|
|
795
|
+
const { view, users } = store.state;
|
|
796
|
+
const featured = view.pins?.at(-1);
|
|
797
|
+
const pinnedBy = users.find((user) => user.id === featured?.by)?.name ?? "someone";
|
|
798
|
+
const [tab, setTab] = useState(ask.tab ?? "chat");
|
|
799
|
+
const [highlight, setHighlight] = useState(ask.highlight ?? null);
|
|
800
|
+
const pins = view.pins?.length ?? 0;
|
|
801
|
+
// Messages arriving while another tab is open: the top bar's badge counts them, and so does the tab.
|
|
802
|
+
const unread = tab === "chat" ? 0 : chatUnread();
|
|
803
|
+
const tabs = collab()
|
|
804
|
+
? html`<div class="segmented tabs">
|
|
805
|
+
<button class=${tab === "chat" ? "on" : ""} onClick=${() => setTab("chat")}>
|
|
806
|
+
Chat${unread > 0 ? ` ${unread}` : ""}
|
|
807
|
+
</button>
|
|
808
|
+
<button class=${tab === "pins" ? "on" : ""} onClick=${() => setTab("pins")}>
|
|
809
|
+
Pinned${pins > 0 ? ` ${pins}` : ""}
|
|
810
|
+
</button>
|
|
811
|
+
<button class=${tab === "notes" ? "on" : ""} onClick=${() => setTab("notes")}>
|
|
812
|
+
Notes
|
|
813
|
+
</button>
|
|
814
|
+
</div>`
|
|
815
|
+
: null;
|
|
816
|
+
|
|
817
|
+
return html`<div class="people-content">
|
|
818
|
+
<${PeopleRoster} />
|
|
819
|
+
${tabs}
|
|
820
|
+
${
|
|
821
|
+
tab === "chat" &&
|
|
822
|
+
featured &&
|
|
823
|
+
html`<button
|
|
824
|
+
class="people-pin"
|
|
825
|
+
onClick=${() => (featured.entryId !== undefined ? jumpToEntry(featured.entryId) : setHighlight(featured.chatId))}
|
|
826
|
+
>
|
|
827
|
+
<${Icon} name="pin" size=${13} />
|
|
828
|
+
<span><b>Pinned by ${pinnedBy}</b> · ${featured.text}</span>
|
|
829
|
+
</button>`
|
|
830
|
+
}
|
|
831
|
+
${
|
|
832
|
+
tab === "chat" &&
|
|
833
|
+
html`<${ChatTab} key=${highlight ?? "chat"} highlight=${highlight} focus=${focus} />`
|
|
834
|
+
}
|
|
835
|
+
${
|
|
836
|
+
tab === "pins" &&
|
|
837
|
+
html`<${PinsTab}
|
|
838
|
+
onShowChat=${(chatId) => {
|
|
839
|
+
setHighlight(chatId);
|
|
840
|
+
setTab("chat");
|
|
841
|
+
}}
|
|
842
|
+
/>`
|
|
843
|
+
}
|
|
844
|
+
${tab === "notes" && html`<${NotesTab} />`}
|
|
845
|
+
</div>`;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
export function ChatSheet() {
|
|
849
|
+
// While the sheet animates out, the store has no sheet any more.
|
|
850
|
+
const sheet = store.state.sheet ?? {};
|
|
851
|
+
|
|
852
|
+
return html`<${Sheet}
|
|
853
|
+
title="People"
|
|
854
|
+
className="people-sheet"
|
|
855
|
+
onClose=${closeSheet}
|
|
856
|
+
actions=${html`<span class="people-count">${store.state.presence.length}</span>`}
|
|
857
|
+
>
|
|
858
|
+
<${PeopleTabs} ask=${sheet} focus=${true} />
|
|
859
|
+
<//>`;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/** Esc in the panel leaves its field, and never counts toward the two that stop Pi. */
|
|
863
|
+
function keepEscape(event) {
|
|
864
|
+
if (event.key !== "Escape" || event.defaultPrevented) {
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
event.preventDefault();
|
|
869
|
+
|
|
870
|
+
if (event.currentTarget.contains(document.activeElement)) {
|
|
871
|
+
document.activeElement.blur();
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/** The last request to show the People panel that it answered. Outlives the panel: coming back to it is no request. */
|
|
876
|
+
let answeredAsk = 0;
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* Wide screens: the People sheet as a window of its own beside the conversation, sliding in from the right. Each request
|
|
880
|
+
* to show it (a pin, a link, Discuss) starts it afresh on what it asked for, with the caret in the chat; another session,
|
|
881
|
+
* or the panel coming back after the home screen or a narrow window, starts it afresh too, but leaves the caret be.
|
|
882
|
+
*/
|
|
883
|
+
export function PeoplePanel({ leaving = false }) {
|
|
884
|
+
const { peopleAsk, conversationId } = store.state;
|
|
885
|
+
const focus = peopleAsk.n > answeredAsk;
|
|
886
|
+
|
|
887
|
+
useEffect(() => {
|
|
888
|
+
answeredAsk = peopleAsk.n;
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
return html`<section
|
|
892
|
+
class=${`people window ${leaving ? "leaving" : ""}`}
|
|
893
|
+
data-part="people-panel"
|
|
894
|
+
data-state=${leaving ? "closing" : undefined}
|
|
895
|
+
aria-label="People"
|
|
896
|
+
inert=${leaving}
|
|
897
|
+
onKeyDown=${keepEscape}
|
|
898
|
+
>
|
|
899
|
+
<header class="people-head">
|
|
900
|
+
<h2>People</h2>
|
|
901
|
+
<span class="people-count">${store.state.presence.length}</span>
|
|
902
|
+
<button
|
|
903
|
+
class="icon-button"
|
|
904
|
+
aria-label="Close the People panel"
|
|
905
|
+
title="Close"
|
|
906
|
+
onClick=${closePeople}
|
|
907
|
+
>
|
|
908
|
+
<${Icon} name="close" />
|
|
909
|
+
</button>
|
|
910
|
+
</header>
|
|
911
|
+
<div class="sheet-body">
|
|
912
|
+
<${PeopleTabs}
|
|
913
|
+
key=${`${conversationId}:${peopleAsk.n}`}
|
|
914
|
+
ask=${peopleAsk}
|
|
915
|
+
focus=${focus}
|
|
916
|
+
/>
|
|
917
|
+
</div>
|
|
918
|
+
</section>`;
|
|
919
|
+
}
|