Skip to content
Open
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
214 changes: 125 additions & 89 deletions desktop/src/features/forum/ui/ForumComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import {
hasMentionClipboardHtml,
normalizeMentionClipboardHtml,
} from "@/features/messages/lib/normalizeMentionClipboard";
import { useRichTextEditor } from "@/features/messages/lib/useRichTextEditor";
import {
type LinkSelectionInfo,
useRichTextEditor,
} from "@/features/messages/lib/useRichTextEditor";
import { useLinkEditor } from "@/features/messages/lib/useLinkEditor";
import { DropZoneOverlay } from "@/features/messages/ui/ComposerAttachments";
import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete";
import { MessageComposerToolbar } from "@/features/messages/ui/MessageComposerToolbar";
Expand Down Expand Up @@ -77,13 +81,24 @@ export function ForumComposer({

const submitMessageRef = React.useRef<() => void>(() => {});

// Set after `useLinkEditor` exists; the editor's link-click handler
// delegates through this ref to break the hook ordering cycle.
const onEditLinkRef = React.useRef<
((info: LinkSelectionInfo) => void) | null
>(null);
const onLinkSelectionChangeRef = React.useRef<
((info: LinkSelectionInfo | null) => void) | null
>(null);

const richText = useRichTextEditor({
placeholder,
editable: !disabled,
mentionNames: mentions.knownNames,
channelNames: channelLinks.knownChannelNames,
onSubmit: () => submitMessageRef.current(),
isAutocompleteOpen: isAutocompleteOpenRef,
onEditLink: (info) => onEditLinkRef.current?.(info),
onLinkSelectionChange: (info) => onLinkSelectionChangeRef.current?.(info),
onUpdate: ({ markdown, text }) => {
setContent(markdown);
contentRef.current = markdown;
Expand All @@ -94,6 +109,10 @@ export function ForumComposer({
},
});

const linkEditor = useLinkEditor(richText);
onEditLinkRef.current = linkEditor.openFromClick;
onLinkSelectionChangeRef.current = linkEditor.showFromCursor;

// ── Mention / channel autocomplete insertion ────────────────────────
// Native ProseMirror transactions — no markdown round-trip.
const applyMentionInsert = React.useCallback(
Expand Down Expand Up @@ -263,12 +282,22 @@ export function ForumComposer({
}
return;
}

if (event.key === "Tab" && !event.shiftKey && linkEditor.isCardOpen) {
event.preventDefault();
if (!linkEditor.focusCardFirstControl()) {
requestAnimationFrame(linkEditor.focusCardFirstControl);
}
return;
}
},
[
channelLinks.handleChannelKeyDown,
applyChannelInsert,
mentions.handleMentionKeyDown,
applyMentionInsert,
linkEditor.isCardOpen,
linkEditor.focusCardFirstControl,
],
);

Expand Down Expand Up @@ -379,97 +408,104 @@ export function ForumComposer({
}, [compact, isCompactExpanded, richText.focus]);
const autocompletePosition = autocompleteBelow ? "below" : "above";
return (
<form
className={cn(
"relative rounded-2xl border border-input bg-card px-3 py-2 sm:px-4",
className,
)}
onBlurCapture={handleFormBlur}
onDragEnter={(event) => {
expandCompactComposer();
media.handleDragEnter(event);
}}
onDragLeave={media.handleDragLeave}
onDragOver={media.handleDragOver}
onDrop={(e) => {
void media.handleDrop(e);
}}
onFocusCapture={expandCompactComposer}
onSubmit={handleSubmit}
>
{media.isDragOver && <DropZoneOverlay />}
{isCompactLayout ? (
<ForumComposerCompactLayout
editor={richText.editor}
header={header}
isSending={isSending}
onEditorKeyDown={handleEditorKeyDown}
sendDisabled={sendDisabled}
/>
) : (
<>
{header ? (
<>
<form
className={cn(
"relative rounded-2xl border border-input bg-card px-3 py-2 sm:px-4",
className,
)}
onBlurCapture={handleFormBlur}
onDragEnter={(event) => {
expandCompactComposer();
media.handleDragEnter(event);
}}
onDragLeave={media.handleDragLeave}
onDragOver={media.handleDragOver}
onDrop={(e) => {
void media.handleDrop(e);
}}
onFocusCapture={expandCompactComposer}
onSubmit={handleSubmit}
>
{media.isDragOver && <DropZoneOverlay />}
{isCompactLayout ? (
<ForumComposerCompactLayout
editor={richText.editor}
header={header}
isSending={isSending}
onEditorKeyDown={handleEditorKeyDown}
sendDisabled={sendDisabled}
/>
) : (
<>
{header ? (
<div
className={cn("mb-2", compact && "flex min-h-10 items-center")}
>
{header}
</div>
) : null}
<ForumComposerAutocompletes
channelSelectedIndex={channelLinks.channelSelectedIndex}
channelSuggestions={
channelLinks.isChannelOpen
? channelLinks.channelSuggestions
: []
}
mentionSelectedIndex={mentions.mentionSelectedIndex}
mentionSuggestions={
mentions.isMentionOpen ? mentions.suggestions : []
}
onChannelSelect={applyChannelInsert}
onMentionSelect={applyMentionInsert}
position={autocompletePosition}
/>

<ForumComposerMediaStatus media={media} />

{/* biome-ignore lint/a11y/noStaticElementInteractions: keydown handler bridges Tiptap editor to autocomplete and submit */}
<div
className={cn("mb-2", compact && "flex min-h-10 items-center")}
className="rich-text-composer max-h-32 overflow-y-auto"
onKeyDown={handleEditorKeyDown}
>
{header}
<EditorContent editor={richText.editor} />
</div>
) : null}
<ForumComposerAutocompletes
channelSelectedIndex={channelLinks.channelSelectedIndex}
channelSuggestions={
channelLinks.isChannelOpen ? channelLinks.channelSuggestions : []
}
mentionSelectedIndex={mentions.mentionSelectedIndex}
mentionSuggestions={
mentions.isMentionOpen ? mentions.suggestions : []
}
onChannelSelect={applyChannelInsert}
onMentionSelect={applyMentionInsert}
position={autocompletePosition}
/>

<ForumComposerMediaStatus media={media} />

{/* 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={handleToolbarMouseDown}
onEmojiPickerOpenChange={setIsEmojiPickerOpen}
onEmojiSelect={insertEmoji}
onFormattingToggle={handleFormattingToggle}
onOpenMentionPicker={openMentionPicker}
onPaperclip={handlePaperclipClick}
sendDisabled={sendDisabled}
/>
</>
)}
</form>
<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={handleToolbarMouseDown}
onEmojiPickerOpenChange={setIsEmojiPickerOpen}
onEmojiSelect={insertEmoji}
onFormattingToggle={handleFormattingToggle}
onLinkButton={linkEditor.openFromToolbar}
onOpenMentionPicker={openMentionPicker}
onPaperclip={handlePaperclipClick}
sendDisabled={sendDisabled}
/>
</>
)}
</form>
{linkEditor.card}
{linkEditor.dialog}
</>
);
}
52 changes: 52 additions & 0 deletions desktop/src/features/messages/lib/linkEditorFocus.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import assert from "node:assert/strict";
import test from "node:test";

import { getLinkEditorInitialFocus } from "./linkEditorFocus.ts";

test("focuses URL when adding a link to selected text", () => {
assert.equal(
getLinkEditorInitialFocus({
href: "",
text: "selected text",
from: 3,
to: 16,
}),
"url",
);
});

test("focuses display text when editing an existing link", () => {
assert.equal(
getLinkEditorInitialFocus({
href: "https://example.com",
text: "existing link",
from: 3,
to: 16,
}),
"text",
);
});

test("focuses display text when inserting a link with no selected text", () => {
assert.equal(
getLinkEditorInitialFocus({
href: "",
text: "",
from: 3,
to: 3,
}),
"text",
);
});

test("focuses display text when the selected text is whitespace only", () => {
assert.equal(
getLinkEditorInitialFocus({
href: "",
text: " ",
from: 3,
to: 6,
}),
"text",
);
});
14 changes: 14 additions & 0 deletions desktop/src/features/messages/lib/linkEditorFocus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { LinkSelectionInfo } from "./useRichTextEditor";

export type LinkEditorInitialFocus = "text" | "url";

export function getLinkEditorInitialFocus(
info: LinkSelectionInfo,
): LinkEditorInitialFocus {
const isSelectedTextLinkInsert =
info.href.length === 0 &&
info.text.trim().length > 0 &&
info.from !== info.to;

return isSelectedTextLinkInsert ? "url" : "text";
}
Loading