mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat: @mentions, highlighting, and user profile popovers (#45)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -23,6 +23,7 @@ export default defineConfig({
|
||||
"**/channels.spec.ts",
|
||||
"**/channel-browser.spec.ts",
|
||||
"**/messaging.spec.ts",
|
||||
"**/mentions.spec.ts",
|
||||
],
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
|
||||
Generated
+39
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user