feat(desktop): rich text forum composer, autocomplete fix, version in settings (#358)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wes
2026-04-17 13:50:04 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 136961a074
commit 13a14b11ec
8 changed files with 407 additions and 144 deletions
+6 -2
View File
@@ -49,9 +49,11 @@ export function useCreateForumPostMutation(channel: Channel | null) {
mutationFn: async ({
content,
mentionPubkeys,
mediaTags,
}: {
content: string;
mentionPubkeys?: string[];
mediaTags?: string[][];
}) => {
if (!channel) {
throw new Error("No channel selected.");
@@ -61,7 +63,7 @@ export function useCreateForumPostMutation(channel: Channel | null) {
channel.id,
content,
null,
undefined,
mediaTags,
mentionPubkeys,
KIND_FORUM_POST,
);
@@ -126,10 +128,12 @@ export function useCreateForumReplyMutation(channel: Channel | null) {
content,
parentEventId,
mentionPubkeys,
mediaTags,
}: {
content: string;
parentEventId: string;
mentionPubkeys?: string[];
mediaTags?: string[][];
}) => {
if (!channel) {
throw new Error("No channel selected.");
@@ -139,7 +143,7 @@ export function useCreateForumReplyMutation(channel: Channel | null) {
channel.id,
content,
parentEventId,
undefined,
mediaTags,
mentionPubkeys,
KIND_FORUM_COMMENT,
);
+348 -122
View File
@@ -1,176 +1,402 @@
import { Send } from "lucide-react";
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 {
ALLOWED_MEDIA_TYPES,
useMediaUpload,
} from "@/features/messages/lib/useMediaUpload";
import { useMentions } from "@/features/messages/lib/useMentions";
import {
hasMentionClipboardHtml,
normalizeMentionClipboardHtml,
} from "@/features/messages/lib/normalizeMentionClipboard";
import { useRichTextEditor } from "@/features/messages/lib/useRichTextEditor";
import { ChannelAutocomplete } from "@/features/messages/ui/ChannelAutocomplete";
import { MentionAutocomplete } from "@/features/messages/ui/MentionAutocomplete";
import { ComposerAttachments } from "@/features/messages/ui/ComposerAttachments";
import {
MentionAutocomplete,
type MentionSuggestion,
} from "@/features/messages/ui/MentionAutocomplete";
import { MessageComposerToolbar } from "@/features/messages/ui/MessageComposerToolbar";
import { Button } from "@/shared/ui/button";
import { Textarea } from "@/shared/ui/textarea";
type ForumComposerProps = {
channelId?: string | null;
placeholder: string;
submitLabel: string;
disabled?: boolean;
isSending?: boolean;
onCancel?: () => void;
onSubmit: (content: string, mentionPubkeys: string[]) => void;
onSubmit: (
content: string,
mentionPubkeys: string[],
mediaTags?: string[][],
) => void;
/** When true, autocomplete renders below the input (for top-of-view composers). */
autocompleteBelow?: boolean;
};
export function ForumComposer({
channelId = null,
placeholder,
submitLabel,
disabled,
isSending,
onCancel,
onSubmit,
autocompleteBelow = false,
}: ForumComposerProps) {
const [value, setValue] = React.useState("");
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const [content, setContent] = React.useState("");
const contentRef = React.useRef(content);
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 mentions = useMentions(channelId);
const channelLinks = useChannelLinks();
const media = useMediaUpload();
function handleSubmit(event: React.FormEvent) {
event.preventDefault();
const trimmed = value.trim();
if (!trimmed) return;
const pubkeys = mentions.extractMentionPubkeys(trimmed);
onSubmit(trimmed, pubkeys);
setValue("");
mentions.clearMentions();
channelLinks.clearChannels();
}
const disabledRef = React.useRef(disabled);
const isSendingRef = React.useRef(isSending);
const onSubmitRef = React.useRef(onSubmit);
disabledRef.current = disabled;
isSendingRef.current = isSending;
onSubmitRef.current = onSubmit;
function handleChange(event: React.ChangeEvent<HTMLTextAreaElement>) {
const next = event.target.value;
setValue(next);
mentions.updateMentionQuery(next, event.target.selectionStart);
channelLinks.updateChannelQuery(next, event.target.selectionStart);
}
const isAutocompleteOpenRef = React.useRef(false);
isAutocompleteOpenRef.current =
mentions.isMentionOpen || channelLinks.isChannelOpen;
function handleKeyDown(event: React.KeyboardEvent<HTMLTextAreaElement>) {
const channelResult = channelLinks.handleChannelKeyDown(event);
if (channelResult.handled) {
if (channelResult.suggestion) {
const textarea = textareaRef.current;
const result = channelLinks.insertChannel(
channelResult.suggestion,
value,
textarea?.selectionEnd ?? value.length,
);
setValue(result.nextContent);
requestAnimationFrame(() => {
textarea?.setSelectionRange(result.nextCursor, result.nextCursor);
});
}
const submitMessageRef = React.useRef<() => void>(() => {});
const richText = useRichTextEditor({
placeholder,
editable: !disabled,
mentionNames: mentions.knownNames,
channelNames: channelLinks.knownChannelNames,
onSubmit: () => submitMessageRef.current(),
isAutocompleteOpen: isAutocompleteOpenRef,
onUpdate: ({ markdown, text }) => {
setContent(markdown);
contentRef.current = markdown;
const { cursor } = richText.getTextAndCursor();
mentions.updateMentionQuery(text, cursor);
channelLinks.updateChannelQuery(text, cursor);
},
});
// ── Mention / channel autocomplete insertion ────────────────────────
const applyMentionInsert = React.useCallback(
(suggestion: MentionSuggestion) => {
const { text, cursor } = richText.getTextAndCursor();
const result = mentions.insertMention(suggestion, text, cursor);
richText.setContentWithTrailingSpace(result.nextContent);
setContent(result.nextContent);
contentRef.current = result.nextContent;
},
[
mentions.insertMention,
richText.getTextAndCursor,
richText.setContentWithTrailingSpace,
],
);
const applyChannelInsert = React.useCallback(
(suggestion: ChannelSuggestion) => {
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,
richText.getTextAndCursor,
richText.setContentWithTrailingSpace,
],
);
// ── Emoji insertion ─────────────────────────────────────────────────
const insertEmoji = React.useCallback(
(emoji: string) => {
if (!richText.editor) return;
richText.editor.chain().focus().insertContent(emoji).run();
setIsEmojiPickerOpen(false);
mentions.clearMentions();
},
[richText.editor, mentions.clearMentions],
);
// ── @ mention picker (toolbar button) ───────────────────────────────
const openMentionPicker = React.useCallback(() => {
if (!richText.editor) return;
const { text, cursor } = richText.getTextAndCursor();
const beforeCursor = text.slice(0, cursor);
if (/(?:^|[\s])@[^\s]*$/.test(beforeCursor)) {
mentions.updateMentionQuery(text, cursor);
richText.focus();
return;
}
const { handled, suggestion } = mentions.handleMentionKeyDown(event);
if (handled) {
if (suggestion) {
const textarea = textareaRef.current;
const result = mentions.insertMention(
suggestion,
value,
textarea?.selectionEnd ?? value.length,
);
setValue(result.nextContent);
requestAnimationFrame(() => {
textarea?.setSelectionRange(result.nextCursor, result.nextCursor);
});
}
return;
}
const previousChar = text.slice(0, cursor).slice(-1);
const prefix =
cursor > 0 && previousChar && !/\s/.test(previousChar) ? " @" : "@";
richText.editor.chain().focus().insertContent(prefix).run();
setIsEmojiPickerOpen(false);
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 ──────────────────────────────────────────────────────────
const submitMessage = React.useCallback(() => {
const trimmed = contentRef.current.trim();
const currentPendingImeta = media.pendingImetaRef.current;
const hasMedia = currentPendingImeta.length > 0;
if (
event.key === "Enter" &&
(event.metaKey || event.ctrlKey) &&
!event.nativeEvent.isComposing
(!trimmed && !hasMedia) ||
disabledRef.current ||
isSendingRef.current
) {
event.preventDefault();
const trimmed = value.trim();
if (trimmed) {
const pubkeys = mentions.extractMentionPubkeys(trimmed);
onSubmit(trimmed, pubkeys);
setValue("");
mentions.clearMentions();
channelLinks.clearChannels();
}
return;
}
}
const pubkeys = mentions.extractMentionPubkeys(trimmed);
const mediaTags =
currentPendingImeta.length > 0
? currentPendingImeta.map((d) => [
"imeta",
`url ${d.url}`,
`m ${d.type}`,
`x ${d.sha256}`,
`size ${d.size}`,
...(d.dim ? [`dim ${d.dim}`] : []),
...(d.blurhash ? [`blurhash ${d.blurhash}`] : []),
...(d.thumb ? [`thumb ${d.thumb}`] : []),
...(d.duration != null ? [`duration ${d.duration}`] : []),
...(d.image ? [`image ${d.image}`] : []),
])
: undefined;
let finalContent = trimmed;
for (const d of currentPendingImeta) {
const isVideo = d.type.startsWith("video/");
finalContent += isVideo ? `\n![video](${d.url})` : `\n![image](${d.url})`;
}
setContent("");
contentRef.current = "";
richText.clearContent();
media.setPendingImeta([]);
mentions.clearMentions();
channelLinks.clearChannels();
setIsEmojiPickerOpen(false);
onSubmitRef.current(finalContent, pubkeys, mediaTags);
}, [
media.pendingImetaRef,
media.setPendingImeta,
mentions.extractMentionPubkeys,
mentions.clearMentions,
channelLinks.clearChannels,
richText.clearContent,
]);
submitMessageRef.current = submitMessage;
const handleSubmit = React.useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
submitMessage();
},
[submitMessage],
);
// ── Keyboard handling ───────────────────────────────────────────────
const handleEditorKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
const channelResult = channelLinks.handleChannelKeyDown(event);
if (channelResult.handled) {
if (channelResult.suggestion) {
applyChannelInsert(channelResult.suggestion);
}
return;
}
const { handled, suggestion } = mentions.handleMentionKeyDown(event);
if (handled) {
if (suggestion) {
applyMentionInsert(suggestion);
}
return;
}
},
[
channelLinks.handleChannelKeyDown,
applyChannelInsert,
mentions.handleMentionKeyDown,
applyMentionInsert,
],
);
// ── Media paste ─────────────────────────────────────────────────────
const uploadFileRef = React.useRef(media.uploadFile);
uploadFileRef.current = media.uploadFile;
React.useEffect(() => {
if (!richText.editor) return;
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) {
const file = mediaItem.getAsFile();
if (file) {
void uploadFileRef.current(file);
}
return true;
}
const html = event.clipboardData?.getData("text/html");
if (html && hasMentionClipboardHtml(html)) {
const cleanText = normalizeMentionClipboardHtml(html);
event.preventDefault();
_view.dispatch(
_view.state.tr.insertText(
cleanText,
_view.state.selection.from,
_view.state.selection.to,
),
);
return true;
}
return false;
},
},
});
}, [richText.editor]);
// ── Send button state ───────────────────────────────────────────────
const sendDisabled = React.useMemo(
() =>
disabled ||
(content.trim().length === 0 && media.pendingImeta.length === 0),
[disabled, content, media.pendingImeta.length],
);
const handlePaperclipClick = React.useCallback(() => {
void media.handlePaperclip();
}, [media.handlePaperclip]);
// ── Render ──────────────────────────────────────────────────────────
const autocompletePosition = autocompleteBelow ? "below" : "above";
return (
<form className="relative flex flex-col gap-2" onSubmit={handleSubmit}>
<form
className="relative rounded-2xl border border-input bg-card px-3 py-2 sm:px-4"
onDragOver={media.handleDragOver}
onDrop={(e) => {
void media.handleDrop(e);
}}
onSubmit={handleSubmit}
>
<ChannelAutocomplete
onSelect={(suggestion) => {
const textarea = textareaRef.current;
const result = channelLinks.insertChannel(
suggestion,
value,
textarea?.selectionEnd ?? value.length,
);
setValue(result.nextContent);
requestAnimationFrame(() => {
textarea?.setSelectionRange(result.nextCursor, result.nextCursor);
textarea?.focus();
});
}}
onSelect={applyChannelInsert}
position={autocompletePosition}
selectedIndex={channelLinks.channelSelectedIndex}
suggestions={
channelLinks.isChannelOpen ? channelLinks.channelSuggestions : []
}
/>
<MentionAutocomplete
onSelect={(suggestion) => {
const textarea = textareaRef.current;
const result = mentions.insertMention(
suggestion,
value,
textarea?.selectionEnd ?? value.length,
);
setValue(result.nextContent);
requestAnimationFrame(() => {
textarea?.setSelectionRange(result.nextCursor, result.nextCursor);
textarea?.focus();
});
}}
onSelect={applyMentionInsert}
position={autocompletePosition}
selectedIndex={mentions.mentionSelectedIndex}
suggestions={mentions.isMentionOpen ? mentions.suggestions : []}
/>
<Textarea
className="min-h-[100px] resize-none bg-background/80"
disabled={disabled || isSending}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
ref={textareaRef}
value={value}
/>
<div className="flex justify-end gap-2">
{onCancel ? (
<Button
disabled={isSending}
onClick={onCancel}
size="sm"
{media.uploadState.status === "error" ? (
<div className="mb-2 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
Upload failed: {media.uploadState.message}
<button
className="ml-2 underline"
onClick={() => media.setUploadState({ status: "idle" })}
type="button"
variant="ghost"
>
Cancel
</Button>
) : null}
<Button
disabled={disabled || isSending || value.trim().length === 0}
size="sm"
type="submit"
>
<Send className="mr-1.5 h-3.5 w-3.5" />
{isSending ? "Sending..." : submitLabel}
</Button>
Dismiss
</button>
</div>
) : null}
{(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>
)}
{/* 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 ?? false}
editor={richText.editor}
extraActions={
onCancel ? (
<Button
disabled={isSending}
onClick={onCancel}
size="sm"
type="button"
variant="ghost"
>
Cancel
</Button>
) : undefined
}
formattingDisabled={disabled ?? false}
isEmojiPickerOpen={isEmojiPickerOpen}
isFormattingOpen={isFormattingOpen}
isSending={isSending ?? false}
isUploading={media.isUploading}
onCaptureSelection={() => {}}
onEmojiPickerOpenChange={setIsEmojiPickerOpen}
onEmojiSelect={insertEmoji}
onFormattingToggle={handleFormattingToggle}
onOpenMentionPicker={openMentionPicker}
onPaperclip={handlePaperclipClick}
sendDisabled={sendDisabled}
/>
</form>
);
}
@@ -41,7 +41,11 @@ type ForumThreadPanelProps = {
currentPubkey?: string;
profiles?: UserProfileLookup;
onBack: () => void;
onReply: (content: string, mentionPubkeys: string[]) => void;
onReply: (
content: string,
mentionPubkeys: string[],
mediaTags?: string[][],
) => void;
onDeletePost?: (eventId: string) => void;
onDeleteReply?: (eventId: string) => void;
onTargetReached?: (eventId: string) => void;
@@ -366,7 +370,6 @@ export function ForumThreadPanel({
isSending={isSendingReply}
onSubmit={onReply}
placeholder="Reply to this post..."
submitLabel="Reply"
/>
</div>
</div>
+5 -4
View File
@@ -126,11 +126,12 @@ export function ForumView({
deleteReplyMutation.mutate({ eventId });
}}
channelId={channel.id}
onReply={(content, mentionPubkeys) => {
onReply={(content, mentionPubkeys, mediaTags) => {
createReplyMutation.mutate({
content,
parentEventId: selectedPostId,
mentionPubkeys,
mediaTags,
});
}}
onTargetReached={onTargetReached}
@@ -147,12 +148,13 @@ export function ForumView({
<div className="border-b border-border/60 p-4">
{isComposerOpen ? (
<ForumComposer
autocompleteBelow
channelId={channel.id}
isSending={createPostMutation.isPending}
onCancel={() => setIsComposerOpen(false)}
onSubmit={(content, mentionPubkeys) => {
onSubmit={(content, mentionPubkeys, mediaTags) => {
createPostMutation.mutate(
{ content, mentionPubkeys },
{ content, mentionPubkeys, mediaTags },
{
onSuccess: () => {
setIsComposerOpen(false);
@@ -161,7 +163,6 @@ export function ForumView({
);
}}
placeholder="Write your post..."
submitLabel="Post"
/>
) : (
<button
@@ -7,12 +7,14 @@ type ChannelAutocompleteProps = {
suggestions: ChannelSuggestion[];
selectedIndex: number;
onSelect: (suggestion: ChannelSuggestion) => void;
position?: "above" | "below";
};
export const ChannelAutocomplete = React.memo(function ChannelAutocomplete({
suggestions,
selectedIndex,
onSelect,
position = "above",
}: ChannelAutocompleteProps) {
const listRef = React.useRef<HTMLDivElement>(null);
@@ -28,7 +30,12 @@ export const ChannelAutocomplete = React.memo(function ChannelAutocomplete({
}
return (
<div className="absolute bottom-full left-0 right-0 z-50 mb-1 px-3 sm:px-4">
<div
className={cn(
"absolute left-0 right-0 z-50 px-3 sm:px-4",
position === "below" ? "top-full mt-1" : "bottom-full mb-1",
)}
>
<div
className="max-h-48 overflow-y-auto rounded-xl border bg-popover p-1 shadow-lg"
ref={listRef}
@@ -13,12 +13,14 @@ type MentionAutocompleteProps = {
suggestions: MentionSuggestion[];
selectedIndex: number;
onSelect: (suggestion: MentionSuggestion) => void;
position?: "above" | "below";
};
export const MentionAutocomplete = React.memo(function MentionAutocomplete({
suggestions,
selectedIndex,
onSelect,
position = "above",
}: MentionAutocompleteProps) {
const listRef = React.useRef<HTMLDivElement>(null);
@@ -34,7 +36,12 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
}
return (
<div className="absolute bottom-full left-0 right-0 z-50 mb-1 px-3 sm:px-4">
<div
className={cn(
"absolute left-0 right-0 z-50 px-3 sm:px-4",
position === "below" ? "top-full mt-1" : "bottom-full mb-1",
)}
>
<div
className="max-h-48 overflow-y-auto rounded-xl border bg-popover p-1 shadow-lg"
ref={listRef}
@@ -25,6 +25,7 @@ export const MessageComposerToolbar = React.memo(
function MessageComposerToolbar({
composerDisabled,
editor,
extraActions,
formattingDisabled,
isEmojiPickerOpen,
isFormattingOpen,
@@ -40,6 +41,7 @@ export const MessageComposerToolbar = React.memo(
}: {
composerDisabled: boolean;
editor: Editor | null;
extraActions?: React.ReactNode;
formattingDisabled: boolean;
isEmojiPickerOpen: boolean;
isFormattingOpen: boolean;
@@ -202,16 +204,19 @@ export const MessageComposerToolbar = React.memo(
</AnimatePresence>
</div>
<Button
className="gap-2"
data-testid="send-message"
disabled={sendDisabled || isSending}
title="Send (Enter)"
type="submit"
>
<SendHorizontal className="h-4 w-4" />
{isSending ? "Sending" : "Send"}
</Button>
<div className="flex items-center gap-2">
{extraActions}
<Button
className="gap-2"
data-testid="send-message"
disabled={sendDisabled || isSending}
title="Send (Enter)"
type="submit"
>
<SendHorizontal className="h-4 w-4" />
{isSending ? "Sending" : "Send"}
</Button>
</div>
</div>
);
},
@@ -1,4 +1,5 @@
import * as React from "react";
import { getVersion } from "@tauri-apps/api/app";
import { X } from "lucide-react";
import { cn } from "@/shared/lib/cn";
@@ -76,10 +77,14 @@ export function SettingsView({
section,
}: SettingsViewProps) {
const [isLoaded, setIsLoaded] = React.useState(false);
const [appVersion, setAppVersion] = React.useState<string | null>(null);
React.useEffect(() => {
const frameId = window.requestAnimationFrame(() => setIsLoaded(true));
return () => window.cancelAnimationFrame(frameId);
}, []);
React.useEffect(() => {
void getVersion().then(setAppVersion);
}, []);
React.useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
@@ -144,7 +149,7 @@ export function SettingsView({
<div className="grid min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)] overflow-hidden lg:grid-cols-[220px_minmax(0,1fr)] lg:grid-rows-1">
<aside
className={cn(
"border-b border-border/70 bg-muted/20 motion-safe:transition-all motion-safe:duration-200 motion-safe:ease-out lg:border-b-0 lg:border-r",
"flex flex-col border-b border-border/70 bg-muted/20 motion-safe:transition-all motion-safe:duration-200 motion-safe:ease-out lg:border-b-0 lg:border-r",
isLoaded
? "opacity-100 translate-x-0"
: "opacity-0 -translate-x-2",
@@ -152,7 +157,7 @@ export function SettingsView({
>
<nav
aria-label="Settings sections"
className="flex gap-1 overflow-x-auto px-3 py-3 lg:flex-col lg:overflow-y-auto lg:pt-1"
className="flex gap-1 overflow-x-auto px-3 py-3 lg:flex-1 lg:flex-col lg:overflow-y-auto lg:pt-1"
>
{settingsSections.map((entry) => (
<SettingsSectionButton
@@ -164,6 +169,11 @@ export function SettingsView({
/>
))}
</nav>
{appVersion ? (
<p className="hidden px-3 pb-3 text-xs text-muted-foreground/60 lg:block">
v{appVersion}
</p>
) : null}
</aside>
<section className="min-h-0 overflow-y-auto px-4 py-4 sm:px-6">