@frockbot/plugin-flock 0.3.10 → 0.3.12
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/package.json +8 -8
- package/src/client/BotAvatar.vue +118 -4
- package/src/client/FlockSidebar.vue +106 -6
- package/src/client/SheepAvatar.vue +1 -1
- package/src/client/delivered-notifications.test.ts +103 -0
- package/src/client/delivered-notifications.ts +100 -0
- package/src/client/index.test.ts +97 -2
- package/src/client/index.ts +236 -52
- package/src/client/sidebar.test.ts +47 -1
- package/src/client/sidebar.ts +37 -0
- package/src/client/state.ts +4 -2
- package/src/client/styles.css +107 -0
- package/src/shared.test.ts +22 -0
- package/src/shared.ts +18 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-flock",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.12",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -27,20 +27,20 @@
|
|
|
27
27
|
"typecheck": "vue-tsc --noEmit -p tsconfig.json"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@frockbot/client-core": "0.3.
|
|
31
|
-
"@frockbot/client-ui": "0.3.
|
|
32
|
-
"@frockbot/configuration-core": "0.3.
|
|
33
|
-
"@frockbot/kernel-contracts": "0.3.
|
|
34
|
-
"@frockbot/plugin-shell": "0.3.
|
|
30
|
+
"@frockbot/client-core": "0.3.12",
|
|
31
|
+
"@frockbot/client-ui": "0.3.12",
|
|
32
|
+
"@frockbot/configuration-core": "0.3.12",
|
|
33
|
+
"@frockbot/kernel-contracts": "0.3.12",
|
|
34
|
+
"@frockbot/plugin-shell": "0.3.12",
|
|
35
35
|
"cordis": "4.0.0-rc.8",
|
|
36
36
|
"vue": "3.5.41"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
|
-
"@frockbot/plugin-testkit": "0.3.
|
|
39
|
+
"@frockbot/plugin-testkit": "0.3.12",
|
|
40
40
|
"@types/bun": "1.4.0",
|
|
41
41
|
"@vitejs/plugin-vue": "6.0.8",
|
|
42
42
|
"css-tree": "2.3.1",
|
|
43
|
-
"typescript": "
|
|
43
|
+
"typescript": "npm:typescript-native-bridge@6.0.3-bridge.16.tsgo.7.0.2",
|
|
44
44
|
"vite": "8.2.2",
|
|
45
45
|
"vue-tsc": "3.3.10"
|
|
46
46
|
},
|
package/src/client/BotAvatar.vue
CHANGED
|
@@ -1,20 +1,134 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
/**
|
|
3
3
|
* What a Bot looks like. The Flock's generated sheep recipe is the avatar.
|
|
4
|
+
*
|
|
5
|
+
* `working` draws the same language the thread uses for the Bot it is talking
|
|
6
|
+
* to — particles drifting off the avatar's right — so a person reading one
|
|
7
|
+
* conversation can see another Bot still going, on a list row and on a pinned
|
|
8
|
+
* tile alike.
|
|
9
|
+
*
|
|
10
|
+
* It is three CSS dots rather than the thread's canvas. A sidebar can hold
|
|
11
|
+
* twenty rows, and twenty `requestAnimationFrame` loops running full particle
|
|
12
|
+
* fields to say one bit each — "this Bot is busy" — is a cost the list should
|
|
13
|
+
* not pay. Out here that is genuinely all there is to say: the sidebar knows a
|
|
14
|
+
* Turn is running and nothing about its pace, which is the open
|
|
15
|
+
* conversation's to show.
|
|
4
16
|
*/
|
|
5
17
|
import SheepAvatar from "./SheepAvatar.vue";
|
|
6
18
|
import type { SheepRecipeV1 } from "../shared.js";
|
|
7
19
|
|
|
8
|
-
|
|
20
|
+
withDefaults(
|
|
9
21
|
defineProps<{
|
|
10
22
|
botId: string;
|
|
11
23
|
sheep: SheepRecipeV1;
|
|
12
24
|
label?: string;
|
|
13
|
-
size?: "mini" | "small" | "large";
|
|
25
|
+
size?: "mini" | "small" | "tile" | "large";
|
|
26
|
+
working?: boolean;
|
|
14
27
|
}>(),
|
|
15
|
-
{ label: "Bot avatar", size: "small" },
|
|
28
|
+
{ label: "Bot avatar", size: "small", working: false },
|
|
16
29
|
);
|
|
17
30
|
</script>
|
|
18
31
|
<template>
|
|
19
|
-
<
|
|
32
|
+
<span class="flock-avatar-slot">
|
|
33
|
+
<SheepAvatar :sheep="sheep" :label="label" :size="size" />
|
|
34
|
+
<Transition name="flock-avatar-drift">
|
|
35
|
+
<span v-if="working" class="flock-avatar-drift" aria-hidden="true">
|
|
36
|
+
<i />
|
|
37
|
+
<i />
|
|
38
|
+
<i />
|
|
39
|
+
</span>
|
|
40
|
+
</Transition>
|
|
41
|
+
</span>
|
|
20
42
|
</template>
|
|
43
|
+
|
|
44
|
+
<style scoped>
|
|
45
|
+
.flock-avatar-slot {
|
|
46
|
+
position: relative;
|
|
47
|
+
display: inline-grid;
|
|
48
|
+
flex: 0 0 auto;
|
|
49
|
+
place-items: center;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/*
|
|
53
|
+
* Clear of the avatar rather than over it, in the strip of row to its right.
|
|
54
|
+
* `pointer-events` keeps the whole thing out of the way of the row's click,
|
|
55
|
+
* and it lives inside the gap the row already leaves between the avatar and the
|
|
56
|
+
* name, so it never runs under the text.
|
|
57
|
+
*/
|
|
58
|
+
.flock-avatar-drift {
|
|
59
|
+
position: absolute;
|
|
60
|
+
z-index: 1;
|
|
61
|
+
top: 50%;
|
|
62
|
+
left: 100%;
|
|
63
|
+
width: 12px;
|
|
64
|
+
height: 6px;
|
|
65
|
+
margin-left: 2px;
|
|
66
|
+
pointer-events: none;
|
|
67
|
+
transform: translateY(-50%);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.flock-avatar-drift i {
|
|
71
|
+
position: absolute;
|
|
72
|
+
top: 2px;
|
|
73
|
+
width: 2px;
|
|
74
|
+
height: 2px;
|
|
75
|
+
border-radius: 50%;
|
|
76
|
+
background: var(--frock-action-primary);
|
|
77
|
+
animation: frock-drift 2400ms linear infinite;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.flock-avatar-drift i:nth-child(2) {
|
|
81
|
+
animation-delay: 800ms;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.flock-avatar-drift i:nth-child(3) {
|
|
85
|
+
animation-delay: 1600ms;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/*
|
|
89
|
+
* The same journey the thread's particles make — born at the avatar's edge,
|
|
90
|
+
* out to the right, fading as they go — at a size a list can afford.
|
|
91
|
+
*/
|
|
92
|
+
@keyframes frock-drift {
|
|
93
|
+
0% {
|
|
94
|
+
opacity: 0;
|
|
95
|
+
transform: translate(0, 0);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
20% {
|
|
99
|
+
opacity: 0.9;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
100% {
|
|
103
|
+
opacity: 0;
|
|
104
|
+
transform: translate(10px, -2px);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.flock-avatar-drift-enter-active,
|
|
109
|
+
.flock-avatar-drift-leave-active {
|
|
110
|
+
transition: opacity 420ms ease-out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.flock-avatar-drift-enter-from,
|
|
114
|
+
.flock-avatar-drift-leave-to {
|
|
115
|
+
opacity: 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/*
|
|
119
|
+
* Reduced motion keeps the fact and drops the movement: one still dot beside
|
|
120
|
+
* the avatar, which is the whole of what this was saying.
|
|
121
|
+
*/
|
|
122
|
+
@media (prefers-reduced-motion: reduce) {
|
|
123
|
+
.flock-avatar-drift i {
|
|
124
|
+
animation: none;
|
|
125
|
+
opacity: 0.9;
|
|
126
|
+
transform: translate(4px, 0);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.flock-avatar-drift i:nth-child(2),
|
|
130
|
+
.flock-avatar-drift i:nth-child(3) {
|
|
131
|
+
display: none;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
</style>
|
|
@@ -3,7 +3,11 @@ import { UiSkeleton } from "@frockbot/client-ui";
|
|
|
3
3
|
import { computed, inject, onMounted } from "vue";
|
|
4
4
|
import { frockBotWebDataKey } from "@frockbot/plugin-shell/shared";
|
|
5
5
|
import { flockWebDataKey } from "./state.js";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
formatSidebarMessageTimeV1,
|
|
8
|
+
groupSidebarBotsV1,
|
|
9
|
+
partitionPinnedSidebarBotsV1,
|
|
10
|
+
} from "./sidebar.js";
|
|
7
11
|
import BotAvatar from "./BotAvatar.vue";
|
|
8
12
|
|
|
9
13
|
const injectedFlock = inject(flockWebDataKey);
|
|
@@ -33,8 +37,16 @@ const listedBots = computed(() =>
|
|
|
33
37
|
const visibleBots = computed(() =>
|
|
34
38
|
listedBots.value.filter((bot) => !isHidden(bot.botId)),
|
|
35
39
|
);
|
|
40
|
+
/*
|
|
41
|
+
* A pinned Bot is a tile above the list instead of a row inside it, never
|
|
42
|
+
* both: the tile is the row, moved, so grouping runs over what is left.
|
|
43
|
+
*/
|
|
44
|
+
const partitionedBots = computed(() =>
|
|
45
|
+
partitionPinnedSidebarBotsV1(visibleBots.value, flock.value.profiles),
|
|
46
|
+
);
|
|
47
|
+
const pinnedBots = computed(() => partitionedBots.value.pinned);
|
|
36
48
|
const groupedVisibleBots = computed(() =>
|
|
37
|
-
groupSidebarBotsV1(
|
|
49
|
+
groupSidebarBotsV1(partitionedBots.value.rest, flock.value.profiles),
|
|
38
50
|
);
|
|
39
51
|
const hiddenBots = computed(() =>
|
|
40
52
|
listedBots.value.filter((bot) => isHidden(bot.botId)),
|
|
@@ -65,6 +77,18 @@ function previewTime(botId: string): string {
|
|
|
65
77
|
function isUnread(botId: string): boolean {
|
|
66
78
|
return flock.value.unread[botId]?.unread === true;
|
|
67
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* Whether the row draws an activity ring.
|
|
82
|
+
*
|
|
83
|
+
* The open Bot's own Turn is the Shell's — it is projecting the run into the
|
|
84
|
+
* conversation and knows about it a poll sooner — so that row reads the Shell.
|
|
85
|
+
* Every other row reads the unread fan-out, which is the only thing that knows
|
|
86
|
+
* a Bot in another conversation is working.
|
|
87
|
+
*/
|
|
88
|
+
function isWorking(botId: string): boolean {
|
|
89
|
+
if (botId === active.value) return shell.value.activeRunId !== undefined;
|
|
90
|
+
return flock.value.unread[botId]?.working === true;
|
|
91
|
+
}
|
|
68
92
|
function unreadLabel(botId: string): string | undefined {
|
|
69
93
|
const view = flock.value.unread[botId];
|
|
70
94
|
if (!view?.unread || view.count === 0) return undefined;
|
|
@@ -85,17 +109,39 @@ onMounted(() => void flock.value.load());
|
|
|
85
109
|
|
|
86
110
|
<template>
|
|
87
111
|
<div class="flock-list-actions">
|
|
88
|
-
<button
|
|
89
|
-
|
|
112
|
+
<button
|
|
113
|
+
type="button"
|
|
114
|
+
class="flock-manage"
|
|
115
|
+
:aria-pressed="flock.showArchived"
|
|
116
|
+
@click="flock.toggleArchived"
|
|
117
|
+
>
|
|
118
|
+
{{ flock.showArchived ? "Done" : "Manage" }}
|
|
90
119
|
</button>
|
|
91
120
|
</div>
|
|
121
|
+
<!--
|
|
122
|
+
An unreadable list is not an empty one, and it is not a loading one either.
|
|
123
|
+
Offering to add a first Bot to a User whose Bots simply did not load is the
|
|
124
|
+
worst thing this column can say; leaving the skeleton up forever is the
|
|
125
|
+
second worst, because it says the read is still coming when it has already
|
|
126
|
+
failed. So the failure takes the slot first, and offers the read again.
|
|
127
|
+
-->
|
|
128
|
+
<p
|
|
129
|
+
v-if="flock.error && !flock.directory.bots.length"
|
|
130
|
+
class="flock-error"
|
|
131
|
+
role="alert"
|
|
132
|
+
>
|
|
133
|
+
{{ flock.error }}
|
|
134
|
+
<button type="button" class="flock-retry" @click="flock.load()">
|
|
135
|
+
Retry
|
|
136
|
+
</button>
|
|
137
|
+
</p>
|
|
92
138
|
<!--
|
|
93
139
|
The skeleton is for a list nobody has yet, not for every request: the first
|
|
94
140
|
paint happens before `load()` is even called, and a reload after creating a
|
|
95
141
|
Bot must keep the list already on screen rather than blanking it. "No Bots
|
|
96
142
|
yet." is a fact about the account, so it waits for an answer.
|
|
97
143
|
-->
|
|
98
|
-
<div v-if="!flock.loaded" class="flock-skeleton" aria-busy="true">
|
|
144
|
+
<div v-else-if="!flock.loaded" class="flock-skeleton" aria-busy="true">
|
|
99
145
|
<span class="flock-skeleton-label">Loading your flock…</span>
|
|
100
146
|
<div v-for="row in 3" :key="row" class="flock-skeleton-row">
|
|
101
147
|
<UiSkeleton shape="circle" />
|
|
@@ -109,6 +155,50 @@ onMounted(() => void flock.value.load());
|
|
|
109
155
|
No Bots yet. Add your first sheep.
|
|
110
156
|
</p>
|
|
111
157
|
<template v-else>
|
|
158
|
+
<!--
|
|
159
|
+
Pinned Bots: large tiles above the labelled groups, in pin order. The
|
|
160
|
+
tile is the row moved, so clicking it selects exactly as a row does.
|
|
161
|
+
-->
|
|
162
|
+
<TransitionGroup
|
|
163
|
+
v-if="pinnedBots.length"
|
|
164
|
+
name="flock-row"
|
|
165
|
+
tag="div"
|
|
166
|
+
class="flock-pinned"
|
|
167
|
+
>
|
|
168
|
+
<button
|
|
169
|
+
v-for="bot in pinnedBots"
|
|
170
|
+
:key="bot.botId"
|
|
171
|
+
type="button"
|
|
172
|
+
class="flock-pinned-tile"
|
|
173
|
+
:class="{
|
|
174
|
+
active: active === bot.botId,
|
|
175
|
+
archived: flock.lifecycles[bot.botId] === 'archived',
|
|
176
|
+
unread: isUnread(bot.botId),
|
|
177
|
+
}"
|
|
178
|
+
:disabled="flock.lifecycles[bot.botId] === 'archived'"
|
|
179
|
+
:aria-current="active === bot.botId ? 'true' : undefined"
|
|
180
|
+
@click="flock.select(bot.botId)"
|
|
181
|
+
>
|
|
182
|
+
<span class="flock-pinned-art">
|
|
183
|
+
<BotAvatar
|
|
184
|
+
:bot-id="bot.botId"
|
|
185
|
+
:sheep="flock.identities[bot.botId]?.sheep ?? bot.sheep"
|
|
186
|
+
size="tile"
|
|
187
|
+
:label="`${botName(bot.botId, bot.initialName)} avatar`"
|
|
188
|
+
:working="isWorking(bot.botId)"
|
|
189
|
+
/>
|
|
190
|
+
<i
|
|
191
|
+
v-if="isUnread(bot.botId)"
|
|
192
|
+
class="flock-pinned-dot"
|
|
193
|
+
role="img"
|
|
194
|
+
:aria-label="`${botName(bot.botId, bot.initialName)} has unread`"
|
|
195
|
+
/>
|
|
196
|
+
</span>
|
|
197
|
+
<span class="flock-pinned-name">{{
|
|
198
|
+
botName(bot.botId, bot.initialName)
|
|
199
|
+
}}</span>
|
|
200
|
+
</button>
|
|
201
|
+
</TransitionGroup>
|
|
112
202
|
<div class="flock-groups">
|
|
113
203
|
<section
|
|
114
204
|
v-for="group in groupedVisibleBots.groups"
|
|
@@ -140,6 +230,7 @@ onMounted(() => void flock.value.load());
|
|
|
140
230
|
:bot-id="bot.botId"
|
|
141
231
|
:sheep="flock.identities[bot.botId]?.sheep ?? bot.sheep"
|
|
142
232
|
:label="`${botName(bot.botId, bot.initialName)} avatar`"
|
|
233
|
+
:working="isWorking(bot.botId)"
|
|
143
234
|
/>
|
|
144
235
|
<span class="flock-bot-copy">
|
|
145
236
|
<span class="flock-bot-primary">
|
|
@@ -238,6 +329,7 @@ onMounted(() => void flock.value.load());
|
|
|
238
329
|
:bot-id="bot.botId"
|
|
239
330
|
:sheep="flock.identities[bot.botId]?.sheep ?? bot.sheep"
|
|
240
331
|
:label="`${botName(bot.botId, bot.initialName)} avatar`"
|
|
332
|
+
:working="isWorking(bot.botId)"
|
|
241
333
|
/>
|
|
242
334
|
<span class="flock-bot-copy">
|
|
243
335
|
<span class="flock-bot-primary">
|
|
@@ -256,5 +348,13 @@ onMounted(() => void flock.value.load());
|
|
|
256
348
|
</TransitionGroup>
|
|
257
349
|
</div>
|
|
258
350
|
</template>
|
|
259
|
-
|
|
351
|
+
<!-- The same failure over a list that still has rows: a banner, not a
|
|
352
|
+
replacement, because what is on screen is still the last thing known. -->
|
|
353
|
+
<p
|
|
354
|
+
v-if="flock.error && flock.directory.bots.length"
|
|
355
|
+
class="flock-error"
|
|
356
|
+
role="alert"
|
|
357
|
+
>
|
|
358
|
+
{{ flock.error }}
|
|
359
|
+
</p>
|
|
260
360
|
</template>
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
claimNotificationDeliveryV1,
|
|
4
|
+
deliveredNotificationKeyV1,
|
|
5
|
+
DELIVERED_NOTIFICATIONS_KEY,
|
|
6
|
+
DELIVERED_NOTIFICATIONS_LIMIT,
|
|
7
|
+
releaseNotificationDeliveryV1,
|
|
8
|
+
} from "./delivered-notifications.js";
|
|
9
|
+
|
|
10
|
+
/** The half of `localStorage` the ledger uses, shared as a browser shares it. */
|
|
11
|
+
function storage(initial?: string): Pick<Storage, "getItem" | "setItem"> {
|
|
12
|
+
const values = new Map<string, string>(
|
|
13
|
+
initial === undefined ? [] : [[DELIVERED_NOTIFICATIONS_KEY, initial]],
|
|
14
|
+
);
|
|
15
|
+
return {
|
|
16
|
+
getItem: (key) => values.get(key) ?? null,
|
|
17
|
+
setItem: (key, value) => {
|
|
18
|
+
values.set(key, value);
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("the delivered-notification ledger", () => {
|
|
24
|
+
test("the first claim wins and every later one loses", () => {
|
|
25
|
+
const shared = storage();
|
|
26
|
+
const key = deliveredNotificationKeyV1("beta", "run-1");
|
|
27
|
+
expect(claimNotificationDeliveryV1(key, shared)).toBe(true);
|
|
28
|
+
expect(claimNotificationDeliveryV1(key, shared)).toBe(false);
|
|
29
|
+
// A second tab reads the same storage, so it loses too — which is the
|
|
30
|
+
// whole point: one notification per message, not one per tab.
|
|
31
|
+
expect(claimNotificationDeliveryV1(key, shared)).toBe(false);
|
|
32
|
+
// A different message is still news.
|
|
33
|
+
expect(
|
|
34
|
+
claimNotificationDeliveryV1(
|
|
35
|
+
deliveredNotificationKeyV1("beta", "run-2"),
|
|
36
|
+
shared,
|
|
37
|
+
),
|
|
38
|
+
).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("a claim survives the reload that empties the page's own set", () => {
|
|
42
|
+
const values = new Map<string, string>();
|
|
43
|
+
const persistent = (): Pick<Storage, "getItem" | "setItem"> => ({
|
|
44
|
+
getItem: (key) => values.get(key) ?? null,
|
|
45
|
+
setItem: (key, value) => {
|
|
46
|
+
values.set(key, value);
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
const key = deliveredNotificationKeyV1("beta", "run-1");
|
|
50
|
+
expect(claimNotificationDeliveryV1(key, persistent())).toBe(true);
|
|
51
|
+
// A brand-new page, the same browser.
|
|
52
|
+
expect(claimNotificationDeliveryV1(key, persistent())).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("a notification that could not be shown gives its claim back", () => {
|
|
56
|
+
const shared = storage();
|
|
57
|
+
const key = deliveredNotificationKeyV1("beta", "run-1");
|
|
58
|
+
expect(claimNotificationDeliveryV1(key, shared)).toBe(true);
|
|
59
|
+
releaseNotificationDeliveryV1(key, shared);
|
|
60
|
+
expect(claimNotificationDeliveryV1(key, shared)).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("the ledger is bounded, oldest first", () => {
|
|
64
|
+
const shared = storage();
|
|
65
|
+
for (let index = 0; index <= DELIVERED_NOTIFICATIONS_LIMIT; index += 1) {
|
|
66
|
+
claimNotificationDeliveryV1(
|
|
67
|
+
deliveredNotificationKeyV1("beta", `run-${index}`),
|
|
68
|
+
shared,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
// The oldest fell off; the newest is still remembered.
|
|
72
|
+
expect(
|
|
73
|
+
claimNotificationDeliveryV1(
|
|
74
|
+
deliveredNotificationKeyV1("beta", "run-0"),
|
|
75
|
+
shared,
|
|
76
|
+
),
|
|
77
|
+
).toBe(true);
|
|
78
|
+
expect(
|
|
79
|
+
claimNotificationDeliveryV1(
|
|
80
|
+
deliveredNotificationKeyV1(
|
|
81
|
+
"beta",
|
|
82
|
+
`run-${DELIVERED_NOTIFICATIONS_LIMIT}`,
|
|
83
|
+
),
|
|
84
|
+
shared,
|
|
85
|
+
),
|
|
86
|
+
).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("junk in storage is not a reason to go silent", () => {
|
|
90
|
+
for (const junk of ["", "{", "null", '{"not":"an array"}', "[1,2,3]"]) {
|
|
91
|
+
const key = deliveredNotificationKeyV1("beta", "run-1");
|
|
92
|
+
const shared = storage(junk);
|
|
93
|
+
expect(claimNotificationDeliveryV1(key, shared)).toBe(true);
|
|
94
|
+
expect(claimNotificationDeliveryV1(key, shared)).toBe(false);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("no storage at all still shows the notification", () => {
|
|
99
|
+
const key = deliveredNotificationKeyV1("beta", "run-1");
|
|
100
|
+
expect(claimNotificationDeliveryV1(key, undefined)).toBe(true);
|
|
101
|
+
expect(claimNotificationDeliveryV1(key, undefined)).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which notification intents this browser has already shown.
|
|
3
|
+
*
|
|
4
|
+
* "One notification per message" is a promise about the *person*, not about
|
|
5
|
+
* the page. The durable acknowledgement the Bot records is what closes an
|
|
6
|
+
* intent, but it lands after the notification is shown, and in that window a
|
|
7
|
+
* second tab polling the same fan-out — or the same tab after a reload —
|
|
8
|
+
* showed the identical intent again. A set on the page could not see either.
|
|
9
|
+
*
|
|
10
|
+
* `localStorage` can, because both tabs of one browser share it. It is a
|
|
11
|
+
* ledger of ids, never of content: an id already here is one this browser has
|
|
12
|
+
* spoken. It is bounded and oldest-first, so a long-lived session cannot grow
|
|
13
|
+
* it without limit; an id that falls off the end is one whose acknowledgement
|
|
14
|
+
* settled long ago.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const DELIVERED_NOTIFICATIONS_KEY =
|
|
18
|
+
"frockbot.flock.delivered-notifications.v1";
|
|
19
|
+
|
|
20
|
+
/** How many ids one browser remembers. Comfortably past any in-flight burst. */
|
|
21
|
+
export const DELIVERED_NOTIFICATIONS_LIMIT = 200;
|
|
22
|
+
|
|
23
|
+
type WritableStorage = Pick<Storage, "getItem" | "setItem">;
|
|
24
|
+
|
|
25
|
+
function browserStorage(): Storage | undefined {
|
|
26
|
+
try {
|
|
27
|
+
return typeof localStorage === "undefined" ? undefined : localStorage;
|
|
28
|
+
} catch {
|
|
29
|
+
// A browser with storage denied still shows notifications; it only loses
|
|
30
|
+
// the cross-tab half of the promise.
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The key one intent is remembered under. */
|
|
36
|
+
export function deliveredNotificationKeyV1(
|
|
37
|
+
botId: string,
|
|
38
|
+
notificationId: string,
|
|
39
|
+
): string {
|
|
40
|
+
return `${botId}:${notificationId}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parse(raw: string | null): string[] {
|
|
44
|
+
if (!raw) return [];
|
|
45
|
+
try {
|
|
46
|
+
const value: unknown = JSON.parse(raw);
|
|
47
|
+
return Array.isArray(value)
|
|
48
|
+
? value.filter((entry): entry is string => typeof entry === "string")
|
|
49
|
+
: [];
|
|
50
|
+
} catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Claims the right to show the intent behind `key`, returning `false` when
|
|
57
|
+
* some other tab — or this one, before a reload — already has it.
|
|
58
|
+
*
|
|
59
|
+
* Check-and-write in one call, and the write happens *before* the notification
|
|
60
|
+
* is shown, because the gap between showing and recording is exactly where the
|
|
61
|
+
* duplicate got in.
|
|
62
|
+
*/
|
|
63
|
+
export function claimNotificationDeliveryV1(
|
|
64
|
+
key: string,
|
|
65
|
+
storage: WritableStorage | undefined = browserStorage(),
|
|
66
|
+
): boolean {
|
|
67
|
+
if (!storage) return true;
|
|
68
|
+
try {
|
|
69
|
+
const existing = parse(storage.getItem(DELIVERED_NOTIFICATIONS_KEY));
|
|
70
|
+
if (existing.includes(key)) return false;
|
|
71
|
+
const next = [...existing, key].slice(-DELIVERED_NOTIFICATIONS_LIMIT);
|
|
72
|
+
storage.setItem(DELIVERED_NOTIFICATIONS_KEY, JSON.stringify(next));
|
|
73
|
+
return true;
|
|
74
|
+
} catch {
|
|
75
|
+
// Storage that will not take the ledger must not silence the notification.
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Gives a claim back, for the one case where the notification was never
|
|
82
|
+
* actually shown: no permission yet. Without this the intent would be
|
|
83
|
+
* remembered as spoken and stay silent after the User granted it.
|
|
84
|
+
*/
|
|
85
|
+
export function releaseNotificationDeliveryV1(
|
|
86
|
+
key: string,
|
|
87
|
+
storage: WritableStorage | undefined = browserStorage(),
|
|
88
|
+
): void {
|
|
89
|
+
if (!storage) return;
|
|
90
|
+
try {
|
|
91
|
+
const existing = parse(storage.getItem(DELIVERED_NOTIFICATIONS_KEY));
|
|
92
|
+
if (!existing.includes(key)) return;
|
|
93
|
+
storage.setItem(
|
|
94
|
+
DELIVERED_NOTIFICATIONS_KEY,
|
|
95
|
+
JSON.stringify(existing.filter((entry) => entry !== key)),
|
|
96
|
+
);
|
|
97
|
+
} catch {
|
|
98
|
+
// Nothing to undo that anybody can see.
|
|
99
|
+
}
|
|
100
|
+
}
|