feat: @mentions, highlighting, and user profile popovers (#45)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Wes
2026-03-12 16:41:20 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent b6fde6fec4
commit 039d4eb2e5
16 changed files with 694 additions and 24 deletions
+1
View File
@@ -21,6 +21,7 @@
"dependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
+1
View File
@@ -23,6 +23,7 @@ export default defineConfig({
"**/channels.spec.ts",
"**/channel-browser.spec.ts",
"**/messaging.spec.ts",
"**/mentions.spec.ts",
],
use: {
...devices["Desktop Chrome"],
+39
View File
@@ -14,6 +14,9 @@ importers:
'@radix-ui/react-dropdown-menu':
specifier: ^2.1.16
version: 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-popover':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-separator':
specifier: ^1.1.8
version: 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -603,6 +606,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-popover@1.1.15':
resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-popper@1.2.8':
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
peerDependencies:
@@ -2358,6 +2374,29 @@ snapshots:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
aria-hidden: 1.2.6
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@floating-ui/react-dom': 2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+6 -2
View File
@@ -462,6 +462,7 @@ export function AppShell() {
}
/>
<MessageComposer
channelId={activeChannel?.id ?? null}
channelName={activeChannel?.name ?? "channel"}
disabled={
!activeChannel ||
@@ -472,8 +473,11 @@ export function AppShell() {
}
isSending={sendMessageMutation.isPending}
key={activeChannel?.id ?? "no-channel"}
onSend={async (content) => {
await sendMessageMutation.mutateAsync(content);
onSend={async (content, mentionPubkeys) => {
await sendMessageMutation.mutateAsync({
content,
mentionPubkeys,
});
}}
placeholder={
activeChannel?.archivedAt
+14 -7
View File
@@ -49,13 +49,19 @@ function createOptimisticMessage(
channelId: string,
content: string,
identity: Identity,
mentionPubkeys: string[] = [],
): RelayEvent {
const tags: string[][] = [["h", channelId]];
for (const pubkey of mentionPubkeys) {
tags.push(["p", pubkey]);
}
return {
id: `optimistic-${crypto.randomUUID()}`,
pubkey: identity.pubkey,
created_at: Math.floor(Date.now() / 1_000),
kind: 4_0001,
tags: [["h", channelId]],
tags,
content,
sig: "",
pending: true,
@@ -144,17 +150,17 @@ export function useSendMessageMutation(
return useMutation<
RelayEvent,
Error,
string,
{ content: string; mentionPubkeys?: string[] },
MessageQueryContext | undefined
>({
mutationFn: async (content) => {
mutationFn: async ({ content, mentionPubkeys }) => {
if (!channel || channel.channelType === "forum") {
throw new Error("This channel does not support message sending yet.");
}
return relayClient.sendMessage(channel.id, content);
return relayClient.sendMessage(channel.id, content, mentionPubkeys ?? []);
},
onMutate: async (content) => {
onMutate: async ({ content, mentionPubkeys }) => {
if (!channel || !identity || channel.channelType === "forum") {
return undefined;
}
@@ -168,6 +174,7 @@ export function useSendMessageMutation(
channel.id,
content.trim(),
identity,
mentionPubkeys ?? [],
);
queryClient.setQueryData<RelayEvent[]>(
@@ -181,14 +188,14 @@ export function useSendMessageMutation(
queryKey,
};
},
onError: (_error, _content, context) => {
onError: (_error, _variables, context) => {
if (!context) {
return;
}
queryClient.setQueryData(context.queryKey, context.previousMessages);
},
onSuccess: (message, _content, context) => {
onSuccess: (message, _variables, context) => {
if (channel) {
updateChannelLastMessageAt(
queryClient,
@@ -44,6 +44,7 @@ export function formatTimelineMessages(
): TimelineMessage[] {
return events.map((event) => ({
id: event.id,
pubkey: event.pubkey,
author: formatMessageAuthor(event, channel, currentPubkey, profiles),
avatarUrl:
currentPubkey === event.pubkey ? (currentUserAvatarUrl ?? null) : null,
+1
View File
@@ -1,5 +1,6 @@
export type TimelineMessage = {
id: string;
pubkey?: string;
author: string;
avatarUrl?: string | null;
role?: string;
@@ -0,0 +1,70 @@
import * as React from "react";
import { cn } from "@/shared/lib/cn";
export type MentionSuggestion = {
pubkey: string;
displayName: string;
role?: string | null;
};
type MentionAutocompleteProps = {
suggestions: MentionSuggestion[];
selectedIndex: number;
onSelect: (suggestion: MentionSuggestion) => void;
};
export function MentionAutocomplete({
suggestions,
selectedIndex,
onSelect,
}: MentionAutocompleteProps) {
const listRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
const activeItem = listRef.current?.children[selectedIndex] as
| HTMLElement
| undefined;
activeItem?.scrollIntoView({ block: "nearest" });
}, [selectedIndex]);
if (suggestions.length === 0) {
return null;
}
return (
<div className="absolute bottom-full left-0 right-0 z-50 mb-1 px-3 sm:px-4">
<div
className="max-h-48 overflow-y-auto rounded-xl border bg-popover p-1 shadow-lg"
ref={listRef}
>
{suggestions.map((suggestion, index) => (
<button
className={cn(
"flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-1.5 text-left text-sm",
index === selectedIndex
? "bg-accent text-accent-foreground"
: "text-popover-foreground hover:bg-accent/50",
)}
key={suggestion.pubkey}
onMouseDown={(event) => {
event.preventDefault();
onSelect(suggestion);
}}
tabIndex={-1}
type="button"
>
<span className="truncate font-medium">
{suggestion.displayName}
</span>
{suggestion.role ? (
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
{suggestion.role}
</span>
) : null}
</button>
))}
</div>
</div>
);
}
@@ -1,20 +1,47 @@
import { Paperclip, SendHorizontal, SmilePlus } from "lucide-react";
import * as React from "react";
import { useChannelMembersQuery } from "@/features/channels/hooks";
import { Button } from "@/shared/ui/button";
import { Textarea } from "@/shared/ui/textarea";
import {
MentionAutocomplete,
type MentionSuggestion,
} from "./MentionAutocomplete";
type MessageComposerProps = {
channelId?: string | null;
channelName: string;
disabled?: boolean;
isSending?: boolean;
onSend: (content: string) => Promise<void>;
onSend: (content: string, mentionPubkeys: string[]) => Promise<void>;
placeholder?: string;
};
const MAX_TEXTAREA_ROWS = 4;
/**
* Detect an @mention query at the cursor position.
* Returns the query string (after @) or null if no active mention trigger.
*/
function detectMentionQuery(
value: string,
cursorPosition: number,
): { query: string; startIndex: number } | null {
const beforeCursor = value.slice(0, cursorPosition);
// Find the last @ that is preceded by whitespace or is at the start
const match = beforeCursor.match(/(?:^|[\s])@([^\s]*)$/);
if (!match) {
return null;
}
const query = match[1];
const startIndex = beforeCursor.length - query.length - 1; // -1 for @
return { query, startIndex };
}
export function MessageComposer({
channelId = null,
channelName,
disabled = false,
isSending = false,
@@ -25,20 +52,88 @@ export function MessageComposer({
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const pendingSelectionRef = React.useRef<number | null>(null);
// Mention state
const [mentionQuery, setMentionQuery] = React.useState<string | null>(null);
const [mentionStartIndex, setMentionStartIndex] = React.useState(0);
const [mentionSelectedIndex, setMentionSelectedIndex] = React.useState(0);
const mentionMapRef = React.useRef<Map<string, string>>(new Map());
const membersQuery = useChannelMembersQuery(channelId);
const members = membersQuery.data ?? [];
const suggestions = React.useMemo<MentionSuggestion[]>(() => {
if (mentionQuery === null) {
return [];
}
const lowerQuery = mentionQuery.toLowerCase();
return members
.filter((member) =>
member.displayName?.toLowerCase().includes(lowerQuery),
)
.slice(0, 8)
.map((member) => ({
pubkey: member.pubkey,
displayName: member.displayName ?? member.pubkey.slice(0, 8),
role: member.role === "admin" ? "admin" : null,
}));
}, [members, mentionQuery]);
const isMentionOpen = mentionQuery !== null && suggestions.length > 0;
const insertMention = React.useCallback(
(suggestion: MentionSuggestion) => {
const textarea = textareaRef.current;
if (!textarea) {
return;
}
const displayName = suggestion.displayName;
const before = content.slice(0, mentionStartIndex);
const after = content.slice(textarea.selectionEnd);
const inserted = `@${displayName} `;
const nextContent = `${before}${inserted}${after}`;
const nextCursor = before.length + inserted.length;
mentionMapRef.current.set(displayName, suggestion.pubkey);
pendingSelectionRef.current = nextCursor;
setContent(nextContent);
setMentionQuery(null);
setMentionSelectedIndex(0);
},
[content, mentionStartIndex],
);
const extractMentionPubkeys = React.useCallback((text: string): string[] => {
const pubkeys: string[] = [];
const mentionMap = mentionMapRef.current;
for (const [displayName, pubkey] of mentionMap) {
if (text.includes(`@${displayName}`)) {
pubkeys.push(pubkey);
}
}
return [...new Set(pubkeys)];
}, []);
const submitMessage = React.useCallback(async () => {
const trimmed = content.trim();
if (!trimmed || disabled || isSending) {
return;
}
const pubkeys = extractMentionPubkeys(trimmed);
setContent("");
mentionMapRef.current.clear();
setMentionQuery(null);
try {
await onSend(trimmed);
await onSend(trimmed, pubkeys);
} catch {
setContent(trimmed);
}
}, [content, disabled, isSending, onSend]);
}, [content, disabled, isSending, onSend, extractMentionPubkeys]);
const handleSubmit = React.useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
@@ -48,8 +143,64 @@ export function MessageComposer({
[submitMessage],
);
const handleChange = React.useCallback(
(event: React.ChangeEvent<HTMLTextAreaElement>) => {
const nextContent = event.target.value;
const cursorPos = event.target.selectionStart;
setContent(nextContent);
const mention = detectMentionQuery(nextContent, cursorPos);
if (mention) {
setMentionQuery(mention.query);
setMentionStartIndex(mention.startIndex);
setMentionSelectedIndex(0);
} else {
setMentionQuery(null);
}
},
[],
);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Handle mention autocomplete keyboard navigation
if (isMentionOpen) {
if (event.key === "ArrowDown") {
event.preventDefault();
setMentionSelectedIndex((current) =>
current < suggestions.length - 1 ? current + 1 : 0,
);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setMentionSelectedIndex((current) =>
current > 0 ? current - 1 : suggestions.length - 1,
);
return;
}
if (
event.key === "Tab" ||
(event.key === "Enter" &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
!event.shiftKey)
) {
event.preventDefault();
insertMention(suggestions[mentionSelectedIndex]);
return;
}
if (event.key === "Escape") {
event.preventDefault();
setMentionQuery(null);
return;
}
}
if (event.key !== "Enter" || event.nativeEvent.isComposing) {
return;
}
@@ -72,7 +223,13 @@ export function MessageComposer({
event.preventDefault();
void submitMessage();
},
[submitMessage],
[
isMentionOpen,
suggestions,
mentionSelectedIndex,
insertMention,
submitMessage,
],
);
React.useLayoutEffect(() => {
@@ -105,18 +262,24 @@ export function MessageComposer({
<footer className="border-t border-border/80 bg-background p-4">
<div className="mx-auto flex w-full max-w-4xl flex-col gap-3">
<form
className="rounded-2xl border border-input bg-card px-3 py-4 shadow-sm sm:px-4"
className="relative rounded-2xl border border-input bg-card px-3 py-4 shadow-sm sm:px-4"
data-testid="message-composer"
onSubmit={(event) => {
handleSubmit(event);
}}
>
<MentionAutocomplete
onSelect={insertMention}
selectedIndex={mentionSelectedIndex}
suggestions={isMentionOpen ? suggestions : []}
/>
<Textarea
aria-label="Message channel"
className="min-h-0 resize-none overflow-y-hidden border-0 bg-transparent px-0 py-0 text-sm leading-6 shadow-none focus-visible:ring-0"
data-testid="message-input"
disabled={disabled}
onChange={(event) => setContent(event.target.value)}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={placeholder ?? `Message #${channelName}`}
ref={textareaRef}
@@ -2,6 +2,7 @@ import { ArrowDown } from "lucide-react";
import * as React from "react";
import type { TimelineMessage } from "@/features/messages/types";
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { KIND_STREAM_MESSAGE_DIFF } from "@/shared/constants/kinds";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
@@ -75,7 +76,39 @@ function MessageRow({ message }: { message: TimelineMessage }) {
data-message-id={message.id}
data-testid="message-row"
>
{message.avatarUrl && !hasAvatarError ? (
{message.pubkey ? (
<UserProfilePopover pubkey={message.pubkey}>
<button
className="shrink-0 rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
type="button"
>
{message.avatarUrl && !hasAvatarError ? (
<img
alt={`${message.author} avatar`}
className="h-9 w-9 rounded-xl object-cover shadow-sm"
data-testid="message-avatar-image"
onError={() => {
setHasAvatarError(true);
}}
referrerPolicy="no-referrer"
src={message.avatarUrl}
/>
) : (
<div
className={cn(
"flex h-9 w-9 items-center justify-center rounded-xl text-xs font-semibold shadow-sm",
message.accent
? "bg-primary text-primary-foreground"
: "bg-secondary text-secondary-foreground",
)}
data-testid="message-avatar-fallback"
>
{initials}
</div>
)}
</button>
</UserProfilePopover>
) : message.avatarUrl && !hasAvatarError ? (
<img
alt={`${message.author} avatar`}
className="h-9 w-9 shrink-0 rounded-xl object-cover shadow-sm"
@@ -102,9 +135,20 @@ function MessageRow({ message }: { message: TimelineMessage }) {
<div className="min-w-0 flex-1 space-y-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="truncate text-sm font-semibold tracking-tight">
{message.author}
</h3>
{message.pubkey ? (
<UserProfilePopover pubkey={message.pubkey}>
<button
className="truncate text-sm font-semibold tracking-tight hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded"
type="button"
>
{message.author}
</button>
</UserProfilePopover>
) : (
<h3 className="truncate text-sm font-semibold tracking-tight">
{message.author}
</h3>
)}
{message.role ? (
<p className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
{message.role}
@@ -0,0 +1,82 @@
import * as React from "react";
import { useUserProfileQuery } from "@/features/profile/hooks";
import { usePresenceQuery } from "@/features/presence/hooks";
import { PresenceBadge } from "@/features/presence/ui/PresenceBadge";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
type UserProfilePopoverProps = {
children: React.ReactNode;
pubkey: string;
};
function truncatePubkey(pubkey: string) {
if (pubkey.length <= 16) {
return pubkey;
}
return `${pubkey.slice(0, 8)}…${pubkey.slice(-8)}`;
}
export function UserProfilePopover({
children,
pubkey,
}: UserProfilePopoverProps) {
const [open, setOpen] = React.useState(false);
const profileQuery = useUserProfileQuery(open ? pubkey : undefined);
const presenceQuery = usePresenceQuery(open ? [pubkey] : [], {
enabled: open,
});
const profile = profileQuery.data;
const presenceStatus = presenceQuery.data?.[pubkey.toLowerCase()];
return (
<Popover onOpenChange={setOpen} open={open}>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent align="start" className="w-72" side="top" sideOffset={8}>
<div className="flex flex-col gap-3">
<div className="flex items-start gap-3">
{profile?.avatarUrl ? (
<img
alt={profile.displayName ?? "User avatar"}
className="h-10 w-10 shrink-0 rounded-xl object-cover shadow-sm"
referrerPolicy="no-referrer"
src={profile.avatarUrl}
/>
) : (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-secondary text-xs font-semibold text-secondary-foreground shadow-sm">
{(profile?.displayName ?? pubkey.slice(0, 2))
.slice(0, 2)
.toUpperCase()}
</div>
)}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">
{profile?.displayName ?? truncatePubkey(pubkey)}
</p>
{profile?.nip05Handle ? (
<p className="truncate text-xs text-muted-foreground">
{profile.nip05Handle}
</p>
) : null}
</div>
{presenceStatus ? <PresenceBadge status={presenceStatus} /> : null}
</div>
{profile?.about ? (
<p className="text-xs leading-relaxed text-muted-foreground">
{profile.about}
</p>
) : null}
<p className="truncate font-mono text-[10px] text-muted-foreground/60">
{truncatePubkey(pubkey)}
</p>
</div>
</PopoverContent>
</Popover>
);
}
+11 -3
View File
@@ -116,14 +116,22 @@ class RelayClient {
});
}
async sendMessage(channelId: string, content: string) {
async sendMessage(
channelId: string,
content: string,
mentionPubkeys: string[] = [],
) {
await this.ensureConnected();
const tags: string[][] = [["h", channelId]];
for (const pubkey of mentionPubkeys) {
tags.push(["p", pubkey]);
}
const event = await signRelayEvent({
kind: 40001,
content: content.trim(),
// Channel-scoped events use the NIP-29 `h` tag.
tags: [["h", channelId]],
tags,
});
return this.publishEvent(
+70
View File
@@ -0,0 +1,70 @@
/**
* Remark plugin that detects @mention patterns in text nodes and wraps them
* in custom HAST `mention` elements for styled rendering via react-markdown.
*/
export default function remarkMentions() {
// biome-ignore lint/suspicious/noExplicitAny: remark tree types are not available
return (tree: any) => {
walkChildren(tree);
};
}
// biome-ignore lint/suspicious/noExplicitAny: remark tree types are not available
function walkChildren(node: any) {
if (!node?.children || !Array.isArray(node.children)) {
return;
}
for (let i = node.children.length - 1; i >= 0; i--) {
const child = node.children[i];
if (child.type === "text") {
const parts = splitMentions(child.value);
if (parts.length > 1) {
node.children.splice(i, 1, ...parts);
}
} else {
walkChildren(child);
}
}
}
function splitMentions(text: string) {
const mentionPattern = /@\S+/g;
// biome-ignore lint/suspicious/noExplicitAny: building mdast-compatible nodes
const parts: any[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null = null;
while (true) {
match = mentionPattern.exec(text);
if (!match) {
break;
}
if (match.index > lastIndex) {
parts.push({ type: "text", value: text.slice(lastIndex, match.index) });
}
parts.push({
type: "mention",
value: match[0],
data: {
hName: "mention",
hChildren: [{ type: "text", value: match[0] }],
},
});
lastIndex = match.index + match[0].length;
}
if (parts.length === 0) {
return [{ type: "text", value: text }];
}
if (lastIndex < text.length) {
parts.push({ type: "text", value: text.slice(lastIndex) });
}
return parts;
}
+8 -2
View File
@@ -4,6 +4,7 @@ import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import { cn } from "@/shared/lib/cn";
import remarkMentions from "@/shared/lib/remarkMentions";
type MarkdownProps = {
className?: string;
@@ -121,7 +122,12 @@ const markdownComponents: Components = {
{children}
</ul>
),
};
mention: ({ children }: { children?: React.ReactNode }) => (
<span className="rounded-md bg-primary/15 px-1 py-0.5 text-sm font-medium text-primary">
{children}
</span>
),
} as Components;
export function Markdown({
className,
@@ -152,7 +158,7 @@ export function Markdown({
>
<ReactMarkdown
components={markdownComponents}
remarkPlugins={[remarkGfm, remarkBreaks]}
remarkPlugins={[remarkGfm, remarkBreaks, remarkMentions]}
>
{processedContent}
</ReactMarkdown>
+31
View File
@@ -0,0 +1,31 @@
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/shared/lib/cn";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverAnchor = PopoverPrimitive.Anchor;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
+142
View File
@@ -0,0 +1,142 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
/** Locator scoped to the mention autocomplete dropdown inside the composer. */
function autocomplete(page: import("@playwright/test").Page) {
return page
.getByTestId("message-composer")
.locator(".rounded-xl.border.bg-popover");
}
test("@ trigger shows autocomplete dropdown with channel members", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const input = page.getByTestId("message-input");
await input.fill("@");
const dropdown = autocomplete(page);
await expect(dropdown).toBeVisible();
await expect(dropdown.getByText("alice")).toBeVisible();
await expect(dropdown.getByText("bob")).toBeVisible();
});
test("autocomplete filters suggestions as user types", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const input = page.getByTestId("message-input");
await input.fill("@ali");
const dropdown = autocomplete(page);
await expect(dropdown.getByText("alice")).toBeVisible();
await expect(dropdown.getByText("bob")).not.toBeVisible();
});
test("selecting a mention inserts @Name into input", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const input = page.getByTestId("message-input");
await input.fill("Hey @ali");
const dropdown = autocomplete(page);
await dropdown.getByText("alice").click();
await expect(input).toHaveValue("Hey @alice ");
});
test("keyboard navigation selects mention with Enter", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const input = page.getByTestId("message-input");
await input.fill("@ali");
const dropdown = autocomplete(page);
await expect(dropdown.getByText("alice")).toBeVisible();
// Press Enter to select the first (and only) suggestion
await input.press("Enter");
// Should insert @alice and NOT send the message
await expect(input).toHaveValue("@alice ");
});
test("Escape dismisses autocomplete dropdown", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const input = page.getByTestId("message-input");
await input.fill("@");
const dropdown = autocomplete(page);
await expect(dropdown).toBeVisible();
await input.press("Escape");
await expect(dropdown).not.toBeVisible();
});
test("mention text is highlighted in sent messages", async ({ page }) => {
const message = `Hey @alice check this out ${Date.now()}`;
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const input = page.getByTestId("message-input");
await input.fill(message);
await page.getByTestId("send-message").click();
// The mention should render inside a styled span with the mention class
const mentionSpan = page
.getByTestId("message-row")
.last()
.locator("span.text-primary", { hasText: "@alice" });
await expect(mentionSpan).toBeVisible();
});
test("clicking author name opens user profile popover", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// The seed message in general is from the mock identity (npub1mock...)
const firstMessage = page.getByTestId("message-row").first();
const authorButton = firstMessage.locator("button", {
hasText: "npub1mock...",
});
await authorButton.click();
const popover = page.locator("[data-radix-popper-content-wrapper]");
await expect(popover).toBeVisible();
await expect(popover).toContainText("deadbeef");
});
test("clicking avatar opens user profile popover", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// Click the avatar button on the first message
const firstMessage = page.getByTestId("message-row").first();
const avatarButton = firstMessage.locator("button").first();
await avatarButton.click();
await expect(
page.locator("[data-radix-popper-content-wrapper]"),
).toBeVisible();
});