Skip to content
Merged
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
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export default defineConfig({
"**/composer-tooltip-dismiss.spec.ts",
"**/mentions.spec.ts",
"**/mention-spacing.spec.ts",
"**/mention-clipboard.spec.ts",
"**/cloud-provenance.spec.ts",
"**/team-mentions.spec.ts",
"**/persistent-agent-audience.spec.ts",
Expand Down
30 changes: 20 additions & 10 deletions desktop/src/features/forum/ui/ForumComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,8 @@ import { useComposerFocusOwnership } from "@/features/messages/lib/useComposerFo
import { useMediaUpload } from "@/features/messages/lib/useMediaUpload";
import { isMentionCodeContext } from "@/features/messages/lib/mentionCodeContext";
import { useMentions } from "@/features/messages/lib/useMentions";
import {
hasMentionClipboardHtml,
normalizeMentionClipboardHtml,
} from "@/features/messages/lib/normalizeMentionClipboard";
import { hasMentionClipboardHtml } from "@/features/messages/lib/normalizeMentionClipboard";
import { handleMentionClipboardPaste } from "@/features/messages/lib/mentionClipboardPaste";
import {
type LinkSelectionInfo,
useRichTextEditor,
Expand Down Expand Up @@ -121,6 +119,7 @@ export function ForumComposer({
mentionNames: mentions.knownNames,
channelNames: channelLinks.knownChannelNames,
messageLinkChannels: channelLinks.channels,
getMentionIdentities: mentions.getMentionIdentities,
onSubmit: () => submitMessageRef.current(),
isAutocompleteOpen: isAutocompleteOpenRef,
onEditLink: (info) => onEditLinkRef.current?.(info),
Expand Down Expand Up @@ -242,6 +241,9 @@ export function ForumComposer({
channelLinks.clearChannels();
setIsEmojiPickerOpen(false);
try {
// A pasted mention's identity check can still be in flight; extracting
// first would publish the label with no `p` tag. Bounded internally.
await mentions.settlePendingMentionBindings();
const pubkeys = await mentions.revalidateMentionPubkeys(
mentions.extractMentionPubkeys(trimmed),
);
Expand Down Expand Up @@ -292,6 +294,7 @@ export function ForumComposer({
mentions.cancelMentionAutocomplete,
mentions.extractMentionPubkeys,
mentions.revalidateMentionPubkeys,
mentions.settlePendingMentionBindings,
mentions.clearMentions,
channelLinks.clearChannels,
richText.clearContent,
Expand Down Expand Up @@ -359,6 +362,10 @@ export function ForumComposer({
// ── Media paste ─────────────────────────────────────────────────────
const uploadFileRef = React.useRef(media.uploadFile);
uploadFileRef.current = media.uploadFile;
const bindMentionIdentitiesRef = React.useRef(
mentions.bindPastedMentionIdentities,
);
bindMentionIdentitiesRef.current = mentions.bindPastedMentionIdentities;

React.useEffect(() => {
if (!richText.editor) return;
Expand All @@ -379,12 +386,15 @@ export function ForumComposer({
return true;
}

const html = event.clipboardData?.getData("text/html");
if (html && hasMentionClipboardHtml(html)) {
const cleanHtml = normalizeMentionClipboardHtml(html);
event.preventDefault();
_view.pasteHTML(cleanHtml);
return true;
const clipboardData = event.clipboardData;
const html = clipboardData?.getData("text/html");
if (clipboardData && html && hasMentionClipboardHtml(html)) {
return handleMentionClipboardPaste({
bindMentionIdentities: bindMentionIdentitiesRef.current,
clipboardData,
preventDefault: () => event.preventDefault(),
view: _view,
});
}

return false;
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/features/forum/ui/ForumThreadPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ArrowLeft, MessageSquare } from "lucide-react";
import * as React from "react";

import { handleTimelineMentionCopy } from "@/features/messages/lib/timelineMentionCopy";
import {
resolveUserLabel,
type UserProfileLookup,
Expand Down Expand Up @@ -238,6 +239,7 @@ export function ForumThreadPanel({
<div
className="flex-1 overflow-y-auto"
data-scroll-restoration-id={`forum-thread:${channelId}`}
onCopy={handleTimelineMentionCopy}
ref={scrollRef}
>
<div
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/features/forum/ui/ForumView.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { MessageSquareText } from "lucide-react";
import * as React from "react";

import { handleTimelineMentionCopy } from "@/features/messages/lib/timelineMentionCopy";
import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks";
import { mergeCurrentProfileIntoLookup } from "@/features/profile/lib/identity";
import { getMentionTagPubkey } from "@/shared/lib/resolveMentionNames";
Expand Down Expand Up @@ -207,6 +208,7 @@ export function ForumView({
<div
className="flex-1 overflow-y-auto"
data-scroll-restoration-id={`forum-list:${channel.id}`}
onCopy={handleTimelineMentionCopy}
ref={postsScrollRef}
>
{postsQuery.isLoading ? (
Expand Down
7 changes: 7 additions & 0 deletions desktop/src/features/home/ui/InboxDetailPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
hasRenderedVideoAttachment,
} from "@/features/messages/lib/videoReviewContext";
import { getThreadReference } from "@/features/messages/lib/threading";
import { handleTimelineMentionCopy } from "@/features/messages/lib/timelineMentionCopy";
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
import { useAnchoredScroll } from "@/features/messages/ui/useAnchoredScroll";
import { useComposerHeightPadding } from "@/features/messages/ui/useComposerHeightPadding";
Expand Down Expand Up @@ -694,6 +695,11 @@ function InboxMessageDetailPane({
aria-busy={isThreadContextLoading}
className="-mt-13 min-h-0 flex-1 overflow-y-auto overscroll-contain pb-32 pt-13 [overflow-anchor:none]"
data-testid="home-inbox-detail-scroll"
// Selection copy across a rendered mention chip: restores the sigil
// and the identity sidecar the browser's default copy would drop.
// Covers only the messages — the composer is a sibling overlay, so
// its own copy handler is untouched.
onCopy={handleTimelineMentionCopy}
onScroll={onScroll}
ref={scrollContainerRef}
>
Expand Down Expand Up @@ -768,6 +774,7 @@ function InboxMessageDetailPane({
onEdit={canEditMessage ? handleSelectEditTarget : undefined}
onSelectReplyTarget={handleSelectReplyTarget}
onToggleReaction={onToggleReaction}
profiles={profiles}
showUnreadBoundary={hasUnreadBoundary}
videoReviewCommentRootId={videoReviewPresentation.commentRootIdsByMessageId.get(
message.id,
Expand Down
5 changes: 5 additions & 0 deletions desktop/src/features/home/ui/InboxMessageRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { MessageReactions } from "@/features/messages/ui/MessageReactions";
import { UnreadDivider } from "@/features/messages/ui/UnreadDivider";
import { useReactionHandler } from "@/features/messages/ui/useReactionHandler";
import { useMessageEmoji } from "@/features/messages/lib/useMessageEmoji";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { cn } from "@/shared/lib/cn";
import { normalizePubkey } from "@/shared/lib/pubkey";
Expand Down Expand Up @@ -44,6 +45,8 @@ type InboxMessageRowProps = {
emoji: string,
remove: boolean,
) => Promise<void>;
/** Resolves the mention identities carried by "Copy message". */
profiles?: UserProfileLookup;
showUnreadBoundary?: boolean;
videoReviewCommentRootId?: string;
videoReviewContext?: VideoReviewContext;
Expand All @@ -61,6 +64,7 @@ export function InboxMessageRow({
onEdit,
onSelectReplyTarget,
onToggleReaction,
profiles,
showUnreadBoundary = false,
videoReviewCommentRootId,
videoReviewContext,
Expand Down Expand Up @@ -170,6 +174,7 @@ export function InboxMessageRow({
onReply={
canReply ? () => onSelectReplyTarget(message) : undefined
}
profiles={profiles}
reactionErrorMessage={reactionErrorMessage}
reactions={reactions}
/>
Expand Down
52 changes: 52 additions & 0 deletions desktop/src/features/messages/lib/composerMentionCopy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { EditorView } from "@tiptap/pm/view";

import {
buildMentionClipboardHtml,
type MentionIdentity,
} from "./mentionClipboard";

/**
* Copy / cut out of the composer.
*
* A composer mention is plain `@Name ` text decorated from a known-names list;
* the pubkey lives outside the document. A default copy therefore moves the
* words but not the identity, so moving a draft between channels silently
* re-resolves (or drops) who was tagged. Writing the identity sidecar here
* keeps the exact pubkey attached to the draft.
*/
export function handleComposerMentionCopy({
event,
identities,
isCut,
view,
}: {
event: ClipboardEvent;
identities: readonly MentionIdentity[];
isCut: boolean;
view: EditorView;
}): boolean {
const clipboardData = event.clipboardData;
if (!clipboardData || view.state.selection.empty) return false;

const slice = view.state.selection.content();
// The same serializer ProseMirror would have used, so the plain flavor is
// byte-identical to a default copy (Markdown syntax included).
const serializeText = view.someProp("clipboardTextSerializer");
const text = serializeText
? serializeText(slice, view)
: slice.content.textBetween(0, slice.content.size, "\n\n");
if (!text) return false;

const html = buildMentionClipboardHtml({ identities, text });
// No known mention in the selection — leave the copy on its default path
// rather than replacing ProseMirror's richer HTML flavor for no gain.
if (!html) return false;

event.preventDefault();
clipboardData.setData("text/plain", text);
clipboardData.setData("text/html", html);
if (isCut) {
view.dispatch(view.state.tr.deleteSelection().scrollIntoView());
}
return true;
}
107 changes: 107 additions & 0 deletions desktop/src/features/messages/lib/macEmacsTextShortcuts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { Extension, type KeyboardShortcutCommand } from "@tiptap/core";
import type { ResolvedPos } from "@tiptap/pm/model";
import { Selection } from "@tiptap/pm/state";

import { isMacPlatform } from "@/shared/lib/platform";

/**
* Bounds of the hard-break-delimited "line" containing `$from`.
*
* Chat composers use hard breaks for continuation lines, so a ProseMirror
* block spans several visual lines. Emacs-style movement has to respect the
* visual line, not the block.
*/
export function hardBreakLineBounds($from: ResolvedPos) {
const parentStart = $from.start();
let start = parentStart;
let end = parentStart + $from.parent.content.size;

$from.parent.forEach((node, offset) => {
if (node.type.name !== "hardBreak") return;
const breakPosition = parentStart + offset;
if (breakPosition < $from.pos) {
start = breakPosition + node.nodeSize;
} else if (breakPosition >= $from.pos && end > breakPosition) {
end = breakPosition;
}
});

return { end, start };
}

/**
* macOS text fields traditionally support a small set of Emacs-style Control
* shortcuts. Keep movement and kill-line scoped to the current
* hard-break-delimited line rather than the whole ProseMirror block.
*/
export const MacEmacsTextShortcuts = Extension.create({
name: "macEmacsTextShortcuts",
addKeyboardShortcuts() {
const shortcuts: Record<string, KeyboardShortcutCommand> = {};
if (!isMacPlatform()) {
return shortcuts;
}

return {
"Ctrl-a": ({ editor: ed }) => {
const { $from } = ed.state.selection;
if (!$from.parent.inlineContent) return false;
return ed.commands.setTextSelection(hardBreakLineBounds($from).start);
},
"Ctrl-e": ({ editor: ed }) => {
const { $from } = ed.state.selection;
if (!$from.parent.inlineContent) return false;
return ed.commands.setTextSelection(hardBreakLineBounds($from).end);
},
"Ctrl-b": ({ editor: ed }) => {
const { empty, from } = ed.state.selection;
if (!empty || from <= 0) return false;
return ed.commands.setTextSelection(from - 1);
},
"Ctrl-f": ({ editor: ed }) => {
const { empty, from } = ed.state.selection;
if (!empty || from >= ed.state.doc.content.size) return false;
return ed.commands.setTextSelection(from + 1);
},
"Ctrl-k": ({ editor: ed }) => {
const { state, view } = ed;
const { $from, empty, from, to } = state.selection;

if (!empty) {
return ed.commands.deleteSelection();
}

if ($from.parent.inlineContent) {
const lineEnd = hardBreakLineBounds($from).end;
if (from < lineEnd) {
return ed.commands.deleteRange({ from, to: lineEnd });
}

const nodeAfter = $from.nodeAfter;
if (nodeAfter?.type.name === "hardBreak") {
return ed.commands.deleteRange({
from,
to: from + nodeAfter.nodeSize,
});
}
}

const blockEnd = $from.end();
if (from < blockEnd) {
return ed.commands.deleteRange({ from, to: blockEnd });
}

const nextSelection = Selection.findFrom(
state.doc.resolve(to),
1,
true,
);
if (!nextSelection) return false;

const transaction = state.tr.delete(to, nextSelection.from);
view.dispatch(transaction.scrollIntoView());
return true;
},
};
},
});
Loading
Loading