feat: extract text editor and allow editing (#150)

This commit was merged in pull request #150.
This commit is contained in:
Arjun Patel
2026-04-12 12:13:25 -07:00
committed by GitHub
parent f03868e65b
commit 7568ceb80d
6 changed files with 471 additions and 271 deletions
+1
View File
@@ -136,6 +136,7 @@ export type FileProperties = z.infer<typeof FilePropertiesSchema>;
export const TextPropertiesSchema = z.object({
content: z.string(),
edited_at: z.coerce.date().optional(),
});
export type TextProperties = z.infer<typeof TextPropertiesSchema>;
+15 -262
View File
@@ -1,14 +1,5 @@
import { useEffect, useRef, useCallback, useState } from "react";
import { Paperclip } from "lucide-react";
import { cn } from "@/lib/utils";
import { useAllLinkMetadata } from "@/hooks/use-link-metadata";
import { AttachmentStrip } from "@/features/compose/attachment-strip";
import { TextEditor } from "@/features/compose/text-editor";
import type { PendingAttachment } from "@/features/compose/attachment-strip";
import { Button } from "@/components/ui/button";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeHighlight from "rehype-highlight";
import "highlight.js/styles/github-dark.css";
interface TextComposeStepProps {
textContent: string;
@@ -27,74 +18,6 @@ interface TextComposeStepProps {
};
}
const IMMERSIVE_CHAR_LIMIT = 120;
function getImmersiveTextStyle(length: number) {
if (length < 70) return { size: "text-5xl", weight: "font-semibold" };
if (length < 130) return { size: "text-3xl", weight: "font-semibold" };
return { size: "text-2xl", weight: "font-normal" };
}
const markdownComponents: React.ComponentProps<typeof ReactMarkdown>["components"] = {
h1: ({ children }) => <h1 className="mb-3 text-3xl font-bold text-white">{children}</h1>,
h2: ({ children }) => <h2 className="mb-2 text-2xl font-semibold text-white">{children}</h2>,
h3: ({ children }) => <h3 className="mb-2 text-xl font-semibold text-white">{children}</h3>,
h4: ({ children }) => <h4 className="mb-1 text-lg font-medium text-white">{children}</h4>,
h5: ({ children }) => <h5 className="mb-1 text-base font-medium text-white">{children}</h5>,
h6: ({ children }) => <h6 className="mb-1 text-sm font-medium text-white">{children}</h6>,
p: ({ children }) => <p className="mb-3 leading-relaxed text-white last:mb-0">{children}</p>,
strong: ({ children }) => <strong className="font-semibold text-white">{children}</strong>,
em: ({ children }) => <em className="italic text-white">{children}</em>,
a: ({ href, children }) => (
<a href={href} className="text-blue-400 underline" target="_blank" rel="noreferrer">
{children}
</a>
),
code: ({ className, children, ...props }) => {
const isBlock = className?.startsWith("language-");
if (isBlock) {
return (
<code className={cn(className, "text-sm")} {...props}>
{children}
</code>
);
}
return (
<code className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-sm text-white" {...props}>
{children}
</code>
);
},
pre: ({ children }) => (
<pre className="mb-3 overflow-x-auto rounded-lg bg-black/40 p-4 text-sm last:mb-0">
{children}
</pre>
),
ul: ({ children }) => <ul className="mb-3 list-disc pl-5 text-white last:mb-0">{children}</ul>,
ol: ({ children }) => <ol className="mb-3 list-decimal pl-5 text-white last:mb-0">{children}</ol>,
li: ({ children }) => <li className="mb-1 leading-relaxed">{children}</li>,
blockquote: ({ children }) => (
<blockquote className="mb-3 border-l-2 border-white/30 pl-4 italic text-white/70 last:mb-0">
{children}
</blockquote>
),
hr: () => <hr className="my-4 border-white/10" />,
};
function MarkdownPreview({ content }: { content: string }) {
return (
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden break-words">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
components={markdownComponents}
>
{content}
</ReactMarkdown>
</div>
);
}
export function TextComposeStep({
textContent,
onTextChange,
@@ -106,190 +29,20 @@ export function TextComposeStep({
isDragging,
dropZoneProps,
}: TextComposeStepProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [previewMode, setPreviewMode] = useState(false);
const [forceCardMode, setForceCardMode] = useState(false);
// Debounce URL detection to avoid fetching on every keystroke
const [debouncedText, setDebouncedText] = useState(textContent);
useEffect(() => {
const t = setTimeout(() => setDebouncedText(textContent), 500);
return () => clearTimeout(t);
}, [textContent]);
const linkPreviews = useAllLinkMetadata(debouncedText);
const hasEnrichments = attachments.length > 0 || linkPreviews.length > 0;
const immersive = textContent.length < IMMERSIVE_CHAR_LIMIT && !hasEnrichments && !forceCardMode;
useEffect(() => {
if (!previewMode) {
// Small timeout so the textarea is mounted before focusing
const t = setTimeout(() => {
const el = textareaRef.current;
if (el) {
el.focus();
el.selectionStart = el.selectionEnd = el.value.length;
}
}, 0);
return () => clearTimeout(t);
}
}, [previewMode, forceCardMode, immersive]);
useEffect(() => {
textareaRef.current?.focus();
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
if (textContent.trim()) onAdvance();
} else if (e.key === "m" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setForceCardMode(true);
}
},
[onCancel, onAdvance, textContent],
);
const strip = (
<AttachmentStrip
attachments={attachments}
onRemove={onRemoveAttachment}
onAddClick={onAddFiles}
linkPreviews={linkPreviews}
return (
<TextEditor
textContent={textContent}
onTextChange={onTextChange}
onSubmit={onAdvance}
onCancel={onCancel}
submitHint="next"
attachmentProps={{
attachments,
onRemoveAttachment,
onAddFiles,
isDragging,
dropZoneProps,
}}
/>
);
const keyboardHints = (
<div className="absolute bottom-4 flex items-center gap-4 text-sm text-white/50">
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc
</kbd>{" "}
cancel
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
+Enter
</kbd>{" "}
next
</span>
{immersive && (
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
+M
</kbd>{" "}
markdown
</span>
)}
<span>
<Button
variant="ghost"
type="button"
onClick={(e) => {
e.stopPropagation();
onAddFiles();
}}
title="Attach files"
>
<Paperclip className="size-4" />
attach
</Button>
</span>
</div>
);
if (immersive) {
const style = getImmersiveTextStyle(textContent.length);
return (
<div
className={cn(
"absolute inset-0 z-50 flex items-center justify-center bg-black/90",
isDragging && "ring-2 ring-inset ring-white/30",
)}
{...dropZoneProps}
>
<div className="flex w-full flex-col items-center justify-center gap-6 px-6">
<textarea
ref={textareaRef}
value={textContent}
onChange={(e) => onTextChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message..."
className={cn(
"w-full resize-none border-none bg-transparent p-8 text-center text-white placeholder-white/40 outline-none",
style.size,
style.weight,
)}
rows={4}
/>
</div>
{keyboardHints}
</div>
);
}
return (
<div
className={cn(
"absolute inset-0 z-50 flex items-center justify-center bg-black/90",
isDragging && "ring-2 ring-inset ring-white/30",
)}
{...dropZoneProps}
onKeyDown={handleKeyDown}
>
<div className="mx-8 flex h-[calc(100%-8rem)] w-full min-w-0 flex-col overflow-hidden rounded-2xl border border-white/10 bg-white/5 p-5 backdrop-blur-xl">
<div className="mb-3 flex shrink-0 items-center justify-end gap-1">
<button
type="button"
onClick={() => setPreviewMode(false)}
className={cn(
"rounded-md px-3 py-1 text-xs font-medium transition-colors",
!previewMode
? "bg-white/15 text-white"
: "text-white/40 hover:text-white/60",
)}
>
Write
</button>
<button
type="button"
onClick={() => setPreviewMode(true)}
className={cn(
"rounded-md px-3 py-1 text-xs font-medium transition-colors",
previewMode
? "bg-white/15 text-white"
: "text-white/40 hover:text-white/60",
)}
>
Preview
</button>
</div>
{previewMode ? (
<MarkdownPreview content={textContent} />
) : (
<textarea
ref={textareaRef}
value={textContent}
onChange={(e) => onTextChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message... (markdown supported)"
className="min-h-0 flex-1 resize-none border-none bg-transparent font-mono text-sm leading-relaxed text-white placeholder-white/40 outline-none"
/>
)}
{hasEnrichments && (
<div className="shrink-0 border-t border-white/10 pt-3">
{strip}
</div>
)}
</div>
{keyboardHints}
</div>
);
}
+304
View File
@@ -0,0 +1,304 @@
import { useEffect, useRef, useCallback, useState } from "react";
import { Paperclip } from "lucide-react";
import { cn } from "@/lib/utils";
import { useAllLinkMetadata } from "@/hooks/use-link-metadata";
import { AttachmentStrip } from "@/features/compose/attachment-strip";
import type { PendingAttachment } from "@/features/compose/attachment-strip";
import { Button } from "@/components/ui/button";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeHighlight from "rehype-highlight";
import "highlight.js/styles/github-dark.css";
export interface TextEditorAttachmentProps {
attachments: PendingAttachment[];
onRemoveAttachment: (id: string) => void;
onAddFiles: () => void;
isDragging: boolean;
dropZoneProps: {
onDragOver: (e: React.DragEvent) => void;
onDragEnter: (e: React.DragEvent) => void;
onDragLeave: (e: React.DragEvent) => void;
onDrop: (e: React.DragEvent) => void;
};
}
interface TextEditorProps {
textContent: string;
onTextChange: (text: string) => void;
onSubmit: () => void;
onCancel: () => void;
/** Label on the ⌘+Enter hint. Defaults to "next". */
submitHint?: string;
/** When omitted, the editor renders without attachment support (no attach button, no drop zone, no strip). */
attachmentProps?: TextEditorAttachmentProps;
}
const IMMERSIVE_CHAR_LIMIT = 120;
function getImmersiveTextStyle(length: number) {
if (length < 70) return { size: "text-5xl", weight: "font-semibold" };
if (length < 130) return { size: "text-3xl", weight: "font-semibold" };
return { size: "text-2xl", weight: "font-normal" };
}
const markdownComponents: React.ComponentProps<typeof ReactMarkdown>["components"] = {
h1: ({ children }) => <h1 className="mb-3 text-3xl font-bold text-white">{children}</h1>,
h2: ({ children }) => <h2 className="mb-2 text-2xl font-semibold text-white">{children}</h2>,
h3: ({ children }) => <h3 className="mb-2 text-xl font-semibold text-white">{children}</h3>,
h4: ({ children }) => <h4 className="mb-1 text-lg font-medium text-white">{children}</h4>,
h5: ({ children }) => <h5 className="mb-1 text-base font-medium text-white">{children}</h5>,
h6: ({ children }) => <h6 className="mb-1 text-sm font-medium text-white">{children}</h6>,
p: ({ children }) => <p className="mb-3 leading-relaxed text-white last:mb-0">{children}</p>,
strong: ({ children }) => <strong className="font-semibold text-white">{children}</strong>,
em: ({ children }) => <em className="italic text-white">{children}</em>,
a: ({ href, children }) => (
<a href={href} className="text-blue-400 underline" target="_blank" rel="noreferrer">
{children}
</a>
),
code: ({ className, children, ...props }) => {
const isBlock = className?.startsWith("language-");
if (isBlock) {
return (
<code className={cn(className, "text-sm")} {...props}>
{children}
</code>
);
}
return (
<code className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-sm text-white" {...props}>
{children}
</code>
);
},
pre: ({ children }) => (
<pre className="mb-3 overflow-x-auto rounded-lg bg-black/40 p-4 text-sm last:mb-0">
{children}
</pre>
),
ul: ({ children }) => <ul className="mb-3 list-disc pl-5 text-white last:mb-0">{children}</ul>,
ol: ({ children }) => <ol className="mb-3 list-decimal pl-5 text-white last:mb-0">{children}</ol>,
li: ({ children }) => <li className="mb-1 leading-relaxed">{children}</li>,
blockquote: ({ children }) => (
<blockquote className="mb-3 border-l-2 border-white/30 pl-4 italic text-white/70 last:mb-0">
{children}
</blockquote>
),
hr: () => <hr className="my-4 border-white/10" />,
};
function MarkdownPreview({ content }: { content: string }) {
return (
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden break-words">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
components={markdownComponents}
>
{content}
</ReactMarkdown>
</div>
);
}
export function TextEditor({
textContent,
onTextChange,
onSubmit,
onCancel,
submitHint = "next",
attachmentProps,
}: TextEditorProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [previewMode, setPreviewMode] = useState(false);
const [forceCardMode, setForceCardMode] = useState(false);
const [debouncedText, setDebouncedText] = useState(textContent);
useEffect(() => {
const t = setTimeout(() => setDebouncedText(textContent), 500);
return () => clearTimeout(t);
}, [textContent]);
const linkPreviews = useAllLinkMetadata(debouncedText);
const attachmentCount = attachmentProps?.attachments.length ?? 0;
const hasEnrichments = attachmentCount > 0 || linkPreviews.length > 0;
const immersive =
textContent.length < IMMERSIVE_CHAR_LIMIT && !hasEnrichments && !forceCardMode;
useEffect(() => {
if (!previewMode) {
const t = setTimeout(() => {
const el = textareaRef.current;
if (el) {
el.focus();
el.selectionStart = el.selectionEnd = el.value.length;
}
}, 0);
return () => clearTimeout(t);
}
}, [previewMode, forceCardMode, immersive]);
useEffect(() => {
textareaRef.current?.focus();
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
if (textContent.trim()) onSubmit();
} else if (e.key === "m" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setForceCardMode(true);
}
},
[onCancel, onSubmit, textContent],
);
const strip = attachmentProps && (
<AttachmentStrip
attachments={attachmentProps.attachments}
onRemove={attachmentProps.onRemoveAttachment}
onAddClick={attachmentProps.onAddFiles}
linkPreviews={linkPreviews}
/>
);
const keyboardHints = (
<div className="absolute bottom-4 flex items-center gap-4 text-sm text-white/50">
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc
</kbd>{" "}
cancel
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
+Enter
</kbd>{" "}
{submitHint}
</span>
{immersive && (
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
+M
</kbd>{" "}
markdown
</span>
)}
{attachmentProps && (
<span>
<Button
variant="ghost"
type="button"
onClick={(e) => {
e.stopPropagation();
attachmentProps.onAddFiles();
}}
title="Attach files"
>
<Paperclip className="size-4" />
attach
</Button>
</span>
)}
</div>
);
const dropZoneProps = attachmentProps?.dropZoneProps;
const isDragging = attachmentProps?.isDragging ?? false;
if (immersive) {
const style = getImmersiveTextStyle(textContent.length);
return (
<div
className={cn(
"absolute inset-0 z-50 flex items-center justify-center bg-black/90",
isDragging && "ring-2 ring-inset ring-white/30",
)}
{...dropZoneProps}
>
<div className="flex w-full flex-col items-center justify-center gap-6 px-6">
<textarea
ref={textareaRef}
value={textContent}
onChange={(e) => onTextChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message..."
className={cn(
"w-full resize-none border-none bg-transparent p-8 text-center text-white placeholder-white/40 outline-none",
style.size,
style.weight,
)}
rows={4}
/>
</div>
{keyboardHints}
</div>
);
}
return (
<div
className={cn(
"absolute inset-0 z-50 flex items-center justify-center bg-black/90",
isDragging && "ring-2 ring-inset ring-white/30",
)}
{...dropZoneProps}
onKeyDown={handleKeyDown}
>
<div className="mx-8 flex h-[calc(100%-8rem)] w-full min-w-0 flex-col overflow-hidden rounded-2xl border border-white/10 bg-white/5 p-5 backdrop-blur-xl">
<div className="mb-3 flex shrink-0 items-center justify-end gap-1">
<button
type="button"
onClick={() => setPreviewMode(false)}
className={cn(
"rounded-md px-3 py-1 text-xs font-medium transition-colors",
!previewMode
? "bg-white/15 text-white"
: "text-white/40 hover:text-white/60",
)}
>
Write
</button>
<button
type="button"
onClick={() => setPreviewMode(true)}
className={cn(
"rounded-md px-3 py-1 text-xs font-medium transition-colors",
previewMode
? "bg-white/15 text-white"
: "text-white/40 hover:text-white/60",
)}
>
Preview
</button>
</div>
{previewMode ? (
<MarkdownPreview content={textContent} />
) : (
<textarea
ref={textareaRef}
value={textContent}
onChange={(e) => onTextChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message... (markdown supported)"
className="min-h-0 flex-1 resize-none border-none bg-transparent font-mono text-sm leading-relaxed text-white placeholder-white/40 outline-none"
/>
)}
{hasEnrichments && strip && (
<div className="shrink-0 border-t border-white/10 pt-3">
{strip}
</div>
)}
</div>
{keyboardHints}
</div>
);
}
@@ -0,0 +1,65 @@
import { useCallback, useState } from "react";
import { createPortal } from "react-dom";
import { toast } from "sonner";
import type { Particle } from "@/api/types";
import {
particlePath,
parseParticlePath,
toFirestoreDocPath,
type ParticlePath,
} from "@/lib/particle-path";
import { editTextParticleContent } from "@/lib/firestore-particles";
import { TextEditor } from "@/features/compose/text-editor";
type TextParticle = Extract<Particle, { type: "text" }>;
interface TextEditOverlayProps {
particle: TextParticle;
streamPath: ParticlePath;
onClose: () => void;
}
export function TextEditOverlay({
particle,
streamPath,
onClose,
}: TextEditOverlayProps) {
const [textContent, setTextContent] = useState(particle.properties.content);
const [saving, setSaving] = useState(false);
const handleSubmit = useCallback(async () => {
if (saving) return;
const trimmed = textContent.trim();
if (!trimmed) return;
if (trimmed === particle.properties.content) {
onClose();
return;
}
setSaving(true);
try {
const { networkId, segments } = parseParticlePath(streamPath);
const docPath = toFirestoreDocPath(
particlePath(networkId, [...segments, particle.id]),
);
await editTextParticleContent(docPath, trimmed);
onClose();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to save");
setSaving(false);
}
}, [saving, textContent, particle.properties.content, particle.id, streamPath, onClose]);
return createPortal(
<div className="fixed inset-0 z-[100]">
<TextEditor
textContent={textContent}
onTextChange={setTextContent}
onSubmit={handleSubmit}
onCancel={onClose}
submitHint="save"
/>
</div>,
document.body,
);
}
@@ -1,4 +1,5 @@
import { useEffect, useRef } from "react";
import { useEffect, useRef, 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";
@@ -10,6 +11,9 @@ import {
} 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 ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeHighlight from "rehype-highlight";
@@ -144,6 +148,10 @@ export function TextParticleView({
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;
@@ -157,7 +165,7 @@ export function TextParticleView({
}, [particle.id]);
useEffect(() => {
if (paused) return;
if (paused || isEditing) return;
const interval = setInterval(() => {
elapsedRef.current += TICK_MS / 1000;
@@ -171,18 +179,55 @@ export function TextParticleView({
}, TICK_MS);
return () => clearInterval(interval);
}, [paused, durationS, onEnded, onProgress, particle.id]);
}, [paused, isEditing, durationS, onEnded, onProgress, particle.id]);
// 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="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="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>
);
}
@@ -191,7 +236,7 @@ export function TextParticleView({
if (content.length < IMMERSIVE_CHAR_LIMIT && !hasEnrichments && !hasMarkdownFormatting(content)) {
const style = getImmersiveTextStyle(content.length);
return (
<div className="flex h-full w-full flex-col 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="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",
@@ -201,13 +246,16 @@ export function TextParticleView({
>
{content}
</p>
{editedLabel}
{editButton}
{editOverlay}
</div>
);
}
// Mode 3: card layout
return (
<div className="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="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={cn(
"flex max-h-full w-full max-w-2xl flex-col gap-4 overflow-y-auto overscroll-contain rounded-2xl bg-white/10 p-6 backdrop-blur-md",
@@ -218,12 +266,16 @@ export function TextParticleView({
"[&::-webkit-scrollbar-thumb]:hover:bg-white/50",
)}
>
<MarkdownContent content={content} className="select-text cursor-text" />
<MarkdownContent content={content} className="select-text cursor-text pb-3" />
{hasLinks && <LinkPreviews entries={linkPreviews} />}
{hasAttachments && <ParticleAttachments attachments={attachments} />}
{editedLabel}
</div>
{editButton}
{editOverlay}
</div>
);
}
+27 -2
View File
@@ -81,16 +81,26 @@ const particleConverter: FirestoreDataConverter<Particle> = {
case "file":
case "text":
case "quest":
case "paper":
case "paper": {
// Firestore stores timestamps as `Timestamp`; zod expects `Date`. Text
// particles carry `properties.edited_at`, so coerce it if present.
const properties =
type === "text" && raw.properties?.edited_at
? {
...raw.properties,
edited_at: (raw.properties.edited_at as Timestamp).toDate(),
}
: raw.properties;
return ParticleSchema.parse({
id: snap.id,
type: raw.type,
properties: raw.properties,
properties,
created_at: (raw.created_at as Timestamp).toDate(),
created_by_human_id: raw.created_by_human_id,
updated_at: raw.updated_at ? (raw.updated_at as Timestamp).toDate() : undefined,
reactions: raw.reactions ?? undefined,
});
}
default:
throw new Error(`Unknown particle type: ${type}`);
}
@@ -296,6 +306,21 @@ export async function updateParticleProperties<T extends ParticleType>(
});
}
// Edits the body of a text particle and stamps `properties.edited_at` so
// readers can see that the message was edited (distinct from `updated_at`,
// which is bumped by any write — visibility, reactions, etc.).
export async function editTextParticleContent(
docPath: string,
content: string,
): Promise<void> {
const particleRef = typedDoc(docPath);
await updateDoc(particleRef, {
"properties.content": content,
"properties.edited_at": serverTimestamp(),
updated_at: serverTimestamp(),
});
}
export async function updateParticleVisibleTo(
docPath: string,
visibleTo: string[],