Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
ca8b00d
feat: rich text editor with formatting toolbar, image ref chips, and …
tellaho Apr 13, 2026
dd9b929
feat: move formatting toolbar inline with bottom toolbar ingresses
tellaho Apr 13, 2026
444036d
refactor(composer): rearrange toolbar — passive [@ 📎 😊 Aa], expanded …
tellaho Apr 13, 2026
50c0574
feat(composer): animate toolbar passive ↔ expanded toggle with motion…
tellaho Apr 13, 2026
f7b4e92
fix(composer): emoji picker state leak, dedupe Aa toggle, add aria la…
tellaho Apr 13, 2026
f82c889
fix(composer): make toolbar animations fire simultaneously
tellaho Apr 13, 2026
50c0f6c
fix(composer): clip toolbar exit ghosting with overflow-hidden
tellaho Apr 13, 2026
10c43ef
feat(composer): mention highlights, fix list/quote Enter, remove inli…
tellaho Apr 14, 2026
e16c0a5
feat(composer): optimistic upload placeholder skeleton
tellaho Apr 14, 2026
3695f90
fix(composer): preserve trailing space after @-mention and #channel a…
tellaho Apr 14, 2026
e4ff2d9
feat(markdown): image preview max-width + click-to-open lightbox
tellaho Apr 14, 2026
7e96acb
fix(composer): prevent Aa toggle clipping during layout animation
tellaho Apr 14, 2026
90c5bf5
feat(composer): click-to-open lightbox on attachment thumbnails
tellaho Apr 14, 2026
0068ecf
feat(composer): show per-file upload skeletons for concurrent uploads
tellaho Apr 14, 2026
5c70101
revert: undo Aa toggle clipping fix (5672463) — made it worse per tho
tellaho Apr 14, 2026
1b1e8f4
fix(composer): prevent GPU layer promotion on Aa toggle to fix stale …
tellaho Apr 14, 2026
c64b856
feat(markdown): responsive images with 2-column gallery for multiple …
tellaho Apr 14, 2026
cb9cb47
fix(composer): preserve attachment order during concurrent uploads
tellaho Apr 14, 2026
7f7a910
style(composer): remove shadow, add bottom margin to form container
tellaho Apr 14, 2026
82b15fd
feat(composer): highlight #channel-name patterns in TipTap editor
tellaho Apr 14, 2026
3c67487
test: mention/channel highlight patterns and upload slot ordering
tellaho Apr 14, 2026
131dce1
fix(composer): move Aa toggle inside AnimatePresence to fix intermitt…
tellaho Apr 14, 2026
8e3e56e
fix(markdown): resolve TypeScript errors in ImageGalleryGrouper props…
tellaho Apr 14, 2026
4b640ac
fix(composer): disable heading input rules to preserve literal # char…
tellaho Apr 14, 2026
f4a45e5
refactor: extract timeline media changes to separate PR (#316)
tellaho Apr 14, 2026
05fcc2c
fix: revert local worktree exclude and unintentional sidebar shortcut…
tellaho Apr 14, 2026
1975703
fix: restore sidebar shortcut to 's' (Cmd+B is now bold)
tellaho Apr 14, 2026
7ec0f59
fix(composer): prevent upload slot corruption when removing attachmen…
tellaho Apr 14, 2026
9cc81c9
fix(composer): account for block boundary newlines in cursor mapping
tellaho Apr 14, 2026
2b6322d
fix: resolve Biome lint and format violations
tellaho Apr 14, 2026
296d576
fix(e2e): update tests for Tiptap contenteditable composer
tellaho Apr 14, 2026
c5022b3
refactor(composer): remove inline image reference system
tellaho Apr 14, 2026
1f8e8c7
fix(composer): preserve drafts across channel switches
tellaho Apr 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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": {
Expand Down
754 changes: 752 additions & 2 deletions desktop/pnpm-lock.yaml

Large diffs are not rendered by default.

140 changes: 140 additions & 0 deletions desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs
Original file line number Diff line number Diff line change
@@ -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 changes: 144 additions & 0 deletions desktop/src/features/messages/lib/mentionHighlightExtension.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading