Files
eranos/src/components/ReplyComposeModal.tsx
T
mkfain 850f35c29d i18n: translate ComposeBox + ReplyComposeModal
Adds the compose.* namespace covering ComposeBox: placeholders
(default, poll, country-scoped, content warning), preview / edit toggle,
poll mode (Add option, Option N, Single/Multiple choice, Back to post,
Publish poll, success/failure toasts), voice recording (Cancel, Send,
Sending, mic-denied toast, voice-message tooltip), toolbar
(Attach file, Emoji/GIF, More, Poll, Spoiler, Emoji, Stickers tabs),
submit (Posting / Posted toasts, upload-failed / publish-failed),
and destination dropdown (Post to label, Global / community
explainer popover, Choose another country picker, search placeholder,
empty state, Set as default, default-updated toasts).

Also adds replyModal.* for ReplyComposeModal — the six title fallbacks
(New poll, New comment, Comment on profile, Reply to post, Quote post,
New post), three placeholder fallbacks, and the Bluesky disclaimer.

ComposeBox's hardcoded default placeholder ('What's on your mind?') and
submitLabel ('Post!') props moved from prop defaults to t() fallbacks
inside the component so existing call sites can keep passing translated
strings unchanged. Renamed an inner .map((t) => …) variable in the poll
type/duration row to .map((pt) => …) since t shadowed the i18next t.
Added rtl:rotate-180 to the 'Back to post' chevron since it visually
points left/back.

ar / es / zh / fa / ps / km / sn translations drafted inline; km, sn,
ps, fa still pending native-speaker review.
2026-05-23 19:16:53 -05:00

184 lines
7.8 KiB
TypeScript

import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { X } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import {
Dialog,
DialogContent,
DialogTitle,
} from '@/components/ui/dialog';
import { PortalContainerProvider } from '@/hooks/usePortalContainer';
import { EmbeddedPost } from '@/components/EmbeddedPost';
import { ComposeBox, type ExternalReplyRoot } from '@/components/ComposeBox';
import { LinkEmbed } from '@/components/LinkEmbed';
import { cn } from '@/lib/utils';
interface ReplyComposeModalProps {
/** The event being replied to, a URL for commenting on web content, or a NIP-73 identifier (e.g. `bitcoin:tx:...`, `isbn:...`). When `null`, the modal acts as a "New post" composer. */
event?: NostrEvent | ExternalReplyRoot | null;
/** The event being quoted (for quote posts). */
quotedEvent?: NostrEvent | null;
open: boolean;
onOpenChange: (open: boolean) => void;
/** Called after a post is successfully published. */
onSuccess?: () => void;
/** Pre-filled content for the compose box. */
initialContent?: string;
/** Open directly in poll mode. */
initialMode?: 'post' | 'poll';
/** Override the modal title. */
title?: string;
/** Override the compose box placeholder text. */
placeholder?: string;
}
export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange, onSuccess, initialContent, initialMode, title: titleOverride, placeholder: placeholderOverride }: ReplyComposeModalProps) {
const { t } = useTranslation();
const isUrl = event instanceof URL;
const isExternalId = typeof event === 'string';
const isExternal = isUrl || isExternalId;
const isReply = !!event;
const isQuote = !!quotedEvent;
const [previewMode, setPreviewMode] = useState(false);
const [hasPreviewableContent, setHasPreviewableContent] = useState(false);
const [portalContainer, setPortalContainer] = useState<HTMLElement | undefined>(undefined);
const isProfileRoot = !isExternal && event instanceof Object && 'kind' in event && event.kind === 0;
const title = titleOverride ?? (
initialMode === 'poll' ? t('replyModal.title.newPoll')
: isExternal ? t('replyModal.title.newComment')
: isProfileRoot ? t('replyModal.title.commentOnProfile')
: isReply ? t('replyModal.title.replyToPost')
: isQuote ? t('replyModal.title.quotePost')
: t('replyModal.title.newPost')
);
const placeholder = placeholderOverride ?? (
isExternal ? t('replyModal.placeholder.writeComment')
: isReply ? t('compose.placeholderDefault')
: isQuote ? t('replyModal.placeholder.addComment')
: t('replyModal.placeholder.whatsHappening')
);
const dialogContentRef = useCallback((node: HTMLElement | null) => {
setPortalContainer(node ?? undefined);
}, []);
// Always prevent closing the compose modal by clicking the backdrop overlay.
// Users must explicitly close via the X button or Escape key. This prevents
// accidental content loss from stray taps on mobile or misclicks.
const handleInteractOutside = useCallback((e: Event) => {
e.preventDefault();
}, []);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
ref={dialogContentRef}
className="max-w-[520px] max-h-[95dvh] sm:max-h-[85dvh] rounded-2xl p-0 gap-0 border-border overflow-hidden [&>button]:hidden !flex !flex-col"
onOpenAutoFocus={(e) => {
// Prevent Radix from focusing its own first-focusable (the X button).
e.preventDefault();
// Immediately focus the textarea — this MUST happen synchronously
// inside this handler so iOS treats it as part of the original user
// gesture and raises the virtual keyboard.
const textarea = (e.currentTarget as HTMLElement).querySelector('textarea');
if (textarea) {
textarea.focus();
}
}}
onInteractOutside={handleInteractOutside}
>
<PortalContainerProvider value={portalContainer}>
{/* Header */}
<div className="flex items-center justify-between px-4 h-12 shrink-0">
<DialogTitle className="text-base font-semibold">
{title}
</DialogTitle>
<div className="flex items-center gap-2">
{/* Preview toggle */}
{hasPreviewableContent && (
<div className="inline-flex items-center gap-0.5 p-1 bg-muted/50 rounded-lg">
<button
onClick={() => setPreviewMode(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={() => setPreviewMode(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>
)}
<button
onClick={() => onOpenChange(false)}
className="p-1.5 -mr-1.5 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors"
>
<X className="size-5" />
</button>
</div>
</div>
{/* Embedded original post (reply only, not for quotes)
Capped at 20% of viewport so it never dominates the modal. */}
{event && !isQuote && (
<div className="overflow-y-auto max-h-[20dvh] shrink-0">
{isUrl ? (
<div className="mx-4 mb-2">
<LinkEmbed url={event.href} showActions={false} hideImage />
</div>
) : isExternalId ? (
/* NIP-73 identifier root — no rich preview; the page context already shows it. */
null
) : (
<EmbeddedPost event={event} className="mx-4 mb-2" disableHoverCards />
)}
</div>
)}
{/* Bluesky disclaimer */}
{isUrl && /bsky\.(app|social)/.test(event.href) && (
<div className="mx-4 mb-2 rounded-lg border border-amber-500/30 bg-amber-500/5 px-3 py-2 flex items-start gap-2 shrink-0">
<span className="text-sm leading-relaxed shrink-0" aria-hidden>&#x26A0;&#xFE0F;</span>
<p className="text-xs text-amber-700 dark:text-amber-400 leading-relaxed">
{t('replyModal.blueskyDisclaimer')}
</p>
</div>
)}
{/* Compose area — takes remaining space; ComposeBox handles its own scroll */}
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
<ComposeBox
replyTo={isQuote ? undefined : (event ?? undefined)}
quotedEvent={quotedEvent ?? undefined}
onSuccess={() => { onOpenChange(false); onSuccess?.(); }}
placeholder={placeholder}
forceExpanded
hideAvatar
previewMode={previewMode}
onHasPreviewableContentChange={setHasPreviewableContent}
initialContent={initialContent}
initialMode={initialMode}
/>
</div>
</PortalContainerProvider>
</DialogContent>
</Dialog>
);
}