Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ members = [
"crates/sprout-sdk",
"crates/sprout-persona",
]
exclude = ["desktop/src-tauri"]
exclude = ["desktop/src-tauri", ".worktree/chat-style/desktop/src-tauri"]
resolver = "2"

[workspace.package]
Expand Down
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,11 +43,18 @@
"@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",
"lucide-react": "^0.577.0",
"motion": "^12.38.0",
"react": "^19.1.0",
"react-diff-view": "^3.3.2",
"react-dom": "^19.1.0",
Expand All @@ -55,6 +63,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.

3 changes: 1 addition & 2 deletions desktop/src/features/channels/ui/ChannelPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,7 @@ export const ChannelPane = React.memo(function ChannelPane({
!activeChannel ||
!activeChannel.isMember ||
activeChannel.archivedAt !== null ||
activeChannel.channelType === "forum" ||
isSending;
activeChannel.channelType === "forum";

return (
<div className="flex min-h-0 flex-1 overflow-hidden">
Expand Down
67 changes: 67 additions & 0 deletions desktop/src/features/messages/lib/imageRefExtension.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { mergeAttributes, Node } from "@tiptap/core";

/**
* Custom Tiptap inline node for image reference chips.
*
* Stores the attachment URL and short hash. Renders as a non-editable
* inline pill like `![a3f2]`. On send, the composer resolves these to
* `![image](url)` markdown.
*/
export const ImageRefNode = Node.create({
name: "imageRef",
group: "inline",
inline: true,
atom: true, // non-editable, treated as a single unit

addAttributes() {
return {
url: { default: null },
hash: { default: null },
mediaType: { default: "image" },
thumb: { default: null },
};
},

parseHTML() {
return [{ tag: "span[data-image-ref]" }];
},

addStorage() {
return {
markdown: {
serialize(
state: { write: (text: string) => void },
node: { attrs: { hash?: string } },
) {
state.write(`![${node.attrs.hash ?? "?"}]`);
},
parse: {},
},
};
},

renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, string> }) {
const thumb = HTMLAttributes.thumb || HTMLAttributes.url || "";
const hash = HTMLAttributes.hash ?? "?";

return [
"span",
mergeAttributes(HTMLAttributes, {
"data-image-ref": "",
class:
"inline-flex items-center rounded-lg overflow-hidden align-baseline cursor-default select-none border border-border/50",
contenteditable: "false",
title: `![${hash}]`,
}),
[
"img",
{
src: thumb,
alt: `![${hash}]`,
class: "inline-block h-16 max-w-48 rounded-lg object-contain",
draggable: "false",
},
],
];
},
});
91 changes: 91 additions & 0 deletions desktop/src/features/messages/lib/mentionHighlightExtension.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
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` patterns in the document.
*
* Accepts a `names` storage option — an array of known display names.
* On every doc update the plugin scans text nodes and decorates matches.
*/
export const MentionHighlightExtension = Extension.create({
name: "mentionHighlight",

addStorage() {
return {
names: [] as string[],
};
},

addProseMirrorPlugins() {
const extension = this;

return [
new Plugin({
key: mentionHighlightKey,
state: {
init(_, state) {
return buildDecorations(state.doc, extension.storage.names);
},
apply(tr, oldDecorations) {
if (tr.docChanged || tr.getMeta(mentionHighlightKey)) {
return buildDecorations(
tr.doc,
extension.storage.names,
);
}
return oldDecorations;
},
},
props: {
decorations(state) {
return this.getState(state) ?? DecorationSet.empty;
},
},
}),
];
},
});

function buildDecorations(
doc: Parameters<typeof DecorationSet.create>[0],
names: string[],
): DecorationSet {
if (names.length === 0) return DecorationSet.empty;

const decorations: Decoration[] = [];

// Build a regex that matches @Name for any known name.
// Escape special regex chars in names and sort longest-first for greedy matching.
const sorted = [...names].sort((a, b) => b.length - a.length);
const escaped = sorted.map((n) =>
n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
);
const pattern = new RegExp(
`(?:^|(?<=\\s))@(${escaped.join("|")})`,
"gi",
);

doc.descendants((node, pos) => {
if (!node.isText || !node.text) return;

// We need to match against text that may span from start-of-node.
// ProseMirror pos points to the start of the text node content.
pattern.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = pattern.exec(node.text)) !== null) {
const from = pos + match.index;
// The match may include a leading whitespace char from the lookbehind —
// but lookbehind doesn't consume, so match[0] starts at @.
const to = from + match[0].length;
decorations.push(
Decoration.inline(from, to, { class: "mention-highlight" }),
);
}
});

return DecorationSet.create(doc, decorations);
}
113 changes: 113 additions & 0 deletions desktop/src/features/messages/lib/useImageRefSuggestions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import * as React from "react";

import type { BlobDescriptor } from "@/shared/api/tauri";
import { shortHash } from "./useMediaUpload";

export type ImageRefSuggestion = {
url: string;
hash: string;
type: string;
thumb?: string;
};

/**
* Detects `![` typed in the editor and shows suggestions from attached
* images. Lightweight alternative to @tiptap/suggestion — works with
* plain text + cursor position from the existing bridge.
*/
export function useImageRefSuggestions(attachments: BlobDescriptor[]) {
const [isOpen, setIsOpen] = React.useState(false);
const [query, setQuery] = React.useState("");
const [selectedIndex, setSelectedIndex] = React.useState(0);

const suggestions: ImageRefSuggestion[] = React.useMemo(() => {
const items = attachments.map((a) => ({
url: a.url,
hash: shortHash(a.sha256),
type: a.type,
thumb: a.thumb,
}));
if (!query) return items;
return items.filter((s) =>
s.hash.toLowerCase().includes(query.toLowerCase()),
);
}, [attachments, query]);

/** Call on every editor update with the plain text and cursor position. */
const updateQuery = React.useCallback(
(text: string, cursor: number) => {
if (attachments.length === 0) {
if (isOpen) setIsOpen(false);
return;
}

// Look for `![` before cursor, not yet closed with `]`
const before = text.slice(0, cursor);
const match = /!\[([^\]]*)$/.exec(before);

if (match) {
setQuery(match[1]);
setIsOpen(true);
setSelectedIndex(0);
} else if (isOpen) {
setIsOpen(false);
setQuery("");
}
},
[attachments.length, isOpen],
);

/** Handle keyboard navigation. Returns { handled, suggestion? }. */
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent) => {
if (!isOpen || suggestions.length === 0) {
return { handled: false } as const;
}

if (event.key === "ArrowDown") {
event.preventDefault();
setSelectedIndex((i) => (i + 1) % suggestions.length);
return { handled: true } as const;
}

if (event.key === "ArrowUp") {
event.preventDefault();
setSelectedIndex((i) => (i <= 0 ? suggestions.length - 1 : i - 1));
return { handled: true } as const;
}

if (event.key === "Tab" || event.key === "Enter") {
event.preventDefault();
const suggestion = suggestions[selectedIndex];
setIsOpen(false);
setQuery("");
return { handled: true, suggestion } as const;
}

if (event.key === "Escape") {
event.preventDefault();
setIsOpen(false);
setQuery("");
return { handled: true } as const;
}

return { handled: false } as const;
},
[isOpen, suggestions, selectedIndex],
);

const clear = React.useCallback(() => {
setIsOpen(false);
setQuery("");
setSelectedIndex(0);
}, []);

return {
isOpen,
suggestions,
selectedIndex,
updateQuery,
handleKeyDown,
clear,
};
}
Loading
Loading