fix(editor): real comment authors + overlay menu redesign
Comments showed the author's SLUG — which doubles as their personal scope — instead of their name, because WasmTool passed presenceUser().id where .name already existed. - denormalize authorName/authorEmail onto each comment message + thread at write time, so a comment still reads correctly when its author is offline, renamed, or gone. `author` stays the SLUG: colorFor() and the "is this mine?" ownership checks compare it. Legacy comments fall back. - session-identity keeps the email it was already fetching and discarding. Overlay menu: restyle + reorganise into labelled sections (People / Document / Comments / View) over one shared row shape, so sections can't drift apart again. - comments: four bare icons -> labelled rows (Add comment / Show list with count / Hide pins). The nested expand toggle is gone: the section IS the group, so it was a collapsible inside a collapsible. - presence: facepile of initials -> one row per person with an explicit Follow/Stop. The separate "Following X" banner is deleted; that state now lives on the person's own row, so there is nothing to keep in sync. - SourceChip gains a `muted` tone for known-dark surfaces; its solid variant is untouched for the light project pages that share it. Fixes a regression the redesign introduced: the taller panel (z-50) covered the comment popover (z-40), and since a popover can be opened FROM the menu's thread list, the menu swallowed clicks on the popover it had just spawned — delete was unreachable. Popover now z-[60]. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016p9kjdGBdcpwUSjJ3q5xg2
This commit is contained in:
parent
f92266fcee
commit
fc2efeff2c
11 changed files with 387 additions and 178 deletions
|
|
@ -29,10 +29,11 @@ async function bootAs(page: Page, user: string): Promise<void> {
|
||||||
intervals: [1000],
|
intervals: [1000],
|
||||||
})
|
})
|
||||||
.toMatch(TITLE);
|
.toMatch(TITLE);
|
||||||
await openOverlayMenu(page); // the comment bar lives in the overlay menu (0010)
|
// The comment controls live in the overlay menu's "Comments" section and are
|
||||||
await expect(page.getByTestId('comment-bar-toggle')).toBeVisible({ timeout: 30000 });
|
// visible as soon as it opens — the nested expand toggle (comment-bar-toggle)
|
||||||
await page.getByTestId('comment-bar-toggle').click();
|
// is gone, since the section itself is the group.
|
||||||
await expect(page.getByTestId('comment-mode-toggle')).toBeVisible();
|
await openOverlayMenu(page); // the comment controls live in the overlay menu (0010)
|
||||||
|
await expect(page.getByTestId('comment-mode-toggle')).toBeVisible({ timeout: 30000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Comments persist in the room's ydoc — start each run from a clean slate. */
|
/** Comments persist in the room's ydoc — start each run from a clean slate. */
|
||||||
|
|
|
||||||
|
|
@ -27,12 +27,12 @@ async function bootAs(page: Page, user: string): Promise<void> {
|
||||||
intervals: [1000],
|
intervals: [1000],
|
||||||
})
|
})
|
||||||
.toMatch(TITLE);
|
.toMatch(TITLE);
|
||||||
// The comment controls mount once the collab session + bridge are up; the
|
// The comment controls mount once the collab session + bridge are up. They
|
||||||
// action buttons live inside the expandable bar — open it for the test.
|
// live in the overlay menu's "Comments" section and are visible as soon as it
|
||||||
await openOverlayMenu(page); // the comment bar lives in the overlay menu (0010)
|
// opens — the old nested expand toggle (comment-bar-toggle) is gone, since the
|
||||||
await expect(page.getByTestId('comment-bar-toggle')).toBeVisible({ timeout: 30000 });
|
// section itself is the group.
|
||||||
await page.getByTestId('comment-bar-toggle').click();
|
await openOverlayMenu(page); // the comment controls live in the overlay menu (0010)
|
||||||
await expect(page.getByTestId('comment-mode-toggle')).toBeVisible();
|
await expect(page.getByTestId('comment-mode-toggle')).toBeVisible({ timeout: 30000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Delete every leftover thread from previous runs — comments PERSIST in the
|
/** Delete every leftover thread from previous runs — comments PERSIST in the
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
Subproject commit e044c86ea538a1ec6dd2120d1f85fcf9da6b8aa2
|
Subproject commit 10ef7c1a0278970277cfb67c7b0360e3f1d03ce9
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
import { overlayRowClass } from "@/components/OverlayMenu";
|
||||||
import type { CommentAnchor } from "@pcbjam/shared";
|
import type { CommentAnchor } from "@pcbjam/shared";
|
||||||
import { Eye, EyeOff, List, MessageSquarePlus, MessageSquareText, X } from "lucide-react";
|
import { Eye, EyeOff, List, MessageSquarePlus, X } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
screenToWorld,
|
screenToWorld,
|
||||||
worldToScreen,
|
worldToScreen,
|
||||||
|
|
@ -29,6 +30,28 @@ interface CssRect {
|
||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What to show for a comment author, and what to reveal on hover.
|
||||||
|
*
|
||||||
|
* `author`/`createdBy` is the SLUG — the identity key used for colors and
|
||||||
|
* ownership. It is what used to be rendered, which is why comments showed a
|
||||||
|
* scope-looking string instead of a person. Prefer the denormalized display
|
||||||
|
* name; legacy messages (written before authorName existed) have none and fall
|
||||||
|
* back to the slug, so old threads keep working.
|
||||||
|
*/
|
||||||
|
function authorLabel(a: { author?: string; createdBy?: string; authorName?: string; authorEmail?: string }): {
|
||||||
|
text: string;
|
||||||
|
title: string;
|
||||||
|
} {
|
||||||
|
const slug = a.author ?? a.createdBy ?? "";
|
||||||
|
const text = a.authorName || slug;
|
||||||
|
// Tooltip: email when we captured one, otherwise the slug — always something
|
||||||
|
// more identifying than the label itself.
|
||||||
|
const title = a.authorEmail ? `${text} <${a.authorEmail}>` : slug;
|
||||||
|
return { text, title };
|
||||||
|
}
|
||||||
|
|
||||||
function glCanvasRect(): CssRect | null {
|
function glCanvasRect(): CssRect | null {
|
||||||
const el = Array.from(document.querySelectorAll('[id^="glcanvas-"]')).find((c) => {
|
const el = Array.from(document.querySelectorAll('[id^="glcanvas-"]')).find((c) => {
|
||||||
const r = (c as HTMLElement).getBoundingClientRect();
|
const r = (c as HTMLElement).getBoundingClientRect();
|
||||||
|
|
@ -66,7 +89,6 @@ export function CommentLayer({
|
||||||
menuSlot: HTMLElement | null;
|
menuSlot: HTMLElement | null;
|
||||||
}) {
|
}) {
|
||||||
const [threads, setThreads] = React.useState<ResolvedThread[]>(controller.threads());
|
const [threads, setThreads] = React.useState<ResolvedThread[]>(controller.threads());
|
||||||
const [barOpen, setBarOpen] = React.useState(false);
|
|
||||||
const [mode, setMode] = React.useState(false);
|
const [mode, setMode] = React.useState(false);
|
||||||
const [openId, setOpenId] = React.useState<string | null>(null);
|
const [openId, setOpenId] = React.useState<string | null>(null);
|
||||||
const [panel, setPanel] = React.useState(false);
|
const [panel, setPanel] = React.useState(false);
|
||||||
|
|
@ -211,54 +233,57 @@ export function CommentLayer({
|
||||||
const menuUi = menuSlot
|
const menuUi = menuSlot
|
||||||
? createPortal(
|
? createPortal(
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2">
|
{/* The overlay menu's "Comments" section IS the group, so there is no
|
||||||
|
nested open/close toggle here any more — that was a collapsible
|
||||||
|
inside a collapsible. And every action is a LABELLED row: the old
|
||||||
|
bar was four bare icons whose meanings you had to hover to learn. */}
|
||||||
|
<div className="flex w-full flex-col">
|
||||||
<button
|
<button
|
||||||
data-testid="comment-bar-toggle"
|
data-testid="comment-mode-toggle"
|
||||||
title="Comments"
|
aria-pressed={mode}
|
||||||
onClick={() => setBarOpen((o) => !o)}
|
title={mode ? "Cancel (Esc)" : "Click the canvas to place a pin"}
|
||||||
className={`flex h-8 min-w-8 items-center justify-center gap-1 rounded-full px-2 text-xs shadow-sm ring-1 ring-inset ring-white/20 ${
|
onClick={() => {
|
||||||
barOpen ? "bg-sky-600 text-white" : "bg-black/70 text-white hover:bg-black/85"
|
if (hidden) toggleHidden();
|
||||||
}`}
|
setMode((m) => !m);
|
||||||
|
setDraft(null);
|
||||||
|
}}
|
||||||
|
className={`${overlayRowClass} ${mode ? "bg-amber-500/20 text-amber-200" : ""}`}
|
||||||
>
|
>
|
||||||
<MessageSquareText size={15} />
|
<MessageSquarePlus size={14} className="shrink-0 text-white/50" />
|
||||||
{threads.length > 0 && <span>{threads.length}</span>}
|
<span>{mode ? "Placing comment…" : "Add comment"}</span>
|
||||||
|
<span className="ml-auto text-[10px] text-white/40">
|
||||||
|
{mode ? "Esc" : ""}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
data-testid="comment-panel-toggle"
|
||||||
|
aria-pressed={panel}
|
||||||
|
title="Show every comment in this file"
|
||||||
|
onClick={() => setPanel((p) => !p)}
|
||||||
|
className={`${overlayRowClass} ${panel ? "bg-white/10" : ""}`}
|
||||||
|
>
|
||||||
|
<List size={14} className="shrink-0 text-white/50" />
|
||||||
|
<span>{panel ? "Hide list" : "Show list"}</span>
|
||||||
|
<span className="ml-auto text-[10px] text-white/40">
|
||||||
|
{threads.length}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
data-testid="comment-visibility-toggle"
|
||||||
|
aria-pressed={!hidden}
|
||||||
|
title={hidden ? "Show the pins on the canvas" : "Hide the pins on the canvas"}
|
||||||
|
onClick={toggleHidden}
|
||||||
|
className={overlayRowClass}
|
||||||
|
>
|
||||||
|
{hidden ? (
|
||||||
|
<EyeOff size={14} className="shrink-0 text-white/50" />
|
||||||
|
) : (
|
||||||
|
<Eye size={14} className="shrink-0 text-white/50" />
|
||||||
|
)}
|
||||||
|
<span>{hidden ? "Show pins" : "Hide pins"}</span>
|
||||||
</button>
|
</button>
|
||||||
{barOpen && (
|
|
||||||
<div className="flex items-center gap-1 rounded-full bg-black/70 p-1 shadow-sm ring-1 ring-inset ring-white/20">
|
|
||||||
<button
|
|
||||||
data-testid="comment-mode-toggle"
|
|
||||||
title={mode ? "Cancel comment (Esc)" : "New comment"}
|
|
||||||
onClick={() => {
|
|
||||||
if (hidden) toggleHidden();
|
|
||||||
setMode((m) => !m);
|
|
||||||
setDraft(null);
|
|
||||||
}}
|
|
||||||
className={`flex h-6 w-6 items-center justify-center rounded-full ${
|
|
||||||
mode ? "bg-amber-500 text-black" : "text-white hover:bg-white/15"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<MessageSquarePlus size={14} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
data-testid="comment-panel-toggle"
|
|
||||||
title="Comment list"
|
|
||||||
onClick={() => setPanel((p) => !p)}
|
|
||||||
className={`flex h-6 w-6 items-center justify-center rounded-full ${
|
|
||||||
panel ? "bg-white/25 text-white" : "text-white hover:bg-white/15"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<List size={14} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
data-testid="comment-visibility-toggle"
|
|
||||||
title={hidden ? "Show comments" : "Hide comments"}
|
|
||||||
onClick={toggleHidden}
|
|
||||||
className="flex h-6 w-6 items-center justify-center rounded-full text-white hover:bg-white/15"
|
|
||||||
>
|
|
||||||
{hidden ? <EyeOff size={14} /> : <Eye size={14} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Threads panel (filter + jump-to). */}
|
{/* Threads panel (filter + jump-to). */}
|
||||||
|
|
@ -294,8 +319,9 @@ export function CommentLayer({
|
||||||
<span
|
<span
|
||||||
className="font-semibold"
|
className="font-semibold"
|
||||||
style={{ color: controller.colorFor(t.createdBy) }}
|
style={{ color: controller.colorFor(t.createdBy) }}
|
||||||
|
title={authorLabel(t).title}
|
||||||
>
|
>
|
||||||
{t.createdBy}
|
{authorLabel(t).text}
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
<span className="text-white/50">
|
<span className="text-white/50">
|
||||||
{timeAgo(t.createdAt)} ago{t.resolved ? " · resolved" : ""}
|
{timeAgo(t.createdAt)} ago{t.resolved ? " · resolved" : ""}
|
||||||
|
|
@ -336,7 +362,7 @@ export function CommentLayer({
|
||||||
key={t.id}
|
key={t.id}
|
||||||
data-testid="comment-pin"
|
data-testid="comment-pin"
|
||||||
data-thread-id={t.id}
|
data-thread-id={t.id}
|
||||||
title={`${t.createdBy}: ${t.messages[0]?.body ?? ""}${t.detached ? " (detached)" : ""} — drag to move`}
|
title={`${authorLabel(t).text}: ${t.messages[0]?.body ?? ""}${t.detached ? " (detached)" : ""} — drag to move`}
|
||||||
onPointerDown={onPinPointerDown(t)}
|
onPointerDown={onPinPointerDown(t)}
|
||||||
onPointerMove={onPinPointerMove}
|
onPointerMove={onPinPointerMove}
|
||||||
onPointerUp={onPinPointerUp(t)}
|
onPointerUp={onPinPointerUp(t)}
|
||||||
|
|
@ -448,10 +474,15 @@ function ThreadPopover({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// z-[60] beats the overlay menu's z-50 ON PURPOSE: the menu panel is tall
|
||||||
|
// enough to cover a pin popover, and a popover can be opened FROM the menu
|
||||||
|
// (the thread list's jump-to), so the menu would otherwise swallow clicks on
|
||||||
|
// the popover it just spawned. The focused surface wins; the menu stays one
|
||||||
|
// click-away from dismissal.
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-testid="comment-popover"
|
data-testid="comment-popover"
|
||||||
className="absolute z-40 w-72 rounded-lg bg-black/90 text-white shadow-lg ring-1 ring-inset ring-white/20"
|
className="absolute z-[60] w-72 rounded-lg bg-black/90 text-white shadow-lg ring-1 ring-inset ring-white/20"
|
||||||
style={{
|
style={{
|
||||||
left: Math.min(css.x + 16, window.innerWidth - 300),
|
left: Math.min(css.x + 16, window.innerWidth - 300),
|
||||||
top: Math.min(css.y - 8, window.innerHeight - 260),
|
top: Math.min(css.y - 8, window.innerHeight - 260),
|
||||||
|
|
@ -493,8 +524,12 @@ function ThreadPopover({
|
||||||
{thread.messages.map((m) => (
|
{thread.messages.map((m) => (
|
||||||
<div key={m.id} data-testid="comment-message" className="group px-3 py-2 text-xs">
|
<div key={m.id} data-testid="comment-message" className="group px-3 py-2 text-xs">
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
<span className="font-semibold" style={{ color: controller.colorFor(m.author) }}>
|
<span
|
||||||
{m.author}
|
className="font-semibold"
|
||||||
|
style={{ color: controller.colorFor(m.author) }}
|
||||||
|
title={authorLabel(m).title}
|
||||||
|
>
|
||||||
|
{authorLabel(m).text}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-white/40">
|
<span className="text-[10px] text-white/40">
|
||||||
{timeAgo(m.createdAt)} ago{m.editedAt ? " · edited" : ""}
|
{timeAgo(m.createdAt)} ago{m.editedAt ? " · edited" : ""}
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,48 @@ import { Users } from "lucide-react";
|
||||||
* toasts) by decision: it is trivially dismissed (click-away, Esc, the FAB)
|
* toasts) by decision: it is trivially dismissed (click-away, Esc, the FAB)
|
||||||
* and can be dragged out of the way. Stays up in chrome-hidden mode — it is
|
* and can be dragged out of the way. Stays up in chrome-hidden mode — it is
|
||||||
* the canvas-only survivor the chrome toggle used to be.
|
* the canvas-only survivor the chrome toggle used to be.
|
||||||
|
*
|
||||||
|
* VISUAL SYSTEM. The panel previously stacked whatever its children happened to
|
||||||
|
* look like — free-floating pills of different heights, radii and surfaces, all
|
||||||
|
* left-aligned with a gap. It read as debris rather than a menu. The primitives
|
||||||
|
* below are the fix and the contract:
|
||||||
|
*
|
||||||
|
* OverlayMenuSection — a labelled group, separated from its neighbours.
|
||||||
|
* overlayRowClass — the shared row shape for anything interactive.
|
||||||
|
*
|
||||||
|
* Children compose these instead of inventing their own chrome. Two deliberate
|
||||||
|
* exceptions stay self-styled because they are shared with light-background
|
||||||
|
* pages (SourceChip) or own a nontrivial internal layout (PresenceRoster) —
|
||||||
|
* those are wrapped in a section rather than restyled.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/** Row shape for any interactive item in the panel. Full-width so the panel
|
||||||
|
* reads as a list; the hover/active states are the only affordance needed. */
|
||||||
|
export const overlayRowClass =
|
||||||
|
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs " +
|
||||||
|
"text-white/90 transition-colors hover:bg-white/10 " +
|
||||||
|
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-white/40";
|
||||||
|
|
||||||
|
/** A labelled group. `label` is omitted for the first/unnamed group. */
|
||||||
|
export function OverlayMenuSection({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-col gap-1 border-t border-white/10 pt-2 first:border-t-0 first:pt-0">
|
||||||
|
{label && (
|
||||||
|
<div className="px-2 text-[10px] font-semibold uppercase tracking-wide text-white/40">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const POS_KEY = "pcbjam:overlay-menu-pos";
|
const POS_KEY = "pcbjam:overlay-menu-pos";
|
||||||
const FAB_SIZE = 36;
|
const FAB_SIZE = 36;
|
||||||
const DRAG_THRESHOLD_PX = 4;
|
const DRAG_THRESHOLD_PX = 4;
|
||||||
|
|
@ -156,8 +196,10 @@ export function OverlayMenu({
|
||||||
onPointerDown={onFabPointerDown}
|
onPointerDown={onFabPointerDown}
|
||||||
onPointerMove={onFabPointerMove}
|
onPointerMove={onFabPointerMove}
|
||||||
onPointerUp={onFabPointerUp}
|
onPointerUp={onFabPointerUp}
|
||||||
className={`relative flex h-9 w-9 items-center justify-center rounded-full shadow-md ring-1 ring-inset ring-white/25 ${
|
className={`relative flex h-9 w-9 items-center justify-center rounded-full text-white shadow-lg ring-1 ring-inset transition-colors ${
|
||||||
open ? "bg-sky-600 text-white" : "bg-black/75 text-white hover:bg-black/90"
|
open
|
||||||
|
? "bg-sky-600 ring-sky-300/40"
|
||||||
|
: "bg-neutral-950/80 ring-white/15 backdrop-blur-sm hover:bg-neutral-900/90"
|
||||||
}`}
|
}`}
|
||||||
style={{ touchAction: "none" }}
|
style={{ touchAction: "none" }}
|
||||||
>
|
>
|
||||||
|
|
@ -175,10 +217,20 @@ export function OverlayMenu({
|
||||||
{open && (
|
{open && (
|
||||||
<div
|
<div
|
||||||
data-testid="overlay-menu-panel"
|
data-testid="overlay-menu-panel"
|
||||||
className={`absolute flex w-72 flex-col items-start gap-2 rounded-lg bg-black/85 p-2 shadow-xl ring-1 ring-inset ring-white/20 ${
|
className={`absolute flex w-72 flex-col gap-2 rounded-xl bg-neutral-950/90 p-2 shadow-2xl ring-1 ring-inset ring-white/15 backdrop-blur-sm ${
|
||||||
onLeftHalf ? "left-0" : "right-0"
|
onLeftHalf ? "left-0" : "right-0"
|
||||||
} ${onTopHalf ? "top-11" : "bottom-11"}`}
|
} ${onTopHalf ? "top-11" : "bottom-11"}`}
|
||||||
>
|
>
|
||||||
|
<div className="flex items-center justify-between px-2 pt-0.5">
|
||||||
|
<span className="text-[11px] font-semibold tracking-wide text-white/70">
|
||||||
|
Session
|
||||||
|
</span>
|
||||||
|
{badge > 0 && (
|
||||||
|
<span className="text-[10px] text-white/40">
|
||||||
|
{badge} {badge === 1 ? "other" : "others"} here
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,28 @@
|
||||||
|
import { Eye } from "lucide-react";
|
||||||
import type { PresencePeer } from "@/wasm/collab/presence";
|
import type { PresencePeer } from "@/wasm/collab/presence";
|
||||||
import type { FollowTarget } from "@/wasm/collab/follow-user";
|
import type { FollowTarget } from "@/wasm/collab/follow-user";
|
||||||
|
import { overlayRowClass } from "@/components/OverlayMenu";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "Who else is in this file" (collab-presence 0001/0003): a compact facepile of
|
* "Who else is in this file" (collab-presence 0001/0003), as a readable LIST.
|
||||||
* the room's OTHER users, one colored initial-avatar per person, fed by the
|
|
||||||
* collab session's awareness. For eeschema (per-sheet rooms + warm-pool
|
|
||||||
* skeleton states) peers on a DIFFERENT sheet render dimmed, with the sheet
|
|
||||||
* they're on in the tooltip. Rendered in the editor's top-right overlay stack
|
|
||||||
* next to SourceChip; the parent hides it when there are no peers. Chip styling
|
|
||||||
* mirrors SourceChip (solid fill + inset ring) so it is legible on any backdrop.
|
|
||||||
*
|
*
|
||||||
* Follow-user (0008): when `onFollow` is provided, clicking a same-sheet
|
* This used to be a facepile — overlapping 20px circles showing one initial
|
||||||
* peer's avatar follows their viewport (click again to stop); the followed
|
* each. That is a fine density trade in a cramped toolbar, but it lives in the
|
||||||
* peer gets a ring in their color. The parent renders the "Following…"
|
* overlay menu now (0010), which is 288px wide: there is room for names, and a
|
||||||
* banner — the roster only toggles.
|
* row of colored initials told you neither who was here nor that clicking one
|
||||||
|
* followed them. One row per person instead: color dot, name, where they are,
|
||||||
|
* and an explicit follow affordance.
|
||||||
|
*
|
||||||
|
* For eeschema (per-sheet rooms) peers on a DIFFERENT sheet are dimmed, say
|
||||||
|
* which sheet they're on, and are not followable — following someone you cannot
|
||||||
|
* see would just teleport you.
|
||||||
|
*
|
||||||
|
* Follow-user (0008): clicking a same-sheet peer follows their viewport; the
|
||||||
|
* followed peer's row stays highlighted with a "Stop" action, so the state and
|
||||||
|
* its exit live on the same row (the parent no longer renders a separate
|
||||||
|
* "Following…" banner).
|
||||||
*/
|
*/
|
||||||
const MAX_AVATARS = 5;
|
const MAX_ROWS = 6;
|
||||||
|
|
||||||
function sheetLabel(sheetPath?: string): string {
|
function sheetLabel(sheetPath?: string): string {
|
||||||
if (!sheetPath) return "";
|
if (!sheetPath) return "";
|
||||||
|
|
@ -42,13 +49,10 @@ export function PresenceRoster({
|
||||||
const sameSheet = (p: PresencePeer) =>
|
const sameSheet = (p: PresencePeer) =>
|
||||||
(p.sheetPath ?? undefined) === (activeSheetPath ?? undefined);
|
(p.sheetPath ?? undefined) === (activeSheetPath ?? undefined);
|
||||||
// Same-sheet peers first, then elsewhere (dimmed) — stable within each group.
|
// Same-sheet peers first, then elsewhere (dimmed) — stable within each group.
|
||||||
const ordered = [...peers].sort((a, b) => Number(sameSheet(b)) - Number(sameSheet(a)));
|
const ordered = [...peers].sort(
|
||||||
const names = ordered
|
(a, b) => Number(sameSheet(b)) - Number(sameSheet(a)),
|
||||||
.map((p) =>
|
);
|
||||||
sameSheet(p) ? p.user.name : `${p.user.name} (on ${sheetLabel(p.sheetPath) || "another sheet"})`,
|
const shown = ordered.slice(0, MAX_ROWS);
|
||||||
)
|
|
||||||
.join(", ");
|
|
||||||
const shown = ordered.slice(0, MAX_AVATARS);
|
|
||||||
|
|
||||||
const followable = (p: PresencePeer) => !!onFollow && sameSheet(p);
|
const followable = (p: PresencePeer) => !!onFollow && sameSheet(p);
|
||||||
const isFollowed = (p: PresencePeer) => following?.clientId === p.clientId;
|
const isFollowed = (p: PresencePeer) => following?.clientId === p.clientId;
|
||||||
|
|
@ -56,46 +60,69 @@ export function PresenceRoster({
|
||||||
const toggleFollow = (p: PresencePeer) => {
|
const toggleFollow = (p: PresencePeer) => {
|
||||||
if (!onFollow) return;
|
if (!onFollow) return;
|
||||||
onFollow(
|
onFollow(
|
||||||
isFollowed(p) ? null : { clientId: p.clientId, userId: p.user.id, name: p.user.name },
|
isFollowed(p)
|
||||||
|
? null
|
||||||
|
: { clientId: p.clientId, userId: p.user.id, name: p.user.name },
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<div data-testid="presence-roster" className="flex w-full flex-col">
|
||||||
data-testid="presence-roster"
|
{shown.map((p) => {
|
||||||
title={`Also here: ${names}`}
|
const here = sameSheet(p);
|
||||||
className="inline-flex items-center rounded-full bg-black/70 py-0.5 pl-1 pr-1.5 shadow-sm ring-1 ring-inset ring-white/20"
|
const followed = isFollowed(p);
|
||||||
>
|
const elsewhere = sheetLabel(p.sheetPath) || "another sheet";
|
||||||
{shown.map((p) => (
|
return (
|
||||||
<button
|
<button
|
||||||
key={p.user.id}
|
key={p.user.id}
|
||||||
type="button"
|
type="button"
|
||||||
data-presence-user={p.user.id}
|
data-presence-user={p.user.id}
|
||||||
data-presence-elsewhere={sameSheet(p) ? undefined : "1"}
|
data-presence-elsewhere={here ? undefined : "1"}
|
||||||
data-presence-following={isFollowed(p) ? "1" : undefined}
|
data-presence-following={followed ? "1" : undefined}
|
||||||
disabled={!followable(p)}
|
disabled={!followable(p)}
|
||||||
onClick={() => toggleFollow(p)}
|
onClick={() => toggleFollow(p)}
|
||||||
title={
|
title={
|
||||||
sameSheet(p)
|
here
|
||||||
? `${p.user.name}${onFollow ? (isFollowed(p) ? " — click to stop following" : " — click to follow") : ""}`
|
? onFollow
|
||||||
: `${p.user.name} — on ${sheetLabel(p.sheetPath) || "another sheet"}`
|
? followed
|
||||||
}
|
? `${p.user.name} — click to stop following`
|
||||||
style={{
|
: `${p.user.name} — click to follow their view`
|
||||||
backgroundColor: p.user.color,
|
: p.user.name
|
||||||
...(isFollowed(p) ? { boxShadow: `0 0 0 2px ${p.user.color}` } : {}),
|
: `${p.user.name} — on ${elsewhere}`
|
||||||
}}
|
}
|
||||||
className={`-ml-1.5 flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-semibold text-white ring-2 ring-black/50 first:ml-0 ${
|
className={`${overlayRowClass} ${
|
||||||
sameSheet(p) ? "cursor-pointer" : "opacity-40"
|
followed ? "bg-white/10" : ""
|
||||||
}`}
|
} ${here ? "" : "cursor-default opacity-50 hover:bg-transparent"}`}
|
||||||
>
|
>
|
||||||
{p.user.name.charAt(0).toUpperCase()}
|
<span
|
||||||
</button>
|
aria-hidden
|
||||||
))}
|
className="h-2.5 w-2.5 shrink-0 rounded-full ring-1 ring-inset ring-black/40"
|
||||||
{peers.length > MAX_AVATARS && (
|
style={{ backgroundColor: p.user.color }}
|
||||||
<span className="ml-1 text-[10px] font-medium text-white/80">
|
/>
|
||||||
+{peers.length - MAX_AVATARS}
|
<span className="truncate">{p.user.name}</span>
|
||||||
|
{!here && (
|
||||||
|
<span className="ml-auto shrink-0 truncate text-[10px] text-white/40">
|
||||||
|
on {elsewhere}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{here && followed && (
|
||||||
|
<span className="ml-auto flex shrink-0 items-center gap-1 text-[10px] font-medium text-white/70">
|
||||||
|
<Eye size={12} /> Stop
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{here && !followed && followable(p) && (
|
||||||
|
<span className="ml-auto shrink-0 text-[10px] text-white/35">
|
||||||
|
Follow
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{peers.length > MAX_ROWS && (
|
||||||
|
<span className="px-2 py-1 text-[10px] text-white/40">
|
||||||
|
+{peers.length - MAX_ROWS} more
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,14 +23,41 @@ const TONES: Record<SourceKind, string> = {
|
||||||
"remote-rw": "bg-sky-600 text-white ring-sky-300/30",
|
"remote-rw": "bg-sky-600 text-white ring-sky-300/30",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// MUTED variant, for use on a known-dark surface (the editor's overlay menu).
|
||||||
|
// The saturated fills above exist because the chip must survive an unknown
|
||||||
|
// backdrop; inside the menu the backdrop IS known, and a solid sky/emerald pill
|
||||||
|
// just shouts. Here the colour drops to a small leading dot and the chip itself
|
||||||
|
// becomes another neutral row.
|
||||||
|
const MUTED_TONES: Record<SourceKind, string> = {
|
||||||
|
local: "text-emerald-300/90",
|
||||||
|
"remote-ro": "text-amber-300/90",
|
||||||
|
"remote-rw": "text-sky-300/90",
|
||||||
|
};
|
||||||
|
|
||||||
export function SourceChip({
|
export function SourceChip({
|
||||||
descriptor,
|
descriptor,
|
||||||
className = "",
|
className = "",
|
||||||
|
tone = "solid",
|
||||||
}: {
|
}: {
|
||||||
descriptor: SourceDescriptor;
|
descriptor: SourceDescriptor;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** `muted` for known-dark surfaces (see MUTED_TONES). */
|
||||||
|
tone?: "solid" | "muted";
|
||||||
}) {
|
}) {
|
||||||
const Icon = ICONS[descriptor.kind];
|
const Icon = ICONS[descriptor.kind];
|
||||||
|
|
||||||
|
if (tone === "muted") {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
title={descriptor.description}
|
||||||
|
className={`inline-flex items-center gap-2 text-xs font-medium text-white/90 ${className}`}
|
||||||
|
>
|
||||||
|
<Icon size={14} className={`shrink-0 ${MUTED_TONES[descriptor.kind]}`} />
|
||||||
|
{descriptor.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
title={descriptor.description}
|
title={descriptor.description}
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,11 @@ import {
|
||||||
type KicadDoc,
|
type KicadDoc,
|
||||||
type Tool,
|
type Tool,
|
||||||
} from "@pcbjam/shared";
|
} from "@pcbjam/shared";
|
||||||
import { ChevronDown, ChevronUp, EyeOff, Loader2, PanelsTopLeft } from "lucide-react";
|
import { ChevronDown, ChevronUp, Eye, EyeOff, Loader2, PanelsTopLeft } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
API_BASE_URL,
|
API_BASE_URL,
|
||||||
APP_URL,
|
APP_URL,
|
||||||
|
commentAuthor,
|
||||||
currentScope,
|
currentScope,
|
||||||
libsSourceConfig,
|
libsSourceConfig,
|
||||||
modelsSourceConfig,
|
modelsSourceConfig,
|
||||||
|
|
@ -86,7 +87,11 @@ import {
|
||||||
} from "@/wasm/collab/comments";
|
} from "@/wasm/collab/comments";
|
||||||
import { PresenceRoster } from "@/components/PresenceRoster";
|
import { PresenceRoster } from "@/components/PresenceRoster";
|
||||||
import { CommentLayer } from "@/components/CommentLayer";
|
import { CommentLayer } from "@/components/CommentLayer";
|
||||||
import { OverlayMenu } from "@/components/OverlayMenu";
|
import {
|
||||||
|
OverlayMenu,
|
||||||
|
OverlayMenuSection,
|
||||||
|
overlayRowClass,
|
||||||
|
} from "@/components/OverlayMenu";
|
||||||
import { hasTunerBridge, PresenceTuner, type TunerModule } from "@/components/PresenceTuner";
|
import { hasTunerBridge, PresenceTuner, type TunerModule } from "@/components/PresenceTuner";
|
||||||
import {
|
import {
|
||||||
createSheetCollabManager,
|
createSheetCollabManager,
|
||||||
|
|
@ -1164,7 +1169,7 @@ export function WasmTool({
|
||||||
const ctl = createComments({
|
const ctl = createComments({
|
||||||
doc,
|
doc,
|
||||||
mod: win.Module,
|
mod: win.Module,
|
||||||
user: presenceUser().id,
|
user: commentAuthor(),
|
||||||
tool,
|
tool,
|
||||||
// Author colors follow the live nth-in-room assignment when the
|
// Author colors follow the live nth-in-room assignment when the
|
||||||
// author is present; offline authors fall back to the name hash.
|
// author is present; offline authors fall back to the name hash.
|
||||||
|
|
@ -1684,58 +1689,79 @@ export function WasmTool({
|
||||||
the one control that stays up in canvas-only (chrome-hidden) mode. */}
|
the one control that stays up in canvas-only (chrome-hidden) mode. */}
|
||||||
{ready && (
|
{ready && (
|
||||||
<OverlayMenu badge={peers.length}>
|
<OverlayMenu badge={peers.length}>
|
||||||
|
{/* PEOPLE — who else is here, and whose view you're locked to. The
|
||||||
|
follow state lives on each person's own row (PresenceRoster), so
|
||||||
|
there is no separate "Following…" banner to keep in sync. */}
|
||||||
{peers.length > 0 && (
|
{peers.length > 0 && (
|
||||||
<PresenceRoster
|
<OverlayMenuSection label="People">
|
||||||
peers={peers}
|
<PresenceRoster
|
||||||
activeSheetPath={activeSheetPath}
|
peers={peers}
|
||||||
following={followingTarget}
|
activeSheetPath={activeSheetPath}
|
||||||
onFollow={(t) => {
|
following={followingTarget}
|
||||||
if (t) followRef.current?.follow(t);
|
onFollow={(t) => {
|
||||||
else followRef.current?.unfollow();
|
if (t) followRef.current?.follow(t);
|
||||||
}}
|
else followRef.current?.unfollow();
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
</OverlayMenuSection>
|
||||||
)}
|
)}
|
||||||
{sourceDescriptor && <SourceChip descriptor={sourceDescriptor} />}
|
|
||||||
{readOnly && (
|
{/* DOCUMENT — where this file came from and whether you may edit it.
|
||||||
<span
|
SourceChip is shared with the light project pages, so instead of
|
||||||
data-testid="view-only-pill"
|
restyling it we ask for its `muted` tone: colour drops to a dot,
|
||||||
className="flex h-8 items-center rounded-full bg-black/70 px-3 text-xs font-medium text-white shadow-sm ring-1 ring-inset ring-white/20"
|
and the chip sits in a normal row like everything else. */}
|
||||||
>
|
{(sourceDescriptor || readOnly) && (
|
||||||
View only
|
<OverlayMenuSection label="Document">
|
||||||
</span>
|
{sourceDescriptor && (
|
||||||
)}
|
<div className={`${overlayRowClass} cursor-default`}>
|
||||||
{followingTarget && (
|
<SourceChip descriptor={sourceDescriptor} tone="muted" />
|
||||||
<div className="flex items-center gap-2 text-xs text-white">
|
</div>
|
||||||
<span>
|
)}
|
||||||
Following <span className="font-semibold">{followingTarget.name}</span>
|
{readOnly && (
|
||||||
</span>
|
<div
|
||||||
<button
|
data-testid="view-only-pill"
|
||||||
type="button"
|
className={`${overlayRowClass} cursor-default`}
|
||||||
className="rounded-full bg-white/15 px-2 py-0.5 font-medium hover:bg-white/25"
|
>
|
||||||
onClick={() => followRef.current?.unfollow()}
|
<EyeOff size={14} className="shrink-0 text-white/50" />
|
||||||
>
|
<span>View only</span>
|
||||||
Stop
|
<span className="ml-auto text-[10px] text-white/40">
|
||||||
</button>
|
read-only
|
||||||
</div>
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</OverlayMenuSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{commentsCtl && (
|
{commentsCtl && (
|
||||||
<div
|
<OverlayMenuSection label="Comments">
|
||||||
data-testid="overlay-menu-comments"
|
<div
|
||||||
ref={setCommentsSlot}
|
data-testid="overlay-menu-comments"
|
||||||
className="flex w-full flex-col items-start gap-2"
|
ref={setCommentsSlot}
|
||||||
/>
|
className="flex w-full flex-col items-start gap-2"
|
||||||
|
/>
|
||||||
|
</OverlayMenuSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{setChromeFn !== null && !readOnly && (
|
{setChromeFn !== null && !readOnly && (
|
||||||
<button
|
<OverlayMenuSection label="View">
|
||||||
data-testid="chrome-toggle"
|
<button
|
||||||
aria-pressed={chromeHidden}
|
data-testid="chrome-toggle"
|
||||||
className="flex h-8 items-center gap-2 rounded-full bg-black/70 px-3 text-xs text-white shadow-sm ring-1 ring-inset ring-white/20 hover:bg-black/85"
|
aria-pressed={chromeHidden}
|
||||||
title={`${chromeHidden ? "Show" : "Hide"} UI (${CHROME_HOTKEY_LABEL})`}
|
className={overlayRowClass}
|
||||||
onClick={() => toggleChromeHidden()}
|
title={`${chromeHidden ? "Show" : "Hide"} UI (${CHROME_HOTKEY_LABEL})`}
|
||||||
>
|
onClick={() => toggleChromeHidden()}
|
||||||
{chromeHidden ? <PanelsTopLeft size={15} /> : <EyeOff size={15} />}
|
>
|
||||||
{chromeHidden ? "Show UI" : "Hide UI"}
|
{chromeHidden ? (
|
||||||
</button>
|
<PanelsTopLeft size={14} className="shrink-0 text-white/50" />
|
||||||
|
) : (
|
||||||
|
<EyeOff size={14} className="shrink-0 text-white/50" />
|
||||||
|
)}
|
||||||
|
<span>{chromeHidden ? "Show UI" : "Hide UI"}</span>
|
||||||
|
<kbd className="ml-auto rounded bg-white/10 px-1.5 py-0.5 text-[10px] font-medium text-white/50">
|
||||||
|
{CHROME_HOTKEY_LABEL}
|
||||||
|
</kbd>
|
||||||
|
</button>
|
||||||
|
</OverlayMenuSection>
|
||||||
)}
|
)}
|
||||||
</OverlayMenu>
|
</OverlayMenu>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -214,6 +214,28 @@ export function presenceUser(): PresenceUser {
|
||||||
return { id: slug, name, color: colorForUser(slug) };
|
return { id: slug, name, color: colorForUser(slug) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Author identity stamped onto comments. */
|
||||||
|
export interface CommentAuthor {
|
||||||
|
/** Slug — the IDENTITY key (colors, "is this mine?"). Never a display name. */
|
||||||
|
id: string;
|
||||||
|
/** Display name; falls back to the slug when no session identity is loaded. */
|
||||||
|
name: string;
|
||||||
|
/** Only present for a real authenticated session. */
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who to attribute a new comment to. Same identity as `presenceUser()`, plus the
|
||||||
|
* email, and kept separate because comments DENORMALIZE these at write time
|
||||||
|
* (comments-wire.ts) whereas presence re-broadcasts them live.
|
||||||
|
*/
|
||||||
|
export function commentAuthor(): CommentAuthor {
|
||||||
|
const slug = userSlug();
|
||||||
|
const session = sessionIdentity();
|
||||||
|
const mine = session && session.slug === slug ? session : null;
|
||||||
|
return { id: slug, name: mine?.name ?? slug, email: mine?.email };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DEV-TIME presence style tuner (collab-presence): VITE_PRESENCE_TUNER=1 mounts
|
* DEV-TIME presence style tuner (collab-presence): VITE_PRESENCE_TUNER=1 mounts
|
||||||
* a floating panel that live-patches the wasm overlay style
|
* a floating panel that live-patches the wasm overlay style
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,12 @@
|
||||||
* without the endpoint (example backend, demo/static) simply yield null and
|
* without the endpoint (example backend, demo/static) simply yield null and
|
||||||
* the pre-auth slug fallback in config.ts stays in effect.
|
* the pre-auth slug fallback in config.ts stays in effect.
|
||||||
*/
|
*/
|
||||||
export type SessionIdentity = { slug: string; name: string };
|
export type SessionIdentity = {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
/** Undefined for backends that don't return one (example/demo/static). */
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
|
|
||||||
let identity: SessionIdentity | null = null;
|
let identity: SessionIdentity | null = null;
|
||||||
let pending: Promise<SessionIdentity | null> | null = null;
|
let pending: Promise<SessionIdentity | null> | null = null;
|
||||||
|
|
@ -35,12 +40,11 @@ export function loadSessionIdentity(
|
||||||
} | null
|
} | null
|
||||||
)?.user;
|
)?.user;
|
||||||
if (u && typeof u.slug === "string" && u.slug) {
|
if (u && typeof u.slug === "string" && u.slug) {
|
||||||
|
const email = typeof u.email === "string" && u.email ? u.email : undefined;
|
||||||
identity = {
|
identity = {
|
||||||
slug: u.slug,
|
slug: u.slug,
|
||||||
name:
|
name: (typeof u.name === "string" && u.name) || email || u.slug,
|
||||||
(typeof u.name === "string" && u.name) ||
|
email,
|
||||||
(typeof u.email === "string" && u.email) ||
|
|
||||||
u.slug,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return identity;
|
return identity;
|
||||||
|
|
|
||||||
|
|
@ -115,8 +115,12 @@ const PUSH_THROTTLE_MS = 30;
|
||||||
export function createComments(opts: {
|
export function createComments(opts: {
|
||||||
doc: Y.Doc;
|
doc: Y.Doc;
|
||||||
mod: CommentPinsModule;
|
mod: CommentPinsModule;
|
||||||
/** Author slug for new messages (presence identity). */
|
/**
|
||||||
user: string;
|
* Author for new messages. `id` is the slug (identity key); `name`/`email`
|
||||||
|
* are denormalized onto each message at write time so a comment still shows
|
||||||
|
* a real author when they are offline, renamed, or gone (comments-wire.ts).
|
||||||
|
*/
|
||||||
|
user: { id: string; name?: string; email?: string };
|
||||||
tool: string;
|
tool: string;
|
||||||
/** Presence color resolver (nth-in-room); undefined falls back to the hash. */
|
/** Presence color resolver (nth-in-room); undefined falls back to the hash. */
|
||||||
colorFor?: (userId: string) => string | undefined;
|
colorFor?: (userId: string) => string | undefined;
|
||||||
|
|
@ -223,10 +227,21 @@ export function createComments(opts: {
|
||||||
return { pos: { x: world.x, y: world.y } };
|
return { pos: { x: world.x, y: world.y } };
|
||||||
},
|
},
|
||||||
create(anchor, body) {
|
create(anchor, body) {
|
||||||
return createThread(doc, { anchor, author: user, body });
|
return createThread(doc, {
|
||||||
|
anchor,
|
||||||
|
author: user.id,
|
||||||
|
authorName: user.name,
|
||||||
|
authorEmail: user.email,
|
||||||
|
body,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
reply(threadId, body) {
|
reply(threadId, body) {
|
||||||
addMessage(doc, threadId, { author: user, body });
|
addMessage(doc, threadId, {
|
||||||
|
author: user.id,
|
||||||
|
authorName: user.name,
|
||||||
|
authorEmail: user.email,
|
||||||
|
body,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
edit(threadId, messageId, body) {
|
edit(threadId, messageId, body) {
|
||||||
return editMessage(doc, threadId, messageId, body);
|
return editMessage(doc, threadId, messageId, body);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue