2006 lines
86 KiB
TypeScript
2006 lines
86 KiB
TypeScript
import { lazy, Suspense, useState, useRef, useCallback, useMemo, useEffect } from 'react';
|
||
import { Link } from 'react-router-dom';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { Paperclip, Smile, AlertTriangle, X, Loader2, Mic, Square, Sticker, BarChart3, Plus, ChevronLeft, Check, Globe, HelpCircle } from 'lucide-react';
|
||
import { nip19 } from 'nostr-tools';
|
||
import { encode as blurhashEncode } from 'blurhash';
|
||
import type { NostrEvent } from '@nostrify/nostrify';
|
||
|
||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Skeleton } from '@/components/ui/skeleton';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||
|
||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuSeparator,
|
||
DropdownMenuTrigger,
|
||
} from '@/components/ui/dropdown-menu';
|
||
import {
|
||
CommandDialog,
|
||
CommandEmpty,
|
||
CommandGroup,
|
||
CommandInput,
|
||
CommandItem,
|
||
CommandList,
|
||
} from '@/components/ui/command';
|
||
import { useCustomEmojis } from '@/hooks/useCustomEmojis';
|
||
import { useFeedSettings } from '@/hooks/useFeedSettings';
|
||
import { GifPicker } from '@/components/GifPicker';
|
||
import { EmbeddedNote } from '@/components/EmbeddedNote';
|
||
import { EmbeddedNaddr } from '@/components/EmbeddedNaddr';
|
||
import { MentionAutocomplete } from '@/components/MentionAutocomplete';
|
||
import { EmojiShortcodeAutocomplete } from '@/components/EmojiShortcodeAutocomplete';
|
||
import { StickerPicker } from '@/components/StickerPicker';
|
||
|
||
import { NoteContent } from '@/components/NoteContent';
|
||
|
||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||
import { usePostComment } from '@/hooks/usePostComment';
|
||
import { useUploadFile } from '@/hooks/useUploadFile';
|
||
import { useCountryFollows } from '@/hooks/useCountryFollows';
|
||
import { useDefaultPostCountry } from '@/hooks/useDefaultPostCountry';
|
||
import { COUNTRY_LIST, getCountryInfo } from '@/lib/countries';
|
||
import { createCountryIdentifier } from '@/lib/countryIdentifiers';
|
||
import { useQueryClient } from '@tanstack/react-query';
|
||
import { useToast } from '@/hooks/useToast';
|
||
import { useAppContext } from '@/hooks/useAppContext';
|
||
import type { Nip85EventStats } from '@/hooks/useNip85Stats';
|
||
import { invalidateEventStats } from '@/lib/invalidateEventStats';
|
||
import { cn } from '@/lib/utils';
|
||
import { notificationSuccess } from '@/lib/haptics';
|
||
import { extractVideoUrls, extractAudioUrls, IMETA_MEDIA_URL_REGEX, IMETA_MEDIA_URL_TEST_REGEX, mimeFromExt } from '@/lib/mediaUrls';
|
||
|
||
/** Lazy-loaded EmojiPicker — keeps emoji-mart + its data out of the main bundle. */
|
||
const LazyEmojiPicker = lazy(() => import('@/components/EmojiPicker').then(m => ({ default: m.EmojiPicker })));
|
||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||
import { useInsertText } from '@/hooks/useInsertText';
|
||
import { useVoiceRecorder } from '@/hooks/useVoiceRecorder';
|
||
import { formatTime } from '@/lib/formatTime';
|
||
import { genUserName } from '@/lib/genUserName';
|
||
import { DITTO_RELAY } from '@/lib/appRelays';
|
||
import { resizeImage } from '@/lib/resizeImage';
|
||
import { extractHashtags } from '@/lib/hashtag';
|
||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||
import { AGORA_DEFAULT_NOTE_TAGS } from '@/lib/agoraNoteTags';
|
||
|
||
const MAX_CHARS = 5000;
|
||
|
||
/** Short random ID for poll options. */
|
||
function pollOptionId(): string {
|
||
return Math.random().toString(36).slice(2, 8);
|
||
}
|
||
|
||
/**
|
||
* For an image File, returns `{ dim: "WxH", blurhash: "..." }`.
|
||
* Decodes to a small canvas (max 64px wide) for speed — large enough
|
||
* for a good blurhash sample but cheap to compute.
|
||
* Returns an empty object for non-image files or if anything fails.
|
||
*/
|
||
async function getImageMeta(file: File): Promise<{ dim?: string; blurhash?: string }> {
|
||
if (!file.type.startsWith('image/')) return {};
|
||
try {
|
||
const url = URL.createObjectURL(file);
|
||
try {
|
||
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||
const el = new Image();
|
||
el.onload = () => resolve(el);
|
||
el.onerror = reject;
|
||
el.src = url;
|
||
});
|
||
|
||
const naturalWidth = img.naturalWidth;
|
||
const naturalHeight = img.naturalHeight;
|
||
if (!naturalWidth || !naturalHeight) return {};
|
||
|
||
const dim = `${naturalWidth}x${naturalHeight}`;
|
||
|
||
// Downsample for blurhash encoding — 64px wide keeps it fast
|
||
const SAMPLE_W = 64;
|
||
const scale = SAMPLE_W / naturalWidth;
|
||
const sampleW = SAMPLE_W;
|
||
const sampleH = Math.max(1, Math.round(naturalHeight * scale));
|
||
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = sampleW;
|
||
canvas.height = sampleH;
|
||
const ctx = canvas.getContext('2d');
|
||
if (!ctx) return { dim };
|
||
|
||
ctx.drawImage(img, 0, 0, sampleW, sampleH);
|
||
const { data } = ctx.getImageData(0, 0, sampleW, sampleH);
|
||
|
||
// componentX/Y: 4x3 gives a good balance of detail vs hash length
|
||
const blurhash = blurhashEncode(data, sampleW, sampleH, 4, 3);
|
||
return { dim, blurhash };
|
||
} finally {
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
} catch {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
/** Root target for a compose action that isn't a Nostr event — a URL or a NIP-73 hashtag-style identifier (e.g. `bitcoin:tx:...`, `isbn:...`, `iso3166:...`). */
|
||
export type ExternalReplyRoot = URL | `#${string}`;
|
||
|
||
/** True if `replyTo` is an external (non-Nostr-event) root. */
|
||
function isExternalRoot(replyTo: NostrEvent | ExternalReplyRoot | undefined): replyTo is ExternalReplyRoot {
|
||
return replyTo instanceof URL || typeof replyTo === 'string';
|
||
}
|
||
|
||
interface ComposeBoxProps {
|
||
onSuccess?: () => void;
|
||
/** Callback with the freshly published event, useful for optimistic parent caches. */
|
||
onPublished?: (event: NostrEvent) => void;
|
||
placeholder?: string;
|
||
compact?: boolean;
|
||
/** Event being replied to – adds NIP-10 reply tags when set. A URL or NIP-73 identifier triggers NIP-22 comment mode. */
|
||
replyTo?: NostrEvent | ExternalReplyRoot;
|
||
/** Event being quoted – shows embedded preview and adds quote tags. */
|
||
quotedEvent?: NostrEvent;
|
||
/** If true, the compose area is always expanded (e.g. inside a modal). */
|
||
forceExpanded?: boolean;
|
||
/** If true, hides the avatar (useful inside modals with their own layout). */
|
||
hideAvatar?: boolean;
|
||
/** If true, suppresses the bottom border. Use when the composer sits directly above a visually distinct section (e.g. tabs with an arc background) that already provides separation. */
|
||
hideBorder?: boolean;
|
||
/** Extra class names merged onto the outer wrapper. Useful for
|
||
* overriding the default `bg-background/85` when the composer is
|
||
* rendered inside a card surface. */
|
||
className?: string;
|
||
/** Controlled preview mode (for modal usage). */
|
||
previewMode?: boolean;
|
||
/** Callback to notify parent of previewable content changes. */
|
||
onHasPreviewableContentChange?: (hasContent: boolean) => void;
|
||
/** Pre-filled content for the compose box. */
|
||
initialContent?: string;
|
||
/** Open directly in poll mode. */
|
||
initialMode?: 'post' | 'poll';
|
||
/** Publish directly as a custom event kind with fixed tags instead of note/comment mode. */
|
||
customPublish?: {
|
||
kind: number;
|
||
tags: string[][];
|
||
successTitle?: string;
|
||
successDescription?: string;
|
||
suppressSuccessToast?: boolean;
|
||
};
|
||
/** Hide the poll option from the overflow tray. */
|
||
hidePoll?: boolean;
|
||
/** Label for the primary submit button. */
|
||
submitLabel?: string;
|
||
/**
|
||
* Tags added to new top-level kind 1 notes without putting them in content.
|
||
*
|
||
* Defaults to {@link AGORA_DEFAULT_NOTE_TAGS} (the silent `t:agora` tag) when
|
||
* the composer is producing a top-level kind 1 note (no replyTo, not a quote,
|
||
* not poll mode, no custom publish, no country-scoped destination). Replies,
|
||
* quotes, polls, comments, and custom-kind publishes do not receive these
|
||
* tags regardless of this prop. Pass `[]` to opt out explicitly.
|
||
*/
|
||
defaultTags?: string[][];
|
||
/** If true, the composer starts expanded without taking modal/flex behavior. */
|
||
defaultExpanded?: boolean;
|
||
}
|
||
|
||
/** Circular progress ring for character count. */
|
||
function CharRing({ count, max }: { count: number; max: number }) {
|
||
const radius = 10;
|
||
const circumference = 2 * Math.PI * radius;
|
||
const ratio = Math.min(count / max, 1);
|
||
const offset = circumference * (1 - ratio);
|
||
const overLimit = count > max;
|
||
const nearLimit = count > max * 0.9;
|
||
|
||
return (
|
||
<div className="relative flex items-center justify-center size-7">
|
||
<svg width="28" height="28" viewBox="0 0 28 28" className="-rotate-90">
|
||
{/* Background ring */}
|
||
<circle
|
||
cx="14" cy="14" r={radius}
|
||
fill="none"
|
||
strokeWidth="2.5"
|
||
className="stroke-secondary"
|
||
/>
|
||
{/* Progress ring */}
|
||
<circle
|
||
cx="14" cy="14" r={radius}
|
||
fill="none"
|
||
strokeWidth="2.5"
|
||
strokeDasharray={circumference}
|
||
strokeDashoffset={offset}
|
||
strokeLinecap="round"
|
||
className={cn(
|
||
'transition-all duration-150',
|
||
overLimit ? 'stroke-destructive' : nearLimit ? 'stroke-amber-500' : 'stroke-primary',
|
||
)}
|
||
/>
|
||
</svg>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
|
||
export function ComposeBox({
|
||
onSuccess,
|
||
onPublished,
|
||
placeholder,
|
||
compact = false,
|
||
replyTo,
|
||
quotedEvent,
|
||
forceExpanded = false,
|
||
hideAvatar = false,
|
||
hideBorder = false,
|
||
className,
|
||
previewMode: controlledPreviewMode,
|
||
onHasPreviewableContentChange,
|
||
initialContent = '',
|
||
initialMode = 'post',
|
||
customPublish,
|
||
hidePoll = false,
|
||
submitLabel,
|
||
defaultTags,
|
||
defaultExpanded = false,
|
||
}: ComposeBoxProps) {
|
||
const { t } = useTranslation();
|
||
const effectivePlaceholder = placeholder ?? t('compose.placeholderDefault');
|
||
const effectiveSubmitLabel = submitLabel ?? t('compose.submitDefault');
|
||
const { user, metadata, isLoading: isProfileLoading } = useCurrentUser();
|
||
const userProfileUrl = useProfileUrl(user?.pubkey ?? '', metadata);
|
||
const { mutateAsync: createEvent, isPending, isPending: isPollPending } = useNostrPublish();
|
||
const { mutateAsync: postComment, isPending: isCommentPending } = usePostComment();
|
||
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
||
const { feedSettings } = useFeedSettings();
|
||
const customEmojisEnabled = feedSettings.showCustomEmojis !== false;
|
||
const { emojis: allCustomEmojis } = useCustomEmojis();
|
||
const customEmojis = useMemo(() => customEmojisEnabled ? allCustomEmojis : [], [customEmojisEnabled, allCustomEmojis]);
|
||
const queryClient = useQueryClient();
|
||
const { toast } = useToast();
|
||
const { config } = useAppContext();
|
||
const imageQuality = config.imageQuality;
|
||
const statsPubkey = config.nip85StatsPubkey;
|
||
const isMobile = useIsMobile();
|
||
|
||
// Build a stable localStorage key based on compose context.
|
||
// Different contexts (new post, reply, quote) each get their own draft slot.
|
||
const draftKey = useMemo(() => {
|
||
if (replyTo instanceof URL) return `compose-draft:url:${replyTo.href}`;
|
||
if (typeof replyTo === 'string') return `compose-draft:ext:${replyTo}`;
|
||
if (replyTo) return `compose-draft:reply:${replyTo.id}`;
|
||
if (quotedEvent) return `compose-draft:quote:${quotedEvent.id}`;
|
||
return 'compose-draft:new';
|
||
}, [replyTo, quotedEvent]);
|
||
|
||
const [content, setContent] = useState(() => {
|
||
if (initialContent) return initialContent;
|
||
try {
|
||
return localStorage.getItem(draftKey) ?? '';
|
||
} catch {
|
||
return '';
|
||
}
|
||
});
|
||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||
const [cwEnabled, setCwEnabled] = useState(false);
|
||
const [cwText, setCwText] = useState('');
|
||
const [pickerOpen, setPickerOpen] = useState(false);
|
||
const [pickerTab, setPickerTab] = useState<'emoji' | 'gif' | 'stickers'>('emoji');
|
||
const [trayOpen, setTrayOpen] = useState(false);
|
||
const [internalPreviewMode, setInternalPreviewMode] = useState(false);
|
||
|
||
// Poll mode state
|
||
const [mode, setMode] = useState<'post' | 'poll'>(initialMode);
|
||
|
||
// Country-destination toggle. Only meaningful for top-level new posts
|
||
// from the home feed (no replyTo, not a custom-kind publish). When a
|
||
// country code is selected, the post is published as a NIP-22 kind
|
||
// 1111 comment rooted on that country instead of a plain kind 1 note.
|
||
//
|
||
// The dropdown shows: Global + the countries the user follows (quick
|
||
// picks) + a "Choose another country…" item that opens a searchable
|
||
// dialog over the full country list. So a user can post about any
|
||
// country, even one they don't follow.
|
||
const { followedCountries } = useCountryFollows();
|
||
const canChooseDestination =
|
||
!replyTo && !customPublish && mode === 'post' && !!user;
|
||
/**
|
||
* User's saved default destination (persisted to localStorage). Used as
|
||
* the initial value of `destination` on every fresh compose, and updated
|
||
* when the user clicks "Set as default" in the destination menu.
|
||
*/
|
||
const [defaultPostCountry, setDefaultPostCountry] = useDefaultPostCountry();
|
||
/** `'world'` for a regular kind-1 note, or an ISO 3166 country code for a kind-1111 community post. */
|
||
const [destination, setDestination] = useState<'world' | string>(defaultPostCountry);
|
||
/** Open state for the "Choose another country" searchable picker dialog. */
|
||
const [countryPickerOpen, setCountryPickerOpen] = useState(false);
|
||
const selectedCountryCode = destination !== 'world' ? destination : null;
|
||
const selectedCountryInfo = selectedCountryCode ? getCountryInfo(selectedCountryCode) : null;
|
||
// Snap back to world if the currently selected destination is an
|
||
// invalid ISO code (e.g. a previously-followed country that was later
|
||
// removed from the country directory). Picking a non-followed but
|
||
// valid country is allowed — users can post about any country via the
|
||
// "Choose another country" picker, so following is not a prerequisite.
|
||
useEffect(() => {
|
||
if (selectedCountryCode && !getCountryInfo(selectedCountryCode)) {
|
||
setDestination('world');
|
||
if (defaultPostCountry === selectedCountryCode) {
|
||
setDefaultPostCountry('world');
|
||
}
|
||
}
|
||
}, [selectedCountryCode, defaultPostCountry, setDefaultPostCountry]);
|
||
const [pollOptions, setPollOptions] = useState([
|
||
{ id: pollOptionId(), label: '' },
|
||
{ id: pollOptionId(), label: '' },
|
||
]);
|
||
const [pollType, setPollType] = useState<'singlechoice' | 'multiplechoice'>('singlechoice');
|
||
const [pollDuration, setPollDuration] = useState<7 | 3 | 1 | 0>(7);
|
||
const [removedEmbeds, setRemovedEmbeds] = useState<Set<string>>(new Set());
|
||
/** Maps uploaded file URLs to their NIP-94 tags (grouped per upload). */
|
||
const [uploadedFileGroups, setUploadedFileGroups] = useState<Map<string, string[][]>>(new Map());
|
||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||
const { insertAtCursor, insertEmoji: insertEmojiAtCursor } = useInsertText(textareaRef, content, setContent);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
// Voice recording
|
||
const voiceRecorder = useVoiceRecorder();
|
||
const [isPublishingVoice, setIsPublishingVoice] = useState(false);
|
||
|
||
const resetComposeState = useCallback(() => {
|
||
setContent('');
|
||
setCwEnabled(false);
|
||
setCwText('');
|
||
setExpanded(defaultExpanded);
|
||
setPickerOpen(false);
|
||
setTrayOpen(false);
|
||
setInternalPreviewMode(false);
|
||
setMode(initialMode);
|
||
setPollOptions([{ id: pollOptionId(), label: '' }, { id: pollOptionId(), label: '' }]);
|
||
setPollType('singlechoice');
|
||
setPollDuration(7);
|
||
setRemovedEmbeds(new Set());
|
||
setUploadedFileGroups(new Map());
|
||
setDestination(defaultPostCountry);
|
||
// Clear the auto-saved draft
|
||
try { localStorage.removeItem(draftKey); } catch { /* ignore */ }
|
||
}, [initialMode, draftKey, defaultExpanded, defaultPostCountry]);
|
||
|
||
// Use controlled preview mode if provided, otherwise use internal state
|
||
const previewMode = controlledPreviewMode !== undefined ? controlledPreviewMode : internalPreviewMode;
|
||
|
||
// Auto-expand when quotedEvent is provided or draft is restored
|
||
useEffect(() => {
|
||
if (quotedEvent || content) {
|
||
setExpanded(true);
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [quotedEvent]); // Only run on mount / quotedEvent change, not on every content change
|
||
|
||
// Auto-resize textarea height as content grows/shrinks.
|
||
// Also re-run when previewMode toggles off so the remounted textarea
|
||
// is sized to fit its content immediately.
|
||
useEffect(() => {
|
||
const el = textareaRef.current;
|
||
if (!el) return;
|
||
// Reset to auto so shrinking is detected correctly
|
||
el.style.height = 'auto';
|
||
el.style.height = `${el.scrollHeight}px`;
|
||
}, [content, previewMode]);
|
||
|
||
// Auto-save draft content to localStorage (debounced to avoid thrashing)
|
||
useEffect(() => {
|
||
if (initialContent) return; // Don't auto-save when content was pre-filled
|
||
const timer = setTimeout(() => {
|
||
try {
|
||
if (content.trim()) {
|
||
localStorage.setItem(draftKey, content);
|
||
} else {
|
||
localStorage.removeItem(draftKey);
|
||
}
|
||
} catch {
|
||
// localStorage might be full or unavailable
|
||
}
|
||
}, 300);
|
||
return () => clearTimeout(timer);
|
||
}, [content, draftKey, initialContent]);
|
||
|
||
// On mobile, blur the textarea when the picker opens to dismiss the keyboard.
|
||
const pickerWasOpen = useRef(false);
|
||
useEffect(() => {
|
||
if (!isMobile) return;
|
||
if (pickerOpen) {
|
||
textareaRef.current?.blur();
|
||
pickerWasOpen.current = true;
|
||
} else if (pickerWasOpen.current) {
|
||
// Refocus after picker closes so the user can keep typing
|
||
pickerWasOpen.current = false;
|
||
const timer = setTimeout(() => textareaRef.current?.focus(), 150);
|
||
return () => clearTimeout(timer);
|
||
}
|
||
}, [pickerOpen, isMobile]);
|
||
|
||
const charCount = content.length;
|
||
const remaining = MAX_CHARS - charCount;
|
||
|
||
const expand = useCallback(() => {
|
||
if (!expanded) setExpanded(true);
|
||
}, [expanded]);
|
||
|
||
const buildContentWarningTags = useCallback((): string[][] => {
|
||
if (!cwEnabled) return [];
|
||
|
||
const tags: string[][] = [
|
||
['content-warning', cwText || ''],
|
||
['L', 'content-warning'],
|
||
];
|
||
|
||
if (cwText) {
|
||
tags.push(['l', cwText, 'content-warning']);
|
||
}
|
||
|
||
return tags;
|
||
}, [cwEnabled, cwText]);
|
||
|
||
|
||
// When the compose box transitions from collapsed → expanded (feed context),
|
||
// ensure the textarea keeps focus. The height change re-render can
|
||
// occasionally drop focus on desktop browsers. On iOS the native tap
|
||
// already handles focus, so this is mainly a desktop safety net.
|
||
const wasExpanded = useRef(defaultExpanded);
|
||
useEffect(() => {
|
||
if (expanded && !wasExpanded.current) {
|
||
textareaRef.current?.focus();
|
||
}
|
||
wasExpanded.current = expanded;
|
||
}, [expanded]);
|
||
|
||
// Detect embeds in content (nevent, note, naddr, URLs) with their positions
|
||
const detectedEmbeds = useMemo(() => {
|
||
const embeds: Array<{
|
||
type: 'nevent' | 'note' | 'naddr' | 'link';
|
||
value: string;
|
||
index: number;
|
||
eventId?: string;
|
||
addr?: { kind: number; pubkey: string; identifier: string }
|
||
}> = [];
|
||
|
||
// Detect nostr: URIs
|
||
const nostrMatches = content.matchAll(/nostr:(nevent1|note1|naddr1)[023456789acdefghjklmnpqrstuvwxyz]+/g);
|
||
for (const match of nostrMatches) {
|
||
const bech32 = match[0].slice('nostr:'.length);
|
||
try {
|
||
const decoded = nip19.decode(bech32);
|
||
if (decoded.type === 'nevent') {
|
||
embeds.push({ type: 'nevent', value: match[0], index: match.index!, eventId: decoded.data.id });
|
||
} else if (decoded.type === 'note') {
|
||
embeds.push({ type: 'note', value: match[0], index: match.index!, eventId: decoded.data });
|
||
} else if (decoded.type === 'naddr') {
|
||
embeds.push({
|
||
type: 'naddr',
|
||
value: match[0],
|
||
index: match.index!,
|
||
addr: {
|
||
kind: decoded.data.kind,
|
||
pubkey: decoded.data.pubkey,
|
||
identifier: decoded.data.identifier
|
||
}
|
||
});
|
||
}
|
||
} catch {
|
||
// Invalid bech32, skip
|
||
}
|
||
}
|
||
|
||
// Detect raw NIP-19 identifiers (without nostr: prefix)
|
||
const rawNip19Matches = content.matchAll(/\b(nevent1|note1|naddr1)[023456789acdefghjklmnpqrstuvwxyz]+\b/g);
|
||
for (const match of rawNip19Matches) {
|
||
const bech32 = match[0];
|
||
// Skip if it's already prefixed with nostr: (already handled above)
|
||
const beforeIndex = match.index! - 6;
|
||
const before = content.substring(Math.max(0, beforeIndex), match.index);
|
||
if (before.endsWith('nostr:')) continue;
|
||
|
||
try {
|
||
const decoded = nip19.decode(bech32);
|
||
if (decoded.type === 'nevent') {
|
||
embeds.push({ type: 'nevent', value: match[0], index: match.index!, eventId: decoded.data.id });
|
||
} else if (decoded.type === 'note') {
|
||
embeds.push({ type: 'note', value: match[0], index: match.index!, eventId: decoded.data });
|
||
} else if (decoded.type === 'naddr') {
|
||
embeds.push({
|
||
type: 'naddr',
|
||
value: match[0],
|
||
index: match.index!,
|
||
addr: {
|
||
kind: decoded.data.kind,
|
||
pubkey: decoded.data.pubkey,
|
||
identifier: decoded.data.identifier
|
||
}
|
||
});
|
||
}
|
||
} catch {
|
||
// Invalid bech32, skip
|
||
}
|
||
}
|
||
|
||
// Detect regular URLs (but not image/video URLs that will be rendered inline)
|
||
const urlMatches = content.matchAll(/https?:\/\/[^\s]+/g);
|
||
for (const match of urlMatches) {
|
||
const url = match[0];
|
||
// Skip media URLs that render inline
|
||
// Note: SVGs not excluded - LinkPreview checks content-type and handles both cases
|
||
if (!IMETA_MEDIA_URL_TEST_REGEX.test(url)) {
|
||
embeds.push({ type: 'link', value: url, index: match.index! });
|
||
}
|
||
}
|
||
|
||
// Sort by position in content
|
||
return embeds.sort((a, b) => a.index - b.index);
|
||
}, [content]);
|
||
|
||
// Filter out removed embeds
|
||
const visibleEmbeds = useMemo(() =>
|
||
detectedEmbeds.filter(embed => !removedEmbeds.has(embed.value)),
|
||
[detectedEmbeds, removedEmbeds]
|
||
);
|
||
|
||
// Extract videos for preview mode
|
||
const previewVideos = useMemo(() => content ? extractVideoUrls(content) : [], [content]);
|
||
|
||
// Extract audio for preview mode
|
||
const previewAudios = useMemo(() => content ? extractAudioUrls(content) : [], [content]);
|
||
|
||
// Detect inline images for preview mode
|
||
const hasPreviewImages = useMemo(() => {
|
||
if (!content) return false;
|
||
return /https?:\/\/[^\s]+\.(jpg|jpeg|png|gif|webp|svg|avif)(\?[^\s]*)?/i.test(content);
|
||
}, [content]);
|
||
|
||
// Detect nostr:npub/nprofile mentions in content
|
||
const hasMentions = useMemo(() => {
|
||
return /nostr:(npub1|nprofile1)[023456789acdefghjklmnpqrstuvwxyz]+/.test(content);
|
||
}, [content]);
|
||
|
||
// Detect custom emojis in content for preview mode
|
||
const hasCustomEmojis = useMemo(() => {
|
||
if (customEmojis.length === 0 || !content) return false;
|
||
const emojiSet = new Set(customEmojis.map((e) => e.shortcode));
|
||
const matches = content.matchAll(/:([a-zA-Z0-9_-]+):/g);
|
||
for (const match of matches) {
|
||
if (emojiSet.has(match[1])) return true;
|
||
}
|
||
return false;
|
||
}, [content, customEmojis]);
|
||
|
||
// Check if content has any previewable content (link previews, images, videos, audio, mentions, or custom emojis)
|
||
const hasPreviewableContent = useMemo(() => {
|
||
return visibleEmbeds.length > 0 || hasPreviewImages || previewVideos.length > 0 || previewAudios.length > 0 || hasMentions || hasCustomEmojis;
|
||
}, [visibleEmbeds, hasPreviewImages, previewVideos, previewAudios, hasMentions, hasCustomEmojis]);
|
||
|
||
// Notify parent of previewable content changes
|
||
useEffect(() => {
|
||
if (onHasPreviewableContentChange) {
|
||
onHasPreviewableContentChange(hasPreviewableContent);
|
||
}
|
||
}, [hasPreviewableContent, onHasPreviewableContentChange]);
|
||
|
||
// Include quoted event if provided and not removed.
|
||
// Use naddr for addressable events (kinds 30000-39999) so the reference
|
||
// stays stable across event updates; use nevent for everything else.
|
||
const quotedEventNip19 = useMemo(() => {
|
||
if (!quotedEvent) return null;
|
||
if (quotedEvent.kind >= 30000 && quotedEvent.kind < 40000) {
|
||
const dTag = quotedEvent.tags.find(([name]) => name === 'd')?.[1] ?? '';
|
||
return nip19.naddrEncode({
|
||
kind: quotedEvent.kind,
|
||
pubkey: quotedEvent.pubkey,
|
||
identifier: dTag,
|
||
relays: [DITTO_RELAY],
|
||
});
|
||
}
|
||
return nip19.neventEncode({ id: quotedEvent.id, author: quotedEvent.pubkey, relays: [DITTO_RELAY] });
|
||
}, [quotedEvent]);
|
||
const quotedEventKey = quotedEventNip19 ? `nostr:${quotedEventNip19}` : null;
|
||
const showQuotedEvent = quotedEvent && quotedEventKey && !removedEmbeds.has(quotedEventKey);
|
||
|
||
// Create mock event for preview
|
||
const mockEvent = useMemo(() => {
|
||
if (!user || !content) return null;
|
||
|
||
const hashtags = extractHashtags(content);
|
||
const tags: string[][] = hashtags.map((t) => ['t', t]);
|
||
|
||
// NIP-30: Add emoji tags for custom emojis referenced in content
|
||
if (customEmojis.length > 0) {
|
||
const emojiMap = new Map(customEmojis.map((e) => [e.shortcode, e.url]));
|
||
const shortcodeRegex = /:([a-zA-Z0-9_-]+):/g;
|
||
const usedEmojis = new Set<string>();
|
||
let match;
|
||
while ((match = shortcodeRegex.exec(content)) !== null) {
|
||
const shortcode = match[1];
|
||
if (emojiMap.has(shortcode) && !usedEmojis.has(shortcode)) {
|
||
usedEmojis.add(shortcode);
|
||
tags.push(['emoji', shortcode, emojiMap.get(shortcode)!]);
|
||
}
|
||
}
|
||
}
|
||
|
||
// NIP-92: Build imeta tags for uploaded media so preview can render them
|
||
const mediaUrlMatches = content.matchAll(new RegExp(IMETA_MEDIA_URL_REGEX.source, 'gi'));
|
||
const processedUrls = new Set<string>();
|
||
for (const m of mediaUrlMatches) {
|
||
const url = m[0];
|
||
if (processedUrls.has(url)) continue;
|
||
processedUrls.add(url);
|
||
const ext = m[1].toLowerCase();
|
||
const fileTags = uploadedFileGroups.get(url);
|
||
if (fileTags) {
|
||
const imetaFields = fileTags.map(tag => `${tag[0]} ${tag[1]}`);
|
||
tags.push(['imeta', ...imetaFields]);
|
||
} else {
|
||
const mimeType = mimeFromExt(ext);
|
||
tags.push(['imeta', `url ${url}`, `m ${mimeType}`]);
|
||
}
|
||
}
|
||
|
||
return {
|
||
id: 'preview',
|
||
pubkey: user.pubkey,
|
||
content: content.trim(),
|
||
created_at: Math.floor(Date.now() / 1000),
|
||
kind: 1,
|
||
tags,
|
||
sig: '',
|
||
};
|
||
}, [user, content, customEmojis, uploadedFileGroups]);
|
||
|
||
const insertEmoji = useCallback((emoji: string) => {
|
||
insertEmojiAtCursor(emoji);
|
||
expand();
|
||
}, [insertEmojiAtCursor, expand]);
|
||
|
||
const handleInsertMention = insertAtCursor;
|
||
|
||
const handleInsertShortcodeEmoji = insertAtCursor;
|
||
|
||
const handleFileUpload = useCallback(async (file: File) => {
|
||
try {
|
||
const isImage = file.type.startsWith('image/');
|
||
|
||
let uploadableFile: File;
|
||
let resizedDim: string | undefined;
|
||
|
||
if (isImage && imageQuality === 'compressed') {
|
||
// Resize & optimize images before uploading for better performance.
|
||
const resized = await resizeImage(file);
|
||
uploadableFile = resized.file;
|
||
resizedDim = resized.dimensions;
|
||
} else {
|
||
uploadableFile = file;
|
||
}
|
||
|
||
const tags = await uploadFile(uploadableFile);
|
||
const [[, url]] = tags;
|
||
|
||
// Compute dim + blurhash and inject into NIP-94 tags
|
||
if (isImage) {
|
||
// Use dimensions from resizeImage; compute blurhash from the resized file
|
||
if (resizedDim) tags.push(['dim', resizedDim]);
|
||
const { blurhash } = await getImageMeta(uploadableFile);
|
||
if (blurhash) tags.push(['blurhash', blurhash]);
|
||
}
|
||
|
||
// Store the full NIP-94 tags for later use in imeta
|
||
setUploadedFileGroups((prev) => new Map(prev).set(url, tags));
|
||
setContent((prev) => (prev ? prev + '\n' + url : url));
|
||
|
||
expand();
|
||
} catch {
|
||
toast({ title: t('compose.submit.uploadFailed'), description: t('compose.submit.uploadFailedBody'), variant: 'destructive' });
|
||
}
|
||
}, [uploadFile, expand, toast, imageQuality, t]);
|
||
|
||
const handlePaste = useCallback(async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||
const items = e.clipboardData?.items;
|
||
if (!items) return;
|
||
|
||
// Check for image files in clipboard
|
||
for (let i = 0; i < items.length; i++) {
|
||
const item = items[i];
|
||
if (item.type.startsWith('image/')) {
|
||
e.preventDefault(); // Prevent default paste behavior for images
|
||
const file = item.getAsFile();
|
||
if (file) {
|
||
await handleFileUpload(file);
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}, [handleFileUpload]);
|
||
|
||
/** Start voice recording. */
|
||
const handleStartRecording = useCallback(async () => {
|
||
try {
|
||
await voiceRecorder.startRecording();
|
||
expand();
|
||
} catch {
|
||
toast({ title: t('compose.voice.micDeniedTitle'), description: t('compose.voice.micDeniedBody'), variant: 'destructive' });
|
||
}
|
||
}, [voiceRecorder, expand, toast, t]);
|
||
|
||
/** Stop recording, upload, and publish as kind 1222 or 1244. */
|
||
const handleStopAndPublishVoice = useCallback(async () => {
|
||
if (!user) return;
|
||
setIsPublishingVoice(true);
|
||
try {
|
||
const recording = await voiceRecorder.stopRecording();
|
||
if (!recording) return;
|
||
|
||
// Determine file extension from MIME type
|
||
const extMap: Record<string, string> = {
|
||
'audio/mp4': '.m4a',
|
||
'audio/mp4;codecs=aac': '.m4a',
|
||
'audio/aac': '.aac',
|
||
'audio/webm;codecs=opus': '.webm',
|
||
'audio/webm': '.webm',
|
||
'audio/ogg;codecs=opus': '.ogg',
|
||
};
|
||
const ext = extMap[recording.mimeType] ?? '.webm';
|
||
const fileName = `voice-message-${Date.now()}${ext}`;
|
||
const file = new File([recording.blob], fileName, { type: recording.mimeType });
|
||
|
||
// Upload to Blossom
|
||
const tags = await uploadFile(file);
|
||
const [[, audioUrl]] = tags;
|
||
|
||
// Build NIP-A0 imeta tag with waveform and duration
|
||
const imetaFields = [
|
||
`url ${audioUrl}`,
|
||
`m ${recording.mimeType}`,
|
||
`waveform ${recording.waveform.join(' ')}`,
|
||
`duration ${Math.round(recording.duration)}`,
|
||
];
|
||
const imetaTag = ['imeta', ...imetaFields];
|
||
|
||
const cwTags = buildContentWarningTags();
|
||
|
||
if (customPublish) {
|
||
const event = await createEvent({
|
||
kind: customPublish.kind,
|
||
content: audioUrl,
|
||
tags: [...customPublish.tags, imetaTag, ...cwTags],
|
||
});
|
||
onPublished?.(event);
|
||
} else {
|
||
// Determine kind: 1244 for NIP-22 replies, 1222 for root messages
|
||
const isNip22Reply = replyTo && (isExternalRoot(replyTo) || replyTo.kind !== 1);
|
||
const isKind1Reply = replyTo && !isExternalRoot(replyTo) && replyTo.kind === 1;
|
||
|
||
if (isNip22Reply) {
|
||
// NIP-22 voice reply (kind 1244) — use postComment infrastructure
|
||
// but we need to publish kind 1244 directly since postComment uses kind 1111
|
||
// Build NIP-22 tags manually
|
||
const voiceTags: string[][] = [imetaTag, ...cwTags];
|
||
|
||
if (replyTo instanceof URL) {
|
||
const kLabel = replyTo.protocol === 'http:' || replyTo.protocol === 'https:' ? 'web' : replyTo.protocol.replace(/:$/, '');
|
||
voiceTags.push(['I', replyTo.toString()]);
|
||
voiceTags.push(['K', kLabel]);
|
||
// lowercase reply tags pointing to same root
|
||
voiceTags.push(['i', replyTo.toString()]);
|
||
voiceTags.push(['k', kLabel]);
|
||
} else if (typeof replyTo === 'string') {
|
||
// NIP-73 hashtag-style identifier (e.g. `bitcoin:tx:...`, `isbn:...`, `iso3166:...`)
|
||
voiceTags.push(['I', replyTo]);
|
||
voiceTags.push(['K', '#']);
|
||
voiceTags.push(['i', replyTo]);
|
||
voiceTags.push(['k', '#']);
|
||
} else {
|
||
voiceTags.push(['E', replyTo.id]);
|
||
voiceTags.push(['K', replyTo.kind.toString()]);
|
||
voiceTags.push(['P', replyTo.pubkey]);
|
||
// lowercase reply tags
|
||
voiceTags.push(['e', replyTo.id]);
|
||
voiceTags.push(['k', replyTo.kind.toString()]);
|
||
voiceTags.push(['p', replyTo.pubkey]);
|
||
}
|
||
|
||
await createEvent({
|
||
kind: 1244,
|
||
content: audioUrl,
|
||
tags: voiceTags,
|
||
});
|
||
} else if (isKind1Reply && !isExternalRoot(replyTo)) {
|
||
// NIP-10 voice reply to a kind 1 note — still publish as kind 1222 with reply tags
|
||
const voiceTags: string[][] = [imetaTag, ...cwTags];
|
||
const rootTag = replyTo.tags.find(([name, , , marker]) => name === 'e' && marker === 'root');
|
||
if (rootTag) {
|
||
voiceTags.push(['e', rootTag[1], rootTag[2] || DITTO_RELAY, 'root', ...(rootTag[4] ? [rootTag[4]] : [])]);
|
||
voiceTags.push(['e', replyTo.id, DITTO_RELAY, 'reply', replyTo.pubkey]);
|
||
} else {
|
||
voiceTags.push(['e', replyTo.id, DITTO_RELAY, 'root', replyTo.pubkey]);
|
||
}
|
||
voiceTags.push(['p', replyTo.pubkey]);
|
||
|
||
await createEvent({
|
||
kind: 1222,
|
||
content: audioUrl,
|
||
tags: voiceTags,
|
||
});
|
||
} else {
|
||
// Root voice message (kind 1222)
|
||
await createEvent({
|
||
kind: 1222,
|
||
content: audioUrl,
|
||
tags: [imetaTag, ...cwTags],
|
||
});
|
||
}
|
||
}
|
||
|
||
// Reset state
|
||
queryClient.invalidateQueries({ queryKey: ['feed'] });
|
||
// Voice messages can surface in the home Agora activity feed (via
|
||
// the `t:Agora` marker on root messages and through the comment
|
||
// path on replies). Refresh both home feed queries.
|
||
queryClient.invalidateQueries({ queryKey: ['agora-feed'] });
|
||
queryClient.invalidateQueries({ queryKey: ['mixed-feed'] });
|
||
if (replyTo) {
|
||
if (isExternalRoot(replyTo)) {
|
||
queryClient.invalidateQueries({ queryKey: ['nostr', 'comments'] });
|
||
} else {
|
||
queryClient.invalidateQueries({ queryKey: ['replies', replyTo.id] });
|
||
if (replyTo.kind !== 1) {
|
||
queryClient.invalidateQueries({ queryKey: ['nostr', 'comments'] });
|
||
}
|
||
// Bump comment count on the parent event so the UI updates.
|
||
invalidateEventStats(queryClient, replyTo, statsPubkey);
|
||
}
|
||
} else if (canChooseDestination && selectedCountryCode) {
|
||
// Root voice message published to a country community feed.
|
||
queryClient.invalidateQueries({ queryKey: ['agora-feed-paginated', selectedCountryCode] });
|
||
queryClient.invalidateQueries({ queryKey: ['agora-feed-new-posts', selectedCountryCode] });
|
||
}
|
||
notificationSuccess();
|
||
toast({ title: t('compose.voice.sentTitle'), description: t('compose.voice.sentBody') });
|
||
onSuccess?.();
|
||
} catch {
|
||
toast({ title: t('common.error'), description: t('compose.voice.sendFailed'), variant: 'destructive' });
|
||
} finally {
|
||
setIsPublishingVoice(false);
|
||
}
|
||
}, [user, voiceRecorder, uploadFile, buildContentWarningTags, customPublish, createEvent, onPublished, replyTo, queryClient, toast, onSuccess, canChooseDestination, selectedCountryCode, statsPubkey, t]);
|
||
|
||
const handleSubmit = async () => {
|
||
if (!content.trim() || !user || charCount > MAX_CHARS) return;
|
||
|
||
try {
|
||
const hashtags = extractHashtags(content);
|
||
const tags: string[][] = hashtags.map((t) => ['t', t]);
|
||
|
||
// 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]);
|
||
}
|
||
|
||
// Reply tags: NIP-10 for kind 1 targets, NIP-22 for non-kind-1 targets and external roots (URL or NIP-73 id)
|
||
const isNip22Reply = replyTo && (isExternalRoot(replyTo) || replyTo.kind !== 1);
|
||
|
||
if (replyTo && !isNip22Reply && !isExternalRoot(replyTo)) {
|
||
// NIP-10 reply tags (kind 1 targets only)
|
||
const rootTag = replyTo.tags.find(([name, , , marker]) => name === 'e' && marker === 'root');
|
||
if (rootTag) {
|
||
// replyTo is itself a reply – preserve the root and mark replyTo as reply
|
||
tags.push(['e', rootTag[1], rootTag[2] || DITTO_RELAY, 'root', ...(rootTag[4] ? [rootTag[4]] : [])]);
|
||
tags.push(['e', replyTo.id, DITTO_RELAY, 'reply', replyTo.pubkey]);
|
||
} else {
|
||
// replyTo is a top-level note – it becomes the root
|
||
tags.push(['e', replyTo.id, DITTO_RELAY, 'root', replyTo.pubkey]);
|
||
}
|
||
|
||
// 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 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]);
|
||
}
|
||
}
|
||
|
||
// Quote tags (if quoted event and not removed)
|
||
// Per NIP-18, quotes should use the q tag and include the nostr: URI in content.
|
||
// For addressable events (kinds 30000-39999), use event address coordinates
|
||
// so the reference stays stable across event updates.
|
||
let finalContent = content.trim();
|
||
if (showQuotedEvent && quotedEvent && quotedEventNip19) {
|
||
if (quotedEvent.kind >= 30000 && quotedEvent.kind < 40000) {
|
||
const dTag = quotedEvent.tags.find(([name]) => name === 'd')?.[1] ?? '';
|
||
tags.push(['q', `${quotedEvent.kind}:${quotedEvent.pubkey}:${dTag}`, DITTO_RELAY]);
|
||
} else {
|
||
tags.push(['q', quotedEvent.id, DITTO_RELAY, quotedEvent.pubkey]);
|
||
}
|
||
// Add the nostr: URI to the content if not already present
|
||
const quoteUri = `nostr:${quotedEventNip19}`;
|
||
if (!finalContent.includes(quoteUri)) {
|
||
finalContent = finalContent + '\n\n' + quoteUri;
|
||
}
|
||
}
|
||
|
||
// NIP-36: content warning
|
||
tags.push(...buildContentWarningTags());
|
||
|
||
// NIP-30: Add emoji tags for custom emojis referenced in content
|
||
if (customEmojis.length > 0) {
|
||
const emojiMap = new Map(customEmojis.map((e) => [e.shortcode, e.url]));
|
||
const shortcodeRegex = /:([a-zA-Z0-9_-]+):/g;
|
||
const usedEmojis = new Set<string>();
|
||
let emojiMatch;
|
||
while ((emojiMatch = shortcodeRegex.exec(finalContent)) !== null) {
|
||
const shortcode = emojiMatch[1];
|
||
if (emojiMap.has(shortcode) && !usedEmojis.has(shortcode)) {
|
||
usedEmojis.add(shortcode);
|
||
tags.push(['emoji', shortcode, emojiMap.get(shortcode)!]);
|
||
}
|
||
}
|
||
}
|
||
|
||
// NIP-92: Add imeta tags for media URLs in content
|
||
const mediaUrlMatches = finalContent.matchAll(new RegExp(IMETA_MEDIA_URL_REGEX.source, 'gi'));
|
||
const processedUrls = new Set<string>();
|
||
|
||
for (const match of mediaUrlMatches) {
|
||
const url = match[0];
|
||
if (processedUrls.has(url)) continue;
|
||
processedUrls.add(url);
|
||
|
||
const ext = match[1].toLowerCase();
|
||
|
||
// Build imeta from grouped upload tags if available, otherwise infer
|
||
const fileTags = uploadedFileGroups.get(url);
|
||
|
||
if (fileTags) {
|
||
const imetaFields = fileTags.map(tag => `${tag[0]} ${tag[1]}`);
|
||
tags.push(['imeta', ...imetaFields]);
|
||
} else {
|
||
// Fallback: basic imeta tag with URL and inferred mime type
|
||
const mimeType = mimeFromExt(ext);
|
||
tags.push(['imeta', `url ${url}`, `m ${mimeType}`]);
|
||
}
|
||
}
|
||
|
||
|
||
|
||
if (customPublish) {
|
||
const event = await createEvent({
|
||
kind: customPublish.kind,
|
||
content: finalContent,
|
||
tags: [...customPublish.tags, ...tags],
|
||
created_at: Math.floor(Date.now() / 1000),
|
||
});
|
||
onPublished?.(event);
|
||
} else if (isNip22Reply) {
|
||
// NIP-22: use usePostComment for non-kind-1 targets and URL roots
|
||
// Determine root and reply params for the comment hook
|
||
let root: NostrEvent | URL | `#${string}`;
|
||
let reply: NostrEvent | undefined;
|
||
|
||
if (replyTo instanceof URL) {
|
||
// External content root — the URL is the root directly
|
||
root = replyTo;
|
||
} else if (typeof replyTo === 'string') {
|
||
// NIP-73 hashtag-style identifier root (e.g. `bitcoin:tx:...`, `isbn:...`)
|
||
root = replyTo;
|
||
} else if (replyTo.kind === 1111) {
|
||
// Replying to a comment: replyTo is the parent, root is derived from its uppercase tags
|
||
reply = replyTo;
|
||
|
||
// Reconstruct the original root from the comment's uppercase tags
|
||
const K = replyTo.tags.find(([n]) => n === 'K')?.[1];
|
||
const P = replyTo.tags.find(([n]) => n === 'P')?.[1];
|
||
const A = replyTo.tags.find(([n]) => n === 'A')?.[1];
|
||
const E = replyTo.tags.find(([n]) => n === 'E')?.[1];
|
||
const I = replyTo.tags.find(([n]) => n === 'I')?.[1];
|
||
|
||
// External content root (URL or hashtag identifier)
|
||
if (I) {
|
||
if (K === '#') {
|
||
root = I as `#${string}`;
|
||
} else {
|
||
try {
|
||
root = new URL(I);
|
||
} catch {
|
||
root = I as `#${string}`;
|
||
}
|
||
}
|
||
} else {
|
||
const rootKind = K ? parseInt(K, 10) : 0;
|
||
const rootPubkey = P ?? '';
|
||
|
||
if (A) {
|
||
// Addressable/replaceable root: extract d-tag from the A value
|
||
const parts = A.split(':');
|
||
const dValue = parts.length >= 3 ? parts.slice(2).join(':') : '';
|
||
root = {
|
||
id: E ?? '',
|
||
kind: rootKind,
|
||
pubkey: rootPubkey,
|
||
content: '',
|
||
created_at: 0,
|
||
sig: '',
|
||
tags: [['d', dValue]],
|
||
};
|
||
} else {
|
||
root = {
|
||
id: E ?? '',
|
||
kind: rootKind,
|
||
pubkey: rootPubkey,
|
||
content: '',
|
||
created_at: 0,
|
||
sig: '',
|
||
tags: [],
|
||
};
|
||
}
|
||
}
|
||
} else {
|
||
// Replying directly to a non-kind-1 event: it is the root
|
||
root = replyTo;
|
||
}
|
||
|
||
await postComment({ root, reply, content: finalContent, tags });
|
||
} else if (canChooseDestination && selectedCountryCode) {
|
||
// No replyTo, but the user picked a followed country as the
|
||
// destination — publish as a NIP-22 kind 1111 rooted on the
|
||
// country identifier (mirrors the country page's compose flow).
|
||
const countryRoot = new URL(createCountryIdentifier(selectedCountryCode));
|
||
await postComment({ root: countryRoot, reply: undefined, content: finalContent, tags });
|
||
} else {
|
||
// Top-level kind 1 note. If the caller hasn't supplied `defaultTags`,
|
||
// auto-attach the silent Agora tag so the post surfaces in the Agora
|
||
// activity feed. Callers can opt out by passing `defaultTags={[]}`.
|
||
const effectiveDefaultTags = defaultTags ?? AGORA_DEFAULT_NOTE_TAGS;
|
||
await createEvent({
|
||
kind: 1,
|
||
content: finalContent,
|
||
tags: [...effectiveDefaultTags, ...tags],
|
||
created_at: Math.floor(Date.now() / 1000),
|
||
});
|
||
}
|
||
|
||
resetComposeState();
|
||
// Optimistically bump the comment count on the parent event
|
||
if (replyTo && !isExternalRoot(replyTo)) {
|
||
queryClient.setQueryData<Nip85EventStats | null>(
|
||
['nip85-event-stats', replyTo.id, statsPubkey],
|
||
(prev) => prev ? { ...prev, commentCount: prev.commentCount + 1 } : prev,
|
||
);
|
||
}
|
||
queryClient.invalidateQueries({ queryKey: ['feed'] });
|
||
// Top-level kind 1 posts with the silent Agora tag (the default for
|
||
// user-authored notes) surface in the home Agora activity feed
|
||
// (useAgoraFeed / mixed-feed). Invalidate both so the post appears
|
||
// there without a refresh — over-invalidation is cheap here.
|
||
queryClient.invalidateQueries({ queryKey: ['agora-feed'] });
|
||
queryClient.invalidateQueries({ queryKey: ['mixed-feed'] });
|
||
// Top-level kind 1 posts surface on country pages too. Country posts
|
||
// route through `usePostComment` (which handles its own invalidation),
|
||
// but the top-level branch above publishes via `createEvent`, so we
|
||
// need to invalidate the country feed keys here. `selectedCountryCode`
|
||
// is null for global posts, in which case nothing extra needs to
|
||
// refresh (the global Agora feed is served by relays, not a per-country
|
||
// query). For drafts attached to a specific country via customPublish
|
||
// we conservatively invalidate the broader prefix.
|
||
if (canChooseDestination && selectedCountryCode && !replyTo) {
|
||
queryClient.invalidateQueries({ queryKey: ['agora-feed-paginated', selectedCountryCode] });
|
||
queryClient.invalidateQueries({ queryKey: ['agora-feed-new-posts', selectedCountryCode] });
|
||
setTimeout(() => {
|
||
queryClient.invalidateQueries({ queryKey: ['agora-feed-paginated', selectedCountryCode] });
|
||
queryClient.invalidateQueries({ queryKey: ['agora-feed-new-posts', selectedCountryCode] });
|
||
}, 3000);
|
||
}
|
||
if (replyTo) {
|
||
if (isExternalRoot(replyTo)) {
|
||
queryClient.invalidateQueries({ queryKey: ['nostr', 'comments'] });
|
||
} else {
|
||
queryClient.invalidateQueries({ queryKey: ['replies', replyTo.id] });
|
||
// Invalidate the event-comments cache used by CommentsSheet
|
||
if (replyTo.kind !== 1) {
|
||
const dTag = replyTo.tags.find(([n]) => n === 'd')?.[1] ?? '';
|
||
const aTag = `${replyTo.kind}:${replyTo.pubkey}:${dTag}`;
|
||
queryClient.invalidateQueries({ queryKey: ['event-comments', aTag] });
|
||
}
|
||
}
|
||
}
|
||
if (quotedEvent) {
|
||
invalidateEventStats(queryClient, quotedEvent, statsPubkey);
|
||
queryClient.invalidateQueries({ queryKey: ['event-interactions', quotedEvent.id] });
|
||
}
|
||
notificationSuccess();
|
||
if (!customPublish?.suppressSuccessToast) {
|
||
toast({
|
||
title: customPublish?.successTitle ?? t('compose.submit.posted'),
|
||
description: customPublish?.successDescription ?? (replyTo ? t('compose.submit.replyPublished') : quotedEvent ? t('compose.submit.quotePublished') : t('compose.submit.notePublished')),
|
||
});
|
||
}
|
||
onSuccess?.();
|
||
} catch {
|
||
toast({ title: t('common.error'), description: t('compose.submit.publishFailed'), variant: 'destructive' });
|
||
}
|
||
};
|
||
|
||
const handlePollSubmit = async () => {
|
||
const filledOptions = pollOptions.filter((o) => o.label.trim());
|
||
const finalContent = content.trim();
|
||
if (!finalContent || filledOptions.length < 2 || !user || isPollPending) return;
|
||
|
||
const tags: string[][] = [];
|
||
for (const opt of filledOptions) {
|
||
tags.push(['option', opt.id, opt.label.trim()]);
|
||
}
|
||
tags.push(['polltype', pollType]);
|
||
if (pollDuration > 0) {
|
||
tags.push(['endsAt', String(Math.floor(Date.now() / 1000) + pollDuration * 86_400)]);
|
||
}
|
||
|
||
// NIP-92: Add imeta tags for media URLs in content
|
||
const mediaUrlMatches = finalContent.matchAll(new RegExp(IMETA_MEDIA_URL_REGEX.source, 'gi'));
|
||
const processedUrls = new Set<string>();
|
||
for (const match of mediaUrlMatches) {
|
||
const url = match[0];
|
||
if (processedUrls.has(url)) continue;
|
||
processedUrls.add(url);
|
||
const fileTags = uploadedFileGroups.get(url);
|
||
if (fileTags) {
|
||
tags.push(['imeta', ...fileTags.map(tag => `${tag[0]} ${tag[1]}`)]);
|
||
} else {
|
||
const ext = match[1].toLowerCase();
|
||
tags.push(['imeta', `url ${url}`, `m ${mimeFromExt(ext)}`]);
|
||
}
|
||
}
|
||
|
||
tags.push(['alt', `Poll: ${finalContent}`]);
|
||
|
||
// Country-scoped polls: attach NIP-73 iso3166 tags so the poll appears in
|
||
// the country feed (matches Pathos buildPollTags geo-scoping behavior).
|
||
if (replyTo instanceof URL && replyTo.protocol === 'iso3166:') {
|
||
const countryIdentifier = replyTo.toString();
|
||
tags.push(['I', countryIdentifier]);
|
||
tags.push(['K', 'iso3166']);
|
||
tags.push(['i', countryIdentifier]);
|
||
tags.push(['k', 'iso3166']);
|
||
}
|
||
|
||
try {
|
||
await createEvent({ kind: 1068, content: finalContent, tags });
|
||
resetComposeState();
|
||
queryClient.invalidateQueries({ queryKey: ['feed'] });
|
||
// World-layer polls (iso3166 root) and Agora-marked polls surface
|
||
// in the home Agora activity feed.
|
||
queryClient.invalidateQueries({ queryKey: ['agora-feed'] });
|
||
queryClient.invalidateQueries({ queryKey: ['mixed-feed'] });
|
||
// Polls published with an iso3166 root surface on the country feed.
|
||
if (replyTo instanceof URL && replyTo.protocol === 'iso3166:') {
|
||
const countryCode = replyTo.pathname.toUpperCase();
|
||
queryClient.invalidateQueries({ queryKey: ['agora-feed-paginated', countryCode] });
|
||
queryClient.invalidateQueries({ queryKey: ['agora-feed-new-posts', countryCode] });
|
||
} else if (canChooseDestination && selectedCountryCode) {
|
||
queryClient.invalidateQueries({ queryKey: ['agora-feed-paginated', selectedCountryCode] });
|
||
queryClient.invalidateQueries({ queryKey: ['agora-feed-new-posts', selectedCountryCode] });
|
||
}
|
||
notificationSuccess();
|
||
toast({ title: t('compose.poll.published') });
|
||
onSuccess?.();
|
||
} catch {
|
||
toast({ title: t('common.error'), description: t('compose.poll.publishFailed'), variant: 'destructive' });
|
||
}
|
||
};
|
||
|
||
const pollFilledCount = pollOptions.filter((o) => o.label.trim()).length;
|
||
const isPollValid = content.trim().length > 0 && pollFilledCount >= 2;
|
||
|
||
const isExpanded = forceExpanded || expanded || content.length > 0 || !compact;
|
||
|
||
// Early return after all hooks to avoid violating Rules of Hooks
|
||
if (!user && compact) return null;
|
||
|
||
return (
|
||
<div className={cn(
|
||
"px-4 pt-3 bg-background/85 flex flex-col",
|
||
forceExpanded ? "flex-1 min-h-0 rounded-2xl" : "",
|
||
pickerOpen ? "pb-0" : "pb-3",
|
||
!forceExpanded && !hideBorder && "border-b border-border",
|
||
className,
|
||
)}>
|
||
{/* Preview toggle at top when not controlled and has previewable content */}
|
||
{hasPreviewableContent && controlledPreviewMode === undefined && (
|
||
<div className="flex items-center justify-end mb-3">
|
||
<div className="inline-flex items-center gap-0.5 p-1 bg-muted/50 rounded-lg">
|
||
<button
|
||
onClick={() => setInternalPreviewMode(false)}
|
||
className={cn(
|
||
"px-3.5 py-1.5 text-xs font-medium rounded-md transition-all",
|
||
!previewMode
|
||
? "bg-background text-foreground shadow-sm"
|
||
: "text-muted-foreground hover:text-foreground"
|
||
)}
|
||
>
|
||
{t('compose.preview.edit')}
|
||
</button>
|
||
<button
|
||
onClick={() => setInternalPreviewMode(true)}
|
||
className={cn(
|
||
"px-3.5 py-1.5 text-xs font-medium rounded-md transition-all",
|
||
previewMode
|
||
? "bg-background text-foreground shadow-sm"
|
||
: "text-muted-foreground hover:text-foreground"
|
||
)}
|
||
>
|
||
{t('compose.preview.previewMode')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className={cn("flex gap-3", forceExpanded && "flex-1 min-h-0")}>
|
||
{!hideAvatar && user && (
|
||
isProfileLoading ? (
|
||
<Skeleton className="size-12 shrink-0 mt-0.5 rounded-full" />
|
||
) : (
|
||
<Link to={userProfileUrl} className="shrink-0">
|
||
<Avatar className="size-12 shrink-0 mt-0.5">
|
||
<AvatarImage src={metadata?.picture} alt={metadata?.name} />
|
||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||
{(metadata?.name || metadata?.display_name || genUserName(user?.pubkey))[0]?.toUpperCase() ?? '?'}
|
||
</AvatarFallback>
|
||
</Avatar>
|
||
</Link>
|
||
)
|
||
)}
|
||
|
||
<div className={cn("flex-1 min-w-0", forceExpanded && "flex flex-col min-h-0")}>
|
||
{/* Scrollable content area (textarea, poll, CW, quoted event) */}
|
||
<div className={cn(forceExpanded && "flex-1 min-h-0 overflow-y-auto")}>
|
||
{!previewMode ? (
|
||
/* ── Edit mode — Textarea ────────────────────────────── */
|
||
<div className="relative">
|
||
<textarea
|
||
ref={textareaRef}
|
||
dir="auto"
|
||
value={content}
|
||
onChange={(e) => setContent(e.target.value)}
|
||
onPointerDown={expand}
|
||
onFocus={expand}
|
||
onPaste={handlePaste}
|
||
placeholder={
|
||
mode === 'poll'
|
||
? t('compose.placeholderPoll')
|
||
: selectedCountryInfo
|
||
? t('compose.placeholderCountry', { country: selectedCountryInfo.name })
|
||
: effectivePlaceholder
|
||
}
|
||
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 break-words overflow-hidden transition-[min-height] duration-200 ease-in-out',
|
||
isExpanded ? 'min-h-[100px]' : 'min-h-[44px]',
|
||
)}
|
||
rows={1}
|
||
disabled={!user}
|
||
// In modal context, auto-focus the textarea so the keyboard
|
||
// opens immediately — especially important on iOS where
|
||
// programmatic focus() outside a user gesture is ignored.
|
||
autoFocus={forceExpanded}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||
handleSubmit();
|
||
}
|
||
}}
|
||
/>
|
||
<MentionAutocomplete
|
||
textareaRef={textareaRef}
|
||
content={content}
|
||
onInsertMention={handleInsertMention}
|
||
/>
|
||
<EmojiShortcodeAutocomplete
|
||
textareaRef={textareaRef}
|
||
content={content}
|
||
onInsertEmoji={handleInsertShortcodeEmoji}
|
||
/>
|
||
</div>
|
||
) : (
|
||
/* Preview mode - Show how post will look */
|
||
mockEvent && (
|
||
<div className="pt-2.5 pb-2 min-h-[100px] overflow-hidden">
|
||
<div className="text-lg opacity-85 [&_img]:max-w-full [&_img]:h-auto">
|
||
<NoteContent event={mockEvent} className="text-foreground" />
|
||
</div>
|
||
</div>
|
||
)
|
||
)}
|
||
|
||
{/* Poll options + settings — shown below the normal textarea/preview */}
|
||
{mode === 'poll' && (
|
||
<div className="space-y-3 pt-1">
|
||
{/* Back to post link — hidden when poll mode is the only mode */}
|
||
{initialMode !== 'poll' && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setMode('post')}
|
||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||
>
|
||
<ChevronLeft className="size-3.5 rtl:rotate-180" />
|
||
{t('compose.poll.backToPost')}
|
||
</button>
|
||
)}
|
||
|
||
{/* Options */}
|
||
<div className="space-y-1.5">
|
||
{pollOptions.map((opt, idx) => (
|
||
<div key={opt.id} className="flex items-center gap-2">
|
||
<input
|
||
type="text"
|
||
value={opt.label}
|
||
onChange={(e) =>
|
||
setPollOptions((prev) =>
|
||
prev.map((o) => (o.id === opt.id ? { ...o, label: e.target.value } : o)),
|
||
)
|
||
}
|
||
placeholder={t('compose.poll.optionLabel', { number: idx + 1 })}
|
||
maxLength={100}
|
||
className="flex-1 bg-secondary/40 rounded-lg px-3 py-1.5 text-sm outline-none focus:ring-1 focus:ring-primary/40 placeholder:text-muted-foreground"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
if (pollOptions.length > 2) {
|
||
setPollOptions((prev) => prev.filter((o) => o.id !== opt.id));
|
||
}
|
||
}}
|
||
disabled={pollOptions.length <= 2}
|
||
className="p-1 rounded-full text-muted-foreground hover:text-destructive transition-colors disabled:opacity-20"
|
||
>
|
||
<X className="size-3.5" />
|
||
</button>
|
||
</div>
|
||
))}
|
||
|
||
{pollOptions.length < 8 && (
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
setPollOptions((prev) => [...prev, { id: pollOptionId(), label: '' }])
|
||
}
|
||
className="flex items-center gap-1.5 text-xs text-primary hover:text-primary/80 transition-colors pt-0.5"
|
||
>
|
||
<Plus className="size-3" />
|
||
{t('compose.poll.addOption')}
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Settings row — pill toggles */}
|
||
<div className="flex flex-wrap gap-2 pt-0.5">
|
||
{(['singlechoice', 'multiplechoice'] as const).map((pt) => (
|
||
<button
|
||
key={pt}
|
||
type="button"
|
||
onClick={() => setPollType(pt)}
|
||
className={cn(
|
||
'text-xs px-2.5 py-1 rounded-full border transition-colors',
|
||
pollType === pt
|
||
? 'border-primary bg-primary/10 text-primary font-medium'
|
||
: 'border-border text-muted-foreground hover:text-foreground hover:border-foreground/30',
|
||
)}
|
||
>
|
||
{pt === 'singlechoice' ? t('compose.poll.singleChoice') : t('compose.poll.multipleChoice')}
|
||
</button>
|
||
))}
|
||
<div className="w-px bg-border self-stretch mx-0.5" />
|
||
{([1, 3, 7, 0] as const).map((d) => (
|
||
<button
|
||
key={d}
|
||
type="button"
|
||
onClick={() => setPollDuration(d)}
|
||
className={cn(
|
||
'text-xs px-2.5 py-1 rounded-full border transition-colors',
|
||
pollDuration === d
|
||
? 'border-primary bg-primary/10 text-primary font-medium'
|
||
: 'border-border text-muted-foreground hover:text-foreground hover:border-foreground/30',
|
||
)}
|
||
>
|
||
{d === 0 ? <span style={{ fontSize: '15px', lineHeight: 1, position: 'relative', top: '-1px' }}>∞</span> : `${d}d`}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Content warning input */}
|
||
{cwEnabled && (
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<AlertTriangle className="size-4 text-amber-500 shrink-0" />
|
||
<Input
|
||
value={cwText}
|
||
onChange={(e) => setCwText(e.target.value)}
|
||
placeholder={t('compose.placeholderCwReason')}
|
||
className="h-8 text-base md:text-sm bg-secondary/50 border-0 rounded-lg"
|
||
/>
|
||
<button
|
||
onClick={() => { setCwEnabled(false); setCwText(''); }}
|
||
className="p-1 rounded-full text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
||
>
|
||
<X className="size-4" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Quoted event preview */}
|
||
{showQuotedEvent && quotedEvent && quotedEventKey && (
|
||
<div className="mt-4 mb-3 overflow-hidden">
|
||
{quotedEvent.kind >= 30000 && quotedEvent.kind < 40000 ? (
|
||
<EmbeddedNaddr addr={{
|
||
kind: quotedEvent.kind,
|
||
pubkey: quotedEvent.pubkey,
|
||
identifier: quotedEvent.tags.find(([name]) => name === 'd')?.[1] ?? '',
|
||
}} />
|
||
) : (
|
||
<EmbeddedNote eventId={quotedEvent.id} authorHint={quotedEvent.pubkey} />
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>{/* end scrollable content area */}
|
||
|
||
</div>{/* end flex-1 content column */}
|
||
</div>{/* end avatar + content row */}
|
||
|
||
{/* Destination row — its own line above the toolbar so the
|
||
"Post to" label gives it semantic context. The dropdown lists
|
||
"Global" first, then every country the user follows. The
|
||
help icon opens a popover that explains the difference
|
||
between a global post and a country community post. Shown
|
||
only for new top-level posts from a logged-in user with
|
||
at least one followed country. */}
|
||
{canChooseDestination && isExpanded && (
|
||
<div className="mt-2 flex items-center justify-end gap-2">
|
||
<span className="text-xs font-medium text-muted-foreground">
|
||
{t('compose.destination.label')}
|
||
</span>
|
||
<Popover>
|
||
<PopoverTrigger asChild>
|
||
<button
|
||
type="button"
|
||
aria-label={t('compose.destination.ariaHelp')}
|
||
className={cn(
|
||
'inline-flex items-center justify-center size-5 rounded-full shrink-0',
|
||
'text-muted-foreground/70 hover:text-foreground hover:bg-muted/60',
|
||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
|
||
'transition-colors',
|
||
)}
|
||
>
|
||
<HelpCircle className="size-3.5" />
|
||
</button>
|
||
</PopoverTrigger>
|
||
<PopoverContent
|
||
side="top"
|
||
align="end"
|
||
className="w-72 p-0 rounded-2xl overflow-hidden"
|
||
>
|
||
{(() => {
|
||
// Use the currently-selected country (falling back to
|
||
// the first followed one) only as the *flag emoji* in
|
||
// the example. The text stays generic so the popover
|
||
// reads as an explanation of the feature, not a
|
||
// description of the user's current pick.
|
||
const exampleCode = selectedCountryCode ?? followedCountries[0];
|
||
const exampleFlag = exampleCode ? getCountryInfo(exampleCode)?.flag : null;
|
||
return (
|
||
<div className="p-4 space-y-3">
|
||
<div className="flex gap-3">
|
||
<span className="text-2xl leading-none shrink-0" aria-hidden="true">🌍</span>
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-sm font-semibold">{t('compose.destination.global')}</p>
|
||
<p className="text-xs text-muted-foreground mt-0.5">
|
||
{t('compose.destination.globalExplainer')}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-3">
|
||
<span className="text-2xl leading-none shrink-0" aria-hidden="true">
|
||
{exampleFlag ?? '🌐'}
|
||
</span>
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-sm font-semibold">{t('compose.destination.community')}</p>
|
||
<p className="text-xs text-muted-foreground mt-0.5">
|
||
{t('compose.destination.communityExplainer')}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
</PopoverContent>
|
||
</Popover>
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger
|
||
aria-label={t('compose.destination.ariaTrigger')}
|
||
className={cn(
|
||
'inline-flex items-center justify-center h-8 w-auto gap-1.5 px-2.5 py-1 text-base leading-none',
|
||
'bg-muted/50 hover:bg-muted shadow-none',
|
||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-0',
|
||
'rounded-lg motion-safe:transition-colors',
|
||
)}
|
||
>
|
||
{/* Show just the flag in the trigger to keep the row
|
||
compact on mobile. The list items below carry the
|
||
country name so users can still tell them apart. */}
|
||
<span aria-hidden="true">
|
||
{selectedCountryInfo?.flag ?? '🌍'}
|
||
</span>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end" className="min-w-[240px]">
|
||
<DropdownMenuItem
|
||
onSelect={() => setDestination('world')}
|
||
className="cursor-pointer"
|
||
>
|
||
<span className="inline-flex items-center gap-2 flex-1">
|
||
<span aria-hidden="true">🌍</span>
|
||
<span>{t('compose.destination.global')}</span>
|
||
</span>
|
||
{destination === 'world' && (
|
||
<Check className="size-4 text-primary" aria-hidden />
|
||
)}
|
||
</DropdownMenuItem>
|
||
{/* Build the quick-pick list. Followed countries appear first;
|
||
if the user has selected an ad-hoc country via the
|
||
searchable picker that they don't follow, show it too so
|
||
they have a one-tap way back to it. De-duplicates by code. */}
|
||
{(() => {
|
||
const codes = new Set<string>();
|
||
const quickPicks: string[] = [];
|
||
for (const code of followedCountries) {
|
||
if (!codes.has(code) && getCountryInfo(code)) {
|
||
codes.add(code);
|
||
quickPicks.push(code);
|
||
}
|
||
}
|
||
if (selectedCountryCode && !codes.has(selectedCountryCode) && getCountryInfo(selectedCountryCode)) {
|
||
quickPicks.push(selectedCountryCode);
|
||
}
|
||
return quickPicks.map((code) => {
|
||
const info = getCountryInfo(code);
|
||
if (!info) return null;
|
||
return (
|
||
<DropdownMenuItem
|
||
key={code}
|
||
onSelect={() => setDestination(code)}
|
||
className="cursor-pointer"
|
||
>
|
||
<span className="inline-flex items-center gap-2 flex-1">
|
||
<span aria-hidden="true">{info.flag}</span>
|
||
<span>{info.name}</span>
|
||
</span>
|
||
{destination === code && (
|
||
<Check className="size-4 text-primary" aria-hidden />
|
||
)}
|
||
</DropdownMenuItem>
|
||
);
|
||
});
|
||
})()}
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem
|
||
onSelect={(e) => {
|
||
e.preventDefault();
|
||
setCountryPickerOpen(true);
|
||
}}
|
||
className="cursor-pointer text-sm"
|
||
>
|
||
<Globe className="size-4 mr-2 text-muted-foreground" aria-hidden />
|
||
{t('compose.destination.chooseAnother')}
|
||
</DropdownMenuItem>
|
||
<DropdownMenuSeparator />
|
||
{destination === defaultPostCountry ? (
|
||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||
{(() => {
|
||
if (defaultPostCountry === 'world') return t('compose.destination.globalIsDefault');
|
||
const info = getCountryInfo(defaultPostCountry);
|
||
return info ? t('compose.destination.isDefault', { name: info.name }) : t('compose.destination.thisIsDefault');
|
||
})()}
|
||
</div>
|
||
) : (
|
||
<DropdownMenuItem
|
||
onSelect={() => {
|
||
setDefaultPostCountry(destination);
|
||
const info = destination === 'world'
|
||
? null
|
||
: getCountryInfo(destination);
|
||
toast({
|
||
title: t('compose.destination.defaultUpdated'),
|
||
description: info
|
||
? t('compose.destination.defaultUpdatedCountry', { name: info.name })
|
||
: t('compose.destination.defaultUpdatedGlobal'),
|
||
});
|
||
}}
|
||
className="cursor-pointer text-sm"
|
||
>
|
||
{t('compose.destination.setAsDefault')}
|
||
</DropdownMenuItem>
|
||
)}
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
|
||
{/* Searchable picker over the full country list. Opened from the
|
||
"Choose another country…" item in the destination dropdown,
|
||
so users can post to any country without having to follow it
|
||
first. */}
|
||
<CommandDialog
|
||
open={countryPickerOpen}
|
||
onOpenChange={setCountryPickerOpen}
|
||
>
|
||
<CommandInput placeholder={t('compose.destination.searchPlaceholder')} />
|
||
<CommandList>
|
||
<CommandEmpty>{t('compose.destination.noResults')}</CommandEmpty>
|
||
<CommandGroup>
|
||
<CommandItem
|
||
value={`${t('compose.destination.global')} 🌍`}
|
||
onSelect={() => {
|
||
setDestination('world');
|
||
setCountryPickerOpen(false);
|
||
}}
|
||
>
|
||
<span aria-hidden="true" className="mr-2">🌍</span>
|
||
<span>{t('compose.destination.global')}</span>
|
||
{destination === 'world' && (
|
||
<Check className="ml-auto size-4 text-primary" aria-hidden />
|
||
)}
|
||
</CommandItem>
|
||
{COUNTRY_LIST.map((country) => (
|
||
<CommandItem
|
||
key={country.code}
|
||
// Include code + name in the searchable value so users
|
||
// can type either "iran" or "IR".
|
||
value={`${country.name} ${country.code}`}
|
||
onSelect={() => {
|
||
setDestination(country.code);
|
||
setCountryPickerOpen(false);
|
||
}}
|
||
>
|
||
<span aria-hidden="true" className="mr-2">{country.flag}</span>
|
||
<span>{country.name}</span>
|
||
<span className="ml-2 text-xs text-muted-foreground">{country.code}</span>
|
||
{destination === country.code && (
|
||
<Check className="ml-auto size-4 text-primary" aria-hidden />
|
||
)}
|
||
</CommandItem>
|
||
))}
|
||
</CommandGroup>
|
||
</CommandList>
|
||
</CommandDialog>
|
||
</div>
|
||
)}
|
||
|
||
{/* Toolbar + post button — full width, not indented by avatar */}
|
||
{isExpanded && (
|
||
voiceRecorder.isRecording || isPublishingVoice ? (
|
||
/* ── Voice recording UI ─────────────────────────────── */
|
||
<div className="flex items-center gap-3 mt-3 rounded-xl bg-destructive/5 border border-destructive/20 px-3 py-2.5">
|
||
{/* Recording indicator */}
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
<div className="size-2.5 rounded-full bg-destructive animate-pulse shrink-0" />
|
||
<span className="text-sm font-medium tabular-nums text-destructive">
|
||
{formatTime(voiceRecorder.recordingDuration)}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Live waveform preview */}
|
||
<div className="flex-1 flex items-center gap-[2px] h-6 overflow-hidden">
|
||
{voiceRecorder.liveWaveform.slice(-60).map((amp, i) => {
|
||
const h = 3 + (amp / 100) * 21;
|
||
return (
|
||
<div
|
||
key={i}
|
||
className="w-[3px] shrink-0 rounded-full bg-destructive/60"
|
||
style={{ height: `${h}px` }}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Cancel button */}
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<button
|
||
type="button"
|
||
onClick={voiceRecorder.cancelRecording}
|
||
disabled={isPublishingVoice}
|
||
className="p-2 rounded-full text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-40"
|
||
>
|
||
<X className="size-[18px]" />
|
||
</button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>{t('compose.voice.cancel')}</TooltipContent>
|
||
</Tooltip>
|
||
|
||
{/* Stop & send button */}
|
||
<Button
|
||
onClick={handleStopAndPublishVoice}
|
||
disabled={isPublishingVoice || voiceRecorder.recordingDuration < 0.5}
|
||
className="rounded-full px-4 font-bold"
|
||
size="sm"
|
||
>
|
||
{isPublishingVoice ? (
|
||
<Loader2 className="size-4 animate-spin mr-1.5" />
|
||
) : (
|
||
<Square className="size-3.5 mr-1.5" fill="currentColor" />
|
||
)}
|
||
{isPublishingVoice ? t('compose.voice.sending') : t('compose.voice.send')}
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
/* ── Normal toolbar ──────────────────────────────────── */
|
||
<div className={cn("flex items-center justify-between mt-3", forceExpanded && "shrink-0")}>
|
||
{/* Left: action icons */}
|
||
<div className="flex items-center gap-1">
|
||
{/* File upload */}
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<button
|
||
type="button"
|
||
onClick={() => fileInputRef.current?.click()}
|
||
disabled={isUploading || !user}
|
||
className="p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors disabled:opacity-40"
|
||
>
|
||
{isUploading ? <Loader2 className="size-[18px] animate-spin" /> : <Paperclip className="size-[18px]" />}
|
||
</button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>{t('compose.toolbar.attachFile')}</TooltipContent>
|
||
</Tooltip>
|
||
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="image/*,video/*,audio/*"
|
||
multiple
|
||
className="hidden"
|
||
onChange={(e) => {
|
||
const files = e.target.files;
|
||
if (files) {
|
||
Array.from(files).forEach((file) => handleFileUpload(file));
|
||
}
|
||
e.target.value = '';
|
||
}}
|
||
/>
|
||
|
||
{/* Voice recording */}
|
||
{voiceRecorder.isSupported && (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<button
|
||
type="button"
|
||
onClick={handleStartRecording}
|
||
disabled={!user}
|
||
className="p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors disabled:opacity-40"
|
||
>
|
||
<Mic className="size-[18px]" />
|
||
</button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>{t('compose.voice.voiceMessage')}</TooltipContent>
|
||
</Tooltip>
|
||
)}
|
||
|
||
{/* Emoji / GIF picker toggle — inline panel renders below the toolbar */}
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<button
|
||
type="button"
|
||
onClick={() => setPickerOpen((v) => !v)}
|
||
className={cn(
|
||
'p-2 rounded-full transition-colors',
|
||
pickerOpen
|
||
? 'text-primary bg-primary/10'
|
||
: 'text-muted-foreground hover:text-primary hover:bg-primary/10',
|
||
)}
|
||
>
|
||
<Smile className="size-[18px]" />
|
||
</button>
|
||
</TooltipTrigger>
|
||
{!pickerOpen && <TooltipContent>{t('compose.toolbar.emojiGif')}</TooltipContent>}
|
||
</Tooltip>
|
||
|
||
{/* Overflow: Poll + CW */}
|
||
<Popover open={trayOpen} onOpenChange={setTrayOpen}>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<PopoverTrigger asChild>
|
||
<button
|
||
type="button"
|
||
disabled={!user}
|
||
className={cn(
|
||
'p-2 rounded-full transition-colors disabled:opacity-40',
|
||
(trayOpen || mode === 'poll' || cwEnabled)
|
||
? 'text-primary bg-primary/10'
|
||
: 'text-muted-foreground hover:text-primary hover:bg-primary/10',
|
||
)}
|
||
>
|
||
<Plus className="size-[18px]" strokeWidth={2.5} />
|
||
</button>
|
||
</PopoverTrigger>
|
||
</TooltipTrigger>
|
||
{!trayOpen && <TooltipContent>{t('compose.toolbar.more')}</TooltipContent>}
|
||
</Tooltip>
|
||
<PopoverContent side="top" align="start" sideOffset={6} className="w-44 p-1.5 rounded-xl border-border shadow-lg">
|
||
<div className="flex flex-col gap-0.5">
|
||
{/* Polls are top-level events (kind 1068), so they only make sense as a
|
||
standalone post or rooted on an external-content URL (e.g. iso3166: country
|
||
page). Hide for actual event replies (NostrEvent replyTo). */}
|
||
{!hidePoll && (!replyTo || replyTo instanceof URL) && !customPublish && (
|
||
<button
|
||
type="button"
|
||
onClick={() => { setMode((m) => m === 'poll' ? 'post' : 'poll'); setTrayOpen(false); expand(); }}
|
||
className={cn('flex items-center gap-2.5 w-full px-3 py-2 rounded-lg text-sm transition-colors', mode === 'poll' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground hover:bg-secondary/60')}
|
||
>
|
||
<BarChart3 className="size-4" /><span className="font-medium">{t('compose.toolbar.poll')}</span>
|
||
</button>
|
||
)}
|
||
<button
|
||
type="button"
|
||
onClick={() => { setCwEnabled((v) => !v); setTrayOpen(false); expand(); }}
|
||
className={cn('flex items-center gap-2.5 w-full px-3 py-2 rounded-lg text-sm transition-colors', cwEnabled ? 'text-amber-500 bg-amber-500/10' : 'text-muted-foreground hover:text-foreground hover:bg-secondary/60')}
|
||
>
|
||
<AlertTriangle className="size-4" /><span className="font-medium">{t('compose.toolbar.spoiler')}</span>
|
||
</button>
|
||
</div>
|
||
</PopoverContent>
|
||
</Popover>
|
||
|
||
</div>
|
||
|
||
{/* Spacer */}
|
||
<div className="flex-1" />
|
||
|
||
{/* Right: char count + post/poll button */}
|
||
<div className="flex items-center gap-3">
|
||
{mode === 'post' && charCount > 0 && (
|
||
<div className="flex items-center gap-1.5">
|
||
<CharRing count={charCount} max={MAX_CHARS} />
|
||
<span className={cn(
|
||
'text-xs tabular-nums',
|
||
remaining < 0 ? 'text-destructive font-semibold' : remaining < 500 ? 'text-amber-500' : 'text-muted-foreground',
|
||
)}>
|
||
{remaining}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{mode === 'poll' ? (
|
||
<Button
|
||
onClick={handlePollSubmit}
|
||
disabled={!isPollValid || isPollPending || !user}
|
||
className="rounded-full px-5 font-bold text-white"
|
||
size="sm"
|
||
>
|
||
{isPollPending ? t('compose.poll.publishing') : t('compose.poll.publish')}
|
||
</Button>
|
||
) : (
|
||
<Button
|
||
onClick={handleSubmit}
|
||
disabled={!content.trim() || isPending || isCommentPending || !user || charCount > MAX_CHARS}
|
||
className="rounded-full px-5 font-bold text-white"
|
||
size="sm"
|
||
>
|
||
{isPending || isCommentPending ? t('compose.submit.posting') : effectiveSubmitLabel}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
)}
|
||
|
||
{/* Inline emoji / GIF / sticker picker panel — rendered outside the
|
||
padded content area so it bleeds edge-to-edge. */}
|
||
{pickerOpen && (
|
||
<div className={cn("-mx-4 shrink-0 overflow-hidden animate-in fade-in-0 duration-150", forceExpanded && "rounded-b-2xl")}>
|
||
{/* Tab bar — pill highlight style for inline mode */}
|
||
<div className="flex gap-1 px-3 pt-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setPickerTab('emoji')}
|
||
className={cn(
|
||
'flex items-center justify-center gap-1.5 px-4 py-1.5 rounded-full text-sm font-medium transition-colors',
|
||
pickerTab === 'emoji'
|
||
? 'bg-primary/15 text-primary'
|
||
: 'text-muted-foreground hover:text-foreground hover:bg-muted',
|
||
)}
|
||
>
|
||
<Smile className="size-3.5" />
|
||
{t('compose.toolbar.emoji')}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setPickerTab('gif')}
|
||
className={cn(
|
||
'flex items-center justify-center gap-1.5 px-4 py-1.5 rounded-full text-sm font-medium transition-colors',
|
||
pickerTab === 'gif'
|
||
? 'bg-primary/15 text-primary'
|
||
: 'text-muted-foreground hover:text-foreground hover:bg-muted',
|
||
)}
|
||
>
|
||
<svg width="14" height="14" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||
<rect x="1" y="1" width="16" height="16" rx="3" stroke="currentColor" strokeWidth="1.5" fill="none"/>
|
||
<text x="9" y="9" textAnchor="middle" dominantBaseline="central" fontSize="7" fontWeight="700" fontFamily="system-ui,sans-serif" fill="currentColor" letterSpacing="0.5">GIF</text>
|
||
</svg>
|
||
GIF
|
||
</button>
|
||
{customEmojis.length > 0 && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setPickerTab('stickers')}
|
||
className={cn(
|
||
'flex items-center justify-center gap-1.5 px-4 py-1.5 rounded-full text-sm font-medium transition-colors',
|
||
pickerTab === 'stickers'
|
||
? 'bg-primary/15 text-primary'
|
||
: 'text-muted-foreground hover:text-foreground hover:bg-muted',
|
||
)}
|
||
>
|
||
<Sticker className="size-3.5" />
|
||
{t('compose.toolbar.stickers')}
|
||
</button>
|
||
)}
|
||
</div>
|
||
{/* Picker content */}
|
||
{pickerTab === 'emoji' ? (
|
||
<Suspense fallback={<div className="w-full h-[280px] flex items-center justify-center"><Loader2 className="size-6 animate-spin text-muted-foreground" /></div>}>
|
||
<LazyEmojiPicker
|
||
customEmojis={customEmojis}
|
||
onSelect={(selection) => {
|
||
if (selection.type === 'native') {
|
||
insertEmoji(selection.emoji);
|
||
} else {
|
||
insertEmoji(`:${selection.shortcode}:`);
|
||
}
|
||
}}
|
||
/>
|
||
</Suspense>
|
||
) : pickerTab === 'stickers' ? (
|
||
<StickerPicker
|
||
customEmojis={customEmojis}
|
||
height={280}
|
||
autoFocus={!isMobile}
|
||
onSelect={(emoji) => {
|
||
setContent((prev) => (prev ? prev + '\n' + emoji.url : emoji.url));
|
||
setPickerOpen(false);
|
||
expand();
|
||
}}
|
||
/>
|
||
) : (
|
||
<GifPicker onSelect={(gif) => {
|
||
setContent((prev) => (prev ? prev + '\n' + gif.url : gif.url));
|
||
setPickerOpen(false);
|
||
expand();
|
||
}} />
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
</div>
|
||
);
|
||
}
|