Files
llink/js/desktop/src/features/particles/text-particle-view.tsx
T
Arjun PatelandGitHub 095b9876f9 feat: stream list view and tasks (#279)
* first attempt at stream sidebar, tasks, and events

* fix folder from root

* cleanup folders and events, and condense changes

* cleanup and add toggle for sidebar

* cleanup

* fix nits
2026-06-12 12:26:49 -07:00

209 lines
6.6 KiB
TypeScript

import { useState } from 'react';
import { Pencil } from 'lucide-react';
import type { Particle } from '@/api/types';
import type { ParticlePath } from '@/lib/particle-path';
import { cn } from '@/lib/utils';
import {
useAllLinkMetadata,
type LinkPreviewEntry,
} from '@/hooks/use-link-metadata';
import { extractUrls } from '@/lib/link-metadata';
import { getImmersiveTextStyle } from '@/lib/immersive-text';
import { hasMarkdownFormatting } from '@/lib/markdown';
import {
LinkPreviewCard,
LinkPreviewCardFallback,
LinkPreviewCardSkeleton,
} from '@/components/link-preview-card';
import { useParticleAttachments } from '@/hooks/use-particle-attachments';
import { ParticleAttachments } from '@/features/particles/particle-attachments';
import { TextEditOverlay } from '@/features/particles/text-edit-overlay';
import { RelativeTimestamp } from '@/components/relative-timestamp';
import { useAuthStore } from '@/stores/auth-store';
import { useFixedDwell } from '@/hooks/use-fixed-dwell';
import { MarkdownEditor } from '@/features/compose/markdown-editor';
type TextParticle = Extract<Particle, { type: 'text' }>;
interface TextParticleViewProps {
particle: TextParticle;
streamPath: ParticlePath;
paused: boolean;
onEnded: () => void;
onProgress?: (ratio: number) => void;
}
// Characters per minute (~1000 cpm ≈ 200 wpm at ~5 chars/word)
const CHARS_PER_MINUTE = 1000;
const MIN_DURATION_S = 3;
const MAX_DURATION_S = 15;
const EXTRA_S_PER_LINK = 2;
const EXTRA_S_PER_ATTACHMENT = 2;
// Below this threshold: immersive centered display
const IMMERSIVE_CHAR_LIMIT = 120;
function computeReadDuration(
text: string,
linkCount: number,
attachmentCount: number,
): number {
const base = (text.length / CHARS_PER_MINUTE) * 60;
const extra =
linkCount * EXTRA_S_PER_LINK + attachmentCount * EXTRA_S_PER_ATTACHMENT;
return Math.min(Math.max(base + extra, MIN_DURATION_S), MAX_DURATION_S);
}
function LinkPreviews({ entries }: { entries: LinkPreviewEntry[] }) {
return (
<div className="flex flex-wrap gap-3">
{entries.map((entry) => (
<div key={entry.url} className="shrink-0">
{entry.isLoading ? (
<LinkPreviewCardSkeleton />
) : entry.metadata ? (
<LinkPreviewCard metadata={entry.metadata} />
) : (
<LinkPreviewCardFallback url={entry.url} />
)}
</div>
))}
</div>
);
}
export function TextParticleView({
particle,
streamPath,
paused,
onEnded,
onProgress,
}: TextParticleViewProps) {
const content = particle.properties.content;
const linkPreviews = useAllLinkMetadata(content);
const { attachments } = useParticleAttachments(streamPath, particle.id);
const urls = extractUrls(content);
const userId = useAuthStore((s) => s.user?.id);
const isCreator = !!userId && userId === particle.created_by_human_id;
const [isEditing, setIsEditing] = useState(false);
const hasLinks = urls.length > 0;
const hasAttachments = attachments.length > 0;
const hasEnrichments = hasLinks || hasAttachments;
const durationS = computeReadDuration(
content,
urls.length,
attachments.length,
);
useFixedDwell({
id: particle.id,
durationS,
paused,
onEnded,
onProgress,
});
// Content is just bare URLs with no surrounding text
const contentTrimmed = content.trim();
const linksOnly =
hasLinks &&
urls.every((url) => contentTrimmed.includes(url)) &&
contentTrimmed.replace(/https?:\/\/[^\s<>"')\]]+/g, '').trim() === '';
const editButton = isCreator && !isEditing && (
<button
type="button"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
setIsEditing(true);
}}
title="Edit"
className="absolute bottom-[calc(var(--stream-safe-bottom,2rem)+0.5rem)] left-1/2 -translate-x-1/2 z-30 flex items-center gap-1.5 rounded-full bg-black/40 px-3 py-1.5 text-xs text-white/70 backdrop-blur-sm transition-colors hover:bg-black/60 hover:text-white"
>
<Pencil className="size-3.5" />
Edit
</button>
);
const editedLabel = particle.properties.edited_at && (
<span className="text-xs text-white/40">
edited <RelativeTimestamp date={particle.properties.edited_at} />
</span>
);
const editOverlay = isEditing && (
<TextEditOverlay
particle={particle}
streamPath={streamPath}
onClose={() => setIsEditing(false)}
/>
);
// Mode 1: bare URLs only — show link cards centered
if (linksOnly && !hasAttachments) {
return (
<div className="group relative flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
<LinkPreviews entries={linkPreviews} />
{editedLabel && (
<div className="absolute bottom-6 left-1/2 -translate-x-1/2">
{editedLabel}
</div>
)}
{editButton}
{editOverlay}
</div>
);
}
// Mode 2: short plain text, no enrichments — immersive centered display
if (
content.length < IMMERSIVE_CHAR_LIMIT &&
!hasEnrichments &&
!hasMarkdownFormatting(content)
) {
const style = getImmersiveTextStyle(content.length);
return (
<div className="group relative flex h-full w-full flex-col items-center justify-center gap-4 bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
<p
className={cn(
'max-w-2xl break-words text-center leading-relaxed text-white select-text cursor-text',
style.size,
style.weight,
)}
>
{content}
</p>
{editedLabel}
{editButton}
{editOverlay}
</div>
);
}
// Mode 3: card layout
return (
<div className="group relative flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
<div className="scrollbar-card flex max-h-full w-full max-w-[calc(var(--message-content-width)_+_var(--message-card-padding)*2)] flex-col gap-4 overflow-y-auto overscroll-contain rounded bg-white/10 p-[var(--message-card-padding)] backdrop-blur-md">
<MarkdownEditor
key={content}
value={content}
readOnly
className="select-text pb-3"
/>
{hasLinks && <LinkPreviews entries={linkPreviews} />}
{hasAttachments && <ParticleAttachments attachments={attachments} />}
{editedLabel}
</div>
{editButton}
{editOverlay}
</div>
);
}