visual-comments 0.1.0 → 0.2.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 +19 -2
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +477 -0
- package/dist/core/presence.d.ts +13 -0
- package/dist/core/presence.js +25 -0
- package/dist/core/threads.svelte.d.ts +36 -2
- package/dist/core/threads.svelte.js +120 -3
- package/dist/index.d.ts +1 -1
- package/dist/server/env.d.ts +78 -0
- package/dist/server/env.js +94 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +10 -3
- package/dist/server/variables.d.ts +28 -0
- package/dist/server/variables.js +73 -0
- package/dist/shared/protocol.d.ts +4 -0
- package/dist/shared/protocol.js +4 -0
- package/dist/shared/types.d.ts +64 -0
- package/dist/sveltekit/CommentTools.svelte +98 -0
- package/dist/sveltekit/CommentTools.svelte.d.ts +6 -0
- package/dist/sveltekit/Comments.svelte +104 -0
- package/dist/sveltekit/Comments.svelte.d.ts +25 -0
- package/dist/sveltekit/Toolbar.svelte +64 -0
- package/dist/sveltekit/Toolbar.svelte.d.ts +13 -0
- package/dist/sveltekit/ToolbarButton.svelte +66 -0
- package/dist/sveltekit/ToolbarButton.svelte.d.ts +12 -0
- package/dist/sveltekit/context.d.ts +16 -0
- package/dist/sveltekit/context.js +9 -0
- package/dist/sveltekit/index.d.ts +6 -0
- package/dist/sveltekit/index.js +6 -0
- package/dist/ui/Account.svelte +45 -7
- package/dist/ui/App.svelte +12 -3
- package/dist/ui/Avatar.svelte +28 -6
- package/dist/ui/Avatar.svelte.d.ts +4 -5
- package/dist/ui/Connect.svelte +128 -0
- package/dist/ui/Connect.svelte.d.ts +6 -0
- package/dist/ui/Menu.svelte +5 -1
- package/dist/ui/Menu.svelte.d.ts +2 -0
- package/dist/ui/Overlay.svelte +15 -1
- package/dist/ui/Panel.svelte +141 -127
- package/dist/ui/Pin.svelte +22 -2
- package/dist/ui/Pin.svelte.d.ts +2 -0
- package/dist/ui/Presence.svelte +43 -0
- package/dist/ui/Presence.svelte.d.ts +8 -0
- package/dist/ui/ThreadCard.svelte +26 -2
- package/dist/ui/generated/styles.css +1 -1
- package/dist/ui/generated/styles.js +2 -2
- package/dist/ui/styles.css +21 -0
- package/dist/ui/text.d.ts +3 -1
- package/dist/ui/text.js +4 -0
- package/dist/visual-comments.svelte.d.ts +53 -4
- package/dist/visual-comments.svelte.js +154 -29
- package/dist/vite/index.d.ts +20 -0
- package/dist/vite/index.js +78 -0
- package/dist-widget/visual-comments.js +17 -10
- package/package.json +88 -66
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
<svelte:options css="injected" />
|
|
2
|
+
|
|
3
|
+
<!--
|
|
4
|
+
Approving an agent. `visual-comments connect` sends the person to
|
|
5
|
+
`#vc-connect=<id>`; Allow adds the agent to the space, under its own name.
|
|
6
|
+
Signed out, it offers to sign in first; the address keeps the request.
|
|
7
|
+
-->
|
|
8
|
+
<script lang="ts">
|
|
9
|
+
import type { Agent, AgentRequest } from '../shared/types.js';
|
|
10
|
+
import { useComments } from './context.js';
|
|
11
|
+
|
|
12
|
+
let { id }: { id: string } = $props();
|
|
13
|
+
|
|
14
|
+
const vc = useComments();
|
|
15
|
+
const space = $derived(vc.store.spaceName || 'this space');
|
|
16
|
+
const signedIn = $derived(Boolean(vc.viewer));
|
|
17
|
+
let request = $state<AgentRequest>();
|
|
18
|
+
let joined = $state<Agent>();
|
|
19
|
+
let failed = $state<string>();
|
|
20
|
+
let busy = $state(false);
|
|
21
|
+
|
|
22
|
+
$effect(() => {
|
|
23
|
+
if (!signedIn) return;
|
|
24
|
+
let current = true;
|
|
25
|
+
request = undefined;
|
|
26
|
+
failed = undefined;
|
|
27
|
+
vc.store.agentRequest(id).then(
|
|
28
|
+
(found) => { if (current) request = found; },
|
|
29
|
+
(error: Error) => {
|
|
30
|
+
if (!current) return;
|
|
31
|
+
failed = error.message;
|
|
32
|
+
// The sign-in may have lapsed since the page loaded; then this offers it again.
|
|
33
|
+
void vc.store.refresh();
|
|
34
|
+
}
|
|
35
|
+
);
|
|
36
|
+
return () => { current = false; };
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
async function answer(allow: boolean) {
|
|
40
|
+
busy = true;
|
|
41
|
+
failed = undefined;
|
|
42
|
+
try {
|
|
43
|
+
if (allow) joined = await vc.store.approveAgent(id);
|
|
44
|
+
else {
|
|
45
|
+
await vc.store.denyAgent(id);
|
|
46
|
+
vc.endConnect();
|
|
47
|
+
}
|
|
48
|
+
} catch (error) {
|
|
49
|
+
failed = (error as Error).message;
|
|
50
|
+
} finally {
|
|
51
|
+
busy = false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
</script>
|
|
55
|
+
|
|
56
|
+
<dialog
|
|
57
|
+
{@attach (node) => node.showModal()}
|
|
58
|
+
onclose={() => vc.endConnect()}
|
|
59
|
+
aria-labelledby="vc-connect-title"
|
|
60
|
+
class="vc-ui pointer-events-auto m-auto w-[360px] max-w-[calc(100vw-24px)] rounded-[12px] bg-white p-5 text-[13px] text-vc-ink shadow-[0_8px_40px_rgb(0_0_0/0.28)] outline-none backdrop:bg-black/30"
|
|
61
|
+
>
|
|
62
|
+
{#if !vc.viewer}
|
|
63
|
+
<h2 id="vc-connect-title" class="text-[15px] font-semibold">An agent wants to join</h2>
|
|
64
|
+
{#if vc.loading}
|
|
65
|
+
<p class="mt-2 text-vc-muted">Connecting…</p>
|
|
66
|
+
{:else}
|
|
67
|
+
<p class="mt-2 text-vc-muted">Sign in to see which agent asks, and to let it read and write comments here.</p>
|
|
68
|
+
<div class="mt-5 flex justify-end gap-2">
|
|
69
|
+
<form method="dialog"><button class="vc-dialog-button">Cancel</button></form>
|
|
70
|
+
<button type="button" class="vc-dialog-button vc-dialog-primary" onclick={() => vc.signIn()}>Sign in</button>
|
|
71
|
+
</div>
|
|
72
|
+
{/if}
|
|
73
|
+
{:else if joined}
|
|
74
|
+
<h2 id="vc-connect-title" class="text-[15px] font-semibold">{joined.name} joined {space}</h2>
|
|
75
|
+
<p class="mt-2 text-vc-muted">It sees new comments and replies under its own name. Remove it from your account menu at any time.</p>
|
|
76
|
+
<form method="dialog" class="mt-5 flex justify-end">
|
|
77
|
+
<button class="vc-dialog-button vc-dialog-primary">Done</button>
|
|
78
|
+
</form>
|
|
79
|
+
{:else if request}
|
|
80
|
+
<h2 id="vc-connect-title" class="text-[15px] font-semibold">{request.name} wants to join {space} as an agent</h2>
|
|
81
|
+
<p class="mt-2 text-vc-muted">
|
|
82
|
+
It will see every comment here, and may reply and act on them under its own name. Allow it only if you started it.
|
|
83
|
+
</p>
|
|
84
|
+
<p class="mt-4 text-vc-muted">The agent shows this code:</p>
|
|
85
|
+
<p class="mt-1 rounded-[8px] bg-vc-surface py-2 text-center font-mono text-xl tracking-[0.2em]">{request.code}</p>
|
|
86
|
+
{#if failed}<p role="alert" class="mt-3 text-red-600">{failed}</p>{/if}
|
|
87
|
+
<div class="mt-5 flex justify-end gap-2">
|
|
88
|
+
<button type="button" class="vc-dialog-button" disabled={busy} onclick={() => answer(false)}>Deny</button>
|
|
89
|
+
<button type="button" class="vc-dialog-button vc-dialog-primary" disabled={busy} onclick={() => answer(true)}>Allow</button>
|
|
90
|
+
</div>
|
|
91
|
+
{:else if failed}
|
|
92
|
+
<h2 id="vc-connect-title" class="text-[15px] font-semibold">This request cannot be answered</h2>
|
|
93
|
+
<p class="mt-2 text-vc-muted">{failed}</p>
|
|
94
|
+
<form method="dialog" class="mt-5 flex justify-end">
|
|
95
|
+
<button class="vc-dialog-button">Close</button>
|
|
96
|
+
</form>
|
|
97
|
+
{:else}
|
|
98
|
+
<h2 id="vc-connect-title" class="text-[15px] font-semibold">An agent wants to join</h2>
|
|
99
|
+
<p class="mt-2 text-vc-muted">Loading…</p>
|
|
100
|
+
{/if}
|
|
101
|
+
</dialog>
|
|
102
|
+
|
|
103
|
+
<style>
|
|
104
|
+
.vc-dialog-button {
|
|
105
|
+
height: 2rem;
|
|
106
|
+
border-radius: 9999px;
|
|
107
|
+
padding: 0 0.875rem;
|
|
108
|
+
font-weight: 500;
|
|
109
|
+
background: var(--color-vc-wash);
|
|
110
|
+
outline: none;
|
|
111
|
+
}
|
|
112
|
+
.vc-dialog-button:hover:not(:disabled) {
|
|
113
|
+
filter: brightness(0.96);
|
|
114
|
+
}
|
|
115
|
+
.vc-dialog-button:focus-visible {
|
|
116
|
+
box-shadow: 0 0 0 2px var(--color-vc-comment);
|
|
117
|
+
}
|
|
118
|
+
.vc-dialog-button:disabled {
|
|
119
|
+
opacity: 0.6;
|
|
120
|
+
}
|
|
121
|
+
.vc-dialog-primary {
|
|
122
|
+
background: var(--color-vc-comment);
|
|
123
|
+
color: white;
|
|
124
|
+
}
|
|
125
|
+
.vc-dialog-primary:focus-visible {
|
|
126
|
+
box-shadow: 0 0 0 2px white, 0 0 0 4px var(--color-vc-comment);
|
|
127
|
+
}
|
|
128
|
+
</style>
|
package/dist/ui/Menu.svelte
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
children,
|
|
9
9
|
align = 'start',
|
|
10
10
|
tone = 'light',
|
|
11
|
-
class: className = ''
|
|
11
|
+
class: className = '',
|
|
12
|
+
onopen
|
|
12
13
|
}: {
|
|
13
14
|
/** Accessible name for the trigger. */
|
|
14
15
|
label: string;
|
|
@@ -17,6 +18,8 @@
|
|
|
17
18
|
align?: 'start' | 'end';
|
|
18
19
|
tone?: 'light' | 'dark';
|
|
19
20
|
class?: string;
|
|
21
|
+
/** Called as it opens, e.g. to load what it lists. */
|
|
22
|
+
onopen?: () => void;
|
|
20
23
|
} = $props();
|
|
21
24
|
|
|
22
25
|
const id = $props.id();
|
|
@@ -27,6 +30,7 @@
|
|
|
27
30
|
function place(event: ToggleEvent) {
|
|
28
31
|
open = event.newState === 'open';
|
|
29
32
|
if (event.newState !== 'open' || !button || !popover) return;
|
|
33
|
+
onopen?.();
|
|
30
34
|
const rect = button.getBoundingClientRect();
|
|
31
35
|
const gutter = 12;
|
|
32
36
|
const left = align === 'start' ? rect.left : rect.right - popover.offsetWidth;
|
package/dist/ui/Menu.svelte.d.ts
CHANGED
|
@@ -7,6 +7,8 @@ type $$ComponentProps = {
|
|
|
7
7
|
align?: 'start' | 'end';
|
|
8
8
|
tone?: 'light' | 'dark';
|
|
9
9
|
class?: string;
|
|
10
|
+
/** Called as it opens, e.g. to load what it lists. */
|
|
11
|
+
onopen?: () => void;
|
|
10
12
|
};
|
|
11
13
|
declare const Menu: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
12
14
|
type Menu = ReturnType<typeof Menu>;
|
package/dist/ui/Overlay.svelte
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { describeTarget, MAGNET, widen, type Box } from '../core/target.js';
|
|
19
19
|
import { docBoxToClient, docBoxToSurface, intersect, spotOf, toClient, type Rect, type Surface } from '../core/surfaces.js';
|
|
20
20
|
import { neck } from '../core/metaball.js';
|
|
21
|
+
import { activity } from '../core/presence.js';
|
|
21
22
|
import type { CommentImage, CommentThread } from '../shared/types.js';
|
|
22
23
|
|
|
23
24
|
const vc = useComments();
|
|
@@ -45,7 +46,7 @@
|
|
|
45
46
|
|
|
46
47
|
/** Where cards on a surface can go: its stage, clear of the list. */
|
|
47
48
|
function stageOf(surface: Surface): Rect {
|
|
48
|
-
const panel = vc.active ? vc.panelRect ?? floatingPanel() : undefined;
|
|
49
|
+
const panel = vc.active && !vc.panelCollapsed ? vc.panelRect ?? floatingPanel() : undefined;
|
|
49
50
|
const stage = intersect(surface.stage, { left: 0, top: 0, right: innerWidth, bottom: innerHeight });
|
|
50
51
|
return panel && panel.left < stage.right && panel.left > stage.left ? { ...stage, right: panel.left } : stage;
|
|
51
52
|
}
|
|
@@ -83,6 +84,18 @@
|
|
|
83
84
|
return where && !where.hidden ? [{ thread, left: where.x, top: where.y }] : [];
|
|
84
85
|
}));
|
|
85
86
|
|
|
87
|
+
/** Per thread, what those working on it say they are doing. */
|
|
88
|
+
const working = $derived.by(() => {
|
|
89
|
+
const lines = new Map<number, string>();
|
|
90
|
+
for (const participant of vc.participants) {
|
|
91
|
+
if (participant.state !== 'working' || participant.thread === undefined) continue;
|
|
92
|
+
const line = activity(participant)!;
|
|
93
|
+
const before = lines.get(participant.thread);
|
|
94
|
+
lines.set(participant.thread, before ? `${before} · ${line}` : line);
|
|
95
|
+
}
|
|
96
|
+
return lines;
|
|
97
|
+
});
|
|
98
|
+
|
|
86
99
|
/** Beside the pin, on the side with room, kept inside the stage. */
|
|
87
100
|
function beside(at: { left: number; top: number }, width: number, height: number, bounds: Rect) {
|
|
88
101
|
let left = at.left + PIN + GAP;
|
|
@@ -310,6 +323,7 @@
|
|
|
310
323
|
left={pin.left}
|
|
311
324
|
top={pin.top}
|
|
312
325
|
now={comments.now}
|
|
326
|
+
working={working.get(pin.thread.id)}
|
|
313
327
|
open={pin.thread.id === vc.openId}
|
|
314
328
|
onopen={() => {
|
|
315
329
|
vc.draft = undefined;
|
package/dist/ui/Panel.svelte
CHANGED
|
@@ -61,146 +61,160 @@
|
|
|
61
61
|
>
|
|
62
62
|
<header class="flex h-11 shrink-0 items-center gap-0.5 pr-1.5 pl-3.5">
|
|
63
63
|
<h2 class="min-w-0 flex-1 truncate font-semibold" title={comments.spaceName || undefined}>Comments</h2>
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
{#if elsewhere || filters.allPages}
|
|
71
|
-
<MenuItem checked={filters.allPages} onselect={() => { close(); filters.allPages = !filters.allPages; }}>
|
|
72
|
-
Show every page
|
|
73
|
-
</MenuItem>
|
|
74
|
-
{/if}
|
|
75
|
-
<MenuItem checked={filters.showResolved} onselect={() => { close(); filters.showResolved = !filters.showResolved; }}>
|
|
76
|
-
Show resolved comments
|
|
77
|
-
</MenuItem>
|
|
78
|
-
{#if viewer}
|
|
79
|
-
<MenuItem checked={filters.onlyMine} onselect={() => { close(); filters.onlyMine = !filters.onlyMine; }}>
|
|
80
|
-
Only your threads
|
|
81
|
-
</MenuItem>
|
|
64
|
+
{#if !vc.panelCollapsed}
|
|
65
|
+
<Menu label="Filter and sort" align="end" class="vc-tool-light size-7 rounded-full">
|
|
66
|
+
{#snippet trigger()}<Icon name="filter" />{/snippet}
|
|
67
|
+
{#snippet children(close)}
|
|
68
|
+
<MenuItem checked={filters.sort === 'date'} onselect={() => { close(); filters.sort = 'date'; }}>Sort by date</MenuItem>
|
|
69
|
+
<MenuItem checked={filters.sort === 'place'} onselect={() => { close(); filters.sort = 'place'; }}>Sort by place</MenuItem>
|
|
82
70
|
<div class="my-1 border-t border-vc-line"></div>
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
71
|
+
{#if elsewhere || filters.allPages}
|
|
72
|
+
<MenuItem checked={filters.allPages} onselect={() => { close(); filters.allPages = !filters.allPages; }}>
|
|
73
|
+
Show every page
|
|
74
|
+
</MenuItem>
|
|
75
|
+
{/if}
|
|
76
|
+
<MenuItem checked={filters.showResolved} onselect={() => { close(); filters.showResolved = !filters.showResolved; }}>
|
|
77
|
+
Show resolved comments
|
|
78
|
+
</MenuItem>
|
|
79
|
+
{#if viewer}
|
|
80
|
+
<MenuItem checked={filters.onlyMine} onselect={() => { close(); filters.onlyMine = !filters.onlyMine; }}>
|
|
81
|
+
Only your threads
|
|
82
|
+
</MenuItem>
|
|
83
|
+
<div class="my-1 border-t border-vc-line"></div>
|
|
84
|
+
<MenuItem checked={false} onselect={() => { close(); void comments.markAllRead(); }}>Mark all as read</MenuItem>
|
|
85
|
+
{/if}
|
|
86
|
+
{/snippet}
|
|
87
|
+
</Menu>
|
|
88
|
+
{/if}
|
|
89
|
+
<button
|
|
90
|
+
type="button"
|
|
91
|
+
class="vc-tool-light size-7 rounded-full"
|
|
92
|
+
title={vc.panelCollapsed ? 'Expand comments' : 'Collapse comments'}
|
|
93
|
+
aria-label={vc.panelCollapsed ? 'Expand comments' : 'Collapse comments'}
|
|
94
|
+
aria-expanded={!vc.panelCollapsed}
|
|
95
|
+
onclick={() => (vc.panelCollapsed = !vc.panelCollapsed)}
|
|
96
|
+
>
|
|
97
|
+
<Icon name={vc.panelCollapsed ? 'chevron-left' : 'chevron-right'} />
|
|
98
|
+
</button>
|
|
87
99
|
<button type="button" class="vc-tool-light size-7 rounded-full" title="Close comments (C)" aria-label="Close comments" onclick={onclose}>
|
|
88
100
|
<Icon name="close" />
|
|
89
101
|
</button>
|
|
90
102
|
</header>
|
|
91
103
|
|
|
92
|
-
|
|
93
|
-
<
|
|
94
|
-
<
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
{#if !viewer && !comments.loading}
|
|
107
|
-
<div class="shrink-0 border-b border-vc-line px-3.5 py-3">
|
|
108
|
-
<p>Sign in to see comments and add your own.</p>
|
|
109
|
-
<button type="button" class="mt-2 h-7 rounded-full bg-vc-comment px-3 font-medium text-white hover:brightness-110"
|
|
110
|
-
onclick={() => vc.signIn()}>Sign in</button>
|
|
104
|
+
{#if !vc.panelCollapsed}
|
|
105
|
+
<div class="shrink-0 border-b border-vc-line px-2.5 pb-2.5">
|
|
106
|
+
<label class="flex h-8 items-center gap-2 rounded-[6px] bg-vc-surface px-2.5 text-vc-muted focus-within:ring-1 focus-within:ring-vc-comment">
|
|
107
|
+
<Icon name="search" size={15} />
|
|
108
|
+
<input
|
|
109
|
+
type="search"
|
|
110
|
+
placeholder="Search"
|
|
111
|
+
aria-label="Search comments"
|
|
112
|
+
bind:value={filters.query}
|
|
113
|
+
onkeydown={(event) => { if (event.key === 'Escape') { filters.query = ''; (event.target as HTMLElement).blur(); } }}
|
|
114
|
+
class="min-w-0 flex-1 bg-transparent text-vc-ink outline-none placeholder:text-vc-muted/80"
|
|
115
|
+
/>
|
|
116
|
+
</label>
|
|
111
117
|
</div>
|
|
112
|
-
{/if}
|
|
113
118
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
<
|
|
128
|
-
<
|
|
129
|
-
|
|
130
|
-
|
|
119
|
+
{#if !viewer && !comments.loading}
|
|
120
|
+
<div class="shrink-0 border-b border-vc-line px-3.5 py-3">
|
|
121
|
+
<p>Sign in to see comments and add your own.</p>
|
|
122
|
+
<button type="button" class="mt-2 h-7 rounded-full bg-vc-comment px-3 font-medium text-white hover:brightness-110"
|
|
123
|
+
onclick={() => vc.signIn()}>Sign in</button>
|
|
124
|
+
</div>
|
|
125
|
+
{/if}
|
|
126
|
+
|
|
127
|
+
<ol class="min-h-0 flex-1 space-y-1 overflow-y-auto overscroll-contain p-2">
|
|
128
|
+
{#each visible as thread (thread.id)}
|
|
129
|
+
{@const root = thread.comments[0]}
|
|
130
|
+
{@const replies = thread.comments.length - 1}
|
|
131
|
+
{@const selected = thread.id === vc.openId}
|
|
132
|
+
<li class={['group relative rounded-[8px] px-3 py-2.5', selected ? 'bg-vc-comment-soft' : 'hover:bg-vc-surface']}>
|
|
133
|
+
<button
|
|
134
|
+
type="button"
|
|
135
|
+
class="absolute inset-0 rounded-[8px] focus-visible:outline-2 focus-visible:outline-vc-comment"
|
|
136
|
+
aria-label="Show comment #{thread.id} on {vc.titleOf(thread)}"
|
|
137
|
+
aria-current={selected || undefined}
|
|
138
|
+
onclick={() => onselect(thread)}
|
|
139
|
+
></button>
|
|
140
|
+
<div class={['pointer-events-none relative', thread.resolvedAt && 'opacity-55']}>
|
|
141
|
+
<div class="flex h-7 items-center">
|
|
142
|
+
{#if thread.unread}
|
|
143
|
+
<span class="absolute top-3 -left-2 size-1.5 rounded-full bg-vc-comment" title="Unread"></span>
|
|
144
|
+
{/if}
|
|
145
|
+
<Avatar user={root.author} size={28} />
|
|
146
|
+
</div>
|
|
147
|
+
<p class="mt-1.5 truncate text-vc-muted">#{thread.id} · {vc.titleOf(thread)}</p>
|
|
148
|
+
<p class="truncate">
|
|
149
|
+
<span class="font-medium">{root.author.name}</span>
|
|
150
|
+
<time class="ml-1 text-vc-muted" title={fullDate(root.createdAt)}>{ago(root.createdAt, comments.now)}</time>
|
|
151
|
+
</p>
|
|
152
|
+
<p class={['line-clamp-3 break-words whitespace-pre-line', thread.unread && 'font-medium']}>{commentText(root)}</p>
|
|
153
|
+
{#if replies > 0}
|
|
154
|
+
<p class="mt-1 text-xs text-vc-muted">
|
|
155
|
+
{replies} {replies === 1 ? 'reply' : 'replies'}
|
|
156
|
+
</p>
|
|
131
157
|
{/if}
|
|
132
|
-
<Avatar user={root.author} size={28} />
|
|
133
158
|
</div>
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
</Menu>
|
|
159
|
+
{#if viewer}
|
|
160
|
+
<div class="absolute top-2.5 right-2 flex items-center">
|
|
161
|
+
<div class={['transition-opacity', !selected && 'opacity-0 group-hover:opacity-100 focus-within:opacity-100 has-[[aria-expanded=true]]:opacity-100']}>
|
|
162
|
+
<Menu label="Thread options" align="end" class="vc-tool-light size-7 rounded-full">
|
|
163
|
+
{#snippet trigger()}<Icon name="more" />{/snippet}
|
|
164
|
+
{#snippet children(close)}
|
|
165
|
+
<MenuItem onselect={() => { close(); void comments.markRead(thread.id, !thread.unread); }}>
|
|
166
|
+
{thread.unread ? 'Mark as read' : 'Mark as unread'}
|
|
167
|
+
</MenuItem>
|
|
168
|
+
<MenuItem onselect={() => { close(); copyLink(thread); }}>Copy link</MenuItem>
|
|
169
|
+
{#if root.author.sub === viewer.sub}
|
|
170
|
+
<MenuItem onselect={() => {
|
|
171
|
+
close();
|
|
172
|
+
if (confirm('Delete this thread and all its replies?')) void comments.removeThread(thread.id);
|
|
173
|
+
}}>Delete thread…</MenuItem>
|
|
174
|
+
{/if}
|
|
175
|
+
{/snippet}
|
|
176
|
+
</Menu>
|
|
177
|
+
</div>
|
|
178
|
+
<button
|
|
179
|
+
type="button"
|
|
180
|
+
class={['vc-tool-light size-7 rounded-full', thread.resolvedAt && 'text-vc-comment']}
|
|
181
|
+
aria-pressed={Boolean(thread.resolvedAt)}
|
|
182
|
+
title={thread.resolvedAt ? 'Reopen' : 'Resolve'}
|
|
183
|
+
aria-label={thread.resolvedAt ? `Reopen #${thread.id}` : `Resolve #${thread.id}`}
|
|
184
|
+
onclick={() => comments.resolve(thread.id, !thread.resolvedAt)}
|
|
185
|
+
>
|
|
186
|
+
<Icon name="resolve" size={18} />
|
|
187
|
+
</button>
|
|
164
188
|
</div>
|
|
165
|
-
<button
|
|
166
|
-
type="button"
|
|
167
|
-
class={['vc-tool-light size-7 rounded-full', thread.resolvedAt && 'text-vc-comment']}
|
|
168
|
-
aria-pressed={Boolean(thread.resolvedAt)}
|
|
169
|
-
title={thread.resolvedAt ? 'Reopen' : 'Resolve'}
|
|
170
|
-
aria-label={thread.resolvedAt ? `Reopen #${thread.id}` : `Resolve #${thread.id}`}
|
|
171
|
-
onclick={() => comments.resolve(thread.id, !thread.resolvedAt)}
|
|
172
|
-
>
|
|
173
|
-
<Icon name="resolve" size={18} />
|
|
174
|
-
</button>
|
|
175
|
-
</div>
|
|
176
|
-
{/if}
|
|
177
|
-
</li>
|
|
178
|
-
{:else}
|
|
179
|
-
{#if viewer && !comments.loading}
|
|
180
|
-
<li class="px-3 py-6 text-center text-vc-muted">
|
|
181
|
-
{#if filters.query.trim()}
|
|
182
|
-
No comments match “{filters.query.trim()}”.
|
|
183
|
-
{:else if filters.onlyMine}
|
|
184
|
-
You have not started or replied to any threads.
|
|
185
|
-
{:else if resolvedCount && !filters.showResolved}
|
|
186
|
-
Every thread is resolved.
|
|
187
|
-
<button type="button" class="text-vc-comment hover:underline" onclick={() => (filters.showResolved = true)}>
|
|
188
|
-
Show resolved
|
|
189
|
-
</button>
|
|
190
|
-
{:else}
|
|
191
|
-
No comments yet. Click {vc.surfaces.some((s) => s.element) ? 'a frame' : 'anywhere on the page'} to add one.
|
|
192
189
|
{/if}
|
|
193
190
|
</li>
|
|
194
|
-
{
|
|
195
|
-
|
|
196
|
-
|
|
191
|
+
{:else}
|
|
192
|
+
{#if viewer && !comments.loading}
|
|
193
|
+
<li class="px-3 py-6 text-center text-vc-muted">
|
|
194
|
+
{#if filters.query.trim()}
|
|
195
|
+
No comments match “{filters.query.trim()}”.
|
|
196
|
+
{:else if filters.onlyMine}
|
|
197
|
+
You have not started or replied to any threads.
|
|
198
|
+
{:else if resolvedCount && !filters.showResolved}
|
|
199
|
+
Every thread is resolved.
|
|
200
|
+
<button type="button" class="text-vc-comment hover:underline" onclick={() => (filters.showResolved = true)}>
|
|
201
|
+
Show resolved
|
|
202
|
+
</button>
|
|
203
|
+
{:else}
|
|
204
|
+
No comments yet. Click {vc.surfaces.some((s) => s.element) ? 'a frame' : 'anywhere on the page'} to add one.
|
|
205
|
+
{/if}
|
|
206
|
+
</li>
|
|
207
|
+
{/if}
|
|
208
|
+
{/each}
|
|
209
|
+
</ol>
|
|
197
210
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
211
|
+
{#if comments.error}
|
|
212
|
+
<div role="alert" class="m-2 mt-0 flex shrink-0 items-start gap-2 rounded-[8px] bg-vc-ink px-3 py-2 text-white">
|
|
213
|
+
<p class="flex-1">{comments.error}</p>
|
|
214
|
+
<button type="button" class="text-white/70 hover:text-white" aria-label="Dismiss" onclick={() => (comments.error = null)}>
|
|
215
|
+
<Icon name="close" size={14} />
|
|
216
|
+
</button>
|
|
217
|
+
</div>
|
|
218
|
+
{/if}
|
|
205
219
|
{/if}
|
|
206
220
|
</aside>
|
package/dist/ui/Pin.svelte
CHANGED
|
@@ -27,13 +27,15 @@
|
|
|
27
27
|
import { ago } from '../core/time.js';
|
|
28
28
|
import type { CommentThread } from '../shared/types.js';
|
|
29
29
|
|
|
30
|
-
let { thread, left, top, open, now, onopen }: {
|
|
30
|
+
let { thread, left, top, open, now, working, onopen }: {
|
|
31
31
|
thread: CommentThread;
|
|
32
32
|
/** The pin's point, in client pixels. */
|
|
33
33
|
left: number;
|
|
34
34
|
top: number;
|
|
35
35
|
open: boolean;
|
|
36
36
|
now: number;
|
|
37
|
+
/** Set while someone works on the thread: what they say they are doing. The pin pulses meanwhile. */
|
|
38
|
+
working?: string;
|
|
37
39
|
onopen: () => void;
|
|
38
40
|
} = $props();
|
|
39
41
|
|
|
@@ -49,6 +51,14 @@
|
|
|
49
51
|
const expanded = $derived((hovered || focused) && !open && textWidth > 0);
|
|
50
52
|
</script>
|
|
51
53
|
|
|
54
|
+
{#if working}
|
|
55
|
+
<span
|
|
56
|
+
class={['vc-pin-pulse vc-working pointer-events-none absolute', expanded ? 'z-30' : open ? 'z-20' : 'z-10']}
|
|
57
|
+
style:left="{left}px"
|
|
58
|
+
style:top="{top}px"
|
|
59
|
+
aria-hidden="true"
|
|
60
|
+
></span>
|
|
61
|
+
{/if}
|
|
52
62
|
<button
|
|
53
63
|
type="button"
|
|
54
64
|
data-vc-ui
|
|
@@ -68,7 +78,7 @@
|
|
|
68
78
|
: expanded
|
|
69
79
|
? '0 4px 18px rgb(0 0 0 / 0.22)'
|
|
70
80
|
: '0 1px 4px rgb(0 0 0 / 0.3)'}
|
|
71
|
-
aria-label="Comment #{thread.id} by {root.author.name}: {text}"
|
|
81
|
+
aria-label="Comment #{thread.id} by {root.author.name}: {text}{working ? `. ${working}` : ''}"
|
|
72
82
|
aria-expanded={open}
|
|
73
83
|
in:pop={{ duration: 240, easing: backOut }}
|
|
74
84
|
out:pop={{ duration: 140, easing: cubicIn }}
|
|
@@ -98,6 +108,9 @@
|
|
|
98
108
|
{#if replies > 0}
|
|
99
109
|
<span class="mt-0.5 block text-xs text-vc-muted">{replies} {replies === 1 ? 'reply' : 'replies'}</span>
|
|
100
110
|
{/if}
|
|
111
|
+
{#if working}
|
|
112
|
+
<span class="mt-0.5 block truncate text-xs text-vc-working">{working}</span>
|
|
113
|
+
{/if}
|
|
101
114
|
</span>
|
|
102
115
|
{#if thread.unread}
|
|
103
116
|
<span class="absolute top-0.5 right-0.5 size-2.5 rounded-full bg-vc-comment ring-2 ring-white"></span>
|
|
@@ -123,6 +136,13 @@
|
|
|
123
136
|
top: 10px;
|
|
124
137
|
left: 10px;
|
|
125
138
|
}
|
|
139
|
+
/* The collapsed pin's shape, under it: its ring pulses out from the pin's edge. */
|
|
140
|
+
.vc-pin-pulse {
|
|
141
|
+
width: 32px;
|
|
142
|
+
height: 32px;
|
|
143
|
+
border-radius: 16px 16px 16px 0;
|
|
144
|
+
transform: translateY(-100%);
|
|
145
|
+
}
|
|
126
146
|
.vc-pin-text {
|
|
127
147
|
transition: opacity 140ms ease 60ms;
|
|
128
148
|
}
|
package/dist/ui/Pin.svelte.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ type $$ComponentProps = {
|
|
|
6
6
|
top: number;
|
|
7
7
|
open: boolean;
|
|
8
8
|
now: number;
|
|
9
|
+
/** Set while someone works on the thread: what they say they are doing. The pin pulses meanwhile. */
|
|
10
|
+
working?: string;
|
|
9
11
|
onopen: () => void;
|
|
10
12
|
};
|
|
11
13
|
declare const Pin: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Who else is here: an avatar for each other person and agent present, the
|
|
3
|
+
busiest first within each, beside the account button. Hover for what they do.
|
|
4
|
+
-->
|
|
5
|
+
<script lang="ts">
|
|
6
|
+
import type { VisualComments } from '../visual-comments.svelte.js';
|
|
7
|
+
import { activity, present, type Present } from '../core/presence.js';
|
|
8
|
+
import Avatar from './Avatar.svelte';
|
|
9
|
+
import { authorLabel } from './text.js';
|
|
10
|
+
|
|
11
|
+
let { vc, size = 24 }: { vc: VisualComments; size?: number } = $props();
|
|
12
|
+
|
|
13
|
+
const SHOWN = 4;
|
|
14
|
+
const others = $derived(present(vc.participants.filter((p) => p.author.sub !== vc.viewer?.sub)));
|
|
15
|
+
const shown = $derived(others.length > SHOWN ? others.slice(0, SHOWN - 1) : others);
|
|
16
|
+
const rest = $derived(others.slice(shown.length));
|
|
17
|
+
|
|
18
|
+
function describe(someone: Present) {
|
|
19
|
+
const doing = someone.participants.map(activity).filter(Boolean);
|
|
20
|
+
return [authorLabel(someone.author), ...doing].join('\n');
|
|
21
|
+
}
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
{#if others.length}
|
|
25
|
+
<ul class="flex items-center gap-1 px-1" aria-label="Here now">
|
|
26
|
+
{#each shown as someone (someone.author.sub)}
|
|
27
|
+
<li class="flex" aria-label={describe(someone)}>
|
|
28
|
+
<Avatar user={someone.author} {size} title={describe(someone)} class={someone.state === 'working' ? 'vc-working' : ''} />
|
|
29
|
+
</li>
|
|
30
|
+
{/each}
|
|
31
|
+
{#if rest.length}
|
|
32
|
+
<li
|
|
33
|
+
class="flex items-center justify-center rounded-full bg-current/15 text-[11px] font-medium"
|
|
34
|
+
style:width="{size}px"
|
|
35
|
+
style:height="{size}px"
|
|
36
|
+
title={rest.map(describe).join('\n\n')}
|
|
37
|
+
aria-label={rest.map(describe).join('\n\n')}
|
|
38
|
+
>
|
|
39
|
+
+{rest.length}
|
|
40
|
+
</li>
|
|
41
|
+
{/if}
|
|
42
|
+
</ul>
|
|
43
|
+
{/if}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { VisualComments } from '../visual-comments.svelte.js';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
vc: VisualComments;
|
|
4
|
+
size?: number;
|
|
5
|
+
};
|
|
6
|
+
declare const Presence: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
7
|
+
type Presence = ReturnType<typeof Presence>;
|
|
8
|
+
export default Presence;
|