mirror of
https://github.com/block/buzz.git
synced 2026-11-13 12:51:34 +00:00
feat(composer): add rich text editor with formatting and mentions (#310)
This commit is contained in:
@@ -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
754
desktop/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
});
|
||||
144
desktop/src/features/messages/lib/mentionHighlightExtension.ts
Normal file
144
desktop/src/features/messages/lib/mentionHighlightExtension.ts
Normal 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);
|
||||
}
|
||||
144
desktop/src/features/messages/lib/useMediaUpload.test.mjs
Normal file
144
desktop/src/features/messages/lib/useMediaUpload.test.mjs
Normal 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
|
||||
});
|
||||
@@ -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\n`
|
||||
: `\n\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,
|
||||
};
|
||||
}
|
||||
|
||||
126
desktop/src/features/messages/lib/useRichTextEditor.test.mjs
Normal file
126
desktop/src/features/messages/lib/useRichTextEditor.test.mjs
Normal 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);
|
||||
});
|
||||
342
desktop/src/features/messages/lib/useRichTextEditor.ts
Normal file
342
desktop/src/features/messages/lib/useRichTextEditor.ts
Normal 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;
|
||||
}
|
||||
143
desktop/src/features/messages/ui/ComposerAttachments.tsx
Normal file
143
desktop/src/features/messages/ui/ComposerAttachments.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
179
desktop/src/features/messages/ui/FormattingToolbar.tsx
Normal file
179
desktop/src/features/messages/ui/FormattingToolbar.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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` : `\n`;
|
||||
}
|
||||
|
||||
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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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";
|
||||
|
||||
43
desktop/src/shared/ui/toggle.tsx
Normal file
43
desktop/src/shared/ui/toggle.tsx
Normal 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 };
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user