mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-06 18:51:21 +02:00
feat: add mention editor and NIP-29 chat enhancements
Implements rich text editing with profile mentions, NIP-29 system messages, day markers, and naddr support for a more complete chat experience. Editor Features: - TipTap-based rich text editor with @mention autocomplete - FlexSearch-powered profile search (case-insensitive) - Converts mentions to nostr:npub URIs on submission - Keyboard navigation (Arrow keys, Enter, Escape) - Fixed Enter key and Send button submission NIP-29 Chat Improvements: - System messages for join/leave events (kinds 9000, 9001, 9021, 9022) - Styled system messages aligned left with muted text - Shows "joined" instead of "was added" for consistency - Accepts kind 39000 naddr (group metadata addresses) - Day markers between messages from different days - Day markers use locale-aware formatting (short month, no year) Components: - src/components/editor/MentionEditor.tsx - TipTap editor with mention support - src/components/editor/ProfileSuggestionList.tsx - Autocomplete dropdown - src/services/profile-search.ts - FlexSearch service for profile indexing - src/hooks/useProfileSearch.ts - React hook for profile search Dependencies: - @tiptap/react, @tiptap/starter-kit, @tiptap/extension-mention - @tiptap/extension-placeholder, @tiptap/suggestion - flexsearch@0.7.43, tippy.js@6.3.7 Tests: - Added 6 new tests for naddr parsing in NIP-29 adapter - All 710 tests passing Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,12 @@ import { MembersDropdown } from "./chat/MembersDropdown";
|
||||
import { RelaysDropdown } from "./chat/RelaysDropdown";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
MentionEditor,
|
||||
type MentionEditorHandle,
|
||||
} from "./editor/MentionEditor";
|
||||
import { useProfileSearch } from "@/hooks/useProfileSearch";
|
||||
import { Label } from "./ui/label";
|
||||
|
||||
interface ChatViewerProps {
|
||||
protocol: ChatProtocol;
|
||||
@@ -29,6 +35,59 @@ interface ChatViewerProps {
|
||||
customTitle?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Format timestamp as a readable day marker
|
||||
*/
|
||||
function formatDayMarker(timestamp: number): string {
|
||||
const date = new Date(timestamp * 1000);
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
// Reset time parts for comparison
|
||||
const dateOnly = new Date(
|
||||
date.getFullYear(),
|
||||
date.getMonth(),
|
||||
date.getDate(),
|
||||
);
|
||||
const todayOnly = new Date(
|
||||
today.getFullYear(),
|
||||
today.getMonth(),
|
||||
today.getDate(),
|
||||
);
|
||||
const yesterdayOnly = new Date(
|
||||
yesterday.getFullYear(),
|
||||
yesterday.getMonth(),
|
||||
yesterday.getDate(),
|
||||
);
|
||||
|
||||
if (dateOnly.getTime() === todayOnly.getTime()) {
|
||||
return "Today";
|
||||
} else if (dateOnly.getTime() === yesterdayOnly.getTime()) {
|
||||
return "Yesterday";
|
||||
} else {
|
||||
// Format as "Jan 15" (short month, no year, respects locale)
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Check if two timestamps are on different days
|
||||
*/
|
||||
function isDifferentDay(timestamp1: number, timestamp2: number): boolean {
|
||||
const date1 = new Date(timestamp1 * 1000);
|
||||
const date2 = new Date(timestamp2 * 1000);
|
||||
|
||||
return (
|
||||
date1.getFullYear() !== date2.getFullYear() ||
|
||||
date1.getMonth() !== date2.getMonth() ||
|
||||
date1.getDate() !== date2.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ComposerReplyPreview - Shows who is being replied to in the composer
|
||||
*/
|
||||
@@ -95,6 +154,19 @@ const MessageItem = memo(function MessageItem({
|
||||
canReply: boolean;
|
||||
onScrollToMessage?: (messageId: string) => void;
|
||||
}) {
|
||||
// System messages (join/leave) have special styling
|
||||
if (message.type === "system") {
|
||||
return (
|
||||
<div className="flex items-center px-3 py-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
* <UserName pubkey={message.author} className="text-xs" />{" "}
|
||||
{message.content}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Regular user messages
|
||||
return (
|
||||
<div className="group flex items-start hover:bg-muted/50 px-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -113,9 +185,9 @@ const MessageItem = memo(function MessageItem({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm leading-relaxed break-words overflow-hidden">
|
||||
<div className="break-words overflow-hidden">
|
||||
{message.event ? (
|
||||
<RichText event={message.event}>
|
||||
<RichText className="text-sm leading-tight" event={message.event}>
|
||||
{message.replyTo && (
|
||||
<ReplyPreview
|
||||
replyToId={message.replyTo}
|
||||
@@ -153,6 +225,9 @@ export function ChatViewer({
|
||||
const activeAccount = use$(accountManager.active$);
|
||||
const hasActiveAccount = !!activeAccount;
|
||||
|
||||
// Profile search for mentions
|
||||
const { searchProfiles } = useProfileSearch();
|
||||
|
||||
// Get the appropriate adapter for this protocol
|
||||
const adapter = useMemo(() => getAdapter(protocol), [protocol]);
|
||||
|
||||
@@ -168,12 +243,50 @@ export function ChatViewer({
|
||||
[adapter, conversation],
|
||||
);
|
||||
|
||||
// Process messages to include day markers
|
||||
const messagesWithMarkers = useMemo(() => {
|
||||
if (!messages || messages.length === 0) return [];
|
||||
|
||||
const items: Array<
|
||||
| { type: "message"; data: Message }
|
||||
| { type: "day-marker"; data: string; timestamp: number }
|
||||
> = [];
|
||||
|
||||
messages.forEach((message, index) => {
|
||||
// Add day marker if this is the first message or if day changed
|
||||
if (index === 0) {
|
||||
items.push({
|
||||
type: "day-marker",
|
||||
data: formatDayMarker(message.timestamp),
|
||||
timestamp: message.timestamp,
|
||||
});
|
||||
} else {
|
||||
const prevMessage = messages[index - 1];
|
||||
if (isDifferentDay(prevMessage.timestamp, message.timestamp)) {
|
||||
items.push({
|
||||
type: "day-marker",
|
||||
data: formatDayMarker(message.timestamp),
|
||||
timestamp: message.timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add the message itself
|
||||
items.push({ type: "message", data: message });
|
||||
});
|
||||
|
||||
return items;
|
||||
}, [messages]);
|
||||
|
||||
// Track reply context (which message is being replied to)
|
||||
const [replyTo, setReplyTo] = useState<string | undefined>();
|
||||
|
||||
// Ref to Virtuoso for programmatic scrolling
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
|
||||
// Ref to MentionEditor for programmatic submission
|
||||
const editorRef = useRef<MentionEditorHandle>(null);
|
||||
|
||||
// Handle sending messages
|
||||
const handleSend = async (content: string, replyToId?: string) => {
|
||||
if (!conversation || !hasActiveAccount) return;
|
||||
@@ -251,23 +364,37 @@ export function ChatViewer({
|
||||
|
||||
{/* Message timeline with virtualization */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{messages && messages.length > 0 ? (
|
||||
{messagesWithMarkers && messagesWithMarkers.length > 0 ? (
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
data={messages}
|
||||
initialTopMostItemIndex={messages.length - 1}
|
||||
data={messagesWithMarkers}
|
||||
initialTopMostItemIndex={messagesWithMarkers.length - 1}
|
||||
followOutput="smooth"
|
||||
itemContent={(_index, message) => (
|
||||
<MessageItem
|
||||
key={message.id}
|
||||
message={message}
|
||||
adapter={adapter}
|
||||
conversation={conversation}
|
||||
onReply={handleReply}
|
||||
canReply={hasActiveAccount}
|
||||
onScrollToMessage={handleScrollToMessage}
|
||||
/>
|
||||
)}
|
||||
itemContent={(_index, item) => {
|
||||
if (item.type === "day-marker") {
|
||||
return (
|
||||
<div
|
||||
className="flex justify-center py-2"
|
||||
key={`marker-${item.timestamp}`}
|
||||
>
|
||||
<Label className="text-[10px] text-muted-foreground">
|
||||
{item.data}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<MessageItem
|
||||
key={item.data.id}
|
||||
message={item.data}
|
||||
adapter={adapter}
|
||||
conversation={conversation}
|
||||
onReply={handleReply}
|
||||
canReply={hasActiveAccount}
|
||||
onScrollToMessage={handleScrollToMessage}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
style={{ height: "100%" }}
|
||||
/>
|
||||
) : (
|
||||
@@ -279,50 +406,37 @@ export function ChatViewer({
|
||||
|
||||
{/* Message composer - only show if user has active account */}
|
||||
{hasActiveAccount ? (
|
||||
<div className="border-t px-3 py-2">
|
||||
<div className="border-t px-2 py-1 pb-0">
|
||||
{replyTo && (
|
||||
<div className="flex items-center gap-2 rounded bg-muted px-2 py-1 text-xs mb-2">
|
||||
<span>Replying to {replyTo.slice(0, 8)}...</span>
|
||||
<button
|
||||
onClick={() => setReplyTo(undefined)}
|
||||
className="ml-auto text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<ComposerReplyPreview
|
||||
replyToId={replyTo}
|
||||
onClear={() => setReplyTo(undefined)}
|
||||
/>
|
||||
)}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const form = e.currentTarget;
|
||||
const input = form.elements.namedItem(
|
||||
"message",
|
||||
) as HTMLTextAreaElement;
|
||||
if (input.value.trim()) {
|
||||
handleSend(input.value, replyTo);
|
||||
input.value = "";
|
||||
}
|
||||
}}
|
||||
className="flex gap-2"
|
||||
>
|
||||
<textarea
|
||||
name="message"
|
||||
autoFocus
|
||||
<div className="flex gap-2 items-center">
|
||||
<MentionEditor
|
||||
ref={editorRef}
|
||||
placeholder="Type a message..."
|
||||
className="flex-1 resize-none bg-background px-3 py-2 text-sm border rounded-md min-w-0"
|
||||
rows={1}
|
||||
onKeyDown={(e) => {
|
||||
// Submit on Enter (without Shift)
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.currentTarget.form?.requestSubmit();
|
||||
searchProfiles={searchProfiles}
|
||||
onSubmit={(content) => {
|
||||
if (content.trim()) {
|
||||
handleSend(content, replyTo);
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
className="flex-1 min-w-0"
|
||||
/>
|
||||
<Button type="submit" variant="secondary" className="flex-shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="flex-shrink-0 h-[2.5rem]"
|
||||
onClick={() => {
|
||||
editorRef.current?.submit();
|
||||
}}
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-t px-3 py-2 text-center text-sm text-muted-foreground">
|
||||
|
||||
275
src/components/editor/MentionEditor.tsx
Normal file
275
src/components/editor/MentionEditor.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Mention from "@tiptap/extension-mention";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import type { SuggestionOptions } from "@tiptap/suggestion";
|
||||
import tippy from "tippy.js";
|
||||
import type { Instance as TippyInstance } from "tippy.js";
|
||||
import "tippy.js/dist/tippy.css";
|
||||
import {
|
||||
ProfileSuggestionList,
|
||||
type ProfileSuggestionListHandle,
|
||||
} from "./ProfileSuggestionList";
|
||||
import type { ProfileSearchResult } from "@/services/profile-search";
|
||||
import { nip19 } from "nostr-tools";
|
||||
|
||||
export interface MentionEditorProps {
|
||||
placeholder?: string;
|
||||
onSubmit?: (content: string) => void;
|
||||
searchProfiles: (query: string) => Promise<ProfileSearchResult[]>;
|
||||
autoFocus?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface MentionEditorHandle {
|
||||
focus: () => void;
|
||||
clear: () => void;
|
||||
getContent: () => string;
|
||||
getContentWithMentions: () => string;
|
||||
isEmpty: () => boolean;
|
||||
submit: () => void;
|
||||
}
|
||||
|
||||
export const MentionEditor = forwardRef<
|
||||
MentionEditorHandle,
|
||||
MentionEditorProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
placeholder = "Type a message...",
|
||||
onSubmit,
|
||||
searchProfiles,
|
||||
autoFocus = true,
|
||||
className = "",
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
// Create mention suggestion configuration
|
||||
const suggestion: Omit<SuggestionOptions, "editor"> = useMemo(
|
||||
() => ({
|
||||
char: "@",
|
||||
allowSpaces: false,
|
||||
items: async ({ query }) => {
|
||||
return await searchProfiles(query);
|
||||
},
|
||||
render: () => {
|
||||
let component: ReactRenderer<ProfileSuggestionListHandle>;
|
||||
let popup: TippyInstance[];
|
||||
|
||||
return {
|
||||
onStart: (props) => {
|
||||
component = new ReactRenderer(ProfileSuggestionList, {
|
||||
props: {
|
||||
items: props.items,
|
||||
command: props.command,
|
||||
onClose: () => {
|
||||
popup[0]?.hide();
|
||||
},
|
||||
},
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect as () => DOMRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
});
|
||||
},
|
||||
|
||||
onUpdate(props) {
|
||||
component.updateProps({
|
||||
items: props.items,
|
||||
command: props.command,
|
||||
});
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup[0]?.setProps({
|
||||
getReferenceClientRect: props.clientRect as () => DOMRect,
|
||||
});
|
||||
},
|
||||
|
||||
onKeyDown(props) {
|
||||
if (props.event.key === "Escape") {
|
||||
popup[0]?.hide();
|
||||
return true;
|
||||
}
|
||||
|
||||
return component.ref?.onKeyDown(props.event) ?? false;
|
||||
},
|
||||
|
||||
onExit() {
|
||||
popup[0]?.destroy();
|
||||
component.destroy();
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
[searchProfiles],
|
||||
);
|
||||
|
||||
// Helper function to serialize editor content with mentions
|
||||
const serializeContent = useCallback((editorInstance: any) => {
|
||||
let text = "";
|
||||
const json = editorInstance.getJSON();
|
||||
|
||||
json.content?.forEach((node: any) => {
|
||||
if (node.type === "paragraph") {
|
||||
node.content?.forEach((child: any) => {
|
||||
if (child.type === "text") {
|
||||
text += child.text;
|
||||
} else if (child.type === "mention") {
|
||||
const pubkey = child.attrs?.id;
|
||||
if (pubkey) {
|
||||
try {
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
text += `nostr:${npub}`;
|
||||
} catch {
|
||||
// Fallback to display name if encoding fails
|
||||
text += `@${child.attrs?.label || "unknown"}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
text += "\n";
|
||||
}
|
||||
});
|
||||
|
||||
return text.trim();
|
||||
}, []);
|
||||
|
||||
// Helper function to handle submission
|
||||
const handleSubmit = useCallback(
|
||||
(editorInstance: any) => {
|
||||
if (!editorInstance || !onSubmit) return;
|
||||
|
||||
const content = serializeContent(editorInstance);
|
||||
if (content) {
|
||||
onSubmit(content);
|
||||
editorInstance.commands.clearContent();
|
||||
}
|
||||
},
|
||||
[onSubmit, serializeContent],
|
||||
);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
// Disable Enter to submit via Mod-Enter instead
|
||||
hardBreak: {
|
||||
keepMarks: false,
|
||||
},
|
||||
}),
|
||||
Mention.configure({
|
||||
HTMLAttributes: {
|
||||
class: "mention",
|
||||
},
|
||||
suggestion: {
|
||||
...suggestion,
|
||||
command: ({ editor, range, props }: any) => {
|
||||
// props is the ProfileSearchResult
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContentAt(range, [
|
||||
{
|
||||
type: "mention",
|
||||
attrs: {
|
||||
id: props.pubkey,
|
||||
label: props.displayName,
|
||||
},
|
||||
},
|
||||
{ type: "text", text: " " },
|
||||
])
|
||||
.run();
|
||||
},
|
||||
},
|
||||
renderLabel({ node }) {
|
||||
return `@${node.attrs.label}`;
|
||||
},
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
}),
|
||||
],
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class:
|
||||
"prose prose-sm max-w-none focus:outline-none min-h-[2rem] px-3 py-1.5",
|
||||
},
|
||||
handleKeyDown: (view, event) => {
|
||||
// Submit on Enter (without Shift)
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
// Get editor from view state
|
||||
const editorInstance = (view as any).editor;
|
||||
handleSubmit(editorInstance);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
autofocus: autoFocus,
|
||||
});
|
||||
|
||||
// Expose editor methods
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
focus: () => editor?.commands.focus(),
|
||||
clear: () => editor?.commands.clearContent(),
|
||||
getContent: () => editor?.getText() || "",
|
||||
getContentWithMentions: () => {
|
||||
if (!editor) return "";
|
||||
return serializeContent(editor);
|
||||
},
|
||||
isEmpty: () => editor?.isEmpty ?? true,
|
||||
submit: () => {
|
||||
if (editor) {
|
||||
handleSubmit(editor);
|
||||
}
|
||||
},
|
||||
}),
|
||||
[editor, serializeContent, handleSubmit],
|
||||
);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
editor?.destroy();
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-md border bg-background transition-colors focus-within:border-primary h-[2.5rem] flex items-center ${className}`}
|
||||
>
|
||||
<EditorContent editor={editor} className="flex-1" />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
MentionEditor.displayName = "MentionEditor";
|
||||
113
src/components/editor/ProfileSuggestionList.tsx
Normal file
113
src/components/editor/ProfileSuggestionList.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { ProfileSearchResult } from "@/services/profile-search";
|
||||
import { UserName } from "../nostr/UserName";
|
||||
|
||||
export interface ProfileSuggestionListProps {
|
||||
items: ProfileSearchResult[];
|
||||
command: (item: ProfileSearchResult) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export interface ProfileSuggestionListHandle {
|
||||
onKeyDown: (event: KeyboardEvent) => boolean;
|
||||
}
|
||||
|
||||
export const ProfileSuggestionList = forwardRef<
|
||||
ProfileSuggestionListHandle,
|
||||
ProfileSuggestionListProps
|
||||
>(({ items, command, onClose }, ref) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Keyboard navigation
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: (event: KeyboardEvent) => {
|
||||
if (event.key === "ArrowUp") {
|
||||
setSelectedIndex((prev) => (prev + items.length - 1) % items.length);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
setSelectedIndex((prev) => (prev + 1) % items.length);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
if (items[selectedIndex]) {
|
||||
command(items[selectedIndex]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
onClose?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
}));
|
||||
|
||||
// Scroll selected item into view
|
||||
useEffect(() => {
|
||||
const selectedElement = listRef.current?.children[selectedIndex];
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({
|
||||
block: "nearest",
|
||||
});
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
// Reset selected index when items change
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [items]);
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="border border-border/50 bg-popover p-4 text-sm text-muted-foreground shadow-md">
|
||||
No profiles found
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={listRef}
|
||||
role="listbox"
|
||||
className="max-h-[300px] w-[320px] overflow-y-auto border border-border/50 bg-popover shadow-md"
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
key={item.pubkey}
|
||||
role="option"
|
||||
aria-selected={index === selectedIndex}
|
||||
onClick={() => command(item)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
className={`flex w-full items-center gap-3 px-3 py-2 text-left transition-colors ${
|
||||
index === selectedIndex ? "bg-muted/60" : "hover:bg-muted/60"
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">
|
||||
<UserName pubkey={item.pubkey} />
|
||||
</div>
|
||||
{item.nip05 && (
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{item.nip05}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ProfileSuggestionList.displayName = "ProfileSuggestionList";
|
||||
54
src/hooks/useProfileSearch.ts
Normal file
54
src/hooks/useProfileSearch.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import {
|
||||
ProfileSearchService,
|
||||
type ProfileSearchResult,
|
||||
} from "@/services/profile-search";
|
||||
import eventStore from "@/services/event-store";
|
||||
|
||||
/**
|
||||
* Hook to provide profile search functionality with automatic indexing
|
||||
* of profiles from the event store
|
||||
*/
|
||||
export function useProfileSearch() {
|
||||
const serviceRef = useRef<ProfileSearchService | null>(null);
|
||||
|
||||
// Create service instance (singleton per component mount)
|
||||
if (!serviceRef.current) {
|
||||
serviceRef.current = new ProfileSearchService();
|
||||
}
|
||||
|
||||
const service = serviceRef.current;
|
||||
|
||||
// Subscribe to profile events from the event store
|
||||
useEffect(() => {
|
||||
const subscription = eventStore
|
||||
.timeline([{ kinds: [0], limit: 1000 }])
|
||||
.subscribe({
|
||||
next: (events) => {
|
||||
service.addProfiles(events);
|
||||
},
|
||||
error: (error) => {
|
||||
console.error("Failed to load profiles for search:", error);
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
service.clear(); // Clean up indexed profiles
|
||||
};
|
||||
}, [service]);
|
||||
|
||||
// Memoize search function
|
||||
const searchProfiles = useMemo(
|
||||
() =>
|
||||
async (query: string): Promise<ProfileSearchResult[]> => {
|
||||
return await service.search(query, { limit: 20 });
|
||||
},
|
||||
[service],
|
||||
);
|
||||
|
||||
return {
|
||||
searchProfiles,
|
||||
service,
|
||||
};
|
||||
}
|
||||
@@ -278,3 +278,39 @@ body.animating-layout
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* TipTap Editor Styles */
|
||||
.ProseMirror {
|
||||
min-height: 2rem;
|
||||
}
|
||||
|
||||
.ProseMirror:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ProseMirror p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ProseMirror p.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: hsl(var(--muted-foreground));
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* Mention styles */
|
||||
.ProseMirror .mention {
|
||||
color: hsl(var(--primary));
|
||||
background-color: hsl(var(--primary) / 0.1);
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ProseMirror .mention:hover {
|
||||
background-color: hsl(var(--primary) / 0.2);
|
||||
}
|
||||
|
||||
@@ -57,11 +57,14 @@ export function parseChatCommand(args: string[]): ChatCommandResult {
|
||||
throw new Error(
|
||||
`Unable to determine chat protocol from identifier: ${identifier}
|
||||
|
||||
Currently supported format:
|
||||
Currently supported formats:
|
||||
- relay.com'group-id (NIP-29 relay group, wss:// prefix optional)
|
||||
Examples:
|
||||
chat relay.example.com'bitcoin-dev
|
||||
chat wss://relay.example.com'nostr-dev
|
||||
- naddr1... (NIP-29 group metadata, kind 39000)
|
||||
Example:
|
||||
chat naddr1qqxnzdesxqmnxvpexqmny...
|
||||
|
||||
More formats coming soon:
|
||||
- npub/nprofile/hex pubkey (NIP-C7/NIP-17 direct messages)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { Nip29Adapter } from "./nip-29-adapter";
|
||||
|
||||
describe("Nip29Adapter", () => {
|
||||
@@ -70,9 +71,93 @@ describe("Nip29Adapter", () => {
|
||||
// These should not match NIP-29 format
|
||||
expect(adapter.parseIdentifier("npub1...")).toBeNull();
|
||||
expect(adapter.parseIdentifier("note1...")).toBeNull();
|
||||
expect(adapter.parseIdentifier("naddr1...")).toBeNull();
|
||||
expect(adapter.parseIdentifier("alice@example.com")).toBeNull();
|
||||
});
|
||||
|
||||
it("should parse kind 39000 naddr (group metadata)", () => {
|
||||
// Create a valid kind 39000 naddr
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 39000,
|
||||
pubkey:
|
||||
"0000000000000000000000000000000000000000000000000000000000000001",
|
||||
identifier: "bitcoin-dev",
|
||||
relays: ["wss://relay.example.com"],
|
||||
});
|
||||
|
||||
const result = adapter.parseIdentifier(naddr);
|
||||
expect(result).toEqual({
|
||||
type: "group",
|
||||
value: "bitcoin-dev",
|
||||
relays: ["wss://relay.example.com"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle naddr with multiple relays (uses first)", () => {
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 39000,
|
||||
pubkey:
|
||||
"0000000000000000000000000000000000000000000000000000000000000001",
|
||||
identifier: "test-group",
|
||||
relays: [
|
||||
"wss://relay1.example.com",
|
||||
"wss://relay2.example.com",
|
||||
"wss://relay3.example.com",
|
||||
],
|
||||
});
|
||||
|
||||
const result = adapter.parseIdentifier(naddr);
|
||||
expect(result).toEqual({
|
||||
type: "group",
|
||||
value: "test-group",
|
||||
relays: ["wss://relay1.example.com"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should add wss:// prefix to naddr relay if missing", () => {
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 39000,
|
||||
pubkey:
|
||||
"0000000000000000000000000000000000000000000000000000000000000001",
|
||||
identifier: "test-group",
|
||||
relays: ["relay.example.com"], // No protocol prefix
|
||||
});
|
||||
|
||||
const result = adapter.parseIdentifier(naddr);
|
||||
expect(result).toEqual({
|
||||
type: "group",
|
||||
value: "test-group",
|
||||
relays: ["wss://relay.example.com"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should return null for non-39000 kind naddr", () => {
|
||||
// kind 30311 (live activity) should not work for NIP-29
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 30311,
|
||||
pubkey:
|
||||
"0000000000000000000000000000000000000000000000000000000000000001",
|
||||
identifier: "some-event",
|
||||
relays: ["wss://relay.example.com"],
|
||||
});
|
||||
|
||||
expect(adapter.parseIdentifier(naddr)).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for naddr without relays", () => {
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 39000,
|
||||
pubkey:
|
||||
"0000000000000000000000000000000000000000000000000000000000000001",
|
||||
identifier: "test-group",
|
||||
relays: [], // No relays
|
||||
});
|
||||
|
||||
expect(adapter.parseIdentifier(naddr)).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for malformed naddr", () => {
|
||||
expect(adapter.parseIdentifier("naddr1invaliddata")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("protocol properties", () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Observable } from "rxjs";
|
||||
import { map, first } from "rxjs/operators";
|
||||
import type { Filter } from "nostr-tools";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { ChatProtocolAdapter } from "./base-adapter";
|
||||
import type {
|
||||
Conversation,
|
||||
@@ -36,12 +37,45 @@ export class Nip29Adapter extends ChatProtocolAdapter {
|
||||
readonly type = "group" as const;
|
||||
|
||||
/**
|
||||
* Parse identifier - accepts group ID format: relay'group-id
|
||||
* Parse identifier - accepts group ID format or naddr
|
||||
* Examples:
|
||||
* - wss://relay.example.com'bitcoin-dev
|
||||
* - relay.example.com'bitcoin-dev (wss:// prefix is optional)
|
||||
* - naddr1... (kind 39000 group metadata address)
|
||||
*/
|
||||
parseIdentifier(input: string): ProtocolIdentifier | null {
|
||||
// Try naddr format first (kind 39000 group metadata)
|
||||
if (input.startsWith("naddr1")) {
|
||||
try {
|
||||
const decoded = nip19.decode(input);
|
||||
if (decoded.type === "naddr" && decoded.data.kind === 39000) {
|
||||
const { identifier, relays } = decoded.data;
|
||||
const relayUrl = relays?.[0];
|
||||
|
||||
if (!identifier || !relayUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure relay URL has wss:// prefix
|
||||
let normalizedRelay = relayUrl;
|
||||
if (
|
||||
!normalizedRelay.startsWith("ws://") &&
|
||||
!normalizedRelay.startsWith("wss://")
|
||||
) {
|
||||
normalizedRelay = `wss://${normalizedRelay}`;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "group",
|
||||
value: identifier,
|
||||
relays: [normalizedRelay],
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Not a valid naddr, fall through to try other formats
|
||||
}
|
||||
}
|
||||
|
||||
// NIP-29 format: [wss://]relay'group-id
|
||||
const match = input.match(/^((?:wss?:\/\/)?[^']+)'([^']+)$/);
|
||||
if (!match) return null;
|
||||
@@ -278,9 +312,12 @@ export class Nip29Adapter extends ChatProtocolAdapter {
|
||||
|
||||
console.log(`[NIP-29] Loading messages for ${groupId} from ${relayUrl}`);
|
||||
|
||||
// Subscribe to group messages (kind 9)
|
||||
// Subscribe to group messages (kind 9) and admin events (9000-9022)
|
||||
// kind 9: chat messages
|
||||
// kind 9000: put-user (admin adds user)
|
||||
// kind 9001: remove-user (admin removes user)
|
||||
const filter: Filter = {
|
||||
kinds: [9],
|
||||
kinds: [9, 9000, 9001],
|
||||
"#h": [groupId],
|
||||
limit: options?.limit || 50,
|
||||
};
|
||||
@@ -534,6 +571,37 @@ export class Nip29Adapter extends ChatProtocolAdapter {
|
||||
* Helper: Convert Nostr event to Message
|
||||
*/
|
||||
private eventToMessage(event: NostrEvent, conversationId: string): Message {
|
||||
// Handle admin events (join/leave) as system messages
|
||||
if (event.kind === 9000 || event.kind === 9001) {
|
||||
// Extract the affected user's pubkey from p-tag
|
||||
const pTags = event.tags.filter((t) => t[0] === "p");
|
||||
const affectedPubkey = pTags[0]?.[1] || event.pubkey; // Fall back to event author
|
||||
|
||||
let content = "";
|
||||
if (event.kind === 9000) {
|
||||
// put-user: admin adds someone (show as joined)
|
||||
content = "joined";
|
||||
} else if (event.kind === 9001) {
|
||||
// remove-user: admin removes someone
|
||||
content = affectedPubkey === event.pubkey ? "left" : "was removed";
|
||||
}
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
conversationId,
|
||||
author: affectedPubkey, // Show the user who joined/left
|
||||
content,
|
||||
timestamp: event.created_at,
|
||||
type: "system",
|
||||
protocol: "nip-29",
|
||||
metadata: {
|
||||
encrypted: false,
|
||||
},
|
||||
event,
|
||||
};
|
||||
}
|
||||
|
||||
// Regular chat message (kind 9)
|
||||
// Look for reply q-tags (NIP-29 uses q-tags like NIP-C7)
|
||||
const qTags = getTagValues(event, "q");
|
||||
const replyTo = qTags[0]; // First q-tag is the reply target
|
||||
@@ -544,6 +612,7 @@ export class Nip29Adapter extends ChatProtocolAdapter {
|
||||
author: event.pubkey,
|
||||
content: event.content,
|
||||
timestamp: event.created_at,
|
||||
type: "user",
|
||||
replyTo,
|
||||
protocol: "nip-29",
|
||||
metadata: {
|
||||
|
||||
136
src/services/profile-search.ts
Normal file
136
src/services/profile-search.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { Index } from "flexsearch";
|
||||
import type { NostrEvent } from "nostr-tools";
|
||||
import { getProfileContent } from "applesauce-core/helpers";
|
||||
import { getDisplayName } from "@/lib/nostr-utils";
|
||||
|
||||
export interface ProfileSearchResult {
|
||||
pubkey: string;
|
||||
displayName: string;
|
||||
username?: string;
|
||||
nip05?: string;
|
||||
picture?: string;
|
||||
event?: NostrEvent;
|
||||
}
|
||||
|
||||
export class ProfileSearchService {
|
||||
private index: Index;
|
||||
private profiles: Map<string, ProfileSearchResult>;
|
||||
|
||||
constructor() {
|
||||
this.profiles = new Map();
|
||||
this.index = new Index({
|
||||
tokenize: "forward",
|
||||
cache: true,
|
||||
resolution: 9,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a profile to the search index
|
||||
*/
|
||||
async addProfile(event: NostrEvent): Promise<void> {
|
||||
if (event.kind !== 0) return;
|
||||
|
||||
const pubkey = event.pubkey;
|
||||
const metadata = getProfileContent(event);
|
||||
|
||||
const profile: ProfileSearchResult = {
|
||||
pubkey,
|
||||
displayName: getDisplayName(pubkey, metadata),
|
||||
username: metadata?.name,
|
||||
nip05: metadata?.nip05,
|
||||
picture: metadata?.picture,
|
||||
event,
|
||||
};
|
||||
|
||||
this.profiles.set(pubkey, profile);
|
||||
|
||||
// Create searchable text from multiple fields (lowercase for case-insensitive search)
|
||||
const searchText = [
|
||||
profile.displayName,
|
||||
profile.username,
|
||||
profile.nip05,
|
||||
pubkey,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
|
||||
await this.index.addAsync(pubkey, searchText);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add multiple profiles in batch
|
||||
*/
|
||||
async addProfiles(events: NostrEvent[]): Promise<void> {
|
||||
for (const event of events) {
|
||||
await this.addProfile(event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a profile from the search index
|
||||
*/
|
||||
async removeProfile(pubkey: string): Promise<void> {
|
||||
this.profiles.delete(pubkey);
|
||||
await this.index.removeAsync(pubkey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search profiles by query string
|
||||
*/
|
||||
async search(
|
||||
query: string,
|
||||
options: { limit?: number; offset?: number } = {},
|
||||
): Promise<ProfileSearchResult[]> {
|
||||
const { limit = 10, offset = 0 } = options;
|
||||
|
||||
if (!query.trim()) {
|
||||
// Return recent profiles when no query
|
||||
const items = Array.from(this.profiles.values()).slice(
|
||||
offset,
|
||||
offset + limit,
|
||||
);
|
||||
return items;
|
||||
}
|
||||
|
||||
// Search index (lowercase for case-insensitive search)
|
||||
const ids = (await this.index.searchAsync(query.toLowerCase(), {
|
||||
limit: limit + offset,
|
||||
})) as string[];
|
||||
|
||||
// Map IDs to profiles
|
||||
const items = ids
|
||||
.slice(offset, offset + limit)
|
||||
.map((id) => this.profiles.get(id))
|
||||
.filter(Boolean) as ProfileSearchResult[];
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get profile by pubkey
|
||||
*/
|
||||
getByPubkey(pubkey: string): ProfileSearchResult | undefined {
|
||||
return this.profiles.get(pubkey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all profiles
|
||||
*/
|
||||
clear(): void {
|
||||
this.profiles.clear();
|
||||
this.index = new Index({
|
||||
tokenize: "forward",
|
||||
cache: true,
|
||||
resolution: 9,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total number of indexed profiles
|
||||
*/
|
||||
get size(): number {
|
||||
return this.profiles.size;
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,11 @@ export interface MessageMetadata {
|
||||
hidden?: boolean; // NIP-28 channel hide
|
||||
}
|
||||
|
||||
/**
|
||||
* Message type - system messages for events like join/leave, user messages for chat
|
||||
*/
|
||||
export type MessageType = "user" | "system";
|
||||
|
||||
/**
|
||||
* Generic message abstraction
|
||||
* Works across all messaging protocols
|
||||
@@ -85,6 +90,7 @@ export interface Message {
|
||||
author: string; // pubkey
|
||||
content: string;
|
||||
timestamp: number;
|
||||
type?: MessageType; // Defaults to "user" if not specified
|
||||
replyTo?: string; // Parent message ID
|
||||
metadata?: MessageMetadata;
|
||||
protocol: ChatProtocol;
|
||||
|
||||
Reference in New Issue
Block a user