* 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
209 lines
6.6 KiB
TypeScript
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>
|
|
);
|
|
}
|