feat(composer): add rich text editor with formatting and mentions (#310)

This commit is contained in:
Taylor Ho
2026-04-14 13:56:38 -10:00
committed by GitHub
parent 3eabe74c61
commit fe673254e6
22 changed files with 2701 additions and 472 deletions

View File

@@ -34,6 +34,7 @@
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-router": "^1.168.10",
@@ -42,12 +43,19 @@
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.0",
"@tiptap/core": "^3.22.3",
"@tiptap/extension-link": "^3.22.3",
"@tiptap/extension-placeholder": "^3.22.3",
"@tiptap/pm": "^3.22.3",
"@tiptap/react": "^3.22.3",
"@tiptap/starter-kit": "^3.22.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"emoji-mart": "^5.6.0",
"jdenticon": "^3.3.0",
"livekit-client": "^2.18.1",
"lucide-react": "^0.577.0",
"motion": "^12.38.0",
"qrcode.react": "^4.2.0",
"react": "^19.1.0",
"react-diff-view": "^3.3.2",
@@ -57,6 +65,7 @@
"remark-gfm": "^4.0.1",
"shiki": "^4.0.2",
"tailwind-merge": "^3.5.0",
"tiptap-markdown": "^0.9.0",
"yaml": "^2.8.3"
},
"devDependencies": {

754
desktop/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,140 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildHighlightPatterns,
findHighlightMatches,
} from "./mentionHighlightExtension.ts";
// ── buildHighlightPatterns ────────────────────────────────────────────
test("returns empty array when no names or channels provided", () => {
assert.deepEqual(buildHighlightPatterns([], []), []);
});
test("builds a single pattern for mentions only", () => {
const patterns = buildHighlightPatterns(["alice"], []);
assert.equal(patterns.length, 1);
});
test("builds a single pattern for channels only", () => {
const patterns = buildHighlightPatterns([], ["general"]);
assert.equal(patterns.length, 1);
});
test("builds two patterns when both names and channels provided", () => {
const patterns = buildHighlightPatterns(["alice"], ["general"]);
assert.equal(patterns.length, 2);
});
test("escapes regex special characters in names", () => {
const patterns = buildHighlightPatterns(["alice (admin)"], []);
// Should not throw when used as regex
const matches = findHighlightMatches("@alice (admin) hello", patterns);
assert.equal(matches.length, 1);
assert.equal(matches[0].match, "@alice (admin)");
});
test("escapes regex special characters in channel names", () => {
const patterns = buildHighlightPatterns([], ["c++ help"]);
const matches = findHighlightMatches("#c++ help", patterns);
assert.equal(matches.length, 1);
});
// ── findHighlightMatches — @mentions ──────────────────────────────────
test("matches @mention at start of text", () => {
const patterns = buildHighlightPatterns(["alice"], []);
const matches = findHighlightMatches("@alice hello", patterns);
assert.equal(matches.length, 1);
assert.equal(matches[0].match, "@alice");
assert.equal(matches[0].from, 0);
assert.equal(matches[0].to, 6);
});
test("matches @mention after whitespace", () => {
const patterns = buildHighlightPatterns(["bob"], []);
const matches = findHighlightMatches("hey @bob", patterns);
assert.equal(matches.length, 1);
assert.equal(matches[0].match, "@bob");
});
test("does not match @mention embedded in a word", () => {
const patterns = buildHighlightPatterns(["bob"], []);
const matches = findHighlightMatches("email@bob.com", patterns);
assert.equal(matches.length, 0);
});
test("matches are case-insensitive", () => {
const patterns = buildHighlightPatterns(["Alice"], []);
const matches = findHighlightMatches("@alice @ALICE @Alice", patterns);
assert.equal(matches.length, 3);
});
test("matches multiple different mentions in one string", () => {
const patterns = buildHighlightPatterns(["alice", "bob"], []);
const matches = findHighlightMatches("@alice and @bob", patterns);
assert.equal(matches.length, 2);
assert.equal(matches[0].match, "@alice");
assert.equal(matches[1].match, "@bob");
});
test("longer names matched first (no partial overlap)", () => {
const patterns = buildHighlightPatterns(["al", "alice"], []);
const matches = findHighlightMatches("@alice", patterns);
// Should match "alice" not just "al"
assert.equal(matches.length, 1);
assert.equal(matches[0].match, "@alice");
});
// ── findHighlightMatches — #channels ──────────────────────────────────
test("matches #channel at start of text", () => {
const patterns = buildHighlightPatterns([], ["general"]);
const matches = findHighlightMatches("#general is cool", patterns);
assert.equal(matches.length, 1);
assert.equal(matches[0].match, "#general");
});
test("matches #channel after whitespace", () => {
const patterns = buildHighlightPatterns([], ["random"]);
const matches = findHighlightMatches("check #random", patterns);
assert.equal(matches.length, 1);
});
test("does not match #channel embedded in a word", () => {
const patterns = buildHighlightPatterns([], ["foo"]);
const matches = findHighlightMatches("bar#foo", patterns);
assert.equal(matches.length, 0);
});
test("channel matches are case-insensitive", () => {
const patterns = buildHighlightPatterns([], ["General"]);
const matches = findHighlightMatches("#general #GENERAL", patterns);
assert.equal(matches.length, 2);
});
// ── findHighlightMatches — mixed ──────────────────────────────────────
test("matches both @mentions and #channels in the same text", () => {
const patterns = buildHighlightPatterns(["alice"], ["general"]);
const matches = findHighlightMatches("@alice in #general", patterns);
assert.equal(matches.length, 2);
});
test("returns empty array for text with no matches", () => {
const patterns = buildHighlightPatterns(["alice"], ["general"]);
const matches = findHighlightMatches("nothing here", patterns);
assert.equal(matches.length, 0);
});
test("handles empty text", () => {
const patterns = buildHighlightPatterns(["alice"], []);
const matches = findHighlightMatches("", patterns);
assert.equal(matches.length, 0);
});
test("handles empty patterns against non-empty text", () => {
const matches = findHighlightMatches("@alice #general", []);
assert.equal(matches.length, 0);
});

View File

@@ -0,0 +1,144 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
export const mentionHighlightKey = new PluginKey("mentionHighlight");
/**
* TipTap extension that applies inline `mention-highlight` decorations
* to `@Name` and `#channel-name` patterns in the document.
*
* Accepts `names` (display names) and `channelNames` storage options.
* On every doc update the plugin scans text nodes and decorates matches.
*/
export const MentionHighlightExtension = Extension.create({
name: "mentionHighlight",
addStorage() {
return {
names: [] as string[],
channelNames: [] as string[],
};
},
addProseMirrorPlugins() {
const extension = this;
return [
new Plugin({
key: mentionHighlightKey,
state: {
init(_, state) {
return buildDecorations(
state.doc,
extension.storage.names,
extension.storage.channelNames,
);
},
apply(tr, oldDecorations) {
if (tr.docChanged || tr.getMeta(mentionHighlightKey)) {
return buildDecorations(
tr.doc,
extension.storage.names,
extension.storage.channelNames,
);
}
return oldDecorations;
},
},
props: {
decorations(state) {
return this.getState(state) ?? DecorationSet.empty;
},
},
}),
];
},
});
/**
* Build highlight patterns for @Name and #channel-name matching.
* Exported for testing — the patterns are the core logic of this extension.
*/
export function buildHighlightPatterns(
names: string[],
channelNames: string[],
): RegExp[] {
const patterns: RegExp[] = [];
if (names.length > 0) {
const sortedNames = [...names].sort((a, b) => b.length - a.length);
const escapedNames = sortedNames.map((n) =>
n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
);
patterns.push(
new RegExp(`(?:^|(?<=\\s))@(${escapedNames.join("|")})`, "gi"),
);
}
if (channelNames.length > 0) {
const sortedChannels = [...channelNames].sort(
(a, b) => b.length - a.length,
);
const escapedChannels = sortedChannels.map((n) =>
n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
);
patterns.push(
new RegExp(`(?:^|(?<=\\s))#(${escapedChannels.join("|")})`, "gi"),
);
}
return patterns;
}
/**
* Find all highlight matches in a text string given a set of patterns.
* Returns an array of { from, to } offsets relative to the text start.
* Exported for testing.
*/
export function findHighlightMatches(
text: string,
patterns: RegExp[],
): { from: number; to: number; match: string }[] {
const results: { from: number; to: number; match: string }[] = [];
for (const pattern of patterns) {
pattern.lastIndex = 0;
let m: RegExpExecArray | null = pattern.exec(text);
while (m !== null) {
results.push({ from: m.index, to: m.index + m[0].length, match: m[0] });
m = pattern.exec(text);
}
}
return results;
}
function buildDecorations(
doc: Parameters<typeof DecorationSet.create>[0],
names: string[],
channelNames: string[],
): DecorationSet {
if (names.length === 0 && channelNames.length === 0)
return DecorationSet.empty;
const decorations: Decoration[] = [];
const patterns = buildHighlightPatterns(names, channelNames);
doc.descendants((node, pos) => {
if (!node.isText || !node.text) return;
for (const pattern of patterns) {
pattern.lastIndex = 0;
let match: RegExpExecArray | null = pattern.exec(node.text);
while (match !== null) {
const from = pos + match.index;
const to = from + match[0].length;
decorations.push(
Decoration.inline(from, to, { class: "mention-highlight" }),
);
match = pattern.exec(node.text);
}
}
});
return DecorationSet.create(doc, decorations);
}

View File

