Merge branch 'main' of gitlab.com:soapbox-pub/ditto-mew
This commit is contained in:
Generated
+20
@@ -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": [
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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 (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user