Files
eranos/src/components/ReplyComposeModal.tsx
T
Chad Curtis 0c29506402 Fix all 50 ESLint warnings by extracting non-component exports and adding missing deps
- Extract utility functions from component files into dedicated modules
  to fix react-refresh/only-export-components warnings:
  - parseBadgeDefinition -> src/lib/parseBadgeDefinition.ts
  - parseProfileBadges -> src/lib/parseProfileBadges.ts
  - getColors, paletteToTheme -> src/lib/colorMomentUtils.ts
  - parseDimToAspectRatio, eventToMediaItem -> src/lib/mediaUtils.ts
  - isAudioUrl, isImageUrl, isVideoUrl -> src/lib/mediaTypeDetection.ts
  - buildKindOptions, parseSelectedKinds -> src/lib/feedFilterUtils.ts
  - useVideoThumbnail -> src/hooks/useVideoThumbnail.ts
  - useEnvelopeDimensions -> src/hooks/useEnvelopeDimensions.ts
  - usePortalContainer -> src/hooks/usePortalContainer.ts
  - useAudioPlayer -> src/contexts/audioPlayerContextDef.ts
  - SubHeaderBar context/hooks -> src/components/SubHeaderBarContext.ts
  - EmotionDev hooks -> src/blobbi/dev/useEmotionDev.ts
  - BlobbiActions context def -> BlobbiActionsContextDef.ts

- Remove export from internal-only functions (useEventComments,
  parseEmojiPack, useScrollCarets, formatEffectSummary, getSortedEffectEntries)

- Fix react-hooks/exhaustive-deps warnings by adding missing dependencies
  to useEffect/useCallback/useMemo hooks across 14 files

- Fix logical expression dependency warnings by wrapping conditional
  values (tasks, pubkeys, authorPubkeys) in useMemo

- Move module-level constants (CORE_TAB_IDS, CORE_TAB_LABELS,
  DEFAULT_TAB_LABELS) out of ProfilePage component body

- Reorder usePushNotifications hooks so syncPreferences is defined
  before enable to fix block-scoped variable error
2026-04-05 11:57:31 -05:00

241 lines
10 KiB
TypeScript