@@ -0,0 +1,144 @@
import assert from "node:assert/strict";
import test from "node:test";
// shortHash is a simple utility: str.slice(0, 4)
// Inline it here to avoid importing from useMediaUpload.ts which has
// unresolvable @/shared path aliases outside the bundler.
function shortHash(hex) {
return hex.slice(0, 4);
}
// ── shortHash ─────────────────────────────────────────────────────────
test("shortHash returns first 4 hex characters", () => {
assert.equal(shortHash("abcdef1234567890"), "abcd");
});
test("shortHash handles minimum-length input", () => {
assert.equal(shortHash("abcd"), "abcd");
});
test("shortHash returns empty string for empty input", () => {
assert.equal(shortHash(""), "");
});
test("shortHash returns partial for short input", () => {
assert.equal(shortHash("ab"), "ab");
});
// ── Upload slot ordering (pure state-update logic) ────────────────────
// The slot system uses reserveSlots → fillSlot to maintain insertion order
// when concurrent uploads finish out of order. We test the state-update
// functions in isolation (they're pure array transforms).
test("reserveSlots creates null placeholders", () => {
// Simulate: start with empty slots, reserve 3
const prev = [];
const count = 3;
const next = [...prev, ...new Array(count).fill(null)];
assert.deepEqual(next, [null, null, null]);
});
test("fillSlot places descriptor at correct index", () => {
// Simulate: 3 reserved slots, fill index 1 first (out of order)
const slots = [null, null, null];
const descriptor = { url: "https://example.com/b.png", sha256: "bbbb" };
const next = [...slots];
next[1] = descriptor;
assert.equal(next[0], null);
assert.deepEqual(next[1], descriptor);
assert.equal(next[2], null);
});
test("concurrent uploads filling out of order preserves slot positions", () => {
// Simulate: reserve 3 slots, uploads finish in order 2, 0, 1
const slots = [null, null, null];
const a = { url: "a.png", sha256: "aaaa" };
const b = { url: "b.png", sha256: "bbbb" };
const c = { url: "c.png", sha256: "cccc" };
// Upload 2 finishes first
const step1 = [...slots];
step1[2] = c;
assert.deepEqual(step1, [null, null, c]);
// Upload 0 finishes second
const step2 = [...step1];
step2[0] = a;
assert.deepEqual(step2, [a, null, c]);
// Upload 1 finishes last
const step3 = [...step2];
step3[1] = b;
assert.deepEqual(step3, [a, b, c]);
// Filter nulls — final order matches original slot order
const result = step3.filter((d) => d !== null);
assert.deepEqual(result, [a, b, c]);
});
test("removing an attachment nulls the slot instead of compacting", () => {
const a = { url: "a.png", sha256: "aaaa" };
const b = { url: "b.png", sha256: "bbbb" };
const c = { url: "c.png", sha256: "cccc" };
const slots = [a, b, c];
// Remove b — null out, don't compact
const next = slots.map((d) => (d?.url === "b.png" ? null : d));
assert.deepEqual(next, [a, null, c]);
// Filtered view (what consumers see) drops nulls
const filtered = next.filter((d) => d !== null);
assert.deepEqual(filtered, [a, c]);
});
test("removing mid-upload does not corrupt in-flight slot indices", () => {
// Scenario: 3 images uploading, image 0 finishes, user removes image 0,
// then image 1 and 2 finish — they must land in their original slots.
const a = { url: "a.png", sha256: "aaaa" };
const b = { url: "b.png", sha256: "bbbb" };
const c = { url: "c.png", sha256: "cccc" };
// Start: 3 reserved slots
let slots = [null, null, null];
// Image 0 finishes
slots = [...slots];
slots[0] = a;
assert.deepEqual(slots, [a, null, null]);
// User removes image 0 — null out, don't compact
slots = slots.map((d) => (d?.url === "a.png" ? null : d));
assert.deepEqual(slots, [null, null, null]);
// Image 1 finishes — fillSlot(1) still works correctly
slots = [...slots];
slots[1] = b;
assert.deepEqual(slots, [null, b, null]);
// Image 2 finishes — fillSlot(2) still works correctly
slots = [...slots];
slots[2] = c;
assert.deepEqual(slots, [null, b, c]);
// Consumer view filters nulls
const result = slots.filter((d) => d !== null);
assert.deepEqual(result, [b, c]);
});
test("reserveSlots pads if slots array is shorter than expected start index", () => {
// Edge case: if somehow prev is shorter than startIndex
const prev = [{ url: "a.png", sha256: "aaaa" }];
const startIndex = 3;
const count = 2;
const padded =
prev.length < startIndex
? [...prev, ...new Array(startIndex - prev.length).fill(null)]
: prev;
const next = [...padded, ...new Array(count).fill(null)];
assert.equal(next.length, 5);
assert.deepEqual(next[0], { url: "a.png", sha256: "aaaa" });
assert.equal(next[1], null); // padding
assert.equal(next[2], null); // padding
assert.equal(next[3], null); // reserved
assert.equal(next[4], null); // reserved
});

View File

