@frockbot/plugin-flock 0.0.0 → 0.1.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 +8 -1
- package/assets/amber-glasses.webp +0 -0
- package/assets/background-bright-orange.webp +0 -0
- package/assets/background-canary-yellow.webp +0 -0
- package/assets/background-electric-blue.webp +0 -0
- package/assets/background-hot-pink.webp +0 -0
- package/assets/background-lime-green.webp +0 -0
- package/assets/background-vivid-purple.webp +0 -0
- package/assets/blue-sailor-cap.webp +0 -0
- package/assets/bronze-aviator-goggles.webp +0 -0
- package/assets/burgundy-beret.webp +0 -0
- package/assets/canary-party-hat.webp +0 -0
- package/assets/canary-rain-cap.webp +0 -0
- package/assets/canonical.webp +0 -0
- package/assets/chef-toque.webp +0 -0
- package/assets/cobalt-round-glasses.webp +0 -0
- package/assets/coral-bucket-hat.webp +0 -0
- package/assets/emerald-necktie.webp +0 -0
- package/assets/forest-ranger-fedora.webp +0 -0
- package/assets/gold-royal-crown.webp +0 -0
- package/assets/gold-sun-medallion.webp +0 -0
- package/assets/graduation-cap.webp +0 -0
- package/assets/green-square-glasses.webp +0 -0
- package/assets/layers.css +159 -0
- package/assets/leather-bell.webp +0 -0
- package/assets/lilac-flower-crown.webp +0 -0
- package/assets/manifest.json +769 -0
- package/assets/matte-black-top-hat.webp +0 -0
- package/assets/navy-wizard-hat.webp +0 -0
- package/assets/orange-hard-hat.webp +0 -0
- package/assets/plum-glasses.webp +0 -0
- package/assets/purple-scarf.webp +0 -0
- package/assets/purple-witch-hat.webp +0 -0
- package/assets/rainbow-propeller-cap.webp +0 -0
- package/assets/rainbow-visor.webp +0 -0
- package/assets/ranger-fedora-feather.webp +0 -0
- package/assets/red-polka-bowtie.webp +0 -0
- package/assets/retro-3d-glasses.webp +0 -0
- package/assets/rose-heart-sunglasses.webp +0 -0
- package/assets/royal-crown-rubies.webp +0 -0
- package/assets/sailor-cap-anchor.webp +0 -0
- package/assets/silver-space-helmet.webp +0 -0
- package/assets/straw-boater.webp +0 -0
- package/assets/teal-bowtie.webp +0 -0
- package/assets/teal-glasses.webp +0 -0
- package/assets/terracotta-beanie.webp +0 -0
- package/assets/tortoiseshell-readers.webp +0 -0
- package/assets/turquoise-bandana.webp +0 -0
- package/assets/violet-masquerade-mask.webp +0 -0
- package/assets/white-snow-goggles.webp +0 -0
- package/assets/white-tennis-visor.webp +0 -0
- package/assets/witch-hat-moon.webp +0 -0
- package/assets/yellow-star-glasses.webp +0 -0
- package/frockbot.json +38 -0
- package/package.json +47 -6
- package/src/agent.test.ts +401 -0
- package/src/agent.ts +571 -0
- package/src/assets.test.ts +31 -0
- package/src/backend.test.ts +205 -0
- package/src/backend.ts +283 -0
- package/src/bot.test.ts +190 -0
- package/src/bot.ts +255 -0
- package/src/client/BotAvatar.vue +20 -0
- package/src/client/FlockAvatar.vue +33 -0
- package/src/client/FlockAvatarEditor.vue +44 -0
- package/src/client/FlockCreateButton.vue +18 -0
- package/src/client/FlockIdentity.vue +31 -0
- package/src/client/FlockOverlay.vue +209 -0
- package/src/client/FlockSidebar.vue +254 -0
- package/src/client/SheepAvatar.vue +28 -0
- package/src/client/dialog-focus.test.ts +15 -0
- package/src/client/dialog-focus.ts +13 -0
- package/src/client/index.test.ts +502 -0
- package/src/client/index.ts +619 -0
- package/src/client/mobile-dialog-layout.test.ts +50 -0
- package/src/client/pending-create.test.ts +71 -0
- package/src/client/pending-create.ts +93 -0
- package/src/client/sidebar.test.ts +77 -0
- package/src/client/sidebar.ts +77 -0
- package/src/client/state.ts +53 -0
- package/src/client/styles.css +611 -0
- package/src/env.d.ts +9 -0
- package/src/index.ts +4 -0
- package/src/manifest.ts +3 -0
- package/src/package.test.ts +12 -0
- package/src/shared.test.ts +197 -0
- package/src/shared.ts +735 -0
- package/src/user.test.ts +455 -0
- package/src/user.ts +465 -0
- package/tsconfig.json +15 -0
- package/validate_readability.py +64 -0
- package/vite.config.ts +30 -0
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
import type { ClientPlugin } from "@frockbot/client-core";
|
|
2
|
+
import type { FrockBotWebData } from "@frockbot/plugin-shell/shared";
|
|
3
|
+
import { ref, type Ref } from "vue";
|
|
4
|
+
import {
|
|
5
|
+
decodeBotLifecycleDirectoryViewV1,
|
|
6
|
+
decodeBotIdentityDirectoryViewV1,
|
|
7
|
+
decodeBotLifecycleReceiptV1,
|
|
8
|
+
decodeCreateBotCommandV1,
|
|
9
|
+
decodeDirectoryViewV1,
|
|
10
|
+
decodeFlockReceiptV1,
|
|
11
|
+
decodeSheepIdentityViewV1,
|
|
12
|
+
decodeUpdateSheepCommandV1,
|
|
13
|
+
randomSheepRecipeV1,
|
|
14
|
+
type CreateBotCommandV1,
|
|
15
|
+
} from "../shared.js";
|
|
16
|
+
// Vue slot implementations remain owned by the hosted Flock Contribution.
|
|
17
|
+
import FlockSidebar from "./FlockSidebar.vue";
|
|
18
|
+
import FlockOverlay from "./FlockOverlay.vue";
|
|
19
|
+
import FlockIdentity from "./FlockIdentity.vue";
|
|
20
|
+
import FlockAvatar from "./FlockAvatar.vue";
|
|
21
|
+
import FlockAvatarEditor from "./FlockAvatarEditor.vue";
|
|
22
|
+
import FlockCreateButton from "./FlockCreateButton.vue";
|
|
23
|
+
import {
|
|
24
|
+
decodeBotNotificationDirectoryViewV1,
|
|
25
|
+
decodeBotUnreadDirectoryViewV1,
|
|
26
|
+
decodeBotUnreadReceiptV1,
|
|
27
|
+
} from "@frockbot/plugin-shell/unread";
|
|
28
|
+
import { showClientNotificationV1 } from "@frockbot/plugin-shell/client/notify";
|
|
29
|
+
import {
|
|
30
|
+
clearPendingCreate,
|
|
31
|
+
clearPendingSheep,
|
|
32
|
+
isDefinitiveFlockFailure,
|
|
33
|
+
readPendingCreate,
|
|
34
|
+
readPendingSheep,
|
|
35
|
+
writePendingCreate,
|
|
36
|
+
writePendingSheep,
|
|
37
|
+
} from "./pending-create.js";
|
|
38
|
+
import { flockWebDataKey, type FlockWebData } from "./state.js";
|
|
39
|
+
import "../../assets/layers.css";
|
|
40
|
+
import "./styles.css";
|
|
41
|
+
|
|
42
|
+
function slug(name: string): string {
|
|
43
|
+
const base =
|
|
44
|
+
name
|
|
45
|
+
.toLowerCase()
|
|
46
|
+
.normalize("NFKD")
|
|
47
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
48
|
+
.replace(/^-|-$/g, "")
|
|
49
|
+
.slice(0, 80) || "bot";
|
|
50
|
+
return `${base}-${crypto.randomUUID().slice(0, 8)}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function replacePreferredBot(botId?: string): void {
|
|
54
|
+
let url: URL;
|
|
55
|
+
try {
|
|
56
|
+
url = new URL(window.location.href);
|
|
57
|
+
} catch {
|
|
58
|
+
throw new Error("Hosted application URL is invalid");
|
|
59
|
+
}
|
|
60
|
+
if (botId) url.searchParams.set("bot", botId);
|
|
61
|
+
else url.searchParams.delete("bot");
|
|
62
|
+
window.history.replaceState(window.history.state, "", url);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* How often the sidebar re-reads the unread fan-out and the pending intents of
|
|
67
|
+
* Bots nobody is looking at. A poll refreshes badges; it never clears one —
|
|
68
|
+
* "read" is an authenticated command the User's own selection sends.
|
|
69
|
+
*/
|
|
70
|
+
const UNREAD_POLL_INTERVAL_MS = 15_000;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Notification permission has to start inside a browser gesture. Bot creation
|
|
74
|
+
* records notification intent regardless of the browser's answer, so this
|
|
75
|
+
* progressive enhancement is deliberately fire-and-forget.
|
|
76
|
+
*/
|
|
77
|
+
function requestNotificationPermissionFromCreateGesture(): void {
|
|
78
|
+
if (
|
|
79
|
+
typeof window === "undefined" ||
|
|
80
|
+
!("Notification" in window) ||
|
|
81
|
+
Notification.permission !== "default"
|
|
82
|
+
) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
void Notification.requestPermission().catch(() => undefined);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const flockClientPlugin: ClientPlugin = (ctx) => {
|
|
89
|
+
if (!ctx.transport.hostedRequest)
|
|
90
|
+
throw new Error("Flock hosted transport is unavailable");
|
|
91
|
+
const request = ctx.transport.hostedRequest.bind(ctx.transport);
|
|
92
|
+
let shell: Ref<FrockBotWebData> | undefined;
|
|
93
|
+
let authenticatedUserId: string | undefined;
|
|
94
|
+
let loadGeneration = 0;
|
|
95
|
+
let selectionGeneration = 0;
|
|
96
|
+
/** Intents already shown by this page, so a poll cannot show one twice. */
|
|
97
|
+
const deliveredNotifications = new Set<string>();
|
|
98
|
+
|
|
99
|
+
async function requireAuthenticatedUserId(): Promise<string> {
|
|
100
|
+
if (!ctx.transport.readAuthenticatedUserId)
|
|
101
|
+
throw new Error("Authenticated User identity is unavailable");
|
|
102
|
+
const userId = await ctx.transport.readAuthenticatedUserId();
|
|
103
|
+
if (!userId) throw new Error("Authenticated User identity is unavailable");
|
|
104
|
+
return userId;
|
|
105
|
+
}
|
|
106
|
+
const state = ref<FlockWebData>({
|
|
107
|
+
directory: { schemaVersion: 1, revision: 0, bots: [] },
|
|
108
|
+
identities: {},
|
|
109
|
+
profiles: {},
|
|
110
|
+
unread: {},
|
|
111
|
+
lifecycles: {},
|
|
112
|
+
showArchived: false,
|
|
113
|
+
showHidden: false,
|
|
114
|
+
loading: false,
|
|
115
|
+
draftName: "",
|
|
116
|
+
draftSheep: randomSheepRecipeV1(),
|
|
117
|
+
bindShell(value) {
|
|
118
|
+
shell = value;
|
|
119
|
+
},
|
|
120
|
+
async load() {
|
|
121
|
+
const generation = ++loadGeneration;
|
|
122
|
+
state.value.loading = true;
|
|
123
|
+
state.value.error = undefined;
|
|
124
|
+
try {
|
|
125
|
+
const userId = await requireAuthenticatedUserId();
|
|
126
|
+
const [directory, lifecycleDirectory] = await Promise.all([
|
|
127
|
+
request("/api/bots").then(decodeDirectoryViewV1),
|
|
128
|
+
request("/api/bots/lifecycles").then(
|
|
129
|
+
decodeBotLifecycleDirectoryViewV1,
|
|
130
|
+
),
|
|
131
|
+
]);
|
|
132
|
+
if (generation !== loadGeneration) return;
|
|
133
|
+
authenticatedUserId = userId;
|
|
134
|
+
state.value.directory = directory;
|
|
135
|
+
state.value.lifecycles = Object.fromEntries(
|
|
136
|
+
lifecycleDirectory.lifecycles.map((item) => [
|
|
137
|
+
item.botId,
|
|
138
|
+
item.status,
|
|
139
|
+
]),
|
|
140
|
+
);
|
|
141
|
+
const pending = readPendingCreate(userId);
|
|
142
|
+
if (pending) {
|
|
143
|
+
if (directory.bots.some((bot) => bot.botId === pending.botId)) {
|
|
144
|
+
clearPendingCreate(userId);
|
|
145
|
+
} else {
|
|
146
|
+
let receipt;
|
|
147
|
+
try {
|
|
148
|
+
receipt = decodeFlockReceiptV1(
|
|
149
|
+
await request("/api/bots", "POST", JSON.stringify(pending)),
|
|
150
|
+
);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
if (isDefinitiveFlockFailure(error)) {
|
|
153
|
+
clearPendingCreate(userId);
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (receipt) {
|
|
158
|
+
clearPendingCreate(userId);
|
|
159
|
+
if (receipt.status === "rejected")
|
|
160
|
+
throw new Error(
|
|
161
|
+
receipt.failure ?? "Pending Bot creation was rejected",
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
state.value.directory = decodeDirectoryViewV1(
|
|
165
|
+
await request("/api/bots"),
|
|
166
|
+
);
|
|
167
|
+
if (
|
|
168
|
+
state.value.directory.bots.some(
|
|
169
|
+
(bot) => bot.botId === pending.botId,
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
clearPendingCreate(userId);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const identities = await Promise.all(
|
|
176
|
+
state.value.directory.bots.map(
|
|
177
|
+
async (bot) =>
|
|
178
|
+
[
|
|
179
|
+
bot.botId,
|
|
180
|
+
decodeSheepIdentityViewV1(
|
|
181
|
+
await request(
|
|
182
|
+
`/api/bots/${encodeURIComponent(bot.botId)}/sheep`,
|
|
183
|
+
),
|
|
184
|
+
),
|
|
185
|
+
] as const,
|
|
186
|
+
),
|
|
187
|
+
);
|
|
188
|
+
const profiles = decodeBotIdentityDirectoryViewV1(
|
|
189
|
+
await request("/api/bots/identities"),
|
|
190
|
+
);
|
|
191
|
+
if (generation !== loadGeneration) return;
|
|
192
|
+
state.value.identities = Object.fromEntries(identities);
|
|
193
|
+
state.value.profiles = Object.fromEntries(
|
|
194
|
+
profiles.identities.map((profile) => [profile.botId, profile]),
|
|
195
|
+
);
|
|
196
|
+
for (const [botId, identity] of identities) {
|
|
197
|
+
const pendingSheep = readPendingSheep(userId, botId);
|
|
198
|
+
if (
|
|
199
|
+
pendingSheep &&
|
|
200
|
+
identity.revision > pendingSheep.expectedRevision &&
|
|
201
|
+
JSON.stringify(identity.sheep) ===
|
|
202
|
+
JSON.stringify(pendingSheep.sheep)
|
|
203
|
+
)
|
|
204
|
+
clearPendingSheep(userId, botId);
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
await state.value.refreshUnread();
|
|
208
|
+
} catch {
|
|
209
|
+
// A badge that cannot be read never blocks the flock from loading.
|
|
210
|
+
}
|
|
211
|
+
if (generation !== loadGeneration) return;
|
|
212
|
+
const preferred = new URL(window.location.href).searchParams.get("bot");
|
|
213
|
+
const activeBots = state.value.directory.bots.filter(
|
|
214
|
+
(bot) => state.value.lifecycles[bot.botId] !== "archived",
|
|
215
|
+
);
|
|
216
|
+
const preferredBot = preferred
|
|
217
|
+
? activeBots.find((bot) => bot.botId === preferred)
|
|
218
|
+
: undefined;
|
|
219
|
+
const selected = preferredBot?.botId ?? activeBots[0]?.botId;
|
|
220
|
+
if (preferred && !preferredBot) replacePreferredBot(selected);
|
|
221
|
+
if (selected && shell) await state.value.select(selected);
|
|
222
|
+
else if (!selected) state.value.openCreate();
|
|
223
|
+
} catch (error) {
|
|
224
|
+
state.value.error =
|
|
225
|
+
error instanceof Error ? error.message : "Could not load your flock";
|
|
226
|
+
} finally {
|
|
227
|
+
if (generation === loadGeneration) state.value.loading = false;
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
async refreshUnread() {
|
|
231
|
+
const directory = decodeBotUnreadDirectoryViewV1(
|
|
232
|
+
await request("/api/bots/unread"),
|
|
233
|
+
);
|
|
234
|
+
state.value.unread = Object.fromEntries(
|
|
235
|
+
directory.unread.map((view) => [view.botId, view]),
|
|
236
|
+
);
|
|
237
|
+
},
|
|
238
|
+
async markRead(botId) {
|
|
239
|
+
const cursor = state.value.unread[botId]?.lastActivityCursor;
|
|
240
|
+
// Nothing has ever settled on this Bot: there is no cursor to read up to.
|
|
241
|
+
if (!cursor) return;
|
|
242
|
+
const receipt = decodeBotUnreadReceiptV1(
|
|
243
|
+
await request(
|
|
244
|
+
`/api/bots/${encodeURIComponent(botId)}/unread`,
|
|
245
|
+
"POST",
|
|
246
|
+
JSON.stringify({
|
|
247
|
+
schemaVersion: 1,
|
|
248
|
+
type: "bot/mark-read",
|
|
249
|
+
commandId: crypto.randomUUID(),
|
|
250
|
+
botId,
|
|
251
|
+
upToCursor: cursor,
|
|
252
|
+
}),
|
|
253
|
+
),
|
|
254
|
+
);
|
|
255
|
+
state.value.unread[botId] = receipt.unread;
|
|
256
|
+
},
|
|
257
|
+
async markUnread(botId) {
|
|
258
|
+
const receipt = decodeBotUnreadReceiptV1(
|
|
259
|
+
await request(
|
|
260
|
+
`/api/bots/${encodeURIComponent(botId)}/unread`,
|
|
261
|
+
"POST",
|
|
262
|
+
JSON.stringify({
|
|
263
|
+
schemaVersion: 1,
|
|
264
|
+
type: "bot/mark-unread",
|
|
265
|
+
commandId: crypto.randomUUID(),
|
|
266
|
+
botId,
|
|
267
|
+
}),
|
|
268
|
+
),
|
|
269
|
+
);
|
|
270
|
+
state.value.unread[botId] = receipt.unread;
|
|
271
|
+
},
|
|
272
|
+
async select(botId) {
|
|
273
|
+
const generation = ++selectionGeneration;
|
|
274
|
+
if (!state.value.directory.bots.some((bot) => bot.botId === botId))
|
|
275
|
+
throw new Error("Bot is not registered");
|
|
276
|
+
if (state.value.lifecycles[botId] === "archived")
|
|
277
|
+
throw new Error("Bot is archived");
|
|
278
|
+
if (!state.value.identities[botId]) {
|
|
279
|
+
const identity = decodeSheepIdentityViewV1(
|
|
280
|
+
await request(`/api/bots/${encodeURIComponent(botId)}/sheep`),
|
|
281
|
+
);
|
|
282
|
+
if (generation !== selectionGeneration) return;
|
|
283
|
+
state.value.identities[botId] = identity;
|
|
284
|
+
}
|
|
285
|
+
if (generation !== selectionGeneration) return;
|
|
286
|
+
if (!shell) throw new Error("Shell selection is unavailable");
|
|
287
|
+
await shell.value.selectBot(botId);
|
|
288
|
+
// Selecting a thread while looking at it is what "read" means. A
|
|
289
|
+
// background poll that refreshed the same runs must never do this.
|
|
290
|
+
if (
|
|
291
|
+
generation === selectionGeneration &&
|
|
292
|
+
typeof document !== "undefined" &&
|
|
293
|
+
document.visibilityState === "visible"
|
|
294
|
+
) {
|
|
295
|
+
try {
|
|
296
|
+
await state.value.markRead(botId);
|
|
297
|
+
} catch {
|
|
298
|
+
// The badge stays until the next selection; nothing durable is lost.
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
openCreate() {
|
|
303
|
+
const pending = authenticatedUserId
|
|
304
|
+
? readPendingCreate(authenticatedUserId)
|
|
305
|
+
: undefined;
|
|
306
|
+
state.value.draftName = pending?.name ?? "";
|
|
307
|
+
state.value.draftSheep = pending?.sheep
|
|
308
|
+
? structuredClone(pending.sheep)
|
|
309
|
+
: randomSheepRecipeV1();
|
|
310
|
+
state.value.overlay = "create";
|
|
311
|
+
state.value.error = undefined;
|
|
312
|
+
},
|
|
313
|
+
toggleHidden() {
|
|
314
|
+
state.value.showHidden = !state.value.showHidden;
|
|
315
|
+
},
|
|
316
|
+
toggleArchived() {
|
|
317
|
+
state.value.showArchived = !state.value.showArchived;
|
|
318
|
+
},
|
|
319
|
+
openArchive(botId) {
|
|
320
|
+
if (state.value.lifecycles[botId] === "archived") return;
|
|
321
|
+
state.value.lifecyclePending = botId;
|
|
322
|
+
state.value.overlay = "archive";
|
|
323
|
+
state.value.error = undefined;
|
|
324
|
+
},
|
|
325
|
+
async archive() {
|
|
326
|
+
const botId = state.value.lifecyclePending;
|
|
327
|
+
if (!botId) return;
|
|
328
|
+
try {
|
|
329
|
+
const receipt = decodeBotLifecycleReceiptV1(
|
|
330
|
+
await request(
|
|
331
|
+
`/api/bots/${encodeURIComponent(botId)}/lifecycle`,
|
|
332
|
+
"POST",
|
|
333
|
+
JSON.stringify({
|
|
334
|
+
schemaVersion: 1,
|
|
335
|
+
type: "bot/archive",
|
|
336
|
+
commandId: crypto.randomUUID(),
|
|
337
|
+
botId,
|
|
338
|
+
}),
|
|
339
|
+
),
|
|
340
|
+
);
|
|
341
|
+
if (receipt.status === "rejected")
|
|
342
|
+
throw new Error(receipt.failure ?? "Bot archive was rejected");
|
|
343
|
+
if (receipt.status === "pending") {
|
|
344
|
+
state.value.error = "Bot archive is retrying in the backend.";
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
state.value.overlay = undefined;
|
|
348
|
+
state.value.lifecyclePending = undefined;
|
|
349
|
+
await state.value.load();
|
|
350
|
+
} catch (error) {
|
|
351
|
+
state.value.error =
|
|
352
|
+
error instanceof Error ? error.message : "Could not archive Bot";
|
|
353
|
+
}
|
|
354
|
+
},
|
|
355
|
+
async restore(botId) {
|
|
356
|
+
try {
|
|
357
|
+
const receipt = decodeBotLifecycleReceiptV1(
|
|
358
|
+
await request(
|
|
359
|
+
`/api/bots/${encodeURIComponent(botId)}/lifecycle`,
|
|
360
|
+
"POST",
|
|
361
|
+
JSON.stringify({
|
|
362
|
+
schemaVersion: 1,
|
|
363
|
+
type: "bot/restore",
|
|
364
|
+
commandId: crypto.randomUUID(),
|
|
365
|
+
botId,
|
|
366
|
+
}),
|
|
367
|
+
),
|
|
368
|
+
);
|
|
369
|
+
if (receipt.status === "rejected")
|
|
370
|
+
throw new Error(receipt.failure ?? "Bot restore was rejected");
|
|
371
|
+
if (receipt.status === "pending") {
|
|
372
|
+
state.value.error = "Bot restore is retrying in the backend.";
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
await state.value.load();
|
|
376
|
+
} catch (error) {
|
|
377
|
+
state.value.error =
|
|
378
|
+
error instanceof Error ? error.message : "Could not restore Bot";
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
async openEdit() {
|
|
382
|
+
const botId = shell?.value.activeBotId;
|
|
383
|
+
let identity = botId ? state.value.identities[botId] : undefined;
|
|
384
|
+
if (!identity || !botId) return;
|
|
385
|
+
const userId = await requireAuthenticatedUserId();
|
|
386
|
+
authenticatedUserId = userId;
|
|
387
|
+
const pending = readPendingSheep(userId, botId);
|
|
388
|
+
let reconciliationFailure: string | undefined;
|
|
389
|
+
if (pending) {
|
|
390
|
+
try {
|
|
391
|
+
const receipt = decodeFlockReceiptV1(
|
|
392
|
+
await request(
|
|
393
|
+
`/api/bots/${encodeURIComponent(botId)}/sheep`,
|
|
394
|
+
"POST",
|
|
395
|
+
JSON.stringify(pending),
|
|
396
|
+
),
|
|
397
|
+
);
|
|
398
|
+
clearPendingSheep(userId, botId);
|
|
399
|
+
if (receipt.status === "rejected")
|
|
400
|
+
reconciliationFailure =
|
|
401
|
+
receipt.failure ?? "Pending sheep update was rejected";
|
|
402
|
+
} catch (error) {
|
|
403
|
+
if (!isDefinitiveFlockFailure(error)) {
|
|
404
|
+
state.value.error =
|
|
405
|
+
"A previous sheep update could not be reconciled. Try again before editing.";
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
clearPendingSheep(userId, botId);
|
|
409
|
+
reconciliationFailure =
|
|
410
|
+
error instanceof Error
|
|
411
|
+
? error.message
|
|
412
|
+
: "Pending sheep update was rejected";
|
|
413
|
+
}
|
|
414
|
+
try {
|
|
415
|
+
identity = decodeSheepIdentityViewV1(
|
|
416
|
+
await request(`/api/bots/${encodeURIComponent(botId)}/sheep`),
|
|
417
|
+
);
|
|
418
|
+
state.value.identities[botId] = identity;
|
|
419
|
+
} catch (error) {
|
|
420
|
+
state.value.error =
|
|
421
|
+
error instanceof Error
|
|
422
|
+
? error.message
|
|
423
|
+
: "Could not reconcile the previous sheep update";
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
state.value.draftSheep = structuredClone(identity.sheep);
|
|
428
|
+
state.value.overlay = "edit";
|
|
429
|
+
state.value.error = reconciliationFailure;
|
|
430
|
+
},
|
|
431
|
+
closeOverlay() {
|
|
432
|
+
state.value.overlay = undefined;
|
|
433
|
+
state.value.lifecyclePending = undefined;
|
|
434
|
+
state.value.error = undefined;
|
|
435
|
+
},
|
|
436
|
+
reroll() {
|
|
437
|
+
state.value.draftSheep = randomSheepRecipeV1();
|
|
438
|
+
},
|
|
439
|
+
async create() {
|
|
440
|
+
requestNotificationPermissionFromCreateGesture();
|
|
441
|
+
try {
|
|
442
|
+
const userId = await requireAuthenticatedUserId();
|
|
443
|
+
authenticatedUserId = userId;
|
|
444
|
+
const command =
|
|
445
|
+
readPendingCreate(userId) ??
|
|
446
|
+
decodeCreateBotCommandV1({
|
|
447
|
+
schemaVersion: 1,
|
|
448
|
+
type: "bot/create",
|
|
449
|
+
commandId: crypto.randomUUID(),
|
|
450
|
+
expectedRevision: state.value.directory.revision,
|
|
451
|
+
botId: slug(state.value.draftName),
|
|
452
|
+
name: state.value.draftName,
|
|
453
|
+
sheep: state.value.draftSheep,
|
|
454
|
+
} satisfies CreateBotCommandV1);
|
|
455
|
+
writePendingCreate(userId, command);
|
|
456
|
+
const receipt = decodeFlockReceiptV1(
|
|
457
|
+
await request("/api/bots", "POST", JSON.stringify(command)),
|
|
458
|
+
);
|
|
459
|
+
if (receipt.status === "rejected") {
|
|
460
|
+
clearPendingCreate(userId);
|
|
461
|
+
throw new Error(receipt.failure ?? "Bot creation was rejected");
|
|
462
|
+
}
|
|
463
|
+
clearPendingCreate(userId);
|
|
464
|
+
replacePreferredBot(command.botId);
|
|
465
|
+
state.value.overlay = undefined;
|
|
466
|
+
await state.value.load();
|
|
467
|
+
await state.value.select(command.botId);
|
|
468
|
+
} catch (error) {
|
|
469
|
+
if (isDefinitiveFlockFailure(error) && authenticatedUserId)
|
|
470
|
+
clearPendingCreate(authenticatedUserId);
|
|
471
|
+
state.value.error =
|
|
472
|
+
error instanceof Error ? error.message : "Could not create Bot";
|
|
473
|
+
}
|
|
474
|
+
},
|
|
475
|
+
async saveSheep() {
|
|
476
|
+
const botId = shell?.value.activeBotId;
|
|
477
|
+
if (!botId) return;
|
|
478
|
+
const current = state.value.identities[botId];
|
|
479
|
+
if (!current) return;
|
|
480
|
+
try {
|
|
481
|
+
const userId = await requireAuthenticatedUserId();
|
|
482
|
+
authenticatedUserId = userId;
|
|
483
|
+
const command =
|
|
484
|
+
readPendingSheep(userId, botId) ??
|
|
485
|
+
decodeUpdateSheepCommandV1({
|
|
486
|
+
schemaVersion: 1,
|
|
487
|
+
type: "bot/update-sheep",
|
|
488
|
+
commandId: crypto.randomUUID(),
|
|
489
|
+
expectedRevision: current.revision,
|
|
490
|
+
botId,
|
|
491
|
+
sheep: state.value.draftSheep,
|
|
492
|
+
});
|
|
493
|
+
writePendingSheep(userId, command);
|
|
494
|
+
const receipt = decodeFlockReceiptV1(
|
|
495
|
+
await request(
|
|
496
|
+
`/api/bots/${encodeURIComponent(botId)}/sheep`,
|
|
497
|
+
"POST",
|
|
498
|
+
JSON.stringify(command),
|
|
499
|
+
),
|
|
500
|
+
);
|
|
501
|
+
if (receipt.status === "rejected") {
|
|
502
|
+
clearPendingSheep(userId, botId);
|
|
503
|
+
throw new Error(receipt.failure ?? "Sheep update was rejected");
|
|
504
|
+
}
|
|
505
|
+
clearPendingSheep(userId, botId);
|
|
506
|
+
state.value.identities[botId] = decodeSheepIdentityViewV1(
|
|
507
|
+
await request(`/api/bots/${encodeURIComponent(botId)}/sheep`),
|
|
508
|
+
);
|
|
509
|
+
state.value.overlay = undefined;
|
|
510
|
+
} catch (error) {
|
|
511
|
+
if (authenticatedUserId) {
|
|
512
|
+
if (isDefinitiveFlockFailure(error))
|
|
513
|
+
clearPendingSheep(authenticatedUserId, botId);
|
|
514
|
+
try {
|
|
515
|
+
const refreshed = decodeSheepIdentityViewV1(
|
|
516
|
+
await request(`/api/bots/${encodeURIComponent(botId)}/sheep`),
|
|
517
|
+
);
|
|
518
|
+
state.value.identities[botId] = refreshed;
|
|
519
|
+
const pending = readPendingSheep(authenticatedUserId, botId);
|
|
520
|
+
if (
|
|
521
|
+
pending &&
|
|
522
|
+
refreshed.revision > pending.expectedRevision &&
|
|
523
|
+
JSON.stringify(refreshed.sheep) === JSON.stringify(pending.sheep)
|
|
524
|
+
) {
|
|
525
|
+
clearPendingSheep(authenticatedUserId, botId);
|
|
526
|
+
state.value.overlay = undefined;
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
} catch {
|
|
530
|
+
/* Keep the exact pending command when reconciliation is uncertain. */
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
state.value.error =
|
|
534
|
+
error instanceof Error ? error.message : "Could not save sheep";
|
|
535
|
+
}
|
|
536
|
+
},
|
|
537
|
+
});
|
|
538
|
+
/**
|
|
539
|
+
* The fan-out half of the notification story: a Turn that settles on a Bot
|
|
540
|
+
* the User is not looking at raises its intent here, through the same seam
|
|
541
|
+
* the open Bot's intents use, and is acknowledged per Bot afterwards.
|
|
542
|
+
*/
|
|
543
|
+
async function deliverBackgroundNotifications(): Promise<void> {
|
|
544
|
+
const directory = decodeBotNotificationDirectoryViewV1(
|
|
545
|
+
await request("/api/bots/notifications"),
|
|
546
|
+
);
|
|
547
|
+
const acknowledge = (intent: { botId: string; notificationId: string }) =>
|
|
548
|
+
request(
|
|
549
|
+
`/api/bots/${encodeURIComponent(intent.botId)}/notifications`,
|
|
550
|
+
"POST",
|
|
551
|
+
JSON.stringify({
|
|
552
|
+
schemaVersion: 1,
|
|
553
|
+
action: "acknowledge",
|
|
554
|
+
notificationId: intent.notificationId,
|
|
555
|
+
}),
|
|
556
|
+
);
|
|
557
|
+
for (const intent of directory.notifications) {
|
|
558
|
+
// The open Bot's intents belong to the Shell: it projects the Turn into
|
|
559
|
+
// the conversation and acknowledges it there.
|
|
560
|
+
if (intent.botId === shell?.value.activeBotId) continue;
|
|
561
|
+
const key = `${intent.botId}:${intent.notificationId}`;
|
|
562
|
+
if (deliveredNotifications.has(key)) {
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
const delivery = await showClientNotificationV1({
|
|
566
|
+
title: intent.title,
|
|
567
|
+
body: intent.body,
|
|
568
|
+
});
|
|
569
|
+
if (delivery === "unavailable") continue;
|
|
570
|
+
deliveredNotifications.add(key);
|
|
571
|
+
await acknowledge(intent);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const poll = setInterval(() => {
|
|
576
|
+
if (!state.value.directory.bots.length) return;
|
|
577
|
+
void (async () => {
|
|
578
|
+
try {
|
|
579
|
+
await state.value.refreshUnread();
|
|
580
|
+
await deliverBackgroundNotifications();
|
|
581
|
+
} catch {
|
|
582
|
+
// A poll is a refresh, not authority: the next tick tries again and
|
|
583
|
+
// nothing the User did is lost by a failed one.
|
|
584
|
+
}
|
|
585
|
+
})();
|
|
586
|
+
}, UNREAD_POLL_INTERVAL_MS);
|
|
587
|
+
|
|
588
|
+
return [
|
|
589
|
+
() => clearInterval(poll),
|
|
590
|
+
ctx.provide(flockWebDataKey, state),
|
|
591
|
+
ctx.slot({
|
|
592
|
+
slot: "frockbot.sidebar-bots",
|
|
593
|
+
order: 10,
|
|
594
|
+
component: FlockSidebar,
|
|
595
|
+
}),
|
|
596
|
+
ctx.slot({ slot: "frockbot.overlays", order: 10, component: FlockOverlay }),
|
|
597
|
+
ctx.slot({
|
|
598
|
+
slot: "frockbot.sidebar-top",
|
|
599
|
+
order: 20,
|
|
600
|
+
component: FlockCreateButton,
|
|
601
|
+
}),
|
|
602
|
+
ctx.slot({
|
|
603
|
+
slot: "frockbot.bot-identity",
|
|
604
|
+
order: 10,
|
|
605
|
+
component: FlockIdentity,
|
|
606
|
+
}),
|
|
607
|
+
ctx.slot({
|
|
608
|
+
slot: "frockbot.bot-avatar",
|
|
609
|
+
order: 10,
|
|
610
|
+
component: FlockAvatar,
|
|
611
|
+
}),
|
|
612
|
+
ctx.slot({
|
|
613
|
+
slot: "frockbot.bot-avatar-editor",
|
|
614
|
+
order: 10,
|
|
615
|
+
component: FlockAvatarEditor,
|
|
616
|
+
}),
|
|
617
|
+
];
|
|
618
|
+
};
|
|
619
|
+
export default flockClientPlugin;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { generate, parse, walk, type CssNode } from "css-tree";
|
|
4
|
+
|
|
5
|
+
const styles = readFileSync(new URL("./styles.css", import.meta.url), "utf8");
|
|
6
|
+
|
|
7
|
+
function isMobileMedia(node: CssNode): boolean {
|
|
8
|
+
return (
|
|
9
|
+
node.type === "Atrule" &&
|
|
10
|
+
node.name === "media" &&
|
|
11
|
+
Boolean(node.prelude) &&
|
|
12
|
+
generate(node.prelude!) === "(max-width:600px)"
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isDialogRule(node: CssNode): boolean {
|
|
17
|
+
return node.type === "Rule" && generate(node.prelude) === ".flock-dialog";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function mobileDialogHeight(ast: CssNode): string | undefined {
|
|
21
|
+
let mobileMediaDepth = 0;
|
|
22
|
+
let inDialogRule = false;
|
|
23
|
+
let height: string | undefined;
|
|
24
|
+
walk(ast, {
|
|
25
|
+
enter(node: CssNode) {
|
|
26
|
+
if (isMobileMedia(node)) mobileMediaDepth += 1;
|
|
27
|
+
if (mobileMediaDepth > 0 && isDialogRule(node)) {
|
|
28
|
+
inDialogRule = true;
|
|
29
|
+
}
|
|
30
|
+
if (
|
|
31
|
+
inDialogRule &&
|
|
32
|
+
node.type === "Declaration" &&
|
|
33
|
+
node.property === "height"
|
|
34
|
+
) {
|
|
35
|
+
height = generate(node.value);
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
leave(node: CssNode) {
|
|
39
|
+
if (mobileMediaDepth > 0 && isDialogRule(node)) inDialogRule = false;
|
|
40
|
+
if (isMobileMedia(node)) mobileMediaDepth -= 1;
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
return height;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("mobile Flock dialog layout", () => {
|
|
47
|
+
test("tracks the dynamic viewport so the Create Bot action remains reachable", () => {
|
|
48
|
+
expect(mobileDialogHeight(parse(styles))).toBe("calc(100dvh - 20px)");
|
|
49
|
+
});
|
|
50
|
+
});
|