import { useCallback, useState } from 'react';
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 { EmbeddedNote } from '@/components/EmbeddedNote';
import { EmbeddedNaddr } from '@/components/EmbeddedNaddr';
import { ComposeBox } from '@/components/ComposeBox';
import { LinkEmbed } from '@/components/LinkEmbed';
import { ProfilePreview } from '@/components/ExternalContentHeader';
import { cn } from '@/lib/utils';
interface ReplyComposeModalProps {
/** The event being replied to, or a URL for commenting on external content. When `null`, the modal acts as a "New post" composer. */
event?: NostrEvent | URL | 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 isUrl = event instanceof URL;
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 = !isUrl && event instanceof Object && 'kind' in event && event.kind === 0;
const title = titleOverride ?? (initialMode === 'poll' ? 'New poll' : isUrl ? 'New comment' : isProfileRoot ? 'Comment on profile' : isReply ? 'Reply to post' : isQuote ? 'Quote post' : 'New post');
const placeholder = placeholderOverride ?? (isUrl ? 'Write a comment...' : isReply ? "What's on your mind?" : isQuote ? 'Add a comment...' : "What's happening?");
const dialogContentRef = useCallback((node: HTMLElement | null) => {
setPortalContainer(node ?? undefined);
}, []);
// Prevent the compose modal from closing when the user interacts with a
// nested dialog (e.g. the emoji/GIF picker). On mobile it is very easy to
// tap the emoji picker overlay and accidentally dismiss the compose modal,
// losing the draft. We detect nested-dialog interactions by checking
// whether the click target lives inside another Radix Dialog portal that
// sits above this modal.
const isNestedDialogInteraction = useCallback((e: Event) => {
const target = e.target as HTMLElement | null;
if (!target) return false;
// Radix Dialog overlays have data-state and sit inside [role="dialog"]
// portals. If the target is inside a dialog element that is NOT our own
// DialogContent, a nested dialog is open.
const closestDialog = target.closest('[role="dialog"]');
if (closestDialog && portalContainer && closestDialog !== portalContainer) {
return true;
}
// Also catch clicks on the overlay itself (data-radix-dialog-overlay or
// the backdrop element) that belongs to a nested dialog.
const closestOverlay = target.closest('[data-radix-dialog-overlay]');
if (closestOverlay) {
// Check if this overlay belongs to our dialog or a nested one.
// Our overlay is a sibling of our DialogContent, not a descendant.
// If the overlay is rendered inside our portal container's parent
// (the same portal), it could be ours. But if there are multiple
// overlays, the topmost (last in DOM) belongs to the nested dialog.
const allOverlays = document.querySelectorAll('[data-radix-dialog-overlay]');
if (allOverlays.length > 1) {
return true;
}
}
return false;
}, [portalContainer]);
const handleInteractOutside = useCallback((e: Event) => {
if (isNestedDialogInteraction(e)) {
e.preventDefault();
}
}, [isNestedDialogInteraction]);
const handleEscapeKeyDown = useCallback((e: KeyboardEvent) => {
// When a nested dialog is open, Radix will close it first via its own
// handler. But the escape event can bubble and also close the parent
// modal. We prevent that by checking if any nested dialog is currently
// open (any dialog with data-state="open" that is not ours).
const openDialogs = document.querySelectorAll('[role="dialog"][data-state="open"]');
const hasNestedDialog = Array.from(openDialogs).some(
(el) => portalContainer && el !== portalContainer,
);
if (hasNestedDialog) {
e.preventDefault();
}
}, [portalContainer]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
ref={dialogContentRef}
className="max-w-[520px] max-h-[85dvh] rounded-2xl p-0 gap-0 border-border overflow-visible [&>button]:hidden !flex !flex-col"
onOpenAutoFocus={(e) => {
e.preventDefault();
const target = e.target as HTMLElement;
const textarea = target.querySelector('textarea');
textarea?.focus();
}}
onInteractOutside={handleInteractOutside}
onEscapeKeyDown={handleEscapeKeyDown}
>
<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"
)}
>
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"
)}
>
Preview
</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) */}
{event && !isQuote && (
<div className="overflow-y-auto min-h-0 shrink">
{isUrl ? (
<div className="mx-4 mb-2">
<LinkEmbed url={event.href} showActions={false} hideImage />
</div>
) : (
<EmbeddedPost event={event} />
)}
</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">
<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">
People on Bluesky can&apos;t see you because they&apos;re not actually decentralized.
</p>
</div>
)}
{/* Compose area */}
<div className="min-h-0 overflow-y-auto">
<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>
);
}
/**
* Compact embedded preview of the post being replied to.
* Delegates to the shared EmbeddedNote / EmbeddedNaddr components used by
* quote posts and hover cards, so every context renders events consistently.
*/
function EmbeddedPost({ event }: { event: NostrEvent }) {
// Kind 0 (profile) — show a profile card instead of trying to render the raw JSON content
if (event.kind === 0) {
return (
<div className="mx-4 mb-2 rounded-xl border border-border bg-secondary/30 overflow-hidden">
<ProfilePreview pubkey={event.pubkey} />
</div>
);
}
// Addressable events (kind 30000-39999) — use EmbeddedNaddr
if (event.kind >= 30000 && event.kind < 40000) {
const dTag = event.tags.find(([name]) => name === 'd')?.[1] ?? '';
return (
<div className="mx-4 mb-2">
<EmbeddedNaddr addr={{ kind: event.kind, pubkey: event.pubkey, identifier: dTag }} />
</div>
);
}
// Everything else — use EmbeddedNote (the event is already in the query cache)
return (
<div className="mx-4 mb-2">
<EmbeddedNote eventId={event.id} authorHint={event.pubkey} disableHoverCards />
</div>
);
}