@@ -6,7 +6,7 @@ import {
uploadMediaBytes,
} from "@/shared/api/tauri";
const ALLOWED_TYPES = [
export const ALLOWED_MEDIA_TYPES = [
"image/jpeg",
"image/png",
"image/gif",
@@ -18,48 +18,101 @@ const ALLOWED_TYPES = [
"video/x-msvideo",
];
/**
* First 4 hex chars of the sha256 — used as a short display name.
* Note: 4 hex chars = 65,536 possible values. Collision is unlikely
* within a single message's attachments but theoretically possible.
* If collisions become an issue, extend to 6+ chars.
*/
export function shortHash(sha256: string): string {
return sha256.slice(0, 4);
}
type UploadState = {
status: "idle" | "uploading" | "error";
message?: string;
};
export function useMediaUpload(
setContent: React.Dispatch<React.SetStateAction<string>>,
) {
export function useMediaUpload() {
const [uploadState, setUploadState] = React.useState<UploadState>({
status: "idle",
});
const [pendingImeta, setPendingImeta] = React.useState<BlobDescriptor[]>([]);
/** Number of files currently in-flight. */
const [uploadingCount, setUploadingCount] = React.useState(0);
/**
* Internal slots array — may contain `null` for reserved-but-pending uploads.
* Consumers see the filtered `pendingImeta` (nulls stripped) so the public
* type stays `BlobDescriptor[]`.
*/
const [imetaSlots, setImetaSlots] = React.useState<(BlobDescriptor | null)[]>(
[],
);
const pendingImeta = React.useMemo(
() => imetaSlots.filter((d): d is BlobDescriptor => d !== null),
[imetaSlots],
);
const pendingImetaRef = React.useRef(pendingImeta);
pendingImetaRef.current = pendingImeta;
const onUploaded = React.useCallback(
(descriptor: BlobDescriptor) => {
const isVideo = descriptor.type === "video/mp4";
const markdown = isVideo
? `\n![video](${descriptor.url})\n`
: `\n![image](${descriptor.url})\n`;
setContent((prev) => prev + markdown);
setPendingImeta((prev) => [...prev, descriptor]);
setUploadState({ status: "idle" });
/** Monotonic slot counter — ensures each batch gets unique indices even
* before React flushes the state update. */
const nextSlotRef = React.useRef(0);
/** Reserve `count` null slots at the end; returns the starting index. */
const reserveSlots = React.useCallback((count: number): number => {
const startIndex = nextSlotRef.current;
nextSlotRef.current += count;
setImetaSlots((prev) => {
// Pad prev if needed (should already be the right length, but be safe)
const padded =
prev.length < startIndex
? [...prev, ...new Array<null>(startIndex - prev.length).fill(null)]
: prev;
return [...padded, ...new Array<null>(count).fill(null)];
});
return startIndex;
}, []);
/** Fill a previously-reserved slot by index. */
const fillSlot = React.useCallback(
(index: number, descriptor: BlobDescriptor) => {
setImetaSlots((prev) => {
const next = [...prev];
next[index] = descriptor;
return next;
});
setUploadingCount((c) => Math.max(0, c - 1));
},
[setContent],
[],
);
/** Append a single descriptor (no pre-reserved slot). */
const onUploaded = React.useCallback((descriptor: BlobDescriptor) => {
nextSlotRef.current += 1;
setImetaSlots((prev) => [...prev, descriptor]);
setUploadingCount((c) => Math.max(0, c - 1));
}, []);
const onUploadError = React.useCallback((err: unknown) => {
setUploadingCount((c) => Math.max(0, c - 1));
setUploadState({ status: "error", message: String(err) });
}, []);
const handlePaperclip = React.useCallback(async () => {
setUploadState({ status: "uploading" });
setUploadingCount((c) => c + 1);
try {
const descriptor = await pickAndUploadMedia();
if (descriptor) {
onUploaded(descriptor);
} else {
setUploadState({ status: "idle" });
setUploadingCount((c) => Math.max(0, c - 1));
}
} catch (err) {
setUploadState({ status: "error", message: String(err) });
onUploadError(err);
}
}, [onUploaded]);
}, [onUploaded, onUploadError]);
const handleDrop = React.useCallback(
async (event: React.DragEvent<HTMLFormElement>) => {
@@ -67,10 +120,11 @@ export function useMediaUpload(
const files = Array.from(event.dataTransfer.files);
if (files.length === 0) return;
const file = files[0];
if (!file) return;
const validFiles = files.filter((f) =>
ALLOWED_MEDIA_TYPES.includes(f.type),
);
if (!ALLOWED_TYPES.includes(file.type)) {
if (validFiles.length === 0) {
setUploadState({
status: "error",
message:
@@ -79,16 +133,27 @@ export function useMediaUpload(
return;
}
setUploadState({ status: "uploading" });
try {
const buffer = await file.arrayBuffer();
const descriptor = await uploadMediaBytes([...new Uint8Array(buffer)]);
onUploaded(descriptor);
} catch (err) {
setUploadState({ status: "error", message: String(err) });
setUploadingCount((c) => c + validFiles.length);
const baseIndex = reserveSlots(validFiles.length);
for (let i = 0; i < validFiles.length; i++) {
const file = validFiles[i];
const slotIndex = baseIndex + i;
// Fire-and-forget each upload concurrently — slot preserves order
(async () => {
try {
const buffer = await file.arrayBuffer();
const descriptor = await uploadMediaBytes([
...new Uint8Array(buffer),
]);
fillSlot(slotIndex, descriptor);
} catch (err) {
onUploadError(err);
}
})();
}
},
[onUploaded],
[reserveSlots, fillSlot, onUploadError],
);
const handleDragOver = React.useCallback(
@@ -99,28 +164,75 @@ export function useMediaUpload(
);
const handlePaste = React.useCallback(
async (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
async (event: {
clipboardData: DataTransfer;
preventDefault: () => void;
}) => {
const items = Array.from(event.clipboardData.items);
const mediaItem = items.find((item) => ALLOWED_TYPES.includes(item.type));
if (!mediaItem) return;
const mediaFiles = items
.filter((item) => ALLOWED_MEDIA_TYPES.includes(item.type))
.map((item) => item.getAsFile())
.filter((f): f is File => f !== null);
if (mediaFiles.length === 0) return;
event.preventDefault();
const file = mediaItem.getAsFile();
if (!file) return;
setUploadState({ status: "uploading" });
setUploadingCount((c) => c + mediaFiles.length);
const baseIndex = reserveSlots(mediaFiles.length);
for (let i = 0; i < mediaFiles.length; i++) {
const file = mediaFiles[i];
const slotIndex = baseIndex + i;
(async () => {
try {
const buffer = await file.arrayBuffer();
const descriptor = await uploadMediaBytes([
...new Uint8Array(buffer),
]);
fillSlot(slotIndex, descriptor);
} catch (err) {
onUploadError(err);
}
})();
}
},
[reserveSlots, fillSlot, onUploadError],
);
/** Upload a File directly — used by Tiptap's editorProps.handlePaste. */
const uploadFile = React.useCallback(
async (file: File) => {
if (!ALLOWED_MEDIA_TYPES.includes(file.type)) return;
setUploadingCount((c) => c + 1);
try {
const buffer = await file.arrayBuffer();
const descriptor = await uploadMediaBytes([...new Uint8Array(buffer)]);
onUploaded(descriptor);
} catch (err) {
setUploadState({ status: "error", message: String(err) });
onUploadError(err);
}
},
[onUploaded],
[onUploaded, onUploadError],
);
const isUploading = uploadState.status === "uploading";
const removeAttachment = React.useCallback((url: string) => {
setImetaSlots((prev) => prev.map((d) => (d?.url === url ? null : d)));
}, []);
/** Public setter — replaces all slots (used by MessageComposer to clear/restore). */
const setPendingImeta = React.useCallback(
(action: React.SetStateAction<BlobDescriptor[]>) => {
setImetaSlots((prev) => {
const current = prev.filter((d): d is BlobDescriptor => d !== null);
const next = typeof action === "function" ? action(current) : action;
nextSlotRef.current = next.length;
return next;
});
},
[],
);
const isUploading = uploadingCount > 0;
return {
handleDragOver,
@@ -130,8 +242,11 @@ export function useMediaUpload(
isUploading,
pendingImeta,
pendingImetaRef,
removeAttachment,
setPendingImeta,
setUploadState,
uploadFile,
uploadingCount,
uploadState,
};
}

View File

@@ -0,0 +1,126 @@
import assert from "node:assert/strict";
import test from "node:test";
/**
* Pure extraction of the ProseMirror → plain-text cursor mapping logic
* from getTextAndCursor in useRichTextEditor.ts.
*
* Takes a list of "visited nodes" (as the descendants callback would see them)
* and a ProseMirror anchor position, returns the plain-text offset.
*/
function mapAnchorToPlainText(nodes, anchor) {
let offset = 0;
let found = false;
for (const { isText, isBlock, pos, nodeSize } of nodes) {
if (found) break;
if (isText) {
const nodeEnd = pos + nodeSize;
if (anchor <= nodeEnd) {
offset += anchor - pos;
found = true;
break;
}
offset += nodeSize;
} else if (isBlock && pos > 0) {
offset += 1;
}
}
return found ? offset : -1; // -1 means "fell through"
}
// ── Single paragraph ──────────────────────────────────────────────────
test("cursor at start of single paragraph", () => {
// doc > paragraph(pos=0) > text "hello"(pos=1, size=5)
const nodes = [
{ isText: false, isBlock: true, pos: 0, nodeSize: 7 },
{ isText: true, isBlock: false, pos: 1, nodeSize: 5 },
];
// Anchor at pos=1 → plain-text offset 0
assert.equal(mapAnchorToPlainText(nodes, 1), 0);
});
test("cursor at end of single paragraph", () => {
const nodes = [
{ isText: false, isBlock: true, pos: 0, nodeSize: 7 },
{ isText: true, isBlock: false, pos: 1, nodeSize: 5 },
];
// Anchor at pos=6 → plain-text offset 5
assert.equal(mapAnchorToPlainText(nodes, 6), 5);
});
test("cursor mid-word in single paragraph", () => {
const nodes = [
{ isText: false, isBlock: true, pos: 0, nodeSize: 7 },
{ isText: true, isBlock: false, pos: 1, nodeSize: 5 },
];
// Anchor at pos=3 → plain-text offset 2 (after "he")
assert.equal(mapAnchorToPlainText(nodes, 3), 2);
});
// ── Two paragraphs (the bug scenario) ─────────────────────────────────
// doc structure: doc > p1("hello") > p2("world")
// ProseMirror positions: doc=0, p1=0, "hello"=1..5, /p1=6, p2=7, "world"=8..12, /p2=13
// textContent = "hello\nworld" (11 chars)
test("cursor in second paragraph accounts for block boundary newline", () => {
const nodes = [
{ isText: false, isBlock: true, pos: 0, nodeSize: 7 }, // p1
{ isText: true, isBlock: false, pos: 1, nodeSize: 5 }, // "hello"
{ isText: false, isBlock: true, pos: 7, nodeSize: 7 }, // p2 (pos > 0 → newline)
{ isText: true, isBlock: false, pos: 8, nodeSize: 5 }, // "world"
];
// Anchor at pos=8 → start of "world" → plain-text offset 6 ("hello\n" = 6 chars)
assert.equal(mapAnchorToPlainText(nodes, 8), 6);
});
test("cursor mid-word in second paragraph", () => {
const nodes = [
{ isText: false, isBlock: true, pos: 0, nodeSize: 7 },
{ isText: true, isBlock: false, pos: 1, nodeSize: 5 },
{ isText: false, isBlock: true, pos: 7, nodeSize: 7 },
{ isText: true, isBlock: false, pos: 8, nodeSize: 5 },
];
// Anchor at pos=10 → "wo|rld" → plain-text offset 8 ("hello\nwo" = 8 chars)
assert.equal(mapAnchorToPlainText(nodes, 10), 8);
});
// ── Three paragraphs (cumulative drift) ───────────────────────────────
// "aaa\nbbb\nccc" — without the fix, offset would drift by 1 per boundary
test("cursor in third paragraph accounts for two block boundaries", () => {
const nodes = [
{ isText: false, isBlock: true, pos: 0, nodeSize: 5 }, // p1
{ isText: true, isBlock: false, pos: 1, nodeSize: 3 }, // "aaa"
{ isText: false, isBlock: true, pos: 5, nodeSize: 5 }, // p2
{ isText: true, isBlock: false, pos: 6, nodeSize: 3 }, // "bbb"
{ isText: false, isBlock: true, pos: 10, nodeSize: 5 }, // p3
{ isText: true, isBlock: false, pos: 11, nodeSize: 3 }, // "ccc"
];
// Anchor at pos=11 → start of "ccc" → plain-text offset 8 ("aaa\nbbb\n" = 8 chars)
assert.equal(mapAnchorToPlainText(nodes, 11), 8);
});
test("cursor at end of third paragraph", () => {
const nodes = [
{ isText: false, isBlock: true, pos: 0, nodeSize: 5 },
{ isText: true, isBlock: false, pos: 1, nodeSize: 3 },
{ isText: false, isBlock: true, pos: 5, nodeSize: 5 },
{ isText: true, isBlock: false, pos: 6, nodeSize: 3 },
{ isText: false, isBlock: true, pos: 10, nodeSize: 5 },
{ isText: true, isBlock: false, pos: 11, nodeSize: 3 },
];
// Anchor at pos=14 → end of "ccc" → plain-text offset 11 ("aaa\nbbb\nccc" = 11 chars)
assert.equal(mapAnchorToPlainText(nodes, 14), 11);
});
// ── First paragraph is unaffected (pos === 0, no newline) ─────────────
test("first block boundary at pos 0 does not add newline", () => {
const nodes = [
{ isText: false, isBlock: true, pos: 0, nodeSize: 7 },
{ isText: true, isBlock: false, pos: 1, nodeSize: 5 },
];
// Anchor at pos=4 → plain-text offset 3 (no extra newline for first block)
assert.equal(mapAnchorToPlainText(nodes, 4), 3);
});

View File

@@ -0,0 +1,342 @@
import * as React from "react";
import { Markdown as TiptapMarkdown } from "tiptap-markdown";
import { useEditor, type Editor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import Link from "@tiptap/extension-link";
import { Extension } from "@tiptap/core";
import { TextSelection } from "@tiptap/pm/state";
import {
MentionHighlightExtension,
mentionHighlightKey,
} from "./mentionHighlightExtension";
export type RichTextEditorOptions = {
placeholder?: string;
onUpdate?: (info: { markdown: string; text: string }) => void;
editable?: boolean;
mentionNames?: string[];
channelNames?: string[];
};
/**
* Creates and manages a Tiptap editor configured for Markdown output.
*
* The editor uses StarterKit (bold, italic, strike, code, blockquote, lists,
* headings, code blocks, hard breaks) plus Link and the tiptap-markdown
* extension for serialisation.
*
* `getMarkdown()` returns the current document as a Markdown string.
*/
export function useRichTextEditor({
placeholder,
onUpdate,
editable = true,
mentionNames,
channelNames,
}: RichTextEditorOptions) {
const onUpdateRef = React.useRef(onUpdate);
onUpdateRef.current = onUpdate;
const placeholderRef = React.useRef(placeholder);
placeholderRef.current = placeholder;
const editor = useEditor(
{
extensions: [
StarterKit.configure({
// Use hard breaks (Shift+Enter) — Enter submits the message.
hardBreak: {
keepMarks: true,
},
// Disable heading input rules — in a chat composer, typing "# "
// should keep the literal "#", not convert to a heading node.
// Users type #channel-name and the "#" would get eaten otherwise.
heading: false,
// Disable the trailing-node plugin — it forces an empty paragraph
// after block nodes (lists, blockquotes, code blocks) which creates
// a phantom empty line in the compact message composer.
trailingNode: false,
}),
// Shift+Enter inside lists/blockquotes: split the node instead of
// inserting a hard break so continuation lines keep their formatting.
Extension.create({
name: "smartShiftEnter",
addKeyboardShortcuts() {
// Exit a list by removing the empty last item and inserting a
// paragraph after the list. Works for both single-item and
// multi-item lists.
const exitListIfEmptyLast = (ed: typeof this.editor): boolean => {
if (!ed.isActive("listItem")) return false;
const { $from } = ed.state.selection;
// Walk up to find the listItem node (handles nested structures).
let listItemDepth = -1;
for (let d = $from.depth; d >= 1; d--) {
if ($from.node(d).type.name === "listItem") {
listItemDepth = d;
break;
}
}
if (listItemDepth < 1) return false;
const listItem = $from.node(listItemDepth);
const isEmpty =
listItem.childCount === 1 &&
listItem.firstChild?.textContent === "";
if (!isEmpty) return false;
// Only trigger on the last item in the list.
const listDepth = listItemDepth - 1;
const list = $from.node(listDepth);
const itemIndex = $from.index(listDepth);
if (itemIndex !== list.childCount - 1) return false;
const { tr, schema } = ed.state;
if (list.childCount === 1) {
// Only item → replace the entire list with an empty paragraph.
const listStart = $from.before(listDepth);
const listEnd = $from.after(listDepth);
const para = schema.nodes.paragraph.create();
tr.replaceWith(listStart, listEnd, para);
tr.setSelection(
TextSelection.near(tr.doc.resolve(listStart + 1)),
);
} else {
// Multiple items → delete the empty item, insert paragraph
// after the list, and move cursor there.
const itemStart = $from.before(listItemDepth);
const itemEnd = $from.after(listItemDepth);
tr.delete(itemStart, itemEnd);
const listEnd = tr.mapping.map($from.after(listDepth));
const para = schema.nodes.paragraph.create();
tr.insert(listEnd, para);
tr.setSelection(
TextSelection.near(tr.doc.resolve(listEnd + 1)),
);
}
ed.view.dispatch(tr);
return true;
};
return {
"Shift-Enter": ({ editor: ed }) => {
// Empty last list item → exit list to paragraph below.
if (exitListIfEmptyLast(ed)) return true;
// Non-empty or non-last list item → split.
if (ed.isActive("listItem")) {
return ed.commands.splitListItem("listItem");
}
if (ed.isActive("blockquote")) {
// Empty blockquote paragraph → exit the blockquote.
const { $from } = ed.state.selection;
if ($from.parent.textContent === "") {
return ed.commands.lift("blockquote");
}
// Non-empty → split the paragraph within the blockquote.
return ed.chain().splitBlock().focus().run();
}
// Default: hard break (StarterKit handles it).
return false;
},
ArrowDown: ({ editor: ed }) => {
// Empty last list item + Down → exit list to paragraph below.
return exitListIfEmptyLast(ed);
},
};
},
}),
MentionHighlightExtension,
Placeholder.configure({
placeholder: () => placeholderRef.current ?? "Write a message…",
}),
Link.configure({
openOnClick: false,
autolink: true,
linkOnPaste: true,
HTMLAttributes: {
class: "text-primary underline underline-offset-4 cursor-pointer",
},
}),
TiptapMarkdown.configure({
html: false,
transformPastedText: true,
transformCopiedText: true,
breaks: true,
}),
],
editorProps: {
attributes: {
class:
"min-h-0 resize-none overflow-y-hidden border-0 bg-transparent px-0 py-0 text-sm leading-6 md:leading-6 shadow-none focus-visible:ring-0 caret-foreground outline-none prose-sm max-w-none",
"data-testid": "message-input",
},
},
onUpdate: ({ editor: ed }) => {
const markdown = getMarkdownFromEditor(ed);
const text = ed.state.doc.textContent;
onUpdateRef.current?.({ markdown, text });
},
},
[],
);
// Toggle editable without destroying the editor instance.
React.useEffect(() => {
if (editor && editor.isEditable !== editable) {
editor.setEditable(editable);
}
}, [editor, editable]);
// Update placeholder text without recreating the editor.
// biome-ignore lint/correctness/useExhaustiveDependencies: placeholder triggers the ref update
React.useEffect(() => {
if (!editor) return;
// Force ProseMirror to re-run decoration plugins so the Placeholder
// extension picks up the new text from placeholderRef.
editor.view.dispatch(editor.state.tr);
}, [editor, placeholder]);
// Keep mention/channel-highlight decorations in sync with known names.
// NOTE: We use `editor.storage.mentionHighlight` (the mutable storage object
// shared with the ProseMirror plugin closure) rather than finding the
// extension instance via extensionManager — the instance's `.storage` getter
// returns a fresh spread-copy on every access, so mutations are silently lost.
React.useEffect(() => {
if (!editor) return;
// biome-ignore lint/suspicious/noExplicitAny: TipTap's Storage type doesn't include dynamic extension keys
const storage = (editor.storage as any).mentionHighlight as
| { names: string[]; channelNames: string[] }
| undefined;
if (storage) {
storage.names = mentionNames ?? [];
storage.channelNames = channelNames ?? [];
// Force the plugin to re-decorate by dispatching a metadata transaction.
const { tr } = editor.state;
editor.view.dispatch(tr.setMeta(mentionHighlightKey, true));
}
}, [editor, mentionNames, channelNames]);
const getMarkdown = React.useCallback((): string => {
if (!editor) return "";
return getMarkdownFromEditor(editor);
}, [editor]);
const isEmpty = React.useCallback((): boolean => {
if (!editor) return true;
return editor.isEmpty;
}, [editor]);
const clearContent = React.useCallback(() => {
editor?.commands.clearContent(true);
}, [editor]);
const setContent = React.useCallback(
(markdown: string) => {
if (!editor) return;
editor.commands.setContent(markdown);
},
[editor],
);
const focus = React.useCallback(() => {
editor?.commands.focus("end");
}, [editor]);
/**
* Replace editor content and append a trailing space that survives parsing.
*
* `setContent(markdown)` roundtrips through TipTap's markdown parser which
* strips trailing whitespace from text nodes. TipTap's `insertContent(" ")`
* also normalises it away. This method bypasses both by creating a raw
* ProseMirror text node and inserting it via a direct transaction — the
* only path that reliably preserves a literal trailing space.
*
* Used by mention and channel-link autocomplete insertion.
*/
const setContentWithTrailingSpace = React.useCallback(
(markdown: string) => {
if (!editor) return;
editor.commands.setContent(markdown);
// Insert a literal space via a raw ProseMirror transaction so it
// bypasses TipTap's content parser which strips trailing whitespace.
const { tr, schema, doc } = editor.state;
const endPos = doc.content.size - 1; // before the closing node token
const spaceNode = schema.text(" ");
tr.insert(endPos, spaceNode);
// Place cursor after the inserted space.
const cursorPos = endPos + spaceNode.nodeSize;
tr.setSelection(TextSelection.create(tr.doc, cursorPos));
editor.view.dispatch(tr);
editor.view.focus();
},
[editor],
);
/**
* Returns the plain-text content and an approximate cursor offset.
* Used to bridge the existing useMentions / useChannelLinks hooks which
* were designed for a plain <textarea>.
*/
const getTextAndCursor = React.useCallback((): {
text: string;
cursor: number;
} => {
if (!editor) return { text: "", cursor: 0 };
const { state } = editor;
const text = state.doc.textContent;
// Map ProseMirror position → plain-text offset.
// Walk through text nodes and accumulate length until we pass the anchor.
const anchor = state.selection.anchor;
let offset = 0;
let found = false;
state.doc.descendants((node, pos) => {
if (found) return false;
if (node.isText) {
const nodeEnd = pos + node.nodeSize;
if (anchor <= nodeEnd) {
offset += anchor - pos;
found = true;
return false;
}
offset += node.nodeSize;
} else if (node.isBlock && pos > 0) {
// Block boundaries add a newline in textContent
// (but only between blocks, not at the very start)
offset += 1;
}
return undefined;
});
if (!found) {
offset = text.length;
}
return { text, cursor: offset };
}, [editor]);
return {
editor,
getMarkdown,
isEmpty,
clearContent,
setContent,
setContentWithTrailingSpace,
focus,
getTextAndCursor,
};
}
function getMarkdownFromEditor(editor: Editor): string {
// biome-ignore lint/suspicious/noExplicitAny: tiptap-markdown storage is untyped
const storage = (editor.storage as any).markdown as
| { getMarkdown?: () => string }
| undefined;
if (storage?.getMarkdown) {
return storage.getMarkdown();
}
// Fallback: plain text
return editor.state.doc.textContent;
}

View File

@@ -0,0 +1,143 @@
import * as React from "react";
import { AnimatePresence, LayoutGroup, motion } from "motion/react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import type { BlobDescriptor } from "@/shared/api/tauri";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import { shortHash } from "@/features/messages/lib/useMediaUpload";
type ComposerAttachmentsProps = {
attachments: BlobDescriptor[];
isUploading?: boolean;
uploadingCount?: number;
onRemove: (url: string) => void;
};
/**
* Thumbnail previews for uploaded attachments in the composer.
* Each attachment shows as a small image with a remove button and
* a short hash label (e.g. "a3f2").
*/
export const ComposerAttachments = React.memo(function ComposerAttachments({
attachments,
isUploading = false,
uploadingCount = 0,
onRemove,
}: ComposerAttachmentsProps) {
if (attachments.length === 0 && !isUploading) return null;
return (
<LayoutGroup>
<motion.div
layout
className="flex items-center gap-2"
transition={{ type: "spring", stiffness: 500, damping: 30 }}
>
<AnimatePresence mode="popLayout">
{attachments.map((attachment) => {
const hash = shortHash(attachment.sha256);
const isVideo = attachment.type.startsWith("video/");
const thumbUrl = attachment.thumb
? rewriteRelayUrl(attachment.thumb)
: rewriteRelayUrl(attachment.url);
return (
<motion.div
key={attachment.url}
layout
initial={false}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className="group relative"
>
<div className="relative h-5 w-10">
<DialogPrimitive.Root>
<DialogPrimitive.Trigger asChild>
<div className="h-full w-full cursor-pointer overflow-hidden rounded border border-border/70">
{isVideo ? (
<div className="flex h-full w-full items-center justify-center bg-muted text-[10px] text-muted-foreground">
▶
</div>
) : (
<img
src={thumbUrl}
alt={`Attachment ${hash}`}
className="h-full w-full object-contain"
/>
)}
</div>
</DialogPrimitive.Trigger>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<DialogPrimitive.Content
className="fixed inset-0 z-50 flex items-center justify-center p-8"
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<DialogPrimitive.Title className="sr-only">
Attachment {hash} preview
</DialogPrimitive.Title>
<DialogPrimitive.Description className="sr-only">
Full-size attachment preview. Press Escape or click
outside to close.
</DialogPrimitive.Description>
<DialogPrimitive.Close
className="absolute inset-0 cursor-default"
aria-label="Close lightbox"
/>
{isVideo ? (
// biome-ignore lint/a11y/useMediaCaption: user-uploaded video, no captions available
<video
src={rewriteRelayUrl(attachment.url)}
controls
className="relative max-h-[90vh] max-w-[90vw] rounded-lg"
/>
) : (
<img
alt={`Attachment ${hash}`}
className="relative max-h-[90vh] max-w-[90vw] rounded-lg object-contain"
src={rewriteRelayUrl(attachment.url)}
/>
)}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-full bg-black/50 p-2 text-white/80 transition-colors hover:bg-black/70 hover:text-white focus:outline-none focus:ring-2 focus:ring-white/30">
<X className="h-5 w-5" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
<button
type="button"
onClick={() => onRemove(attachment.url)}
className="absolute -right-1 -top-1 hidden h-4 w-4 items-center justify-center rounded-full bg-foreground text-background group-hover:flex"
title="Remove attachment"
>
<X className="h-2.5 w-2.5" />
</button>
</div>
</motion.div>
);
})}
{isUploading &&
Array.from({ length: uploadingCount || 1 }).map((_, i) => (
<motion.div
// biome-ignore lint/suspicious/noArrayIndexKey: placeholders have no stable identity
key={`upload-placeholder-${i}`}
layout
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
>
<div className="relative h-5 w-10 overflow-hidden rounded border border-border/70">
<div className="h-full w-full animate-pulse bg-muted" />
</div>
</motion.div>
))}
</AnimatePresence>
</motion.div>
</LayoutGroup>
);
});

View File

@@ -1,116 +0,0 @@
import React from "react";
import { buildPrefixPattern } from "@/shared/lib/mentionPattern";
type Segment = { type: "text" | "mention" | "channel"; value: string };
/**
* Extends the shared mention pattern to also match `#channel-name` references.
*/
function buildOverlayPattern(
mentionNames: string[],
channelNames: string[],
): RegExp {
const mentionSource = buildPrefixPattern("@", mentionNames).source;
const channelSource = buildPrefixPattern("#", channelNames).source;
return new RegExp(`${mentionSource}|${channelSource}`, "gi");
}
function parseSegments(text: string, pattern: RegExp): Segment[] {
const segments: Segment[] = [];
let lastIndex = 0;
pattern.lastIndex = 0;
for (const match of text.matchAll(pattern)) {
const matchStart = match.index;
if (matchStart > lastIndex) {
segments.push({ type: "text", value: text.slice(lastIndex, matchStart) });
}
const value = match[0];
segments.push({
type: value.startsWith("@") ? "mention" : "channel",
value,
});
lastIndex = matchStart + value.length;
}
if (lastIndex < text.length) {
segments.push({ type: "text", value: text.slice(lastIndex) });
}
return segments;
}
type ComposerMentionOverlayProps = {
channelNames: string[];
content: string;
mentionNames: string[];
scrollTop: number;
};
/**
* Overlay that highlights @mentions and #channels in the composer text.
*
* Wrapped in React.memo so it skips re-renders when the parent re-renders
* without changing any of this component's props (e.g. focus state changes).
*/
export const ComposerMentionOverlay = React.memo(
function ComposerMentionOverlay({
channelNames,
content,
mentionNames,
scrollTop,
}: ComposerMentionOverlayProps) {
const pattern = React.useMemo(
() => buildOverlayPattern(mentionNames, channelNames),
[mentionNames, channelNames],
);
// Memoize regex parsing so it only re-runs when content or the mention
// pattern actually changes — avoids expensive matchAll on every render.
const segments = React.useMemo(
() => parseSegments(content, pattern),
[content, pattern],
);
// Memoize the rendered nodes so we don't rebuild the element array unless
// the parsed segments change.
const renderedNodes = React.useMemo(
() =>
segments.reduce<{ offset: number; nodes: React.ReactNode[] }>(
(acc, segment) => {
const key = `${acc.offset}`;
if (segment.type === "mention" || segment.type === "channel") {
acc.nodes.push(
<span
className="rounded-sm bg-primary/15 text-primary"
key={key}
>
{segment.value}
</span>,
);
} else {
acc.nodes.push(
<span className="text-foreground" key={key}>
{segment.value}
</span>,
);
}
acc.offset += segment.value.length;
return acc;
},
{ offset: 0, nodes: [] },
).nodes,
[segments],
);
return (
<div
className="whitespace-pre-wrap break-words px-0 py-0 text-sm leading-6"
style={{ transform: `translateY(-${scrollTop}px)` }}
>
{renderedNodes}
</div>
);
},
);

View File

@@ -0,0 +1,179 @@
import * as React from "react";
import { useEditorState, type Editor } from "@tiptap/react";
import {
Bold,
Code,
Italic,
Link,
List,
ListOrdered,
Quote,
Strikethrough,
} from "lucide-react";
import { Toggle } from "@/shared/ui/toggle";
type FormattingToolbarProps = {
editor: Editor | null;
disabled?: boolean;
};
/**
* Formatting bar shown above the editor when the format toggle is active.
* Renders all formatting buttons in a single row with bg-muted styling.
*/
export const FormattingToolbar = React.memo(function FormattingToolbar({
editor,
disabled = false,
}: FormattingToolbarProps) {
const toggleBold = React.useCallback(() => {
editor?.chain().focus().toggleBold().run();
}, [editor]);
const toggleItalic = React.useCallback(() => {
editor?.chain().focus().toggleItalic().run();
}, [editor]);
const toggleStrike = React.useCallback(() => {
editor?.chain().focus().toggleStrike().run();
}, [editor]);
const toggleCode = React.useCallback(() => {
editor?.chain().focus().toggleCode().run();
}, [editor]);
const toggleLink = React.useCallback(() => {
if (!editor) return;
if (editor.isActive("link")) {
editor.chain().focus().unsetLink().run();
return;
}
const { from, to } = editor.state.selection;
const hasSelection = from !== to;
if (hasSelection) {
const url = window.prompt("Enter URL:");
if (url) {
editor.chain().focus().setLink({ href: url }).run();
}
} else {
const url = window.prompt("Enter URL:");
if (url) {
const label = window.prompt("Link text:", url) || url;
editor.chain().focus().insertContent(`[${label}](${url})`).run();
}
}
}, [editor]);
const toggleBulletList = React.useCallback(() => {
editor?.chain().focus().toggleBulletList().run();
}, [editor]);
const toggleOrderedList = React.useCallback(() => {
editor?.chain().focus().toggleOrderedList().run();
}, [editor]);
const toggleBlockquote = React.useCallback(() => {
editor?.chain().focus().toggleBlockquote().run();
}, [editor]);
// Subscribe to editor state changes so active marks/nodes update on
// selection change — useEditorState triggers re-renders when the
// selector result changes.
const activeStates = useEditorState({
editor,
selector: ({ editor: ed }) =>
ed
? {
bold: ed.isActive("bold"),
italic: ed.isActive("italic"),
strike: ed.isActive("strike"),
code: ed.isActive("code"),
link: ed.isActive("link"),
bulletList: ed.isActive("bulletList"),
orderedList: ed.isActive("orderedList"),
blockquote: ed.isActive("blockquote"),
}
: null,
});
if (!editor || !activeStates) return null;
const items = [
{
icon: Bold,
label: "Bold",
shortcut: "⌘B",
action: toggleBold,
active: activeStates.bold,
},
{
icon: Italic,
label: "Italic",
shortcut: "⌘I",
action: toggleItalic,
active: activeStates.italic,
},
{
icon: Strikethrough,
label: "Strikethrough",
shortcut: "⌘⇧X",
action: toggleStrike,
active: activeStates.strike,
},
{
icon: Code,
label: "Code",
shortcut: "⌘E",
action: toggleCode,
active: activeStates.code,
},
{
icon: Link,
label: "Link",
shortcut: "⌘K",
action: toggleLink,
active: activeStates.link,
},
{
icon: List,
label: "Bullet list",
action: toggleBulletList,
active: activeStates.bulletList,
},
{
icon: ListOrdered,
label: "Ordered list",
action: toggleOrderedList,
active: activeStates.orderedList,
},
{
icon: Quote,
label: "Quote",
action: toggleBlockquote,
active: activeStates.blockquote,
},
] as const;
return (
<div className="flex items-center gap-0.5">
{items.map((item) => (
<Toggle
key={item.label}
aria-label={item.label}
disabled={disabled}
pressed={item.active}
onPressedChange={() => item.action()}
className="h-7 w-7 min-w-7 [&_svg]:size-3.5"
title={
"shortcut" in item ? `${item.label} (${item.shortcut})` : item.label
}
>
<item.icon className="h-3.5 w-3.5" />
</Toggle>
))}
</div>
);
});

View File

@@ -1,16 +1,21 @@
import * as React from "react";
import { EditorContent } from "@tiptap/react";
import { useChannelLinks } from "@/features/messages/lib/useChannelLinks";
import type { ChannelSuggestion } from "@/features/messages/lib/useChannelLinks";
import { useDrafts } from "@/features/messages/lib/useDrafts";
import { useMediaUpload } from "@/features/messages/lib/useMediaUpload";
import {
ALLOWED_MEDIA_TYPES,
useMediaUpload,
} from "@/features/messages/lib/useMediaUpload";
import { useMentions } from "@/features/messages/lib/useMentions";
import { useRichTextEditor } from "@/features/messages/lib/useRichTextEditor";
import { useTypingBroadcast } from "@/features/messages/useTypingBroadcast";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Textarea } from "@/shared/ui/textarea";
import { ChannelAutocomplete } from "./ChannelAutocomplete";
import { ComposerMentionOverlay } from "./ComposerMentionOverlay";
import { ComposerAttachments } from "./ComposerAttachments";
import {
MentionAutocomplete,
type MentionSuggestion,
@@ -46,8 +51,6 @@ type MessageComposerProps = {
typingRootEventId?: string | null;
};
const MAX_TEXTAREA_ROWS = 4;
export function MessageComposer({
channelId = null,
channelName,
@@ -64,18 +67,19 @@ export function MessageComposer({
typingParentEventId = null,
typingRootEventId = null,
}: MessageComposerProps) {
// ── Markdown content state (synced from Tiptap on every update) ──────
const [content, setContent] = React.useState("");
const contentRef = React.useRef(content);
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const pendingSelectionRef = React.useRef<number | null>(null);
const draftSelectionRef = React.useRef({ end: 0, start: 0 });
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = React.useState(false);
const [composerScrollTop, setComposerScrollTop] = React.useState(0);
const lineHeightRef = React.useRef<number | null>(null);
// Keep contentRef in sync — no extra re-render, just a ref assignment.
contentRef.current = content;
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = React.useState(false);
const [isFormattingOpen, setIsFormattingOpen] = React.useState(false);
const handleFormattingToggle = React.useCallback((pressed: boolean) => {
if (pressed) setIsEmojiPickerOpen(false);
setIsFormattingOpen(pressed);
}, []);
const drafts = useDrafts();
const previousChannelIdRef = React.useRef<string | null>(null);
@@ -87,10 +91,12 @@ export function MessageComposer({
typingRootEventId,
);
const media = useMediaUpload(setContent);
// ── Media upload ─────────────────────────────────────────────────────
// We pass a custom setter that both updates React state AND inserts
// markdown into the Tiptap editor when media upload completes.
const media = useMediaUpload();
// Stable refs for values read inside callbacks that should not cause
// callback identity changes when they update.
// ── Stable refs for callbacks ────────────────────────────────────────
const disabledRef = React.useRef(disabled);
const isSendingRef = React.useRef(isSending);
const onSendRef = React.useRef(onSend);
@@ -104,18 +110,46 @@ export function MessageComposer({
editTargetRef.current = editTarget;
channelIdRef.current = channelId;
// biome-ignore lint/correctness/useExhaustiveDependencies: channelId is the sole trigger — save draft for previous channel, restore draft for new channel, reset transient state
// ── Computed placeholder ─────────────────────────────────────────────
const computedPlaceholder = editTarget
? "Edit your message"
: (placeholder ??
(replyTarget
? `Reply to ${replyTarget.author} in #${channelName}`
: `Message #${channelName}`));
// ── Tiptap editor ───────────────────────────────────────────────────
const richText = useRichTextEditor({
placeholder: computedPlaceholder,
editable: !disabled,
mentionNames: mentions.knownNames,
channelNames: channelLinks.knownChannelNames,
onUpdate: ({ markdown, text }) => {
setContent(markdown);
contentRef.current = markdown;
// Bridge to existing mention/channel detection hooks.
const { cursor } = richText.getTextAndCursor();
mentions.updateMentionQuery(text, cursor);
channelLinks.updateChannelQuery(text, cursor);
if (text.trim().length > 0) {
notifyTyping();
}
},
});
// ── Channel switching: save/restore drafts ──────────────────────────
// biome-ignore lint/correctness/useExhaustiveDependencies: channelId is the sole trigger
React.useEffect(() => {
// Save draft for the channel we're leaving
const prevId = previousChannelIdRef.current;
if (prevId) {
const currentContent = contentRef.current;
const sel = draftSelectionRef.current;
if (currentContent.trim().length > 0) {
drafts.saveDraft(prevId, {
content: currentContent,
selectionEnd: sel.end,
selectionStart: sel.start,
selectionEnd: currentContent.length,
selectionStart: currentContent.length,
});
} else {
drafts.clearDraft(prevId);
@@ -123,162 +157,127 @@ export function MessageComposer({
}
previousChannelIdRef.current = channelId;
// Restore draft for the channel we're entering
const saved = channelId ? drafts.loadDraft(channelId) : undefined;
if (saved) {
setContent(saved.content);
contentRef.current = saved.content;
draftSelectionRef.current = {
end: saved.selectionEnd,
start: saved.selectionStart,
};
pendingSelectionRef.current = saved.selectionStart;
richText.setContent(saved.content);
} else {
setContent("");
contentRef.current = "";
draftSelectionRef.current = { end: 0, start: 0 };
richText.clearContent();
}
// Always reset transient state
media.setPendingImeta([]);
media.setUploadState({ status: "idle" });
setIsEmojiPickerOpen(false);
setComposerScrollTop(0);
mentions.clearMentions();
channelLinks.clearChannels();
lineHeightRef.current = null;
}, [channelId]);
// ── Edit mode: pre-fill content ─────────────────────────────────────
// biome-ignore lint/correctness/useExhaustiveDependencies: editTarget?.id is the trigger
React.useEffect(() => {
if (!editTarget) return;
setContent(editTarget.body);
contentRef.current = editTarget.body;
richText.setContent(editTarget.body);
richText.focus();
}, [editTarget?.id]);
// ── Focus on reply ──────────────────────────────────────────────────
React.useEffect(() => {
if (!replyTarget || disabled) return;
richText.focus();
}, [disabled, replyTarget, richText.focus]);
// ── Mention / channel autocomplete insertion ────────────────────────
const applyMentionInsert = React.useCallback(
(suggestion: MentionSuggestion) => {
const textarea = textareaRef.current;
const currentContent = contentRef.current;
const result = mentions.insertMention(
suggestion,
currentContent,
textarea?.selectionEnd ?? currentContent.length,
);
draftSelectionRef.current = {
end: result.nextCursor,
start: result.nextCursor,
};
pendingSelectionRef.current = result.nextCursor;
const { text, cursor } = richText.getTextAndCursor();
const result = mentions.insertMention(suggestion, text, cursor);
// setContentWithTrailingSpace re-injects a space after the markdown
// roundtrip so the cursor lands ready for the next word.
richText.setContentWithTrailingSpace(result.nextContent);
setContent(result.nextContent);
contentRef.current = result.nextContent;
},
[mentions.insertMention],
[
mentions.insertMention,
richText.getTextAndCursor,
richText.setContentWithTrailingSpace,
],
);
const applyChannelInsert = React.useCallback(
(suggestion: ChannelSuggestion) => {
const textarea = textareaRef.current;
const currentContent = contentRef.current;
const result = channelLinks.insertChannel(
suggestion,
currentContent,
textarea?.selectionEnd ?? currentContent.length,
);
draftSelectionRef.current = {
end: result.nextCursor,
start: result.nextCursor,
};
pendingSelectionRef.current = result.nextCursor;
const { text, cursor } = richText.getTextAndCursor();
const result = channelLinks.insertChannel(suggestion, text, cursor);
richText.setContentWithTrailingSpace(result.nextContent);
setContent(result.nextContent);
contentRef.current = result.nextContent;
},
[channelLinks.insertChannel],
);
const updateDraftSelection = React.useCallback(
(target: HTMLTextAreaElement | null) => {
if (!target) {
return;
}
draftSelectionRef.current = {
end: target.selectionEnd ?? target.value.length,
start: target.selectionStart ?? target.value.length,
};
},
[],
[
channelLinks.insertChannel,
richText.getTextAndCursor,
richText.setContentWithTrailingSpace,
],
);
// ── Emoji insertion ─────────────────────────────────────────────────
const insertEmoji = React.useCallback(
(emoji: string) => {
const currentContent = contentRef.current;
const { end, start } = draftSelectionRef.current;
const nextStart = Math.min(start, currentContent.length);
const nextEnd = Math.min(end, currentContent.length);
const nextCursor = nextStart + emoji.length;
const nextContent = `${currentContent.slice(0, nextStart)}${emoji}${currentContent.slice(nextEnd)}`;
draftSelectionRef.current = {
end: nextCursor,
start: nextCursor,
};
pendingSelectionRef.current = nextCursor;
setContent(nextContent);
if (!richText.editor) return;
richText.editor.chain().focus().insertContent(emoji).run();
setIsEmojiPickerOpen(false);
mentions.clearMentions();
},
[mentions.clearMentions],
[richText.editor, mentions.clearMentions],
);
// ── @ mention picker (toolbar button) ───────────────────────────────
const openMentionPicker = React.useCallback(() => {
const textarea = textareaRef.current;
if (!textarea) {
return;
}
if (!richText.editor) return;
const { text, cursor } = richText.getTextAndCursor();
const currentContent = contentRef.current;
const cursorPosition = textarea.selectionStart ?? currentContent.length;
// Quick check: is there already an @-query in progress?
const beforeCursor = currentContent.slice(0, cursorPosition);
// Check if there's already an @-query in progress
const beforeCursor = text.slice(0, cursor);
if (/(?:^|[\s])@[^\s]*$/.test(beforeCursor)) {
mentions.updateMentionQuery(currentContent, cursorPosition);
textarea.focus();
mentions.updateMentionQuery(text, cursor);
richText.focus();
return;
}
const { end, start } = draftSelectionRef.current;
const nextStart = Math.min(start, currentContent.length);
const nextEnd = Math.min(end, currentContent.length);
const previousCharacter = currentContent.slice(0, nextStart).slice(-1);
// Insert @ at cursor
const previousChar = text.slice(0, cursor).slice(-1);
const prefix =
nextStart > 0 && previousCharacter && !/\s/.test(previousCharacter)
? " @"
: "@";
const nextContent = `${currentContent.slice(0, nextStart)}${prefix}${currentContent.slice(nextEnd)}`;
const mentionIndex = nextStart + (prefix.startsWith(" ") ? 1 : 0);
const nextCursor = mentionIndex + 1;
draftSelectionRef.current = {
end: nextCursor,
start: nextCursor,
};
pendingSelectionRef.current = nextCursor;
setContent(nextContent);
cursor > 0 && previousChar && !/\s/.test(previousChar) ? " @" : "@";
richText.editor.chain().focus().insertContent(prefix).run();
setIsEmojiPickerOpen(false);
mentions.updateMentionQuery(nextContent, nextCursor);
}, [mentions.updateMentionQuery]);
const handleScroll = React.useCallback(
(event: React.UIEvent<HTMLTextAreaElement>) => {
setComposerScrollTop(event.currentTarget.scrollTop);
},
[],
);
// Trigger mention detection after inserting @
const updatedText = richText.editor.state.doc.textContent;
const { cursor: updatedCursor } = richText.getTextAndCursor();
mentions.updateMentionQuery(updatedText, updatedCursor);
}, [
richText.editor,
richText.getTextAndCursor,
richText.focus,
mentions.updateMentionQuery,
]);
// ── Submit message ──────────────────────────────────────────────────
const submitMessage = React.useCallback(async () => {
const trimmed = contentRef.current.trim();
// Edit mode: save the edit and return.
// Edit mode
if (editTargetRef.current && onEditSaveRef.current) {
if (!trimmed || isSendingRef.current) {
return;
}
if (!trimmed || isSendingRef.current) return;
const savedContent = trimmed;
setContent("");
draftSelectionRef.current = { end: 0, start: 0 };
contentRef.current = "";
richText.clearContent();
mentions.clearMentions();
channelLinks.clearChannels();
setIsEmojiPickerOpen(false);
@@ -287,10 +286,13 @@ export function MessageComposer({
await onEditSaveRef.current(trimmed);
} catch {
setContent(savedContent);
contentRef.current = savedContent;
richText.setContent(savedContent);
}
return;
}
// Normal send
const currentPendingImeta = media.pendingImetaRef.current;
const hasMedia = currentPendingImeta.length > 0;
if (
@@ -319,11 +321,19 @@ export function MessageComposer({
])
: undefined;
// Append all attachments as markdown images at the end of the message.
let finalContent = trimmed;
for (const d of currentPendingImeta) {
const isVideo = d.type.startsWith("video/");
finalContent += isVideo ? `\n![video](${d.url})` : `\n![image](${d.url})`;
}
const savedContent = trimmed;
const savedImeta = [...currentPendingImeta];
setContent("");
draftSelectionRef.current = { end: 0, start: 0 };
contentRef.current = "";
richText.clearContent();
media.setPendingImeta([]);
mentions.clearMentions();
channelLinks.clearChannels();
@@ -331,12 +341,14 @@ export function MessageComposer({
const sendChannelId = channelIdRef.current;
try {
await onSendRef.current(trimmed, pubkeys, mediaTags);
await onSendRef.current(finalContent, pubkeys, mediaTags);
if (sendChannelId) {
drafts.clearDraft(sendChannelId);
}
} catch {
setContent(savedContent);
contentRef.current = savedContent;
richText.setContent(savedContent);
media.setPendingImeta(savedImeta);
}
}, [
@@ -346,6 +358,8 @@ export function MessageComposer({
mentions.extractMentionPubkeys,
mentions.clearMentions,
channelLinks.clearChannels,
richText.clearContent,
richText.setContent,
]);
const handleSubmit = React.useCallback(
@@ -356,28 +370,13 @@ export function MessageComposer({
[submitMessage],
);
const handleChange = React.useCallback(
(event: React.ChangeEvent<HTMLTextAreaElement>) => {
const nextContent = event.target.value;
const cursorPos = event.target.selectionStart;
setContent(nextContent);
updateDraftSelection(event.target);
mentions.updateMentionQuery(nextContent, cursorPos);
channelLinks.updateChannelQuery(nextContent, cursorPos);
if (nextContent.trim().length > 0) {
notifyTyping();
}
},
[
updateDraftSelection,
mentions.updateMentionQuery,
channelLinks.updateChannelQuery,
notifyTyping,
],
);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
// ── Keyboard handling ───────────────────────────────────────────────
// Tiptap handles formatting shortcuts (⌘B, ⌘I, etc.) natively.
// We intercept Enter (submit) and arrow keys (autocomplete) via a
// keydown listener on the editor wrapper.
const handleEditorKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
// Let autocomplete handle keys first
const channelResult = channelLinks.handleChannelKeyDown(event);
if (channelResult.handled) {
if (channelResult.suggestion) {
@@ -394,6 +393,7 @@ export function MessageComposer({
return;
}
// Escape in edit mode
if (event.key === "Escape" && editTargetRef.current && onCancelEdit) {
event.preventDefault();
onCancelEdit();
@@ -404,25 +404,18 @@ export function MessageComposer({
return;
}
if (event.ctrlKey) {
const textarea = event.currentTarget;
const { selectionEnd, selectionStart, value } = textarea;
const nextContent = `${value.slice(0, selectionStart)}\n${value.slice(selectionEnd)}`;
event.preventDefault();
pendingSelectionRef.current = selectionStart + 1;
draftSelectionRef.current = {
end: selectionStart + 1,
start: selectionStart + 1,
};
setContent(nextContent);
// Shift+Enter or Ctrl+Enter → newline (Tiptap handles this natively
// via hard break, so let it through)
if (event.shiftKey || event.ctrlKey) {
return;
}
if (event.metaKey || event.altKey || event.shiftKey) {
// Meta/Alt+Enter → let through (system shortcuts)
if (event.metaKey || event.altKey) {
return;
}
// Plain Enter → submit
event.preventDefault();
void submitMessage();
},
@@ -436,60 +429,34 @@ export function MessageComposer({
],
);
// biome-ignore lint/correctness/useExhaustiveDependencies: content triggers height recalc and pending selection restore
React.useLayoutEffect(() => {
const textarea = textareaRef.current;
if (!textarea) {
return;
}
if (lineHeightRef.current === null) {
lineHeightRef.current =
Number.parseFloat(window.getComputedStyle(textarea).lineHeight) || 24;
}
const lineHeight = lineHeightRef.current;
const maxHeight = lineHeight * MAX_TEXTAREA_ROWS;
textarea.style.height = "auto";
const nextHeight = Math.max(
lineHeight,
Math.min(textarea.scrollHeight, maxHeight),
);
textarea.style.height = `${nextHeight}px`;
textarea.style.overflowY =
textarea.scrollHeight > maxHeight ? "auto" : "hidden";
const pendingSelection = pendingSelectionRef.current;
if (pendingSelection !== null) {
textarea.focus();
textarea.setSelectionRange(pendingSelection, pendingSelection);
pendingSelectionRef.current = null;
}
}, [content]);
// ── Media paste + ⌘K link shortcut via Tiptap editorProps ──────────
const uploadFileRef = React.useRef(media.uploadFile);
uploadFileRef.current = media.uploadFile;
React.useEffect(() => {
if (!replyTarget || disabled) {
return;
}
if (!richText.editor) return;
textareaRef.current?.focus();
}, [disabled, replyTarget]);
richText.editor.setOptions({
editorProps: {
...richText.editor.options.editorProps,
handlePaste: (_view, event) => {
const items = Array.from(event.clipboardData?.items ?? []);
const mediaItem = items.find((item) =>
ALLOWED_MEDIA_TYPES.includes(item.type),
);
if (!mediaItem) return false;
// Pre-fill content when entering edit mode.
// biome-ignore lint/correctness/useExhaustiveDependencies: editTarget?.id is the trigger — only reset when the edited message changes
React.useEffect(() => {
if (!editTarget) {
return;
}
setContent(editTarget.body);
contentRef.current = editTarget.body;
const cursorPos = editTarget.body.length;
draftSelectionRef.current = { end: cursorPos, start: cursorPos };
pendingSelectionRef.current = cursorPos;
textareaRef.current?.focus();
}, [editTarget?.id]);
const file = mediaItem.getAsFile();
if (file) {
void uploadFileRef.current(file);
}
return true;
},
},
});
}, [richText.editor]);
// ── Send button state ───────────────────────────────────────────────
const sendDisabled = React.useMemo(
() =>
disabled ||
@@ -498,13 +465,14 @@ export function MessageComposer({
);
const handleCaptureSelection = React.useCallback(() => {
updateDraftSelection(textareaRef.current);
}, [updateDraftSelection]);
// No-op for Tiptap — selection is managed by ProseMirror.
}, []);
const handlePaperclipClick = React.useCallback(() => {
void media.handlePaperclip();
}, [media.handlePaperclip]);
// ── Render ──────────────────────────────────────────────────────────
return (
<footer
className={cn(
@@ -514,7 +482,7 @@ export function MessageComposer({
>
<div className="flex w-full flex-col gap-3">
<form
className="relative rounded-2xl border border-input bg-card px-3 py-4 shadow-sm sm:px-4"
className="relative rounded-2xl border border-input bg-card px-3 py-4 sm:px-4"
data-testid="message-composer"
onDragOver={media.handleDragOver}
onDrop={(e) => {
@@ -536,7 +504,6 @@ export function MessageComposer({
selectedIndex={mentions.mentionSelectedIndex}
suggestions={mentions.isMentionOpen ? mentions.suggestions : []}
/>
{editTarget ? (
<div
className="mb-3 flex items-start justify-between gap-3 rounded-2xl border border-primary/30 bg-primary/5 px-3 py-2"
@@ -598,54 +565,37 @@ export function MessageComposer({
</div>
) : null}
<div className="relative">
<div
aria-hidden
className="pointer-events-none absolute inset-0 overflow-hidden"
>
<ComposerMentionOverlay
channelNames={channelLinks.knownChannelNames}
content={content}
mentionNames={mentions.knownNames}
scrollTop={composerScrollTop}
{(media.pendingImeta.length > 0 || media.isUploading) && (
<div className="mb-2 flex items-center gap-2">
<ComposerAttachments
attachments={media.pendingImeta}
isUploading={media.isUploading}
uploadingCount={media.uploadingCount}
onRemove={media.removeAttachment}
/>
</div>
<Textarea
aria-label="Message channel"
className="min-h-0 resize-none overflow-y-hidden border-0 bg-transparent px-0 py-0 text-sm leading-6 md:leading-6 shadow-none focus-visible:ring-0 caret-foreground text-transparent selection:bg-primary/20 selection:text-transparent"
data-testid="message-input"
disabled={disabled}
onChange={handleChange}
onKeyDown={handleKeyDown}
onPaste={(e) => {
void media.handlePaste(e);
}}
onScroll={handleScroll}
onSelect={(event) => {
updateDraftSelection(event.currentTarget);
}}
placeholder={
editTarget
? "Edit your message"
: (placeholder ??
(replyTarget
? `Reply to ${replyTarget.author} in #${channelName}`
: `Message #${channelName}`))
}
ref={textareaRef}
rows={1}
value={content}
/>
)}
{/* biome-ignore lint/a11y/noStaticElementInteractions: keydown handler bridges Tiptap editor to autocomplete and submit */}
<div
className="rich-text-composer max-h-32 overflow-y-auto"
onKeyDown={handleEditorKeyDown}
>
<EditorContent editor={richText.editor} />
</div>
<MessageComposerToolbar
composerDisabled={disabled}
editor={richText.editor}
formattingDisabled={disabled}
isEmojiPickerOpen={isEmojiPickerOpen}
isFormattingOpen={isFormattingOpen}
isSending={isSending}
isUploading={media.isUploading}
onCaptureSelection={handleCaptureSelection}
onEmojiPickerOpenChange={setIsEmojiPickerOpen}
onEmojiSelect={insertEmoji}
onFormattingToggle={handleFormattingToggle}
onOpenMentionPicker={openMentionPicker}
onPaperclip={handlePaperclipClick}
sendDisabled={sendDisabled}

View File

@@ -1,69 +1,205 @@
import * as React from "react";
import { AtSign, Paperclip, SendHorizontal } from "lucide-react";
import type { Editor } from "@tiptap/react";
import { AnimatePresence, motion } from "motion/react";
import {
ALargeSmall,
AtSign,
Paperclip,
SendHorizontal,
X,
} from "lucide-react";
import { Button } from "@/shared/ui/button";
import { Toggle } from "@/shared/ui/toggle";
import { ComposerEmojiPicker } from "./ComposerEmojiPicker";
import { FormattingToolbar } from "./FormattingToolbar";
/** Spring for enter/exit of button groups — all fire simultaneously. */
const presenceSpring = {
type: "spring",
stiffness: 400,
damping: 28,
} as const;
export const MessageComposerToolbar = React.memo(
function MessageComposerToolbar({
composerDisabled,
editor,
formattingDisabled,
isEmojiPickerOpen,
isFormattingOpen,
isSending,
isUploading,
onCaptureSelection,
onEmojiPickerOpenChange,
onEmojiSelect,
onFormattingToggle,
onOpenMentionPicker,
onPaperclip,
sendDisabled,
}: {
composerDisabled: boolean;
editor: Editor | null;
formattingDisabled: boolean;
isEmojiPickerOpen: boolean;
isFormattingOpen: boolean;
isSending: boolean;
isUploading: boolean;
onCaptureSelection: () => void;
onEmojiPickerOpenChange: (open: boolean) => void;
onEmojiSelect: (emoji: string) => void;
onFormattingToggle: (pressed: boolean) => void;
onOpenMentionPicker: () => void;
onPaperclip: () => void;
sendDisabled: boolean;
}) {
return (
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Button
data-testid="message-insert-mention"
disabled={composerDisabled}
onClick={onOpenMentionPicker}
onMouseDown={onCaptureSelection}
size="icon"
title="Mention someone"
type="button"
variant="ghost"
>
<AtSign className="h-4 w-4" />
</Button>
<Button
disabled={composerDisabled || isUploading}
onClick={onPaperclip}
size="icon"
title="Attach image"
type="button"
variant="ghost"
>
{isUploading ? (
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
<div className="flex min-w-0 flex-1 items-center gap-1 py-1 min-h-11">
{/*
* AnimatePresence with mode="popLayout" — exiting elements
* are popped out of flow immediately so entering elements
* can animate in simultaneously. No sequencing.
*
* The Aa toggle is duplicated inside both groups so
* AnimatePresence handles the crossfade. No layoutId,
* no order hacks, no overflow clipping needed.
*/}
<AnimatePresence mode="popLayout" initial={false}>
{isFormattingOpen ? (
/*
* ── Expanded: [Aa] [✕] | [formatting buttons] ──
*/
<motion.div
key="formatting-controls"
className="flex min-w-0 flex-1 items-center gap-1"
initial={false}
animate={{}}
exit={{ opacity: 0 }}
transition={presenceSpring}
>
<motion.div
initial={{ x: 8, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: 8, opacity: 0 }}
transition={presenceSpring}
>
<Toggle
aria-label="Toggle formatting"
disabled={composerDisabled}
pressed={isFormattingOpen}
onPressedChange={onFormattingToggle}
size="sm"
title="Formatting"
>
<ALargeSmall className="h-4 w-4" />
</Toggle>
</motion.div>
<motion.div
className="flex items-center gap-1"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ ...presenceSpring, delay: 0.15 }}
>
<Button
aria-label="Close formatting"
disabled={composerDisabled}
onClick={() => onFormattingToggle(false)}
size="icon"
title="Close formatting"
type="button"
variant="ghost"
className="h-7 w-7 shrink-0"
>
<X className="h-3.5 w-3.5" />
</Button>
<div className="mx-1 h-5 w-px shrink-0 bg-border/60" />
</motion.div>
<motion.div
className="min-w-0 flex-1 overflow-x-auto"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ ...presenceSpring, delay: 0.15 }}
>
<FormattingToolbar
editor={editor}
disabled={formattingDisabled}
/>
</motion.div>
</motion.div>
) : (
<Paperclip className="h-4 w-4" />
/*
* ── Passive: [@ 📎 😊] [Aa] ──
*/
<motion.div
key="ingress-controls"
className="flex items-center gap-1"
initial={{ opacity: 0, x: -12 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -12 }}
transition={presenceSpring}
>
<Button
aria-label="Mention someone"
data-testid="message-insert-mention"
disabled={composerDisabled}
onClick={onOpenMentionPicker}
onMouseDown={onCaptureSelection}
size="icon"
title="Mention someone"
type="button"
variant="ghost"
>
<AtSign className="h-4 w-4" />
</Button>
<Button
aria-label="Attach image"
disabled={composerDisabled || isUploading}
onClick={onPaperclip}
size="icon"
title="Attach image"
type="button"
variant="ghost"
>
{isUploading ? (
<span
className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"
role="status"
aria-label="Uploading"
/>
) : (
<Paperclip className="h-4 w-4" />
)}
</Button>
<ComposerEmojiPicker
disabled={composerDisabled}
onEmojiSelect={onEmojiSelect}
onOpenChange={onEmojiPickerOpenChange}
onTriggerMouseDown={onCaptureSelection}
open={isEmojiPickerOpen}
/>
<motion.div
initial={{ x: -8, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: -8, opacity: 0 }}
transition={presenceSpring}
>
<Toggle
aria-label="Toggle formatting"
disabled={composerDisabled}
pressed={isFormattingOpen}
onPressedChange={onFormattingToggle}
size="sm"
title="Formatting"
>
<ALargeSmall className="h-4 w-4" />
</Toggle>
</motion.div>
</motion.div>
)}
</Button>
<ComposerEmojiPicker
disabled={composerDisabled}
onEmojiSelect={onEmojiSelect}
onOpenChange={onEmojiPickerOpenChange}
onTriggerMouseDown={onCaptureSelection}
open={isEmojiPickerOpen}
/>
</AnimatePresence>
</div>
<Button

View File

@@ -2,6 +2,110 @@
@tailwind components;
@tailwind utilities;
/* ── Tiptap rich-text composer ──────────────────────────────────────── */
.rich-text-composer .tiptap {
outline: none;
min-height: 1.5rem; /* single line height */
font-size: 0.875rem;
line-height: 1.5rem;
}
.rich-text-composer .tiptap p {
margin: 0;
}
.rich-text-composer .tiptap p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: hsl(var(--muted-foreground));
pointer-events: none;
height: 0;
}
.rich-text-composer .tiptap strong {
font-weight: 600;
}
.rich-text-composer .tiptap em {
font-style: italic;
}
.rich-text-composer .tiptap s {
text-decoration: line-through;
}
.rich-text-composer .tiptap code {
border-radius: 0.375rem;
background: hsl(var(--muted));
padding: 0.125rem 0.375rem;
font-family: ui-monospace, monospace;
font-size: 0.8125rem;
}
.rich-text-composer .tiptap pre {
border-radius: 1rem;
border: 1px solid hsl(var(--border) / 0.7);
background: hsl(var(--muted) / 0.6);
padding: 0.75rem 1rem;
overflow-x: auto;
margin: 0.25rem 0;
}
.rich-text-composer .tiptap pre code {
background: none;
padding: 0;
border-radius: 0;
font-size: 0.8125rem;
line-height: 1.5rem;
}
.rich-text-composer .tiptap blockquote {
border-left: 2px solid hsl(var(--border));
padding-left: 1rem;
font-style: italic;
color: hsl(var(--muted-foreground));
margin: 0.25rem 0;
}
.rich-text-composer .tiptap ul {
list-style-type: disc;
padding-left: 1.5rem;
margin: 0.25rem 0;
}
.rich-text-composer .tiptap ol {
list-style-type: decimal;
padding-left: 1.5rem;
margin: 0.25rem 0;
}
.rich-text-composer .tiptap li {
margin: 0.125rem 0;
}
.rich-text-composer .tiptap li p {
display: inline;
}
.rich-text-composer .tiptap a {
color: hsl(var(--primary));
text-decoration: underline;
text-underline-offset: 4px;
cursor: pointer;
}
.rich-text-composer .tiptap hr {
border-color: hsl(var(--border) / 0.8);
margin: 0.5rem 0;
}
.rich-text-composer .tiptap .mention-highlight {
border-radius: 0.375rem;
background: hsl(var(--primary) / 0.15);
padding: 0.125rem 0.375rem;
color: hsl(var(--primary));
}
@layer base {
:root {
/* Catppuccin Latte (mauve accent) */

View File

@@ -28,7 +28,7 @@ const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
const SIDEBAR_KEYBOARD_SHORTCUT = "s";
type SidebarContextProps = {
state: "expanded" | "collapsed";

View File

@@ -0,0 +1,43 @@
import * as React from "react";
import * as TogglePrimitive from "@radix-ui/react-toggle";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/shared/lib/cn";
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-muted data-[state=on]:text-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-3 min-w-9",
sm: "h-8 px-2 min-w-8",
lg: "h-10 px-3 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
const Toggle = React.forwardRef<
React.ComponentRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
));
Toggle.displayName = TogglePrimitive.Root.displayName;
export { Toggle, toggleVariants };

View File

@@ -1109,7 +1109,10 @@ test("manage channel can archive and unarchive a stream", async ({ page }) => {
await closeChannelManagement(page);
await expect(page.getByTestId("stream-list")).not.toContainText("general");
await expect(page.getByTestId("message-input")).toBeDisabled();
await expect(page.getByTestId("message-input")).toHaveAttribute(
"contenteditable",
"false",
);
await expect(page.getByTestId("send-message")).toBeDisabled();
await page.getByTestId("browse-channels").click();
@@ -1128,7 +1131,10 @@ test("manage channel can archive and unarchive a stream", async ({ page }) => {
await closeChannelManagement(page);
await expect(page.getByTestId("stream-list")).toContainText("general");
await expect(page.getByTestId("message-input")).toBeEnabled();
await expect(page.getByTestId("message-input")).toHaveAttribute(
"contenteditable",
"true",
);
});
test("manage channel can delete an owned stream", async ({ page }) => {

View File

@@ -532,7 +532,10 @@ test("manage sheet archive and unarchive survives a reload through the relay", a
await closeChannelManagement(page);
await expect(page.getByTestId("stream-list")).not.toContainText(channelName);
await expect(page.getByTestId("message-input")).toBeDisabled();
await expect(page.getByTestId("message-input")).toHaveAttribute(
"contenteditable",
"false",
);
await expect(page.getByTestId("send-message")).toBeDisabled();
await page.reload();
@@ -546,7 +549,10 @@ test("manage sheet archive and unarchive survives a reload through the relay", a
await page.getByTestId(`browse-channel-${channelName}`).click();
await expect(page.getByTestId("channel-browser-dialog")).not.toBeVisible();
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
await expect(page.getByTestId("message-input")).toBeDisabled();
await expect(page.getByTestId("message-input")).toHaveAttribute(
"contenteditable",
"false",
);
await openChannelManagement(page);
await page.getByTestId("channel-management-unarchive").click();
@@ -554,5 +560,8 @@ test("manage sheet archive and unarchive survives a reload through the relay", a
await closeChannelManagement(page);
await expect(page.getByTestId("stream-list")).toContainText(channelName);
await expect(page.getByTestId("message-input")).toBeEnabled();
await expect(page.getByTestId("message-input")).toHaveAttribute(
"contenteditable",
"true",
);
});

View File

@@ -53,7 +53,7 @@ test("selecting a mention inserts @Name into input", async ({ page }) => {
const dropdown = autocomplete(page);
await dropdown.getByText("alice").click();
await expect(input).toHaveValue("Hey @alice ");
await expect(input).toHaveText("Hey @alice ");
});
test("mention button opens autocomplete and inserts a selected member", async ({
@@ -71,7 +71,7 @@ test("mention button opens autocomplete and inserts a selected member", async ({
await expect(dropdown).toBeVisible();
await dropdown.getByText("alice").click();
await expect(input).toHaveValue("Hey @alice ");
await expect(input).toHaveText("Hey @alice ");
});
test("keyboard navigation selects mention with Enter", async ({ page }) => {
@@ -89,7 +89,7 @@ test("keyboard navigation selects mention with Enter", async ({ page }) => {
await input.press("Enter");
// Should insert @alice and NOT send the message
await expect(input).toHaveValue("@alice ");
await expect(input).toHaveText("@alice ");
});
test("Escape dismisses autocomplete dropdown", async ({ page }) => {

View File

@@ -58,11 +58,11 @@ test("message input clears after send", async ({ page }) => {
await expect(page.getByTestId("chat-title")).toHaveText("general");
await input.fill(message);
await expect(input).toHaveValue(message);
await expect(input).toHaveText(message);
await page.getByTestId("send-message").click();
await expect(page.getByTestId("message-timeline")).toContainText(message);
await expect(input).toHaveValue("");
await expect(input).toHaveText("");
});
test("emoji picker inserts emoji into the draft and keeps focus in the composer", async ({
@@ -84,11 +84,11 @@ test("emoji picker inserts emoji into the draft and keeps focus in the composer"
await searchInput.fill("rocket");
await pickerEl.locator("button[aria-label='🚀']").first().click();
await expect(input).toHaveValue("Ship🚀");
await expect(input).toHaveText("Ship🚀");
await expect(input).toBeFocused();
await input.pressSequentially(" now");
await expect(input).toHaveValue("Ship🚀 now");
await expect(input).toHaveText("Ship🚀 now");
});
test("empty message cannot be sent", async ({ page }) => {
@@ -143,17 +143,17 @@ test("draft is preserved when switching channels", async ({ page }) => {
// Type a draft but do not send it
await input.fill(draft);
await expect(input).toHaveValue(draft);
await expect(input).toHaveText(draft);
// Switch to another channel — composer should be empty
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await expect(input).toHaveValue("");
await expect(input).toHaveText("");
// Switch back — the draft should still be there
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(input).toHaveValue(draft);
await expect(input).toHaveText(draft);
});
test("sending a message clears the draft", async ({ page }) => {
@@ -174,7 +174,7 @@ test("sending a message clears the draft", async ({ page }) => {
await expect(page.getByTestId("chat-title")).toHaveText("random");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(input).toHaveValue("");
await expect(input).toHaveText("");
});
test("different channels have independent messages", async ({ page }) => {

View File

@@ -224,7 +224,10 @@ test("opens accessible unjoined channels from search in read-only mode", async (
await expect(page.getByTestId("message-timeline")).toContainText(
"Design critique notes for the browse flow.",
);
await expect(page.getByTestId("message-input")).toBeDisabled();
await expect(page.getByTestId("message-input")).toHaveAttribute(
"contenteditable",
"false",
);
await page.getByTestId("channel-management-trigger").click();
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
@@ -285,19 +288,21 @@ test("supports multiline drafts with Ctrl+Enter and sends with Enter", async ({
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(page.getByTestId("send-message")).toContainText("Send");
const initialInputHeight = await input.evaluate(
(element) => (element as HTMLTextAreaElement).clientHeight,
(element) => (element as HTMLElement).clientHeight,
);
expect(initialInputHeight).toBeLessThan(40);
await input.fill(firstLine);
for (const line of restOfLines) {
await input.press("Control+Enter");
await input.type(line);
await input.press("Shift+Enter");
await input.pressSequentially(line);
}
for (const line of [firstLine, ...restOfLines]) {
await expect(input).toContainText(line);
}
await expect(input).toHaveValue([firstLine, ...restOfLines].join("\n"));
const expandedInputHeight = await input.evaluate(
(element) => (element as HTMLTextAreaElement).clientHeight,
(element) => (element as HTMLElement).clientHeight,
);
expect(expandedInputHeight).toBeLessThanOrEqual(100);
expect(expandedInputHeight).toBeLessThanOrEqual(130);
await expect(page.getByTestId("message-timeline")).not.toContainText(
firstLine,
);
@@ -332,12 +337,12 @@ test("does not shift the timeline when the composer grows", async ({
const before = await getTimelineMetrics(page);
await input.fill("Composer growth line one");
await input.press("Control+Enter");
await input.type("Composer growth line two");
await input.press("Control+Enter");
await input.type("Composer growth line three");
await input.press("Control+Enter");
await input.type("Composer growth line four");
await input.press("Shift+Enter");
await input.pressSequentially("Composer growth line two");
await input.press("Shift+Enter");
await input.pressSequentially("Composer growth line three");
await input.press("Shift+Enter");
await input.pressSequentially("Composer growth line four");
await page.waitForTimeout(1200);

View File

@@ -43,7 +43,7 @@ async function ensureTimelineScrollable(
const message = `${prefix} seed ${index}`;
await expect(input).toBeEnabled();
await expect(input).toHaveAttribute("contenteditable", "true");
await input.fill(message);
await sendButton.click();
await expectTimelineToContain(receiverPage, message);