Merge branch 'main' of gitlab.com:soapbox-pub/ditto-mew

This commit is contained in:
Alex Gleason
2026-02-21 17:31:07 -06:00
9 changed files with 574 additions and 52 deletions
+20
View File
@@ -3062,6 +3062,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3075,6 +3076,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3088,6 +3090,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3101,6 +3104,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3114,6 +3118,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3127,6 +3132,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3140,6 +3146,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3153,6 +3160,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3166,6 +3174,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3179,6 +3188,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3192,6 +3202,7 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3205,6 +3216,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3218,6 +3230,7 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3231,6 +3244,7 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3244,6 +3258,7 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3257,6 +3272,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3270,6 +3286,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3283,6 +3300,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3296,6 +3314,7 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3309,6 +3328,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
+72 -25
View File
@@ -11,6 +11,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { EmojiPicker } from '@/components/EmojiPicker';
import { EmbeddedNote } from '@/components/EmbeddedNote';
import { MentionAutocomplete } from '@/components/MentionAutocomplete';
import { NoteContent } from '@/components/NoteContent';
import { useCurrentUser } from '@/hooks/useCurrentUser';
@@ -235,10 +236,15 @@ export function ComposeBox({
return content.match(urlRegex) || [];
}, [content]);
// Check if content has any previewable content (link previews, images, or videos)
// Detect nostr:npub/nprofile mentions in content
const hasMentions = useMemo(() => {
return /nostr:(npub1|nprofile1)[023456789acdefghjklmnpqrstuvwxyz]+/.test(content);
}, [content]);
// Check if content has any previewable content (link previews, images, videos, or mentions)
const hasPreviewableContent = useMemo(() => {
return visibleEmbeds.length > 0 || previewImages.length > 0 || previewVideos.length > 0;
}, [visibleEmbeds, previewImages, previewVideos]);
return visibleEmbeds.length > 0 || previewImages.length > 0 || previewVideos.length > 0 || hasMentions;
}, [visibleEmbeds, previewImages, previewVideos, hasMentions]);
// Notify parent of previewable content changes
useEffect(() => {
@@ -289,6 +295,19 @@ export function ComposeBox({
expand();
}, [content, expand]);
const handleInsertMention = useCallback(({ start, end, replacement }: { start: number; end: number; replacement: string }) => {
const newContent = content.slice(0, start) + replacement + content.slice(end);
setContent(newContent);
requestAnimationFrame(() => {
const textarea = textareaRef.current;
if (textarea) {
textarea.focus();
const pos = start + replacement.length;
textarea.setSelectionRange(pos, pos);
}
});
}, [content]);
const handleFileUpload = useCallback(async (file: File) => {
try {
const tags = await uploadFile(file);
@@ -367,6 +386,25 @@ export function ComposeBox({
const hashtags = content.match(/#\w+/g)?.map((t) => t.slice(1)) || [];
const tags: string[][] = hashtags.map((t) => ['t', t.toLowerCase()]);
// NIP-27 mention p tags — extract nostr:npub1... from content
const mentionMatches = content.matchAll(/nostr:(npub1[023456789acdefghjklmnpqrstuvwxyz]+)/g);
const mentionedPubkeys = new Set<string>();
for (const match of mentionMatches) {
try {
const decoded = nip19.decode(match[1]);
if (decoded.type === 'npub') {
mentionedPubkeys.add(decoded.data);
}
} catch {
// Invalid bech32, skip
}
}
// Don't include ourselves
mentionedPubkeys.delete(user.pubkey);
for (const pk of mentionedPubkeys) {
tags.push(['p', pk]);
}
// NIP-10 reply tags
if (replyTo) {
// Determine root of the thread
@@ -381,13 +419,15 @@ export function ComposeBox({
}
// Add p tags: original author + all existing p tags from the parent
// Skip pubkeys already added by mention detection above
const pPubkeys = new Set<string>();
pPubkeys.add(replyTo.pubkey);
for (const tag of replyTo.tags) {
if (tag[0] === 'p' && tag[1]) pPubkeys.add(tag[1]);
}
// Don't include ourselves
// Don't include ourselves or already-mentioned pubkeys
if (user.pubkey) pPubkeys.delete(user.pubkey);
for (const pk of mentionedPubkeys) pPubkeys.delete(pk);
for (const pk of pPubkeys) {
tags.push(['p', pk]);
}
@@ -558,30 +598,37 @@ export function ComposeBox({
<div className="flex-1 min-w-0">
{!previewMode ? (
/* Edit mode - Textarea */
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onFocus={expand}
onPaste={handlePaste}
placeholder={placeholder}
className={cn(
'w-full bg-transparent text-foreground placeholder:text-muted-foreground resize-none outline-none text-lg pt-2.5 pb-2 opacity-85',
isExpanded ? 'min-h-[100px]' : 'min-h-[44px]',
)}
rows={isExpanded ? 4 : 1}
disabled={!user}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleSubmit();
}
}}
/>
<div className="relative">
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onFocus={expand}
onPaste={handlePaste}
placeholder={placeholder}
className={cn(
'w-full bg-transparent text-foreground placeholder:text-muted-foreground resize-none outline-none text-lg pt-2.5 pb-2 opacity-85',
isExpanded ? 'min-h-[100px]' : 'min-h-[44px]',
)}
rows={isExpanded ? 4 : 1}
disabled={!user}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleSubmit();
}
}}
/>
<MentionAutocomplete
textareaRef={textareaRef}
content={content}
onInsertMention={handleInsertMention}
/>
</div>
) : (
/* Preview mode - Show how post will look */
mockEvent && (
<div className="pt-2.5 pb-2 min-h-[100px] overflow-hidden">
<div className="whitespace-pre-wrap break-words text-lg opacity-85">
<div className="pt-2.5 pb-2 min-h-[100px]">
<div className="text-lg opacity-85">
<NoteContent event={mockEvent} className="text-foreground" />
</div>
{/* Render images */}
+4 -8
View File
@@ -27,12 +27,8 @@ export function LinkPreview({ url, className }: LinkPreviewProps) {
return <LinkPreviewSkeleton className={className} />;
}
if (!data) {
return null;
}
const domain = data.provider_name || displayDomain(url);
const image = data.thumbnail_url;
const domain = data?.provider_name || displayDomain(url);
const image = data?.thumbnail_url;
return (
<a
@@ -72,14 +68,14 @@ export function LinkPreview({ url, className }: LinkPreviewProps) {
</div>
{/* Title */}
{data.title && (
{data?.title && (
<p className="text-sm font-semibold leading-snug line-clamp-2">
{data.title}
</p>
)}
{/* Author */}
{data.author_name && (
{data?.author_name && (
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">
{data.author_name}
</p>
+321
View File
@@ -0,0 +1,321 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { nip19 } from 'nostr-tools';
import { UserRoundCheck } from 'lucide-react';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { EmojifiedText } from '@/components/CustomEmoji';
import { useSearchProfiles, type SearchProfile } from '@/hooks/useSearchProfiles';
import { genUserName } from '@/lib/genUserName';
import { cn } from '@/lib/utils';
interface MentionAutocompleteProps {
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
content: string;
onInsertMention: (params: { start: number; end: number; replacement: string }) => void;
}
/** CSS properties that affect text layout and must be copied to the mirror element. */
const MIRROR_PROPS = [
'direction', 'boxSizing', 'width', 'height', 'overflowX', 'overflowY',
'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth',
'borderStyle', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft',
'fontStyle', 'fontVariant', 'fontWeight', 'fontStretch', 'fontSize',
'fontSizeAdjust', 'lineHeight', 'fontFamily', 'textAlign', 'textTransform',
'textIndent', 'textDecoration', 'letterSpacing', 'wordSpacing',
'tabSize', 'MozTabSize', 'whiteSpace', 'wordWrap', 'wordBreak',
] as const;
/**
* Returns the pixel {top, left} of a character position within a textarea,
* relative to the textarea element's top-left corner.
*/
function getCaretCoordinates(textarea: HTMLTextAreaElement, position: number): { top: number; left: number } {
const mirror = document.createElement('div');
mirror.id = 'mention-mirror';
const style = window.getComputedStyle(textarea);
// Copy all layout-affecting styles
for (const prop of MIRROR_PROPS) {
mirror.style[prop as string] = style.getPropertyValue(
prop.replace(/([A-Z])/g, '-$1').toLowerCase(),
);
}
mirror.style.position = 'absolute';
mirror.style.visibility = 'hidden';
mirror.style.whiteSpace = 'pre-wrap';
mirror.style.wordWrap = 'break-word';
mirror.style.overflow = 'hidden';
document.body.appendChild(mirror);
// Set the text up to the caret position
mirror.textContent = textarea.value.substring(0, position);
// Add a span at the caret position to measure
const marker = document.createElement('span');
marker.textContent = '\u200b'; // zero-width space
mirror.appendChild(marker);
const mirrorRect = mirror.getBoundingClientRect();
const markerRect = marker.getBoundingClientRect();
const coords = {
top: markerRect.top - mirrorRect.top - textarea.scrollTop,
left: markerRect.left - mirrorRect.left - textarea.scrollLeft,
};
document.body.removeChild(mirror);
return coords;
}
/**
* Detects `@query` at the cursor position in a textarea and shows
* a profile autocomplete dropdown. On selection, replaces `@query`
* with `nostr:npub1...` in the content.
*/
export function MentionAutocomplete({
textareaRef,
content,
onInsertMention,
}: MentionAutocompleteProps) {
const [mentionQuery, setMentionQuery] = useState('');
const [mentionStart, setMentionStart] = useState(-1);
const [selectedIndex, setSelectedIndex] = useState(0);
const [isOpen, setIsOpen] = useState(false);
const [dropdownPos, setDropdownPos] = useState<{ top: number; left: number } | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const { data: profiles, followedPubkeys } = useSearchProfiles(
isOpen ? mentionQuery : '',
);
// Detect @mention query at cursor
const detectMention = useCallback(() => {
const textarea = textareaRef.current;
if (!textarea) return;
const cursor = textarea.selectionStart;
const text = textarea.value;
// Walk back from cursor to find an unescaped @ that starts a mention
let atPos = -1;
for (let i = cursor - 1; i >= 0; i--) {
const ch = text[i];
// Stop at whitespace or newline — no match
if (ch === ' ' || ch === '\n' || ch === '\t') break;
if (ch === '@') {
// Must be at start of text or preceded by whitespace
if (i === 0 || /\s/.test(text[i - 1])) {
atPos = i;
}
break;
}
}
if (atPos === -1) {
setIsOpen(false);
setMentionQuery('');
setMentionStart(-1);
return;
}
const query = text.slice(atPos + 1, cursor);
// Don't show for empty query or very long queries
if (query.length === 0 || query.length > 50) {
setIsOpen(false);
setMentionQuery('');
setMentionStart(-1);
return;
}
setMentionQuery(query);
setMentionStart(atPos);
setIsOpen(true);
setSelectedIndex(0);
// Position the dropdown below the @ character
const coords = getCaretCoordinates(textarea, atPos);
const lineHeight = parseFloat(window.getComputedStyle(textarea).lineHeight) || 20;
setDropdownPos({
top: coords.top + lineHeight + 4,
left: Math.max(0, Math.min(coords.left, textarea.offsetWidth - 280)),
});
}, [textareaRef]);
// Listen for input/cursor changes
useEffect(() => {
const textarea = textareaRef.current;
if (!textarea) return;
const handleInput = () => detectMention();
const handleClick = () => detectMention();
const handleKeyUp = (e: KeyboardEvent) => {
if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) {
detectMention();
}
};
textarea.addEventListener('input', handleInput);
textarea.addEventListener('click', handleClick);
textarea.addEventListener('keyup', handleKeyUp);
return () => {
textarea.removeEventListener('input', handleInput);
textarea.removeEventListener('click', handleClick);
textarea.removeEventListener('keyup', handleKeyUp);
};
}, [textareaRef, detectMention]);
// Re-detect when content changes externally (e.g. emoji insertion)
useEffect(() => {
detectMention();
}, [content, detectMention]);
// Handle keyboard navigation within the dropdown
useEffect(() => {
if (!isOpen || !profiles || profiles.length === 0) return;
const textarea = textareaRef.current;
if (!textarea) return;
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedIndex((prev) => (prev < (profiles?.length ?? 1) - 1 ? prev + 1 : 0));
break;
case 'ArrowUp':
e.preventDefault();
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : (profiles?.length ?? 1) - 1));
break;
case 'Enter':
case 'Tab':
if (profiles && profiles.length > 0) {
e.preventDefault();
selectProfile(profiles[selectedIndex]);
}
break;
case 'Escape':
e.preventDefault();
setIsOpen(false);
break;
}
};
textarea.addEventListener('keydown', handleKeyDown);
return () => textarea.removeEventListener('keydown', handleKeyDown);
}, [isOpen, profiles, selectedIndex, textareaRef]); // eslint-disable-line react-hooks/exhaustive-deps
// Scroll selected item into view
useEffect(() => {
if (selectedIndex >= 0 && listRef.current) {
const items = listRef.current.querySelectorAll('[data-mention-item]');
items[selectedIndex]?.scrollIntoView({ block: 'nearest' });
}
}, [selectedIndex]);
const selectProfile = useCallback((profile: SearchProfile) => {
const npub = nip19.npubEncode(profile.pubkey);
const replacement = `nostr:${npub} `;
const cursor = textareaRef.current?.selectionStart ?? mentionStart + mentionQuery.length + 1;
onInsertMention({
start: mentionStart,
end: cursor,
replacement,
});
setIsOpen(false);
setMentionQuery('');
setMentionStart(-1);
}, [mentionStart, mentionQuery, textareaRef, onInsertMention]);
if (!isOpen || !dropdownPos || !profiles || profiles.length === 0) {
return null;
}
return (
<div
ref={dropdownRef}
className="absolute z-50 w-[280px] rounded-xl border border-border bg-popover shadow-lg overflow-hidden animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150"
style={{ top: dropdownPos.top, left: dropdownPos.left }}
>
<div ref={listRef} className="max-h-[240px] overflow-y-auto py-1">
{profiles.map((profile, index) => (
<MentionItem
key={profile.pubkey}
profile={profile}
isSelected={index === selectedIndex}
isFollowed={followedPubkeys.has(profile.pubkey)}
onClick={() => selectProfile(profile)}
/>
))}
</div>
</div>
);
}
function MentionItem({
profile,
isSelected,
isFollowed,
onClick,
}: {
profile: SearchProfile;
isSelected: boolean;
isFollowed: boolean;
onClick: () => void;
}) {
const { metadata, pubkey } = profile;
const displayName = metadata.display_name || metadata.name || genUserName(pubkey);
const nip05 = metadata.nip05;
const nip05Display = nip05?.startsWith('_@') ? nip05.slice(2) : nip05;
const identifier = nip05Display || nip19.npubEncode(pubkey);
return (
<button
data-mention-item
className={cn(
'w-full flex items-center gap-3 px-3 py-2 text-left transition-colors cursor-pointer',
isSelected ? 'bg-accent text-accent-foreground' : 'hover:bg-secondary/60',
)}
onClick={onClick}
onMouseDown={(e) => e.preventDefault()}
>
<div className="relative shrink-0">
<Avatar className="size-8">
<AvatarImage src={metadata.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-xs">
{displayName[0]?.toUpperCase() || '?'}
</AvatarFallback>
</Avatar>
{isFollowed && (
<span
className="absolute -bottom-0.5 -right-0.5 size-3.5 rounded-full bg-primary flex items-center justify-center ring-2 ring-popover"
title="Following"
>
<UserRoundCheck className="size-2 text-primary-foreground" strokeWidth={3} />
</span>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-semibold text-sm truncate">
<EmojifiedText tags={profile.event.tags}>{displayName}</EmojifiedText>
</span>
</div>
<div className="text-xs text-muted-foreground truncate">
{nip05Display ? (
<span className="truncate">{identifier}</span>
) : (
<span className="truncate font-mono text-[11px]">{identifier}</span>
)}
</div>
</div>
</button>
);
}
+17
View File
@@ -17,6 +17,8 @@ import type { AddrCoords } from '@/hooks/useEvent';
interface NoteContentProps {
event: NostrEvent;
className?: string;
/** When true, renders URLs as inline links instead of link preview cards / embeds. */
disableEmbeds?: boolean;
}
/** Regex to detect media file URLs (images, video, audio, webxdc, etc.) that are rendered as embeds. */
@@ -98,6 +100,7 @@ function isOnlyEmojis(text: string): boolean {
export function NoteContent({
event,
className,
disableEmbeds = false,
}: NoteContentProps) {
const tokens = useMemo(() => {
const text = event.content;
@@ -277,6 +280,20 @@ export function NoteContent({
case 'text':
return <span key={i}>{emojify(token.value, emojiMap)}</span>;
case 'link-preview':
if (disableEmbeds) {
return (
<a
key={i}
href={token.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline break-all"
onClick={(e) => e.stopPropagation()}
>
{token.url}
</a>
);
}
return <LinkPreview key={i} url={token.url} className="my-2.5" />;
case 'inline-link':
return (
+22 -8
View File
@@ -1,6 +1,6 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Search } from 'lucide-react';
import { Search, UserRoundCheck } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { Input } from '@/components/ui/input';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
@@ -37,7 +37,7 @@ export function ProfileSearchDropdown({
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const { data: profiles, isFetching } = useSearchProfiles(query);
const { data: profiles, isFetching, followedPubkeys } = useSearchProfiles(query);
// Show dropdown when we have results, or when text search is enabled and there's a query
useEffect(() => {
@@ -192,6 +192,7 @@ export function ProfileSearchDropdown({
key={profile.pubkey}
profile={profile}
isSelected={index === selectedIndex}
isFollowed={followedPubkeys.has(profile.pubkey)}
onClick={() => handleSelect(profile)}
/>
))}
@@ -226,6 +227,7 @@ export function ProfileSearchDropdown({
key={profile.pubkey}
profile={profile}
isSelected={index === selectedIndex}
isFollowed={followedPubkeys.has(profile.pubkey)}
onClick={() => handleSelect(profile)}
/>
))}
@@ -248,10 +250,12 @@ export function ProfileSearchDropdown({
function ProfileItem({
profile,
isSelected,
isFollowed,
onClick,
}: {
profile: SearchProfile;
isSelected: boolean;
isFollowed: boolean;
onClick: () => void;
}) {
const { metadata, pubkey } = profile;
@@ -276,12 +280,22 @@ function ProfileItem({
onClick={onClick}
onMouseDown={(e) => e.preventDefault()} // Prevent input blur
>
<Avatar className="size-10 shrink-0">
<AvatarImage src={metadata.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">
{displayName[0]?.toUpperCase() || '?'}
</AvatarFallback>
</Avatar>
<div className="relative shrink-0">
<Avatar className="size-10">
<AvatarImage src={metadata.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">
{displayName[0]?.toUpperCase() || '?'}
</AvatarFallback>
</Avatar>
{isFollowed && (
<span
className="absolute -bottom-0.5 -right-0.5 size-4 rounded-full bg-primary flex items-center justify-center ring-2 ring-popover"
title="Following"
>
<UserRoundCheck className="size-2.5 text-primary-foreground" strokeWidth={3} />
</span>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
+32
View File
@@ -0,0 +1,32 @@
import { useAuthors } from '@/hooks/useAuthors';
import { useFollowList } from '@/hooks/useFollowActions';
import type { SearchProfile } from '@/hooks/useSearchProfiles';
/**
* Returns cached profile metadata for all users the current user follows.
* This enables client-side search matching against the follow list
* so autocomplete can prioritize followed profiles.
*/
export function useFollowedProfiles() {
const { data: followList } = useFollowList();
const pubkeys = followList?.pubkeys ?? [];
const { data: authorsMap } = useAuthors(pubkeys);
// Build SearchProfile[] from the authors map for profiles that have metadata
const profiles: SearchProfile[] = [];
if (authorsMap) {
for (const [, author] of authorsMap) {
if (author.event && author.metadata) {
profiles.push({
pubkey: author.pubkey,
metadata: author.metadata,
event: author.event,
});
}
}
}
return { profiles, pubkeys: new Set(pubkeys) };
}
+66 -1
View File
@@ -1,7 +1,9 @@
import { useMemo } from 'react';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { NSchema as n } from '@nostrify/nostrify';
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
import { useFollowedProfiles } from '@/hooks/useFollowedProfiles';
export interface SearchProfile {
pubkey: string;
@@ -9,11 +11,23 @@ export interface SearchProfile {
event: NostrEvent;
}
/** Check if a profile matches a search query by name, display_name, nip05, or about. */
function profileMatchesQuery(metadata: NostrMetadata, query: string): boolean {
const q = query.toLowerCase();
const fields = [
metadata.name,
metadata.display_name,
metadata.nip05,
];
return fields.some((field) => field?.toLowerCase().includes(q));
}
/** Search for profiles by username/nip05 using NIP-50 search on relay.ditto.pub. */
export function useSearchProfiles(query: string) {
const { nostr } = useNostr();
const { profiles: followedProfiles, pubkeys: followedPubkeys } = useFollowedProfiles();
return useQuery<SearchProfile[]>({
const relayResults = useQuery<SearchProfile[]>({
queryKey: ['search-profiles', query],
queryFn: async ({ signal }) => {
if (!query.trim()) return [];
@@ -50,4 +64,55 @@ export function useSearchProfiles(query: string) {
staleTime: 30 * 1000,
placeholderData: (prev) => prev,
});
// Merge followed profiles (client-side matched) ahead of relay results
const data = useMemo(() => {
const trimmed = query.trim();
if (!trimmed) return relayResults.data;
// Client-side match against followed profiles
const matchedFollows = followedProfiles.filter((p) =>
profileMatchesQuery(p.metadata, trimmed),
);
const relayProfiles = relayResults.data ?? [];
// Build merged list: matched follows first, then relay results (no dupes)
const seen = new Set<string>();
const merged: SearchProfile[] = [];
// Add matched follows first
for (const profile of matchedFollows) {
if (!seen.has(profile.pubkey)) {
seen.add(profile.pubkey);
merged.push(profile);
}
}
// Add relay results, but for followed users move them to follow section
// (they're already there from above, so just skip dupes)
// For non-followed relay results, sort followed ones first too
const remainingFollowed: SearchProfile[] = [];
const remainingOther: SearchProfile[] = [];
for (const profile of relayProfiles) {
if (seen.has(profile.pubkey)) continue;
seen.add(profile.pubkey);
if (followedPubkeys.has(profile.pubkey)) {
remainingFollowed.push(profile);
} else {
remainingOther.push(profile);
}
}
merged.push(...remainingFollowed, ...remainingOther);
return merged;
}, [query, followedProfiles, followedPubkeys, relayResults.data]);
return {
...relayResults,
data,
followedPubkeys,
};
}
+20 -10
View File
@@ -1,5 +1,5 @@
import { useSeoMeta } from '@unhead/react';
import { ChevronUp, ChevronDown, Search as SearchIcon, Flame, TrendingUp, Swords, Image, Video, Film, Languages } from 'lucide-react';
import { ChevronUp, ChevronDown, Search as SearchIcon, Flame, TrendingUp, Swords, Image, Video, Film, Languages, UserRoundCheck } from 'lucide-react';
import { useState, useMemo, useEffect, useCallback } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { NoteCard } from '@/components/NoteCard';
@@ -103,7 +103,7 @@ export function SearchPage() {
// Hooks
const { posts: allPosts, isLoading: postsLoading } = useStreamPosts(searchQuery, { includeReplies, mediaType, language });
const { data: profiles, isLoading: profilesLoading } = useSearchProfiles(activeTab === 'accounts' ? searchQuery : '');
const { data: profiles, isLoading: profilesLoading, followedPubkeys } = useSearchProfiles(activeTab === 'accounts' ? searchQuery : '');
const isTrendsTab = activeTab === 'trends';
const { data: trends, isLoading: trendsLoading } = useTrendingTags(isTrendsTab);
const { data: rawSortedPosts, isLoading: sortedLoading } = useSortedPosts(trendSort, 5, isTrendsTab);
@@ -360,7 +360,7 @@ export function SearchPage() {
) : profiles && profiles.length > 0 ? (
<div className="divide-y divide-border">
{profiles.map((profile) => (
<AccountItem key={profile.pubkey} profile={profile} />
<AccountItem key={profile.pubkey} profile={profile} isFollowed={followedPubkeys.has(profile.pubkey)} />
))}
</div>
) : (
@@ -427,7 +427,7 @@ function TrendItem({ trend }: { trend: { tag: string; count: number } }) {
);
}
function AccountItem({ profile }: { profile: { pubkey: string; metadata: Record<string, unknown>; event?: { tags: string[][] } } }) {
function AccountItem({ profile, isFollowed }: { profile: { pubkey: string; metadata: Record<string, unknown>; event?: { tags: string[][] } }; isFollowed: boolean }) {
const npub = useMemo(() => nip19.npubEncode(profile.pubkey), [profile.pubkey]);
const metadata = profile.metadata as { name?: string; nip05?: string; picture?: string; about?: string; bot?: boolean };
const displayName = metadata?.name || genUserName(profile.pubkey);
@@ -438,12 +438,22 @@ function AccountItem({ profile }: { profile: { pubkey: string; metadata: Record<
to={`/${npub}`}
className="flex items-center gap-3 px-4 py-3 hover:bg-secondary/30 transition-colors"
>
<Avatar className="size-11 shrink-0">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">
{displayName[0]?.toUpperCase() || '?'}
</AvatarFallback>
</Avatar>
<div className="relative shrink-0">
<Avatar className="size-11">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">
{displayName[0]?.toUpperCase() || '?'}
</AvatarFallback>
</Avatar>
{isFollowed && (
<span
className="absolute -bottom-0.5 -right-0.5 size-[18px] rounded-full bg-primary flex items-center justify-center ring-2 ring-background"
title="Following"
>
<UserRoundCheck className="size-2.5 text-primary-foreground" strokeWidth={3} />
</span>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<p className="font-bold text-[15px] truncate">