infra: add linting and formatting for js projects (#230)
* wip * wip * wip * format * wip(mobile): lint and format * cleanup * nits * nits * idiomatic react * nit * format all root files
This commit was merged in pull request #230.
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { ConfirmDestructiveOverlay } from "@/components/confirm-destructive-overlay";
|
||||
import { softDeleteParticle } from "@/lib/firestore-particles";
|
||||
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path";
|
||||
import type { Particle } from "@/api/types";
|
||||
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
||||
import { useCallback, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { ConfirmDestructiveOverlay } from '@/components/confirm-destructive-overlay';
|
||||
import { softDeleteParticle } from '@/lib/firestore-particles';
|
||||
import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
|
||||
import type { Particle } from '@/api/types';
|
||||
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
||||
|
||||
interface DeleteParticleOverlayProps {
|
||||
networkId: string;
|
||||
@@ -21,7 +21,7 @@ export function DeleteParticleOverlay({
|
||||
userId,
|
||||
onClose,
|
||||
}: DeleteParticleOverlayProps) {
|
||||
useSuspendPlayback(true, "delete-particle");
|
||||
useSuspendPlayback(true, 'delete-particle');
|
||||
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
@@ -33,10 +33,11 @@ export function DeleteParticleOverlay({
|
||||
particlePath(networkId, [streamId, particle.id]),
|
||||
);
|
||||
await softDeleteParticle(docPath, userId);
|
||||
toast.success("Particle deleted");
|
||||
toast.success('Particle deleted');
|
||||
onClose();
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : "Failed to delete particle";
|
||||
const message =
|
||||
e instanceof Error ? e.message : 'Failed to delete particle';
|
||||
toast.error(message);
|
||||
setDeleting(false);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import type { Particle } from "@/api/types";
|
||||
import { useNetwork } from "@/hooks/use-networks";
|
||||
import { resolveHumanDisplay } from "@/lib/humans";
|
||||
import { useEffect } from 'react';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import type { Particle } from '@/api/types';
|
||||
import { useNetwork } from '@/hooks/use-networks';
|
||||
import { resolveHumanDisplay } from '@/lib/humans';
|
||||
|
||||
// How long to linger on a tombstone before auto-advancing. Matches the
|
||||
// "reading" cadence of a short text particle.
|
||||
@@ -23,7 +23,9 @@ export function DeletedParticleView({
|
||||
}: DeletedParticleViewProps) {
|
||||
const network = useNetwork(networkId);
|
||||
const deleterId =
|
||||
"deleted_by_human_id" in particle ? particle.deleted_by_human_id : undefined;
|
||||
'deleted_by_human_id' in particle
|
||||
? particle.deleted_by_human_id
|
||||
: undefined;
|
||||
const deleter = deleterId
|
||||
? resolveHumanDisplay(deleterId, network?.humans)
|
||||
: null;
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import type { Particle } from "@/api/types";
|
||||
import type { Particle } from '@/api/types';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { FileIcon, HelpCircleIcon, ScrollTextIcon, BookOpenIcon } from "lucide-react";
|
||||
import { useNetwork } from "@/hooks/use-networks";
|
||||
import { resolveHumanDisplay } from "@/lib/humans";
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
FileIcon,
|
||||
HelpCircleIcon,
|
||||
ScrollTextIcon,
|
||||
BookOpenIcon,
|
||||
} from 'lucide-react';
|
||||
import { useNetwork } from '@/hooks/use-networks';
|
||||
import { resolveHumanDisplay } from '@/lib/humans';
|
||||
|
||||
const TYPE_META: Record<string, { icon: typeof FileIcon; label: string }> = {
|
||||
quest: { icon: ScrollTextIcon, label: "Quest" },
|
||||
paper: { icon: BookOpenIcon, label: "Paper" },
|
||||
file: { icon: FileIcon, label: "File" },
|
||||
quest: { icon: ScrollTextIcon, label: 'Quest' },
|
||||
paper: { icon: BookOpenIcon, label: 'Paper' },
|
||||
file: { icon: FileIcon, label: 'File' },
|
||||
};
|
||||
|
||||
interface FallbackParticleViewProps {
|
||||
@@ -21,9 +26,15 @@ interface FallbackParticleViewProps {
|
||||
networkId: string;
|
||||
}
|
||||
|
||||
export function FallbackParticleView({ particle, networkId }: FallbackParticleViewProps) {
|
||||
export function FallbackParticleView({
|
||||
particle,
|
||||
networkId,
|
||||
}: FallbackParticleViewProps) {
|
||||
const network = useNetwork(networkId);
|
||||
const creator = resolveHumanDisplay(particle.created_by_human_id, network?.humans);
|
||||
const creator = resolveHumanDisplay(
|
||||
particle.created_by_human_id,
|
||||
network?.humans,
|
||||
);
|
||||
const meta = TYPE_META[particle.type] ?? {
|
||||
icon: HelpCircleIcon,
|
||||
label: particle.type,
|
||||
@@ -31,13 +42,13 @@ export function FallbackParticleView({ particle, networkId }: FallbackParticleVi
|
||||
const Icon = meta.icon;
|
||||
const title = (() => {
|
||||
switch (particle.type) {
|
||||
case "quest":
|
||||
case 'quest':
|
||||
return particle.properties.title;
|
||||
case "paper":
|
||||
case 'paper':
|
||||
return particle.properties.title;
|
||||
case "file":
|
||||
case 'file':
|
||||
return particle.properties.filename;
|
||||
case "folder":
|
||||
case 'folder':
|
||||
return particle.properties.name;
|
||||
default:
|
||||
return null;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Particle } from "@/api/types";
|
||||
import { useLiveParticleChildren } from "@/hooks/use-particle";
|
||||
import { parseParticlePath, type ParticlePath } from "@/lib/particle-path";
|
||||
import { ComposeOverlay } from "@/features/compose/compose-overlay";
|
||||
import { Particle } from '@/api/types';
|
||||
import { parseParticlePath, type ParticlePath } from '@/lib/particle-path';
|
||||
import { ComposeOverlay } from '@/features/compose/compose-overlay';
|
||||
|
||||
interface FolderViewProps {
|
||||
folderParticle: Particle;
|
||||
@@ -9,7 +8,6 @@ interface FolderViewProps {
|
||||
}
|
||||
|
||||
export function FolderView({ path, folderParticle }: FolderViewProps) {
|
||||
const { children, error, isLoading } = useLiveParticleChildren(path);
|
||||
const { networkId } = parseParticlePath(path);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
|
||||
import type { Particle } from "@/api/types";
|
||||
import type { ParticlePath } from "@/lib/particle-path";
|
||||
import { useDownloadUrl } from "@/hooks/use-download-url";
|
||||
import { useTranscriptPlayback } from "@/hooks/use-transcript-playback";
|
||||
import { TranscriptOverlay } from "@/features/particles/transcript-overlay";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { AudioLevelBars } from "@/components/audio/audio-level-bars";
|
||||
import { useAudioSource } from "@/components/audio/use-audio-source";
|
||||
import { useParticleAttachments } from "@/hooks/use-particle-attachments";
|
||||
import { ParticleAttachments } from "@/features/particles/particle-attachments";
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type { Particle } from '@/api/types';
|
||||
import type { ParticlePath } from '@/lib/particle-path';
|
||||
import { useDownloadUrl } from '@/hooks/use-download-url';
|
||||
import { useTranscriptPlayback } from '@/hooks/use-transcript-playback';
|
||||
import { TranscriptOverlay } from '@/features/particles/transcript-overlay';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { AudioLevelBars } from '@/components/audio/audio-level-bars';
|
||||
import { useAudioSource } from '@/components/audio/use-audio-source';
|
||||
import { useParticleAttachments } from '@/hooks/use-particle-attachments';
|
||||
import { ParticleAttachments } from '@/features/particles/particle-attachments';
|
||||
|
||||
type MediaParticle = Extract<Particle, { type: "media" }>;
|
||||
type MediaParticle = Extract<Particle, { type: 'media' }>;
|
||||
|
||||
export interface MediaParticleHandle {
|
||||
/** Seek by delta. Returns true if seeked, false if at boundary (should navigate). */
|
||||
@@ -26,50 +32,55 @@ interface MediaParticleViewProps {
|
||||
onProgress?: (ratio: number) => void;
|
||||
}
|
||||
|
||||
export const MediaParticleView = forwardRef<MediaParticleHandle, MediaParticleViewProps>(function MediaParticleView({
|
||||
particle,
|
||||
streamPath,
|
||||
paused,
|
||||
onEnded,
|
||||
onProgress,
|
||||
}, ref) {
|
||||
// Prefer the worker-produced iOS-playable variant when present so desktop
|
||||
// and mobile read the same canonical asset. Falls back to the original.
|
||||
// Pin the choice for the lifetime of this particle: if a transcoded variant
|
||||
// arrives via Firestore mid-playback, swapping the <video> src would restart
|
||||
// playback from 0. Keep whatever we picked first; the original plays fine in
|
||||
// Electron, and the transcoded variant will be picked up on the next view.
|
||||
const pickedSourceRef = useRef<{ id: string; objectId: string; mime: string } | null>(null);
|
||||
if (pickedSourceRef.current?.id !== particle.id) {
|
||||
pickedSourceRef.current = {
|
||||
id: particle.id,
|
||||
objectId: particle.properties.transcoded_object_id ?? particle.properties.object_id,
|
||||
mime: particle.properties.transcoded_mime_type ?? particle.properties.mime_type,
|
||||
};
|
||||
}
|
||||
const activeObjectId = pickedSourceRef.current.objectId;
|
||||
const activeMime = pickedSourceRef.current.mime;
|
||||
export const MediaParticleView = forwardRef<
|
||||
MediaParticleHandle,
|
||||
MediaParticleViewProps
|
||||
>(function MediaParticleView(
|
||||
{ particle, streamPath, paused, onEnded, onProgress },
|
||||
ref,
|
||||
) {
|
||||
// Prefer the worker-produced iOS-playable variant when present so desktop and
|
||||
// mobile read the same canonical asset, falling back to the original. Pinned
|
||||
// on mount (the parent keys this component by particle.id, so a new particle
|
||||
// remounts and re-picks): if a transcoded variant arrives via Firestore for
|
||||
// the same particle, swapping the <video> src would restart playback from 0.
|
||||
const [pickedSource] = useState(() => ({
|
||||
objectId:
|
||||
particle.properties.transcoded_object_id ?? particle.properties.object_id,
|
||||
mime:
|
||||
particle.properties.transcoded_mime_type ?? particle.properties.mime_type,
|
||||
}));
|
||||
const activeObjectId = pickedSource.objectId;
|
||||
const activeMime = pickedSource.mime;
|
||||
const { data: url, error } = useDownloadUrl(activeObjectId);
|
||||
const { attachments } = useParticleAttachments(streamPath, particle.id);
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const isAudio = activeMime?.startsWith("audio/");
|
||||
const isAudio = activeMime?.startsWith('audio/');
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
seek(deltaSec: number) {
|
||||
const el = isAudio ? audioRef.current : videoRef.current;
|
||||
if (!el) return false;
|
||||
if (deltaSec < 0 && el.currentTime < Math.abs(deltaSec)) return false;
|
||||
if (deltaSec > 0 && el.duration - el.currentTime < deltaSec) return false;
|
||||
el.currentTime = Math.max(0, Math.min(el.duration, el.currentTime + deltaSec));
|
||||
return true;
|
||||
},
|
||||
setPlaybackRate(rate: number) {
|
||||
const el = isAudio ? audioRef.current : videoRef.current;
|
||||
if (el) el.playbackRate = rate;
|
||||
},
|
||||
}), [isAudio]);
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
seek(deltaSec: number) {
|
||||
const el = isAudio ? audioRef.current : videoRef.current;
|
||||
if (!el) return false;
|
||||
if (deltaSec < 0 && el.currentTime < Math.abs(deltaSec)) return false;
|
||||
if (deltaSec > 0 && el.duration - el.currentTime < deltaSec)
|
||||
return false;
|
||||
el.currentTime = Math.max(
|
||||
0,
|
||||
Math.min(el.duration, el.currentTime + deltaSec),
|
||||
);
|
||||
return true;
|
||||
},
|
||||
setPlaybackRate(rate: number) {
|
||||
const el = isAudio ? audioRef.current : videoRef.current;
|
||||
if (el) el.playbackRate = rate;
|
||||
},
|
||||
}),
|
||||
[isAudio],
|
||||
);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
|
||||
const transcript = particle.properties.transcript;
|
||||
@@ -91,7 +102,7 @@ export const MediaParticleView = forwardRef<MediaParticleHandle, MediaParticleVi
|
||||
} else if (!el.ended) {
|
||||
// Calling play() on a naturally-finished element restarts it from 0.
|
||||
el.play().catch(() => {
|
||||
console.warn("Playback failed", { particleId: particle.id });
|
||||
console.warn('Playback failed', { particleId: particle.id });
|
||||
});
|
||||
}
|
||||
}, [paused, isAudio, particle.id]);
|
||||
@@ -108,14 +119,17 @@ export const MediaParticleView = forwardRef<MediaParticleHandle, MediaParticleVi
|
||||
return <Skeleton className="h-full w-full rounded-none" />;
|
||||
}
|
||||
|
||||
const handleTimeUpdate = (e: React.SyntheticEvent<HTMLAudioElement | HTMLVideoElement>) => {
|
||||
const handleTimeUpdate = (
|
||||
e: React.SyntheticEvent<HTMLAudioElement | HTMLVideoElement>,
|
||||
) => {
|
||||
const { currentTime: time, duration } = e.currentTarget;
|
||||
setCurrentTime(time);
|
||||
// WebM files from MediaRecorder (screen recordings) often report Infinity/NaN
|
||||
// duration until fully buffered — fall back to the known duration from metadata.
|
||||
const effectiveDuration = Number.isFinite(duration) && duration > 0
|
||||
? duration
|
||||
: particle.properties.duration_ms / 1000;
|
||||
const effectiveDuration =
|
||||
Number.isFinite(duration) && duration > 0
|
||||
? duration
|
||||
: particle.properties.duration_ms / 1000;
|
||||
if (effectiveDuration > 0) onProgress?.(time / effectiveDuration);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Download, ExternalLink, FileIcon, ImageIcon } from "lucide-react";
|
||||
import type { Particle } from "@/api/types";
|
||||
import { useDownloadUrl } from "@/hooks/use-download-url";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Download, ExternalLink, FileIcon, ImageIcon } from 'lucide-react';
|
||||
import type { Particle } from '@/api/types';
|
||||
import { useDownloadUrl } from '@/hooks/use-download-url';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
AttachmentLightbox,
|
||||
getAttachmentHandler,
|
||||
type AttachmentItem,
|
||||
} from "@/features/attachments/attachment-lightbox";
|
||||
import { platform } from "@/lib/platform";
|
||||
} from '@/features/attachments/attachment-lightbox';
|
||||
import { platform } from '@/lib/platform';
|
||||
|
||||
type FileParticle = Extract<Particle, { type: "file" }>;
|
||||
type FileParticle = Extract<Particle, { type: 'file' }>;
|
||||
|
||||
interface ParticleAttachmentsProps {
|
||||
attachments: FileParticle[];
|
||||
variant?: "inline" | "compact";
|
||||
variant?: 'inline' | 'compact';
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
@@ -31,7 +31,7 @@ function particleToItem(p: FileParticle): AttachmentItem {
|
||||
filename: p.properties.filename,
|
||||
mimeType: p.properties.mime_type,
|
||||
sizeBytes: p.properties.size_bytes,
|
||||
source: { kind: "remote", objectId: p.properties.object_id },
|
||||
source: { kind: 'remote', objectId: p.properties.object_id },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ function openParticle(
|
||||
url: string | undefined,
|
||||
onPreview: (index: number) => void,
|
||||
) {
|
||||
if (getAttachmentHandler(particle.properties.mime_type) === "lightbox") {
|
||||
if (getAttachmentHandler(particle.properties.mime_type) === 'lightbox') {
|
||||
onPreview(index);
|
||||
} else if (url) {
|
||||
platform.link.openExternal(url);
|
||||
@@ -58,7 +58,9 @@ function ImageAttachment({
|
||||
particle: FileParticle;
|
||||
onPreview: () => void;
|
||||
}) {
|
||||
const { data: url, isLoading } = useDownloadUrl(particle.properties.object_id);
|
||||
const { data: url, isLoading } = useDownloadUrl(
|
||||
particle.properties.object_id,
|
||||
);
|
||||
|
||||
if (isLoading || !url) {
|
||||
return <Skeleton className="h-20 w-20 shrink-0 rounded-lg bg-white/10" />;
|
||||
@@ -165,7 +167,7 @@ function CompactAttachmentItem({
|
||||
index: number;
|
||||
onPreview: (index: number) => void;
|
||||
}) {
|
||||
const isImage = particle.properties.mime_type.startsWith("image/");
|
||||
const isImage = particle.properties.mime_type.startsWith('image/');
|
||||
const { data: url } = useDownloadUrl(particle.properties.object_id);
|
||||
|
||||
return (
|
||||
@@ -200,13 +202,19 @@ function CompactAttachmentItem({
|
||||
);
|
||||
}
|
||||
|
||||
export function ParticleAttachments({ attachments, variant = "inline" }: ParticleAttachmentsProps) {
|
||||
export function ParticleAttachments({
|
||||
attachments,
|
||||
variant = 'inline',
|
||||
}: ParticleAttachmentsProps) {
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
||||
|
||||
// Only previewable attachments populate the lightbox; the index passed to the
|
||||
// lightbox is the index into this filtered list, not `attachments`.
|
||||
const previewable = useMemo(
|
||||
() => attachments.filter((a) => getAttachmentHandler(a.properties.mime_type) === "lightbox"),
|
||||
() =>
|
||||
attachments.filter(
|
||||
(a) => getAttachmentHandler(a.properties.mime_type) === 'lightbox',
|
||||
),
|
||||
[attachments],
|
||||
);
|
||||
const items = useMemo(() => previewable.map(particleToItem), [previewable]);
|
||||
@@ -228,7 +236,7 @@ export function ParticleAttachments({ attachments, variant = "inline" }: Particl
|
||||
/>
|
||||
);
|
||||
|
||||
if (variant === "compact") {
|
||||
if (variant === 'compact') {
|
||||
return (
|
||||
<>
|
||||
<div className="flex max-w-48 flex-col gap-1">
|
||||
@@ -251,7 +259,8 @@ export function ParticleAttachments({ attachments, variant = "inline" }: Particl
|
||||
<ScrollArea className="w-full">
|
||||
<div className="flex items-center gap-2 py-1">
|
||||
{attachments.map((attachment, i) => {
|
||||
const isImage = attachment.properties.mime_type.startsWith("image/");
|
||||
const isImage =
|
||||
attachment.properties.mime_type.startsWith('image/');
|
||||
return isImage ? (
|
||||
<ImageAttachment
|
||||
key={attachment.id}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { useMemo, useRef, useEffect, useCallback, memo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
useMemo,
|
||||
useRef,
|
||||
useEffect,
|
||||
useCallback,
|
||||
memo,
|
||||
createElement,
|
||||
} from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Radio,
|
||||
MessageSquare,
|
||||
@@ -12,25 +19,28 @@ import {
|
||||
Headphones,
|
||||
Trash2,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useLiveLatestChild } from "@/hooks/use-particle";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { particlePath } from "@/lib/particle-path";
|
||||
import { getInitials } from "@/lib/utils";
|
||||
import { resolveHumanDisplay } from "@/lib/humans";
|
||||
import { RelativeTimestamp } from "@/components/relative-timestamp";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Small } from "@/components/ui/typography";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { isParticleDeleted, type Particle, type StreamProperties } from "@/api/types";
|
||||
import type { StreamParticle } from "@/hooks/use-stream-particles";
|
||||
import { useNetwork } from "@/hooks/use-networks";
|
||||
import { useStreamAutoplay } from "@/hooks/use-stream-autoplay";
|
||||
import { useDownloadUrl } from "@/hooks/use-download-url";
|
||||
import { StreamContextMenu } from "@/features/particles/stream-context-menu";
|
||||
} from 'lucide-react';
|
||||
import { cn, getInitials } from '@/lib/utils';
|
||||
import { useLiveLatestChild } from '@/hooks/use-particle';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { particlePath } from '@/lib/particle-path';
|
||||
import { resolveHumanDisplay } from '@/lib/humans';
|
||||
import { RelativeTimestamp } from '@/components/relative-timestamp';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Small } from '@/components/ui/typography';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
isParticleDeleted,
|
||||
type Particle,
|
||||
type StreamProperties,
|
||||
} from '@/api/types';
|
||||
import type { StreamParticle } from '@/hooks/use-stream-particles';
|
||||
import { useNetwork } from '@/hooks/use-networks';
|
||||
import { useStreamAutoplay } from '@/hooks/use-stream-autoplay';
|
||||
import { useDownloadUrl } from '@/hooks/use-download-url';
|
||||
import { StreamContextMenu } from '@/features/particles/stream-context-menu';
|
||||
|
||||
function VideoThumbnail({
|
||||
objectId,
|
||||
@@ -43,8 +53,8 @@ function VideoThumbnail({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"size-8 shrink-0 overflow-hidden rounded-md bg-muted",
|
||||
isUnseen && "ring-2 ring-primary",
|
||||
'size-8 shrink-0 overflow-hidden rounded-md bg-muted',
|
||||
isUnseen && 'ring-2 ring-primary',
|
||||
)}
|
||||
>
|
||||
{url && (
|
||||
@@ -64,20 +74,20 @@ function VideoThumbnail({
|
||||
function getParticleTypeIcon(particle: Particle): LucideIcon {
|
||||
if (isParticleDeleted(particle)) return Trash2;
|
||||
switch (particle.type) {
|
||||
case "text":
|
||||
case 'text':
|
||||
return MessageSquare;
|
||||
case "media": {
|
||||
case 'media': {
|
||||
const mime = particle.properties.mime_type;
|
||||
if (mime.startsWith("video/")) return Video;
|
||||
if (mime.startsWith("audio/")) return Mic;
|
||||
if (mime.startsWith("image/")) return Image;
|
||||
if (mime.startsWith('video/')) return Video;
|
||||
if (mime.startsWith('audio/')) return Mic;
|
||||
if (mime.startsWith('image/')) return Image;
|
||||
return Video;
|
||||
}
|
||||
case "file":
|
||||
case 'file':
|
||||
return FileText;
|
||||
case "quest":
|
||||
case 'quest':
|
||||
return CircleCheck;
|
||||
case "paper":
|
||||
case 'paper':
|
||||
return StickyNote;
|
||||
default:
|
||||
return Radio;
|
||||
@@ -85,25 +95,25 @@ function getParticleTypeIcon(particle: Particle): LucideIcon {
|
||||
}
|
||||
|
||||
function getMessagePreview(particle: Particle): string {
|
||||
if (isParticleDeleted(particle)) return "Deleted particle";
|
||||
if (isParticleDeleted(particle)) return 'Deleted particle';
|
||||
switch (particle.type) {
|
||||
case "text":
|
||||
case 'text':
|
||||
return particle.properties.content;
|
||||
case "media": {
|
||||
case 'media': {
|
||||
const mime = particle.properties.mime_type;
|
||||
if (mime.startsWith("image/")) return "Photo";
|
||||
if (mime.startsWith("video/") || mime.startsWith("audio/")) {
|
||||
if (mime.startsWith('image/')) return 'Photo';
|
||||
if (mime.startsWith('video/') || mime.startsWith('audio/')) {
|
||||
const transcriptText = particle.properties.transcript?.transcript;
|
||||
if (transcriptText) return transcriptText;
|
||||
return mime.startsWith("video/") ? "Video clip" : "Voice note";
|
||||
return mime.startsWith('video/') ? 'Video clip' : 'Voice note';
|
||||
}
|
||||
return "Media";
|
||||
return 'Media';
|
||||
}
|
||||
case "file":
|
||||
case 'file':
|
||||
return particle.properties.filename;
|
||||
case "quest":
|
||||
case 'quest':
|
||||
return particle.properties.title;
|
||||
case "paper":
|
||||
case 'paper':
|
||||
return particle.properties.title;
|
||||
default:
|
||||
return particle.type;
|
||||
@@ -117,7 +127,7 @@ const StreamRow = memo(function StreamRow({
|
||||
isSelected,
|
||||
shortcutKey,
|
||||
}: {
|
||||
particle: Particle & { type: "stream"; properties: StreamProperties };
|
||||
particle: Particle & { type: 'stream'; properties: StreamProperties };
|
||||
networkId: string;
|
||||
onNavigate: (streamId: string) => void;
|
||||
isSelected?: boolean;
|
||||
@@ -126,18 +136,19 @@ const StreamRow = memo(function StreamRow({
|
||||
const streamPath = particlePath(networkId, [particle.id]);
|
||||
const { latestChild } = useLiveLatestChild(streamPath);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const userId = user?.id ?? "";
|
||||
const userId = user?.id ?? '';
|
||||
const network = useNetwork(networkId);
|
||||
|
||||
useStreamAutoplay(latestChild, particle, networkId, network ?? undefined);
|
||||
|
||||
const hasActiveHuddle =
|
||||
particle.huddle_active_participants && particle.huddle_active_participants.length > 0;
|
||||
particle.huddle_active_participants &&
|
||||
particle.huddle_active_participants.length > 0;
|
||||
const huddleCount = particle.huddle_active_participants?.length ?? 0;
|
||||
|
||||
const isDM =
|
||||
particle.visible_to.length === 2 &&
|
||||
particle.visible_to.every((v) => v.startsWith("human:"));
|
||||
particle.visible_to.every((v) => v.startsWith('human:'));
|
||||
|
||||
const initials = useMemo(() => {
|
||||
if (isDM) {
|
||||
@@ -145,19 +156,28 @@ const StreamRow = memo(function StreamRow({
|
||||
(v) => v !== `human:${userId}`,
|
||||
);
|
||||
if (otherEntry) {
|
||||
const otherId = otherEntry.replace("human:", "");
|
||||
const otherId = otherEntry.replace('human:', '');
|
||||
const otherHuman = network?.humans?.find((h) => h.id === otherId);
|
||||
if (otherHuman) return getInitials(otherHuman.email);
|
||||
}
|
||||
}
|
||||
|
||||
if (latestChild) {
|
||||
const creator = network?.humans?.find((h) => h.id === latestChild.created_by_human_id);
|
||||
const creator = network?.humans?.find(
|
||||
(h) => h.id === latestChild.created_by_human_id,
|
||||
);
|
||||
if (creator) return getInitials(creator.email);
|
||||
}
|
||||
|
||||
return particle.properties.name.slice(0, 2).toUpperCase();
|
||||
}, [isDM, particle.visible_to, particle.properties.name, userId, latestChild, network]);
|
||||
}, [
|
||||
isDM,
|
||||
particle.visible_to,
|
||||
particle.properties.name,
|
||||
userId,
|
||||
latestChild,
|
||||
network,
|
||||
]);
|
||||
|
||||
const isUnseen = useMemo(() => {
|
||||
if (!latestChild) return false;
|
||||
@@ -171,15 +191,16 @@ const StreamRow = memo(function StreamRow({
|
||||
if (!latestChild) return null;
|
||||
const isCurrentUser = latestChild.created_by_human_id === userId;
|
||||
if (isDM) {
|
||||
return isCurrentUser ? "You: " : null;
|
||||
return isCurrentUser ? 'You: ' : null;
|
||||
}
|
||||
// Group stream
|
||||
if (isCurrentUser) return "You: ";
|
||||
if (isCurrentUser) return 'You: ';
|
||||
const { displayName } = resolveHumanDisplay(
|
||||
latestChild.created_by_human_id,
|
||||
network?.humans,
|
||||
);
|
||||
const capitalized = displayName.charAt(0).toUpperCase() + displayName.slice(1);
|
||||
const capitalized =
|
||||
displayName.charAt(0).toUpperCase() + displayName.slice(1);
|
||||
return `${capitalized}: `;
|
||||
}, [latestChild, userId, isDM, network]);
|
||||
|
||||
@@ -187,13 +208,15 @@ const StreamRow = memo(function StreamRow({
|
||||
? getMessagePreview(latestChild)
|
||||
: particle.properties.name;
|
||||
|
||||
const TypeIcon = latestChild ? getParticleTypeIcon(latestChild) : Radio;
|
||||
// Rendered via createElement below: a call-result used directly as a JSX tag
|
||||
// is flagged as a dynamically-created component.
|
||||
const typeIcon = latestChild ? getParticleTypeIcon(latestChild) : Radio;
|
||||
|
||||
const videoThumbObjectId =
|
||||
latestChild &&
|
||||
!isParticleDeleted(latestChild) &&
|
||||
latestChild.type === "media" &&
|
||||
latestChild.properties.mime_type.startsWith("video/")
|
||||
latestChild.type === 'media' &&
|
||||
latestChild.properties.mime_type.startsWith('video/')
|
||||
? latestChild.properties.object_id
|
||||
: null;
|
||||
|
||||
@@ -202,11 +225,13 @@ const StreamRow = memo(function StreamRow({
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onNavigate(particle.id)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onNavigate(particle.id); }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') onNavigate(particle.id);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 px-4 py-3 text-left cursor-pointer transition-colors hover:bg-accent",
|
||||
isSelected && "bg-accent",
|
||||
hasActiveHuddle && "bg-gradient-to-r from-red-500/10 to-transparent",
|
||||
'flex w-full items-center gap-3 px-4 py-3 text-left cursor-pointer transition-colors hover:bg-accent',
|
||||
isSelected && 'bg-accent',
|
||||
hasActiveHuddle && 'bg-gradient-to-r from-red-500/10 to-transparent',
|
||||
)}
|
||||
>
|
||||
{shortcutKey && (
|
||||
@@ -217,7 +242,7 @@ const StreamRow = memo(function StreamRow({
|
||||
{videoThumbObjectId ? (
|
||||
<VideoThumbnail objectId={videoThumbObjectId} isUnseen={!!isUnseen} />
|
||||
) : (
|
||||
<Avatar className={cn(isUnseen && "ring-2 ring-primary")}>
|
||||
<Avatar className={cn(isUnseen && 'ring-2 ring-primary')}>
|
||||
<AvatarFallback className="bg-primary/10 text-primary font-medium">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
@@ -227,10 +252,10 @@ const StreamRow = memo(function StreamRow({
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p
|
||||
className={cn(
|
||||
"truncate text-sm",
|
||||
'truncate text-sm',
|
||||
isUnseen
|
||||
? "font-semibold text-foreground"
|
||||
: "font-medium text-muted-foreground",
|
||||
? 'font-semibold text-foreground'
|
||||
: 'font-medium text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{particle.properties.name}
|
||||
@@ -239,14 +264,16 @@ const StreamRow = memo(function StreamRow({
|
||||
{hasActiveHuddle && (
|
||||
<span className="flex items-center gap-1 rounded-full bg-red-500/15 px-1.5 py-0.5">
|
||||
<Headphones className="size-3 text-red-400" />
|
||||
<span className="text-[10px] font-medium text-red-400">{huddleCount}</span>
|
||||
<span className="text-[10px] font-medium text-red-400">
|
||||
{huddleCount}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{latestChild && (
|
||||
<Small
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
isUnseen ? "text-primary" : "text-muted-foreground",
|
||||
'shrink-0',
|
||||
isUnseen ? 'text-primary' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<RelativeTimestamp date={latestChild.created_at} />
|
||||
@@ -255,18 +282,18 @@ const StreamRow = memo(function StreamRow({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<TypeIcon
|
||||
className={cn(
|
||||
"size-3.5 shrink-0",
|
||||
isUnseen ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
{createElement(typeIcon, {
|
||||
className: cn(
|
||||
'size-3.5 shrink-0',
|
||||
isUnseen ? 'text-foreground' : 'text-muted-foreground',
|
||||
),
|
||||
})}
|
||||
<Small
|
||||
className={cn(
|
||||
"truncate",
|
||||
'truncate',
|
||||
isUnseen
|
||||
? "text-foreground font-medium"
|
||||
: "text-muted-foreground font-normal",
|
||||
? 'text-foreground font-medium'
|
||||
: 'text-muted-foreground font-normal',
|
||||
)}
|
||||
>
|
||||
{senderPrefix && (
|
||||
@@ -276,9 +303,7 @@ const StreamRow = memo(function StreamRow({
|
||||
</Small>
|
||||
</div>
|
||||
</div>
|
||||
{isUnseen && (
|
||||
<span className="size-2 shrink-0 rounded-full bg-primary" />
|
||||
)}
|
||||
{isUnseen && <span className="size-2 shrink-0 rounded-full bg-primary" />}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -313,8 +338,12 @@ export function ParticleListView({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedIndex !== null && selectedIndex !== undefined && selectedIndex >= 0) {
|
||||
rowRefs.current[selectedIndex]?.scrollIntoView({ block: "nearest" });
|
||||
if (
|
||||
selectedIndex !== null &&
|
||||
selectedIndex !== undefined &&
|
||||
selectedIndex >= 0
|
||||
) {
|
||||
rowRefs.current[selectedIndex]?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
@@ -327,7 +356,8 @@ export function ParticleListView({
|
||||
<div className="mx-auto flex h-full max-w-sm flex-col items-center justify-center gap-2 px-4 text-center">
|
||||
<Radio className="text-muted-foreground size-8" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No streams here. Start a conversation using the keyboard shortcuts below.
|
||||
No streams here. Start a conversation using the keyboard shortcuts
|
||||
below.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -336,9 +366,15 @@ export function ParticleListView({
|
||||
return (
|
||||
<div>
|
||||
{streams.map((stream, index) => (
|
||||
<StreamContextMenu key={stream.id} particle={stream} networkId={networkId}>
|
||||
<StreamContextMenu
|
||||
key={stream.id}
|
||||
particle={stream}
|
||||
networkId={networkId}
|
||||
>
|
||||
<div
|
||||
ref={(el) => { rowRefs.current[index] = el; }}
|
||||
ref={(el) => {
|
||||
rowRefs.current[index] = el;
|
||||
}}
|
||||
>
|
||||
<StreamRow
|
||||
particle={stream}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Particle } from "@/api/types";
|
||||
import { apiClient } from "@/api/client";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { Particle } from '@/api/types';
|
||||
import { apiClient } from '@/api/client';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Video,
|
||||
Mic,
|
||||
@@ -9,31 +9,35 @@ import {
|
||||
BookOpen,
|
||||
FileIcon,
|
||||
FolderIcon,
|
||||
} from "lucide-react";
|
||||
} from 'lucide-react';
|
||||
|
||||
export function ParticlePreview({ particle }: { particle: Particle }) {
|
||||
switch (particle.type) {
|
||||
case "text":
|
||||
case 'text':
|
||||
return <TextPreview particle={particle} />;
|
||||
case "media":
|
||||
case 'media':
|
||||
return <MediaPreview particle={particle} />;
|
||||
case "quest":
|
||||
case 'quest':
|
||||
return <QuestPreview particle={particle} />;
|
||||
case "paper":
|
||||
case 'paper':
|
||||
return <PaperPreview particle={particle} />;
|
||||
case "file":
|
||||
case 'file':
|
||||
return <FilePreview particle={particle} />;
|
||||
case "folder":
|
||||
case 'folder':
|
||||
return <FolderPreview particle={particle} />;
|
||||
default:
|
||||
return <EmptyPreview />;
|
||||
}
|
||||
}
|
||||
|
||||
function TextPreview({ particle }: { particle: Extract<Particle, { type: "text" }> }) {
|
||||
function TextPreview({
|
||||
particle,
|
||||
}: {
|
||||
particle: Extract<Particle, { type: 'text' }>;
|
||||
}) {
|
||||
const truncated =
|
||||
particle.properties.content.length > 30
|
||||
? particle.properties.content.slice(0, 30) + "..."
|
||||
? particle.properties.content.slice(0, 30) + '...'
|
||||
: particle.properties.content;
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center p-4">
|
||||
@@ -44,14 +48,23 @@ function TextPreview({ particle }: { particle: Extract<Particle, { type: "text"
|
||||
);
|
||||
}
|
||||
|
||||
function MediaPreview({ particle }: { particle: Extract<Particle, { type: "media" }> }) {
|
||||
function MediaPreview({
|
||||
particle,
|
||||
}: {
|
||||
particle: Extract<Particle, { type: 'media' }>;
|
||||
}) {
|
||||
const { mime_type, duration_ms } = particle.properties;
|
||||
const isVideo = mime_type.startsWith("video");
|
||||
const isVideo = mime_type.startsWith('video');
|
||||
const durationSec = Math.round(duration_ms / 1000);
|
||||
const durationLabel = `${Math.floor(durationSec / 60)}:${String(durationSec % 60).padStart(2, "0")}`;
|
||||
const durationLabel = `${Math.floor(durationSec / 60)}:${String(durationSec % 60).padStart(2, '0')}`;
|
||||
|
||||
if (isVideo) {
|
||||
return <VideoThumbnail particleId={particle.properties.object_id} duration={durationLabel} />;
|
||||
return (
|
||||
<VideoThumbnail
|
||||
particleId={particle.properties.object_id}
|
||||
duration={durationLabel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -115,14 +128,16 @@ function VideoThumbnail({
|
||||
);
|
||||
}
|
||||
|
||||
function QuestPreview({ particle }: { particle: Extract<Particle, { type: "quest" }> }) {
|
||||
function QuestPreview({
|
||||
particle,
|
||||
}: {
|
||||
particle: Extract<Particle, { type: 'quest' }>;
|
||||
}) {
|
||||
const { title, status } = particle.properties;
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-amber-500/10 p-4">
|
||||
<ScrollText className="h-6 w-6 text-amber-600/70 dark:text-amber-400/70" />
|
||||
<p className="line-clamp-2 text-center text-sm font-medium">
|
||||
{title}
|
||||
</p>
|
||||
<p className="line-clamp-2 text-center text-sm font-medium">{title}</p>
|
||||
{status && (
|
||||
<span className="text-muted-foreground text-[10px] uppercase tracking-wide">
|
||||
{status}
|
||||
@@ -132,7 +147,11 @@ function QuestPreview({ particle }: { particle: Extract<Particle, { type: "quest
|
||||
);
|
||||
}
|
||||
|
||||
function PaperPreview({ particle }: { particle: Extract<Particle, { type: "paper" }> }) {
|
||||
function PaperPreview({
|
||||
particle,
|
||||
}: {
|
||||
particle: Extract<Particle, { type: 'paper' }>;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-blue-500/10 p-4">
|
||||
<BookOpen className="h-6 w-6 text-blue-600/70 dark:text-blue-400/70" />
|
||||
@@ -143,7 +162,11 @@ function PaperPreview({ particle }: { particle: Extract<Particle, { type: "paper
|
||||
);
|
||||
}
|
||||
|
||||
function FilePreview({ particle }: { particle: Extract<Particle, { type: "file" }> }) {
|
||||
function FilePreview({
|
||||
particle,
|
||||
}: {
|
||||
particle: Extract<Particle, { type: 'file' }>;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-emerald-500/10 p-4">
|
||||
<FileIcon className="h-6 w-6 text-emerald-600/70 dark:text-emerald-400/70" />
|
||||
@@ -154,7 +177,11 @@ function FilePreview({ particle }: { particle: Extract<Particle, { type: "file"
|
||||
);
|
||||
}
|
||||
|
||||
function FolderPreview({ particle }: { particle: Extract<Particle, { type: "folder" }> }) {
|
||||
function FolderPreview({
|
||||
particle,
|
||||
}: {
|
||||
particle: Extract<Particle, { type: 'folder' }>;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-orange-500/10 p-4">
|
||||
<FolderIcon className="h-6 w-6 text-orange-600/70 dark:text-orange-400/70" />
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Lock } from "lucide-react";
|
||||
import { useLiveParticle } from "@/hooks/use-particle";
|
||||
import { particlePath } from "@/lib/particle-path";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { StreamView } from "@/features/particles/stream-view";
|
||||
import { FolderView } from "@/features/particles/folder-view";
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Lock } from 'lucide-react';
|
||||
import { useLiveParticle } from '@/hooks/use-particle';
|
||||
import { particlePath } from '@/lib/particle-path';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import { StreamView } from '@/features/particles/stream-view';
|
||||
import { FolderView } from '@/features/particles/folder-view';
|
||||
|
||||
/**
|
||||
* Route-level component for /:networkId/*.
|
||||
@@ -16,9 +15,11 @@ import { FolderView } from "@/features/particles/folder-view";
|
||||
* the appropriate view based on particle type.
|
||||
*/
|
||||
export default function ParticleViewResolver() {
|
||||
const { networkId, "*": rest } = useParams();
|
||||
const segments = (rest ?? "").split("/").filter(Boolean);
|
||||
const path = particlePath(networkId!, segments); // path of current container particle
|
||||
const { networkId, '*': rest } = useParams();
|
||||
if (!networkId)
|
||||
throw new Error('ParticleViewResolver requires a :networkId route param');
|
||||
const segments = (rest ?? '').split('/').filter(Boolean);
|
||||
const path = particlePath(networkId, segments); // path of current container particle
|
||||
|
||||
const { particle, isLoading, error } = useLiveParticle(path);
|
||||
|
||||
@@ -38,9 +39,9 @@ export default function ParticleViewResolver() {
|
||||
}
|
||||
|
||||
switch (particle.type) {
|
||||
case "stream":
|
||||
case 'stream':
|
||||
return <StreamView streamParticle={particle} path={path} />;
|
||||
case "folder":
|
||||
case 'folder':
|
||||
return <FolderView folderParticle={particle} path={path} />;
|
||||
default:
|
||||
return (
|
||||
@@ -59,7 +60,7 @@ function InaccessibleParticle() {
|
||||
|
||||
useEffect(() => {
|
||||
// Refresh the networks list so the home page reflects current access.
|
||||
queryClient.invalidateQueries({ queryKey: ["networks"] });
|
||||
queryClient.invalidateQueries({ queryKey: ['networks'] });
|
||||
}, [queryClient]);
|
||||
|
||||
return (
|
||||
@@ -71,7 +72,7 @@ function InaccessibleParticle() {
|
||||
It may have been deleted, or your access was removed.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => navigate("/", { replace: true })}>
|
||||
<Button size="sm" onClick={() => navigate('/', { replace: true })}>
|
||||
Go home
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { HumanPresence } from "@/hooks/use-presence-positions";
|
||||
} from '@/components/ui/tooltip';
|
||||
import type { HumanPresence } from '@/hooks/use-presence-positions';
|
||||
|
||||
const MAX_VISIBLE_AVATARS = 3;
|
||||
const PAGE_SIZE = 10;
|
||||
@@ -18,7 +18,7 @@ interface PlaybackPageIndicatorProps {
|
||||
/** Set of humanIds currently online in the stream channel. */
|
||||
onlineHumanIds?: Set<string>;
|
||||
/** Render only avatars or only tracks. Omit to render both. */
|
||||
layer?: "avatars" | "tracks";
|
||||
layer?: 'avatars' | 'tracks';
|
||||
}
|
||||
|
||||
export function PlaybackPageIndicator({
|
||||
@@ -32,8 +32,8 @@ export function PlaybackPageIndicator({
|
||||
}: PlaybackPageIndicatorProps) {
|
||||
if (total === 0) return null;
|
||||
|
||||
const showAvatars = layer !== "tracks";
|
||||
const showTracks = layer !== "avatars";
|
||||
const showAvatars = layer !== 'tracks';
|
||||
const showTracks = layer !== 'avatars';
|
||||
|
||||
const paginated = total > PAGE_SIZE;
|
||||
const safeCurrent = current < 0 ? 0 : current;
|
||||
@@ -84,11 +84,12 @@ export function PlaybackPageIndicator({
|
||||
style={{
|
||||
width:
|
||||
i < current
|
||||
? "100%"
|
||||
? '100%'
|
||||
: i === current
|
||||
? `${progress * 100}%`
|
||||
: "0%",
|
||||
transition: i === current ? "width 300ms linear" : "none",
|
||||
: '0%',
|
||||
transition:
|
||||
i === current ? 'width 300ms linear' : 'none',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
@@ -160,7 +161,14 @@ function SegmentPresenceAvatars({
|
||||
{visible.map((human) => (
|
||||
<Tooltip key={human.humanId}>
|
||||
<TooltipTrigger asChild>
|
||||
<Avatar size="xs" className={onlineHumanIds?.has(human.humanId) ? "ring-2 ring-green-500" : "ring-1 ring-black/50"}>
|
||||
<Avatar
|
||||
size="xs"
|
||||
className={
|
||||
onlineHumanIds?.has(human.humanId)
|
||||
? 'ring-2 ring-green-500'
|
||||
: 'ring-1 ring-black/50'
|
||||
}
|
||||
>
|
||||
<AvatarFallback>
|
||||
{human.emailPrefix.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
@@ -172,9 +180,7 @@ function SegmentPresenceAvatars({
|
||||
</Tooltip>
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<span className="text-[10px] text-white/70 pl-1">
|
||||
+{overflow}
|
||||
</span>
|
||||
<span className="text-[10px] text-white/70 pl-1">+{overflow}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useState } from "react";
|
||||
import { Plus, Type, X } from "lucide-react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { REACTION_EMOJIS, type Reactions } from "@/api/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { resolveHumanDisplay } from "@/lib/humans";
|
||||
import type { Human } from "@/api/types";
|
||||
import { useState } from 'react';
|
||||
import { Plus, Type, X } from 'lucide-react';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { REACTION_EMOJIS, type Reactions, type Human } from '@/api/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { resolveHumanDisplay } from '@/lib/humans';
|
||||
|
||||
interface ReactionBarProps {
|
||||
reactions: Reactions;
|
||||
@@ -20,7 +23,7 @@ const EMOJI_SET = new Set<string>(REACTION_EMOJIS);
|
||||
function getReactorNames(humanIds: string[], humans?: Human[]): string {
|
||||
return humanIds
|
||||
.map((id) => resolveHumanDisplay(id, humans).displayName)
|
||||
.join(", ");
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
function getReactorList(
|
||||
@@ -61,18 +64,21 @@ export function ReactionBar({
|
||||
<div className="flex flex-col items-end gap-1.5">
|
||||
{/* Emoji reaction pills */}
|
||||
{activeEmojis.map((emoji) => {
|
||||
const reactors = reactions![emoji];
|
||||
const reactors = reactions?.[emoji] ?? [];
|
||||
const isMine = reactors.includes(currentHumanId);
|
||||
return (
|
||||
<Tooltip key={emoji}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleToggle(emoji); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleToggle(emoji);
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-full px-2 py-0.5 text-xs backdrop-blur-sm transition-colors",
|
||||
'flex items-center gap-1 rounded-full px-2 py-0.5 text-xs backdrop-blur-sm transition-colors',
|
||||
isMine
|
||||
? "bg-white/20 ring-1 ring-white/40"
|
||||
: "bg-black/40 hover:bg-black/50",
|
||||
? 'bg-white/20 ring-1 ring-white/40'
|
||||
: 'bg-black/40 hover:bg-black/50',
|
||||
)}
|
||||
>
|
||||
<span className="text-sm">{emoji}</span>
|
||||
@@ -88,7 +94,7 @@ export function ReactionBar({
|
||||
|
||||
{/* Text reaction pills */}
|
||||
{activeTextReactions.map((text) => {
|
||||
const reactors = reactions![text];
|
||||
const reactors = reactions?.[text] ?? [];
|
||||
const isMine = reactors.includes(currentHumanId);
|
||||
const firstReactor = resolveHumanDisplay(reactors[0], humans);
|
||||
const reactorList = getReactorList(reactors, humans, currentHumanId);
|
||||
@@ -96,12 +102,15 @@ export function ReactionBar({
|
||||
<Tooltip key={text}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleToggle(text); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleToggle(text);
|
||||
}}
|
||||
className={cn(
|
||||
"flex max-w-[200px] items-center gap-1.5 rounded-full py-0.5 pl-0.5 pr-2.5 text-xs backdrop-blur-sm transition-colors",
|
||||
'flex max-w-[200px] items-center gap-1.5 rounded-full py-0.5 pl-0.5 pr-2.5 text-xs backdrop-blur-sm transition-colors',
|
||||
isMine
|
||||
? "bg-white/20 ring-1 ring-white/40"
|
||||
: "bg-black/40 hover:bg-black/50",
|
||||
? 'bg-white/20 ring-1 ring-white/40'
|
||||
: 'bg-black/40 hover:bg-black/50',
|
||||
)}
|
||||
>
|
||||
<Avatar size="xs" className="shrink-0">
|
||||
@@ -111,22 +120,30 @@ export function ReactionBar({
|
||||
</Avatar>
|
||||
<span className="truncate text-white/90">{text}</span>
|
||||
{reactors.length > 1 && (
|
||||
<span className="shrink-0 text-white/60">{reactors.length}</span>
|
||||
<span className="shrink-0 text-white/60">
|
||||
{reactors.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="max-w-[260px] space-y-1.5 text-xs">
|
||||
<TooltipContent
|
||||
side="left"
|
||||
className="max-w-[260px] space-y-1.5 text-xs"
|
||||
>
|
||||
<div className="font-medium">“{text}”</div>
|
||||
<ul className="flex flex-col gap-0.5 opacity-80">
|
||||
{reactorList.map((r) => (
|
||||
<li key={r.id} className={cn(r.isMine && "font-medium opacity-100")}>
|
||||
<li
|
||||
key={r.id}
|
||||
className={cn(r.isMine && 'font-medium opacity-100')}
|
||||
>
|
||||
{r.label}
|
||||
{r.isMine && <span className="ml-1 opacity-60">(you)</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="border-t border-current/15 pt-1 text-[10px] opacity-60">
|
||||
{isMine ? "Click to remove" : "Click to add yours"}
|
||||
{isMine ? 'Click to remove' : 'Click to add yours'}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -141,7 +158,10 @@ export function ReactionBar({
|
||||
return (
|
||||
<button
|
||||
key={emoji}
|
||||
onClick={(e) => { e.stopPropagation(); handleToggle(emoji); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleToggle(emoji);
|
||||
}}
|
||||
className="rounded-full px-0.5 py-1 text-sm transition-colors hover:bg-white/15"
|
||||
>
|
||||
{emoji}
|
||||
@@ -149,7 +169,10 @@ export function ReactionBar({
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setExpanded(false); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpanded(false);
|
||||
}}
|
||||
className="flex size-5 items-center justify-center rounded-full transition-colors hover:bg-white/15"
|
||||
>
|
||||
<X className="size-3 text-white/60" />
|
||||
@@ -160,18 +183,27 @@ export function ReactionBar({
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onOpenTextReaction(); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenTextReaction();
|
||||
}}
|
||||
className="flex size-6 items-center justify-center rounded-full bg-black/40 backdrop-blur-sm transition-colors hover:bg-black/50"
|
||||
>
|
||||
<Type className="size-3 text-white/60" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="text-xs">
|
||||
Quick reply <kbd className="ml-1 rounded bg-white/10 px-1 font-mono text-[10px]">R</kbd>
|
||||
Quick reply{' '}
|
||||
<kbd className="ml-1 rounded bg-white/10 px-1 font-mono text-[10px]">
|
||||
R
|
||||
</kbd>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setExpanded(true); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpanded(true);
|
||||
}}
|
||||
className="flex size-6 items-center justify-center rounded-full bg-black/40 backdrop-blur-sm transition-colors hover:bg-black/50"
|
||||
>
|
||||
<Plus className="size-3 text-white/60" />
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { updateParticleProperties } from "@/lib/firestore-particles";
|
||||
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path";
|
||||
import type { Particle } from "@/api/types";
|
||||
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { updateParticleProperties } from '@/lib/firestore-particles';
|
||||
import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
|
||||
import type { Particle } from '@/api/types';
|
||||
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
||||
|
||||
interface RenameStreamOverlayProps {
|
||||
networkId: string;
|
||||
streamParticle: Particle & { type: "stream" };
|
||||
streamParticle: Particle & { type: 'stream' };
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -18,23 +18,23 @@ export function RenameStreamOverlay({
|
||||
streamParticle,
|
||||
onClose,
|
||||
}: RenameStreamOverlayProps) {
|
||||
useSuspendPlayback(true, "rename-stream");
|
||||
useSuspendPlayback(true, 'rename-stream');
|
||||
|
||||
const [name, setName] = useState(streamParticle.properties.name);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const trimmed = name.trim();
|
||||
const canSave =
|
||||
!saving &&
|
||||
trimmed.length > 0 &&
|
||||
trimmed !== streamParticle.properties.name;
|
||||
!saving && trimmed.length > 0 && trimmed !== streamParticle.properties.name;
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!canSave) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const docPath = toFirestoreDocPath(particlePath(networkId, [streamParticle.id]));
|
||||
await updateParticleProperties<"stream">(docPath, { name: trimmed });
|
||||
const docPath = toFirestoreDocPath(
|
||||
particlePath(networkId, [streamParticle.id]),
|
||||
);
|
||||
await updateParticleProperties<'stream'>(docPath, { name: trimmed });
|
||||
onClose();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -43,14 +43,15 @@ export function RenameStreamOverlay({
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler, { capture: true });
|
||||
return () => window.removeEventListener("keydown", handler, { capture: true });
|
||||
window.addEventListener('keydown', handler, { capture: true });
|
||||
return () =>
|
||||
window.removeEventListener('keydown', handler, { capture: true });
|
||||
}, [onClose]);
|
||||
|
||||
return createPortal(
|
||||
@@ -65,7 +66,7 @@ export function RenameStreamOverlay({
|
||||
<span className="text-xs text-white/30">
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
Esc
|
||||
</kbd>{" "}
|
||||
</kbd>{' '}
|
||||
to close
|
||||
</span>
|
||||
</div>
|
||||
@@ -77,7 +78,7 @@ export function RenameStreamOverlay({
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
|
||||
@@ -1,169 +1,179 @@
|
||||
import { forwardRef, useMemo } from "react";
|
||||
import { Headphones } from "lucide-react";
|
||||
import { cn, getInitials } from "@/lib/utils";
|
||||
import { useLiveLatestChild } from "@/hooks/use-particle";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { particlePath } from "@/lib/particle-path";
|
||||
import type { Particle, StreamProperties } from "@/api/types";
|
||||
import { useStreamAutoplay } from "@/hooks/use-stream-autoplay";
|
||||
import { ParticlePreview } from "@/features/particles/particle-preview";
|
||||
import { useNetwork } from "@/hooks/use-networks";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { RelativeTimestamp } from "@/components/relative-timestamp";
|
||||
import { Small } from "@/components/ui/typography";
|
||||
import { forwardRef, useMemo } from 'react';
|
||||
import { Headphones } from 'lucide-react';
|
||||
import { cn, getInitials } from '@/lib/utils';
|
||||
import { useLiveLatestChild } from '@/hooks/use-particle';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { particlePath } from '@/lib/particle-path';
|
||||
import type { Particle, StreamProperties } from '@/api/types';
|
||||
import { useStreamAutoplay } from '@/hooks/use-stream-autoplay';
|
||||
import { ParticlePreview } from '@/features/particles/particle-preview';
|
||||
import { useNetwork } from '@/hooks/use-networks';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { RelativeTimestamp } from '@/components/relative-timestamp';
|
||||
import { Small } from '@/components/ui/typography';
|
||||
|
||||
interface StreamCardProps {
|
||||
particle: Particle & { type: "stream"; properties: StreamProperties };
|
||||
particle: Particle & { type: 'stream'; properties: StreamProperties };
|
||||
networkId: string;
|
||||
onClick: () => void;
|
||||
isSelected?: boolean;
|
||||
shortcutKey?: number;
|
||||
}
|
||||
|
||||
export const StreamCard = forwardRef<HTMLDivElement, StreamCardProps>(function StreamCard({ particle, networkId, onClick, isSelected, shortcutKey }, ref) {
|
||||
const streamPath = particlePath(networkId, [particle.id]);
|
||||
const { latestChild } = useLiveLatestChild(streamPath);
|
||||
const userId = useAuthStore((s) => s.user?.id) ?? "";
|
||||
const network = useNetwork(networkId);
|
||||
export const StreamCard = forwardRef<HTMLDivElement, StreamCardProps>(
|
||||
function StreamCard(
|
||||
{ particle, networkId, onClick, isSelected, shortcutKey },
|
||||
ref,
|
||||
) {
|
||||
const streamPath = particlePath(networkId, [particle.id]);
|
||||
const { latestChild } = useLiveLatestChild(streamPath);
|
||||
const userId = useAuthStore((s) => s.user?.id) ?? '';
|
||||
const network = useNetwork(networkId);
|
||||
|
||||
useStreamAutoplay(latestChild, particle, networkId, network ?? undefined);
|
||||
useStreamAutoplay(latestChild, particle, networkId, network ?? undefined);
|
||||
|
||||
const hasActiveHuddle =
|
||||
particle.huddle_active_participants && particle.huddle_active_participants.length > 0;
|
||||
const huddleCount = particle.huddle_active_participants?.length ?? 0;
|
||||
const hasActiveHuddle =
|
||||
particle.huddle_active_participants &&
|
||||
particle.huddle_active_participants.length > 0;
|
||||
const huddleCount = particle.huddle_active_participants?.length ?? 0;
|
||||
|
||||
const isDM =
|
||||
particle.visible_to.length === 2 &&
|
||||
particle.visible_to.every((v) => v.startsWith("human:"));
|
||||
const isDM =
|
||||
particle.visible_to.length === 2 &&
|
||||
particle.visible_to.every((v) => v.startsWith('human:'));
|
||||
|
||||
const initials = useMemo(() => {
|
||||
if (isDM) {
|
||||
const otherEntry = particle.visible_to.find(
|
||||
(v) => v !== `human:${userId}`,
|
||||
);
|
||||
if (otherEntry) {
|
||||
const otherId = otherEntry.replace("human:", "");
|
||||
const otherHuman = network?.humans?.find((h) => h.id === otherId);
|
||||
if (otherHuman) return getInitials(otherHuman.email);
|
||||
const initials = useMemo(() => {
|
||||
if (isDM) {
|
||||
const otherEntry = particle.visible_to.find(
|
||||
(v) => v !== `human:${userId}`,
|
||||
);
|
||||
if (otherEntry) {
|
||||
const otherId = otherEntry.replace('human:', '');
|
||||
const otherHuman = network?.humans?.find((h) => h.id === otherId);
|
||||
if (otherHuman) return getInitials(otherHuman.email);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (latestChild) {
|
||||
const creator = network?.humans?.find(
|
||||
(h) => h.id === latestChild.created_by_human_id,
|
||||
);
|
||||
if (creator) return getInitials(creator.email);
|
||||
}
|
||||
if (latestChild) {
|
||||
const creator = network?.humans?.find(
|
||||
(h) => h.id === latestChild.created_by_human_id,
|
||||
);
|
||||
if (creator) return getInitials(creator.email);
|
||||
}
|
||||
|
||||
return particle.properties.name.slice(0, 2).toUpperCase();
|
||||
}, [
|
||||
isDM,
|
||||
particle.visible_to,
|
||||
particle.properties.name,
|
||||
userId,
|
||||
latestChild,
|
||||
network,
|
||||
]);
|
||||
return particle.properties.name.slice(0, 2).toUpperCase();
|
||||
}, [
|
||||
isDM,
|
||||
particle.visible_to,
|
||||
particle.properties.name,
|
||||
userId,
|
||||
latestChild,
|
||||
network,
|
||||
]);
|
||||
|
||||
const isUnseen = useMemo(() => {
|
||||
if (!latestChild) return false;
|
||||
const latestChildTimestamp = latestChild.created_at.getTime();
|
||||
const userPlaybackPosition =
|
||||
particle.playback_markers?.[userId]?.getTime() ?? 0;
|
||||
return latestChildTimestamp > userPlaybackPosition;
|
||||
}, [latestChild, particle.playback_markers, userId]);
|
||||
const isUnseen = useMemo(() => {
|
||||
if (!latestChild) return false;
|
||||
const latestChildTimestamp = latestChild.created_at.getTime();
|
||||
const userPlaybackPosition =
|
||||
particle.playback_markers?.[userId]?.getTime() ?? 0;
|
||||
return latestChildTimestamp > userPlaybackPosition;
|
||||
}, [latestChild, particle.playback_markers, userId]);
|
||||
|
||||
// For media particles with a transcript, show it as an overlay on the preview
|
||||
const transcript =
|
||||
latestChild?.type === "media"
|
||||
? latestChild.properties.transcript?.transcript
|
||||
: undefined;
|
||||
// For media particles with a transcript, show it as an overlay on the preview
|
||||
const transcript =
|
||||
latestChild?.type === 'media'
|
||||
? latestChild.properties.transcript?.transcript
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") onClick();
|
||||
}}
|
||||
className={cn(
|
||||
"cursor-pointer overflow-hidden rounded-xl ring-1 ring-foreground/10 transition-all hover:ring-foreground/20",
|
||||
isUnseen && "ring-2 ring-primary",
|
||||
isSelected && "ring-2 ring-ring",
|
||||
hasActiveHuddle && "ring-2 ring-red-500/70",
|
||||
)}
|
||||
>
|
||||
{/* Preview area */}
|
||||
<div className="relative aspect-[4/3] overflow-hidden bg-muted">
|
||||
{hasActiveHuddle && (
|
||||
<div className="pointer-events-none absolute inset-0 z-10 bg-gradient-to-b from-red-500/15 to-transparent" />
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') onClick();
|
||||
}}
|
||||
className={cn(
|
||||
'cursor-pointer overflow-hidden rounded-xl ring-1 ring-foreground/10 transition-all hover:ring-foreground/20',
|
||||
isUnseen && 'ring-2 ring-primary',
|
||||
isSelected && 'ring-2 ring-ring',
|
||||
hasActiveHuddle && 'ring-2 ring-red-500/70',
|
||||
)}
|
||||
{shortcutKey && (
|
||||
<kbd className="absolute top-1.5 left-1.5 z-10 flex size-5 items-center justify-center rounded bg-black/50 font-mono text-xs text-white/70">
|
||||
{shortcutKey}
|
||||
</kbd>
|
||||
)}
|
||||
{latestChild ? (
|
||||
<ParticlePreview particle={latestChild} />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<p className="text-muted-foreground text-xs italic">
|
||||
No messages yet
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transcript overlay for media with transcripts */}
|
||||
{transcript && (
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent px-2.5 pt-6 pb-2">
|
||||
<p className="line-clamp-2 text-md leading-snug text-white/90">
|
||||
{transcript}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info bar */}
|
||||
<div className="flex items-center gap-2 px-2.5 py-2">
|
||||
<Avatar className={cn("size-6 shrink-0", isUnseen && "ring-2 ring-primary")}>
|
||||
<AvatarFallback className="bg-primary/10 text-primary text-[10px] font-medium">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<Small
|
||||
className={cn(
|
||||
"min-w-0 truncate",
|
||||
isUnseen
|
||||
? "font-semibold text-foreground"
|
||||
: "font-medium text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{particle.properties.name}
|
||||
</Small>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
||||
>
|
||||
{/* Preview area */}
|
||||
<div className="relative aspect-[4/3] overflow-hidden bg-muted">
|
||||
{hasActiveHuddle && (
|
||||
<span className="flex items-center gap-1 rounded-full bg-red-500/15 px-1.5 py-0.5">
|
||||
<Headphones className="size-3 text-red-400" />
|
||||
<span className="text-[10px] font-medium text-red-400">{huddleCount}</span>
|
||||
</span>
|
||||
<div className="pointer-events-none absolute inset-0 z-10 bg-gradient-to-b from-red-500/15 to-transparent" />
|
||||
)}
|
||||
{latestChild && (
|
||||
<Small
|
||||
className={cn(
|
||||
"text-[10px]",
|
||||
isUnseen ? "text-primary" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<RelativeTimestamp date={latestChild.created_at} />
|
||||
</Small>
|
||||
{shortcutKey && (
|
||||
<kbd className="absolute top-1.5 left-1.5 z-10 flex size-5 items-center justify-center rounded bg-black/50 font-mono text-xs text-white/70">
|
||||
{shortcutKey}
|
||||
</kbd>
|
||||
)}
|
||||
{isUnseen && (
|
||||
<span className="size-2 shrink-0 rounded-full bg-primary" />
|
||||
{latestChild ? (
|
||||
<ParticlePreview particle={latestChild} />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<p className="text-muted-foreground text-xs italic">
|
||||
No messages yet
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transcript overlay for media with transcripts */}
|
||||
{transcript && (
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent px-2.5 pt-6 pb-2">
|
||||
<p className="line-clamp-2 text-md leading-snug text-white/90">
|
||||
{transcript}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info bar */}
|
||||
<div className="flex items-center gap-2 px-2.5 py-2">
|
||||
<Avatar
|
||||
className={cn('size-6 shrink-0', isUnseen && 'ring-2 ring-primary')}
|
||||
>
|
||||
<AvatarFallback className="bg-primary/10 text-primary text-[10px] font-medium">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<Small
|
||||
className={cn(
|
||||
'min-w-0 truncate',
|
||||
isUnseen
|
||||
? 'font-semibold text-foreground'
|
||||
: 'font-medium text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{particle.properties.name}
|
||||
</Small>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
||||
{hasActiveHuddle && (
|
||||
<span className="flex items-center gap-1 rounded-full bg-red-500/15 px-1.5 py-0.5">
|
||||
<Headphones className="size-3 text-red-400" />
|
||||
<span className="text-[10px] font-medium text-red-400">
|
||||
{huddleCount}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{latestChild && (
|
||||
<Small
|
||||
className={cn(
|
||||
'text-[10px]',
|
||||
isUnseen ? 'text-primary' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<RelativeTimestamp date={latestChild.created_at} />
|
||||
</Small>
|
||||
)}
|
||||
{isUnseen && (
|
||||
<span className="size-2 shrink-0 rounded-full bg-primary" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,11 +3,11 @@ import {
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { CircleCheckBig, CircleDot } from "lucide-react";
|
||||
import { updateStreamStatus } from "@/lib/firestore-particles";
|
||||
import { toFirestoreDocPath, particlePath } from "@/lib/particle-path";
|
||||
import type { StreamParticle } from "@/hooks/use-stream-particles";
|
||||
} from '@/components/ui/context-menu';
|
||||
import { CircleCheckBig, CircleDot } from 'lucide-react';
|
||||
import { updateStreamStatus } from '@/lib/firestore-particles';
|
||||
import { toFirestoreDocPath, particlePath } from '@/lib/particle-path';
|
||||
import type { StreamParticle } from '@/hooks/use-stream-particles';
|
||||
|
||||
interface StreamContextMenuProps {
|
||||
particle: StreamParticle;
|
||||
@@ -15,12 +15,16 @@ interface StreamContextMenuProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function StreamContextMenu({ particle, networkId, children }: StreamContextMenuProps) {
|
||||
const isOpen = particle.status === "open";
|
||||
export function StreamContextMenu({
|
||||
particle,
|
||||
networkId,
|
||||
children,
|
||||
}: StreamContextMenuProps) {
|
||||
const isOpen = particle.status === 'open';
|
||||
const docPath = toFirestoreDocPath(particlePath(networkId, [particle.id]));
|
||||
|
||||
const toggleStatus = async () => {
|
||||
await updateStreamStatus(docPath, isOpen ? "closed" : "open");
|
||||
await updateStreamStatus(docPath, isOpen ? 'closed' : 'open');
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X, UserPlus, Globe, Users, Lock } from "lucide-react";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X, UserPlus, Globe, Users, Lock } from 'lucide-react';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
buildCustomVisibility,
|
||||
buildNetworkVisibility,
|
||||
parseVisibleTo,
|
||||
} from "@/lib/stream-visibility";
|
||||
import { updateParticleVisibleTo } from "@/lib/firestore-particles";
|
||||
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path";
|
||||
import { useNetwork } from "@/hooks/use-networks";
|
||||
import { cn, getInitials } from "@/lib/utils";
|
||||
import { resolveHumanDisplay } from "@/lib/humans";
|
||||
import type { Particle } from "@/api/types";
|
||||
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
||||
} from '@/lib/stream-visibility';
|
||||
import { updateParticleVisibleTo } from '@/lib/firestore-particles';
|
||||
import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
|
||||
import { useNetwork } from '@/hooks/use-networks';
|
||||
import { cn, getInitials } from '@/lib/utils';
|
||||
import { resolveHumanDisplay } from '@/lib/humans';
|
||||
import type { Particle } from '@/api/types';
|
||||
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
||||
|
||||
interface StreamMembersOverlayProps {
|
||||
networkId: string;
|
||||
streamParticle: Particle & { type: "stream" };
|
||||
streamParticle: Particle & { type: 'stream' };
|
||||
isCreator: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
@@ -29,12 +29,15 @@ export function StreamMembersOverlay({
|
||||
isCreator,
|
||||
onClose,
|
||||
}: StreamMembersOverlayProps) {
|
||||
useSuspendPlayback(true, "stream-members");
|
||||
useSuspendPlayback(true, 'stream-members');
|
||||
|
||||
const network = useNetwork(networkId);
|
||||
const humans = network?.humans ?? [];
|
||||
const creatorId = streamParticle.created_by_human_id;
|
||||
const visibility = parseVisibleTo(streamParticle.visible_to, networkId);
|
||||
const visibility = useMemo(
|
||||
() => parseVisibleTo(streamParticle.visible_to, networkId),
|
||||
[streamParticle.visible_to, networkId],
|
||||
);
|
||||
|
||||
const docPath = useMemo(
|
||||
() => toFirestoreDocPath(particlePath(networkId, [streamParticle.id])),
|
||||
@@ -42,7 +45,7 @@ export function StreamMembersOverlay({
|
||||
);
|
||||
|
||||
const memberIds =
|
||||
visibility.mode === "network"
|
||||
visibility.mode === 'network'
|
||||
? humans.map((h) => h.id)
|
||||
: visibility.humanIds;
|
||||
const memberSet = new Set(memberIds);
|
||||
@@ -58,7 +61,7 @@ export function StreamMembersOverlay({
|
||||
|
||||
const removeMember = useCallback(
|
||||
(id: string) => {
|
||||
if (visibility.mode !== "custom") return;
|
||||
if (visibility.mode !== 'custom') return;
|
||||
if (id === creatorId) return;
|
||||
const next = visibility.humanIds.filter((x) => x !== id);
|
||||
if (next.length === 0) return;
|
||||
@@ -69,7 +72,7 @@ export function StreamMembersOverlay({
|
||||
|
||||
const addMember = useCallback(
|
||||
(id: string) => {
|
||||
if (visibility.mode !== "custom") return;
|
||||
if (visibility.mode !== 'custom') return;
|
||||
void updateParticleVisibleTo(
|
||||
docPath,
|
||||
buildCustomVisibility([...visibility.humanIds, id]),
|
||||
@@ -80,14 +83,15 @@ export function StreamMembersOverlay({
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler, { capture: true });
|
||||
return () => window.removeEventListener("keydown", handler, { capture: true });
|
||||
window.addEventListener('keydown', handler, { capture: true });
|
||||
return () =>
|
||||
window.removeEventListener('keydown', handler, { capture: true });
|
||||
}, [onClose]);
|
||||
|
||||
return createPortal(
|
||||
@@ -103,7 +107,7 @@ export function StreamMembersOverlay({
|
||||
<span className="text-xs text-white/30">
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
Esc
|
||||
</kbd>{" "}
|
||||
</kbd>{' '}
|
||||
to close
|
||||
</span>
|
||||
</div>
|
||||
@@ -116,13 +120,13 @@ export function StreamMembersOverlay({
|
||||
{isCreator ? (
|
||||
<div className="grid grid-cols-2 gap-1 rounded-lg bg-white/5 p-1">
|
||||
<VisibilityPill
|
||||
active={visibility.mode === "network"}
|
||||
active={visibility.mode === 'network'}
|
||||
icon={<Globe className="size-3.5" />}
|
||||
label="Network-wide"
|
||||
onClick={setNetworkWide}
|
||||
/>
|
||||
<VisibilityPill
|
||||
active={visibility.mode === "custom"}
|
||||
active={visibility.mode === 'custom'}
|
||||
icon={<Lock className="size-3.5" />}
|
||||
label="Specific people"
|
||||
onClick={setCustomOnlyCreator}
|
||||
@@ -130,10 +134,10 @@ export function StreamMembersOverlay({
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-sm text-white/70">
|
||||
{visibility.mode === "network" ? (
|
||||
{visibility.mode === 'network' ? (
|
||||
<>
|
||||
<Globe className="size-3.5 text-white/40" />
|
||||
<span>Everyone in {network?.name ?? "network"}</span>
|
||||
<span>Everyone in {network?.name ?? 'network'}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -148,7 +152,7 @@ export function StreamMembersOverlay({
|
||||
{/* Member list */}
|
||||
<section className="flex min-h-0 flex-1 flex-col">
|
||||
<h3 className="mb-2 text-[10px] font-medium uppercase tracking-widest text-white/30">
|
||||
{visibility.mode === "network" ? "Has access" : "People"}{" "}
|
||||
{visibility.mode === 'network' ? 'Has access' : 'People'}{' '}
|
||||
<span className="ml-1 text-white/20">{memberIds.length}</span>
|
||||
</h3>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
@@ -157,7 +161,7 @@ export function StreamMembersOverlay({
|
||||
const display = resolveHumanDisplay(id, humans);
|
||||
const isCreatorRow = id === creatorId;
|
||||
const canRemove =
|
||||
isCreator && visibility.mode === "custom" && !isCreatorRow;
|
||||
isCreator && visibility.mode === 'custom' && !isCreatorRow;
|
||||
return (
|
||||
<li
|
||||
key={id}
|
||||
@@ -170,8 +174,8 @@ export function StreamMembersOverlay({
|
||||
</Avatar>
|
||||
<span
|
||||
className={cn(
|
||||
"flex-1 truncate",
|
||||
!display.exists && "italic text-white/40",
|
||||
'flex-1 truncate',
|
||||
!display.exists && 'italic text-white/40',
|
||||
)}
|
||||
>
|
||||
{display.displayName}
|
||||
@@ -199,44 +203,50 @@ export function StreamMembersOverlay({
|
||||
</section>
|
||||
|
||||
{/* Add */}
|
||||
{isCreator && visibility.mode === "custom" && availableToAdd.length > 0 && (
|
||||
<section className="mt-4 border-t border-white/5 pt-4">
|
||||
<h3 className="mb-2 flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-widest text-white/30">
|
||||
<UserPlus className="size-3" />
|
||||
Add people
|
||||
</h3>
|
||||
<ScrollArea className="max-h-32">
|
||||
<ul className="flex flex-col gap-0.5 pr-2">
|
||||
{availableToAdd.map((human) => (
|
||||
<li key={human.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addMember(human.id)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2.5 rounded px-2 py-1.5 text-left text-sm text-white/70 transition-colors hover:bg-white/5",
|
||||
)}
|
||||
>
|
||||
<Avatar size="sm">
|
||||
<AvatarFallback className="text-[10px]">
|
||||
{getInitials(human.email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="flex-1 truncate">{human.email_prefix}</span>
|
||||
<UserPlus className="size-3.5 text-white/30" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
</section>
|
||||
)}
|
||||
{isCreator &&
|
||||
visibility.mode === 'custom' &&
|
||||
availableToAdd.length > 0 && (
|
||||
<section className="mt-4 border-t border-white/5 pt-4">
|
||||
<h3 className="mb-2 flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-widest text-white/30">
|
||||
<UserPlus className="size-3" />
|
||||
Add people
|
||||
</h3>
|
||||
<ScrollArea className="max-h-32">
|
||||
<ul className="flex flex-col gap-0.5 pr-2">
|
||||
{availableToAdd.map((human) => (
|
||||
<li key={human.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addMember(human.id)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2.5 rounded px-2 py-1.5 text-left text-sm text-white/70 transition-colors hover:bg-white/5',
|
||||
)}
|
||||
>
|
||||
<Avatar size="sm">
|
||||
<AvatarFallback className="text-[10px]">
|
||||
{getInitials(human.email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="flex-1 truncate">
|
||||
{human.email_prefix}
|
||||
</span>
|
||||
<UserPlus className="size-3.5 text-white/30" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{isCreator && visibility.mode === "custom" && availableToAdd.length === 0 && (
|
||||
<p className="mt-4 text-center text-xs text-white/30">
|
||||
<Users className="mr-1 inline size-3" />
|
||||
Everyone in the network is already a member
|
||||
</p>
|
||||
)}
|
||||
{isCreator &&
|
||||
visibility.mode === 'custom' &&
|
||||
availableToAdd.length === 0 && (
|
||||
<p className="mt-4 text-center text-xs text-white/30">
|
||||
<Users className="mr-1 inline size-3" />
|
||||
Everyone in the network is already a member
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
@@ -259,10 +269,10 @@ function VisibilityPill({
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs transition-colors",
|
||||
'flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs transition-colors',
|
||||
active
|
||||
? "bg-white/10 text-white/90"
|
||||
: "text-white/50 hover:text-white/80",
|
||||
? 'bg-white/10 text-white/90'
|
||||
: 'text-white/50 hover:text-white/80',
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
|
||||
@@ -7,16 +7,15 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useChannel } from "@/hooks/use-channel";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import type { ChannelMessage } from "@/lib/pusher-client";
|
||||
} from 'react';
|
||||
import { useChannel } from '@/hooks/use-channel';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ComposingMode = "recording" | "typing" | "screen";
|
||||
export type ComposingMode = 'recording' | 'typing' | 'screen';
|
||||
|
||||
export interface ComposingUser {
|
||||
humanId: string;
|
||||
@@ -93,14 +92,14 @@ export function StreamPresenceProvider({
|
||||
// Skip own events
|
||||
if (msg.humanId === currentUserId) continue;
|
||||
|
||||
if (payload.type === "composing_start" && payload.mode) {
|
||||
if (payload.type === 'composing_start' && payload.mode) {
|
||||
map.set(msg.humanId, {
|
||||
humanId: msg.humanId,
|
||||
mode: payload.mode as ComposingMode,
|
||||
lastSeen: Date.now(),
|
||||
});
|
||||
changed = true;
|
||||
} else if (payload.type === "composing_stop") {
|
||||
} else if (payload.type === 'composing_stop') {
|
||||
if (map.delete(msg.humanId)) changed = true;
|
||||
}
|
||||
}
|
||||
@@ -156,14 +155,14 @@ export function StreamPresenceProvider({
|
||||
const startComposing = useCallback(
|
||||
(mode: ComposingMode) => {
|
||||
// Send immediately
|
||||
sendMessage({ type: "composing_start", mode });
|
||||
sendMessage({ type: 'composing_start', mode });
|
||||
|
||||
// Clear any existing heartbeat
|
||||
clearInterval(heartbeatRef.current);
|
||||
|
||||
// Start heartbeat
|
||||
heartbeatRef.current = setInterval(() => {
|
||||
sendMessage({ type: "composing_start", mode });
|
||||
sendMessage({ type: 'composing_start', mode });
|
||||
}, COMPOSING_HEARTBEAT_MS);
|
||||
},
|
||||
[sendMessage],
|
||||
@@ -172,7 +171,7 @@ export function StreamPresenceProvider({
|
||||
const stopComposing = useCallback(() => {
|
||||
clearInterval(heartbeatRef.current);
|
||||
heartbeatRef.current = undefined;
|
||||
sendMessage({ type: "composing_stop" });
|
||||
sendMessage({ type: 'composing_stop' });
|
||||
}, [sendMessage]);
|
||||
|
||||
// Cleanup heartbeat on unmount
|
||||
@@ -207,7 +206,7 @@ function useStreamPresenceContext() {
|
||||
const ctx = useContext(StreamPresenceContext);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"useStreamPresence must be used within a StreamPresenceProvider",
|
||||
'useStreamPresence must be used within a StreamPresenceProvider',
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
|
||||
@@ -1,47 +1,65 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { apiClient } from "@/api/client";
|
||||
import { isParticleDeleted, type Particle } from "@/api/types";
|
||||
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path";
|
||||
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useNetwork } from "@/hooks/use-networks";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { apiClient } from '@/api/client';
|
||||
import { isParticleDeleted, type Particle } from '@/api/types';
|
||||
import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
|
||||
import { Avatar, AvatarFallback, AvatarGroup } from '@/components/ui/avatar';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useNetwork } from '@/hooks/use-networks';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Settings, CircleCheckBig, CircleDot, EllipsisVertical, Pencil, Globe, Trash2 } from "lucide-react";
|
||||
import { updateStreamStatus } from "@/lib/firestore-particles";
|
||||
import { RenameStreamOverlay } from "@/features/particles/rename-stream-overlay";
|
||||
import { DeleteParticleOverlay } from "@/features/particles/delete-particle-overlay";
|
||||
import { StreamMembersOverlay } from "@/features/particles/stream-members-overlay";
|
||||
import { parseVisibleTo } from "@/lib/stream-visibility";
|
||||
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb";
|
||||
import { WindowControls } from "@/components/window-controls";
|
||||
import { RelativeTimestamp } from "@/components/relative-timestamp";
|
||||
import { useStreamPresence } from "@/features/particles/stream-presence-context";
|
||||
import { resolveHumanDisplay } from "@/lib/humans";
|
||||
import { platform } from "@/lib/platform";
|
||||
import { requireDesktop } from "@/lib/platform/desktop-only";
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Settings,
|
||||
CircleCheckBig,
|
||||
CircleDot,
|
||||
EllipsisVertical,
|
||||
Pencil,
|
||||
Globe,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { updateStreamStatus } from '@/lib/firestore-particles';
|
||||
import { RenameStreamOverlay } from '@/features/particles/rename-stream-overlay';
|
||||
import { DeleteParticleOverlay } from '@/features/particles/delete-particle-overlay';
|
||||
import { StreamMembersOverlay } from '@/features/particles/stream-members-overlay';
|
||||
import { parseVisibleTo } from '@/lib/stream-visibility';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@/components/ui/breadcrumb';
|
||||
import { WindowControls } from '@/components/window-controls';
|
||||
import { RelativeTimestamp } from '@/components/relative-timestamp';
|
||||
import { useStreamPresence } from '@/features/particles/stream-presence-context';
|
||||
import { resolveHumanDisplay } from '@/lib/humans';
|
||||
import { platform } from '@/lib/platform';
|
||||
import { requireDesktop } from '@/lib/platform/desktop-only';
|
||||
|
||||
function getParticleDisplayName(particle: Particle): string {
|
||||
switch (particle.type) {
|
||||
case "stream":
|
||||
case "folder":
|
||||
case 'stream':
|
||||
case 'folder':
|
||||
return particle.properties.name;
|
||||
case "quest":
|
||||
case 'quest':
|
||||
return particle.properties.title;
|
||||
case "paper":
|
||||
case 'paper':
|
||||
return particle.properties.title;
|
||||
case "file":
|
||||
case 'file':
|
||||
return particle.properties.filename;
|
||||
case "text":
|
||||
case 'text':
|
||||
return particle.properties.content.slice(0, 30);
|
||||
case "media":
|
||||
case 'media':
|
||||
return particle.type;
|
||||
}
|
||||
}
|
||||
@@ -49,7 +67,7 @@ function getParticleDisplayName(particle: Particle): string {
|
||||
interface TopBarProps {
|
||||
networkId: string;
|
||||
particle: Particle | null;
|
||||
streamParticle: Particle & { type: "stream" };
|
||||
streamParticle: Particle & { type: 'stream' };
|
||||
}
|
||||
|
||||
export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
|
||||
@@ -65,18 +83,20 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
|
||||
!!particle &&
|
||||
!!userId &&
|
||||
particle.created_by_human_id === userId &&
|
||||
particle.type !== "stream" &&
|
||||
particle.type !== "folder" &&
|
||||
particle.type !== 'stream' &&
|
||||
particle.type !== 'folder' &&
|
||||
!isParticleDeleted(particle);
|
||||
|
||||
const huddleParticipants = streamParticle.huddle_active_participants ?? [];
|
||||
const hasActiveHuddle = huddleParticipants.length > 0;
|
||||
|
||||
const handleJoinHuddle = () => {
|
||||
if (!requireDesktop("Huddle")) return;
|
||||
apiClient.getLivekitToken(networkId, streamParticle.id).then(({ token, server_url }) => {
|
||||
platform.huddle.open({ token, serverUrl: server_url });
|
||||
});
|
||||
if (!requireDesktop('Huddle')) return;
|
||||
apiClient
|
||||
.getLivekitToken(networkId, streamParticle.id)
|
||||
.then(({ token, server_url }) => {
|
||||
platform.huddle.open({ token, serverUrl: server_url });
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -88,7 +108,9 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
|
||||
{streamParticle && (
|
||||
<>
|
||||
<BreadcrumbItem className="text-xs">
|
||||
<BreadcrumbPage>{getParticleDisplayName(streamParticle)}</BreadcrumbPage>
|
||||
<BreadcrumbPage>
|
||||
{getParticleDisplayName(streamParticle)}
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</>
|
||||
)}
|
||||
@@ -97,7 +119,12 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
|
||||
<>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem className="text-xs">
|
||||
<BreadcrumbPage><ParticleBreadcrumbContent particle={particle} networkId={networkId} /></BreadcrumbPage>
|
||||
<BreadcrumbPage>
|
||||
<ParticleBreadcrumbContent
|
||||
particle={particle}
|
||||
networkId={networkId}
|
||||
/>
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</>
|
||||
)}
|
||||
@@ -134,7 +161,7 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{streamParticle.status === "closed" && (
|
||||
{streamParticle.status === 'closed' && (
|
||||
<span className="no-drag flex items-center gap-1 rounded-full bg-white/10 px-2 py-0.5 text-xs text-muted-foreground backdrop-blur-sm">
|
||||
<CircleCheckBig className="size-3" />
|
||||
Closed
|
||||
@@ -160,11 +187,16 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onSelect={async () => {
|
||||
const docPath = toFirestoreDocPath(particlePath(networkId, [streamParticle.id]));
|
||||
await updateStreamStatus(docPath, streamParticle.status === "open" ? "closed" : "open");
|
||||
const docPath = toFirestoreDocPath(
|
||||
particlePath(networkId, [streamParticle.id]),
|
||||
);
|
||||
await updateStreamStatus(
|
||||
docPath,
|
||||
streamParticle.status === 'open' ? 'closed' : 'open',
|
||||
);
|
||||
}}
|
||||
>
|
||||
{streamParticle.status === "open" ? (
|
||||
{streamParticle.status === 'open' ? (
|
||||
<>
|
||||
<CircleCheckBig className="size-4" />
|
||||
Close stream
|
||||
@@ -191,7 +223,7 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
|
||||
Delete particle
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onSelect={() => navigate("/settings")}>
|
||||
<DropdownMenuItem onSelect={() => navigate('/settings')}>
|
||||
<Settings className="size-4" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
@@ -234,7 +266,7 @@ function MembersIndicator({
|
||||
onClick,
|
||||
}: {
|
||||
networkId: string;
|
||||
streamParticle: Particle & { type: "stream" };
|
||||
streamParticle: Particle & { type: 'stream' };
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const network = useNetwork(networkId);
|
||||
@@ -242,7 +274,7 @@ function MembersIndicator({
|
||||
const humans = network?.humans ?? [];
|
||||
|
||||
const memberIds =
|
||||
visibility.mode === "network"
|
||||
visibility.mode === 'network'
|
||||
? humans.map((h) => h.id)
|
||||
: visibility.humanIds;
|
||||
const shownMembers = memberIds
|
||||
@@ -258,7 +290,7 @@ function MembersIndicator({
|
||||
onClick={onClick}
|
||||
className="no-drag flex items-center gap-1.5 rounded-full bg-white/5 px-2 py-1 text-xs text-white/70 backdrop-blur-sm transition-colors hover:bg-white/10"
|
||||
>
|
||||
{visibility.mode === "network" ? (
|
||||
{visibility.mode === 'network' ? (
|
||||
<>
|
||||
<Globe className="size-3 text-white/50" />
|
||||
<span>Everyone</span>
|
||||
@@ -274,32 +306,43 @@ function MembersIndicator({
|
||||
</Avatar>
|
||||
))}
|
||||
</AvatarGroup>
|
||||
{overflow > 0 && <span className="text-white/50">+{overflow}</span>}
|
||||
{overflow > 0 && (
|
||||
<span className="text-white/50">+{overflow}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{visibility.mode === "network"
|
||||
? `Everyone in ${network?.name ?? "network"}`
|
||||
: `${memberIds.length} ${memberIds.length === 1 ? "member" : "members"}`}
|
||||
{visibility.mode === 'network'
|
||||
? `Everyone in ${network?.name ?? 'network'}`
|
||||
: `${memberIds.length} ${memberIds.length === 1 ? 'member' : 'members'}`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function ParticleBreadcrumbContent({ particle, networkId }: { particle: Particle; networkId: string }) {
|
||||
function ParticleBreadcrumbContent({
|
||||
particle,
|
||||
networkId,
|
||||
}: {
|
||||
particle: Particle;
|
||||
networkId: string;
|
||||
}) {
|
||||
const network = useNetwork(networkId);
|
||||
const { onlineHumanIds } = useStreamPresence();
|
||||
const display = resolveHumanDisplay(particle.created_by_human_id, network?.humans);
|
||||
const isOnline = particle.created_by_human_id ? onlineHumanIds.has(particle.created_by_human_id) : false;
|
||||
const display = resolveHumanDisplay(
|
||||
particle.created_by_human_id,
|
||||
network?.humans,
|
||||
);
|
||||
const isOnline = particle.created_by_human_id
|
||||
? onlineHumanIds.has(particle.created_by_human_id)
|
||||
: false;
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Avatar size="sm" className={isOnline ? "ring-2 ring-green-500" : ""}>
|
||||
<AvatarFallback>
|
||||
{display.initials}
|
||||
</AvatarFallback>
|
||||
<Avatar size="sm" className={isOnline ? 'ring-2 ring-green-500' : ''}>
|
||||
<AvatarFallback>{display.initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
{display.displayName} - <RelativeTimestamp date={particle.created_at} />
|
||||
</span>
|
||||
|
||||
@@ -1,41 +1,73 @@
|
||||
import { useState, useEffect, useEffectEvent, useCallback, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { apiClient } from "@/api/client";
|
||||
import { isParticleDeleted, type Particle } from "@/api/types";
|
||||
import { parseParticlePath, particlePath, toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path";
|
||||
import { ComposeOverlay, type ComposeStep } from "@/features/compose/compose-overlay";
|
||||
import { useComposeIntentStore } from "@/stores/compose-intent-store";
|
||||
import { PlaybackPageIndicator } from "@/features/particles/playback-page-indicator";
|
||||
import { MediaParticleView, type MediaParticleHandle } from "@/features/particles/media-particle-view";
|
||||
import { TextParticleView } from "@/features/particles/text-particle-view";
|
||||
import { FallbackParticleView } from "@/features/particles/fallback-particle-view";
|
||||
import { DeletedParticleView } from "@/features/particles/deleted-particle-view";
|
||||
import { VideoAudioToggle } from "@/components/video-audio-toggle";
|
||||
import { useMediaSettingsStore } from "@/stores/media-settings-store";
|
||||
import { KeybindingsOverlay, type KeybindingGroup } from "@/components/keybindings-overlay";
|
||||
import { useNetwork } from "@/hooks/use-networks";
|
||||
import { toggleParticleReaction } from "@/lib/firestore-particles";
|
||||
import { ReactionBar } from "@/features/particles/reaction-bar";
|
||||
import { TextReactionInput } from "@/features/particles/text-reaction-input";
|
||||
import { TopBar } from "@/features/particles/stream-top-bar";
|
||||
import { useStreamPlayback } from "@/hooks/use-stream-playback";
|
||||
import { usePrefetchAdjacentMedia } from "@/hooks/use-prefetch-adjacent-media";
|
||||
import { usePresencePositions } from "@/hooks/use-presence-positions";
|
||||
import { StreamPresenceProvider, useStreamPresence, useStreamComposing, useStreamComposingBroadcast, type ComposingMode } from "@/features/particles/stream-presence-context";
|
||||
import { ComposingIndicator } from "@/components/composing-indicator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useMount } from "react-use";
|
||||
import { usePlaybackPauseStore, selectIsPaused } from "@/stores/playback-pause-store";
|
||||
import { usePlaybackKeys } from "@/hooks/use-playback-keys";
|
||||
import { useStreamNavigationKeys } from "@/hooks/use-stream-navigation-keys";
|
||||
import { useStreamActionKeys } from "@/hooks/use-stream-action-keys";
|
||||
import { platform } from "@/lib/platform";
|
||||
import { requireDesktop } from "@/lib/platform/desktop-only";
|
||||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
useEffectEvent,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { apiClient } from '@/api/client';
|
||||
import { isParticleDeleted, type Particle } from '@/api/types';
|
||||
import {
|
||||
parseParticlePath,
|
||||
particlePath,
|
||||
toFirestoreDocPath,
|
||||
type ParticlePath,
|
||||
} from '@/lib/particle-path';
|
||||
import {
|
||||
ComposeOverlay,
|
||||
type ComposeStep,
|
||||
} from '@/features/compose/compose-overlay';
|
||||
import { useComposeIntentStore } from '@/stores/compose-intent-store';
|
||||
import { PlaybackPageIndicator } from '@/features/particles/playback-page-indicator';
|
||||
import {
|
||||
MediaParticleView,
|
||||
type MediaParticleHandle,
|
||||
} from '@/features/particles/media-particle-view';
|
||||
import { TextParticleView } from '@/features/particles/text-particle-view';
|
||||
import { FallbackParticleView } from '@/features/particles/fallback-particle-view';
|
||||
import { DeletedParticleView } from '@/features/particles/deleted-particle-view';
|
||||
import { VideoAudioToggle } from '@/components/video-audio-toggle';
|
||||
import { useMediaSettingsStore } from '@/stores/media-settings-store';
|
||||
import {
|
||||
KeybindingsOverlay,
|
||||
type KeybindingGroup,
|
||||
} from '@/components/keybindings-overlay';
|
||||
import { useNetwork } from '@/hooks/use-networks';
|
||||
import { toggleParticleReaction } from '@/lib/firestore-particles';
|
||||
import { ReactionBar } from '@/features/particles/reaction-bar';
|
||||
import { TextReactionInput } from '@/features/particles/text-reaction-input';
|
||||
import { TopBar } from '@/features/particles/stream-top-bar';
|
||||
import { useStreamPlayback } from '@/hooks/use-stream-playback';
|
||||
import { usePrefetchAdjacentMedia } from '@/hooks/use-prefetch-adjacent-media';
|
||||
import { usePresencePositions } from '@/hooks/use-presence-positions';
|
||||
import {
|
||||
StreamPresenceProvider,
|
||||
useStreamPresence,
|
||||
useStreamComposing,
|
||||
useStreamComposingBroadcast,
|
||||
type ComposingMode,
|
||||
} from '@/features/particles/stream-presence-context';
|
||||
import { ComposingIndicator } from '@/components/composing-indicator';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useMount } from 'react-use';
|
||||
import {
|
||||
usePlaybackPauseStore,
|
||||
selectIsPaused,
|
||||
} from '@/stores/playback-pause-store';
|
||||
import { usePlaybackKeys } from '@/hooks/use-playback-keys';
|
||||
import { useStreamNavigationKeys } from '@/hooks/use-stream-navigation-keys';
|
||||
import { useStreamActionKeys } from '@/hooks/use-stream-action-keys';
|
||||
import { platform } from '@/lib/platform';
|
||||
import { requireDesktop } from '@/lib/platform/desktop-only';
|
||||
|
||||
function getReactions(particle: Particle): Record<string, string[]> | undefined {
|
||||
function getReactions(
|
||||
particle: Particle,
|
||||
): Record<string, string[]> | undefined {
|
||||
if (isParticleDeleted(particle)) return undefined;
|
||||
if (particle.type === "media" || particle.type === "text") return particle.reactions;
|
||||
if (particle.type === 'media' || particle.type === 'text')
|
||||
return particle.reactions;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -44,31 +76,33 @@ function getReactions(particle: Particle): Record<string, string[]> | undefined
|
||||
const EXIT_DELAY_MS = 5000;
|
||||
const EXIT_TICK_MS = 100;
|
||||
|
||||
type PlaybackStatus = "idle" | "playing" | "ended";
|
||||
type PlaybackStatus = 'idle' | 'playing' | 'ended';
|
||||
|
||||
function useExitCountdown(
|
||||
status: PlaybackStatus,
|
||||
disabled: boolean,
|
||||
onExit: () => void,
|
||||
) {
|
||||
const [remainingMs, setRemainingMs] = useState<number | null>(null);
|
||||
const [remainingMs, setRemainingMs] = useState<number | null>(
|
||||
status === 'ended' ? EXIT_DELAY_MS : null,
|
||||
);
|
||||
const [prevStatus, setPrevStatus] = useState(status);
|
||||
|
||||
const handleExit = useEffectEvent(() => {
|
||||
onExit();
|
||||
});
|
||||
|
||||
// Start/cancel countdown based on playback status
|
||||
useEffect(() => {
|
||||
if (status === "ended") {
|
||||
setRemainingMs(EXIT_DELAY_MS);
|
||||
} else {
|
||||
setRemainingMs(null);
|
||||
}
|
||||
}, [status]);
|
||||
// Start the countdown when playback ends; cancel it otherwise.
|
||||
if (status !== prevStatus) {
|
||||
setPrevStatus(status);
|
||||
setRemainingMs(status === 'ended' ? EXIT_DELAY_MS : null);
|
||||
}
|
||||
|
||||
const isCountingDown = remainingMs !== null && remainingMs > 0;
|
||||
|
||||
// Tick the countdown down (pauses when compose is active)
|
||||
useEffect(() => {
|
||||
if (remainingMs === null || remainingMs <= 0 || disabled) return;
|
||||
if (!isCountingDown || disabled) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setRemainingMs((prev) => {
|
||||
@@ -79,7 +113,7 @@ function useExitCountdown(
|
||||
}, EXIT_TICK_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [remainingMs !== null && remainingMs > 0, disabled]);
|
||||
}, [isCountingDown, disabled]);
|
||||
|
||||
// Navigate once countdown hits zero
|
||||
useEffect(() => {
|
||||
@@ -95,36 +129,36 @@ function useExitCountdown(
|
||||
|
||||
const STREAM_VIEW_KEYBINDINGS: KeybindingGroup[] = [
|
||||
{
|
||||
label: "Navigation",
|
||||
label: 'Navigation',
|
||||
bindings: [
|
||||
{ keys: ["←", "→", "↑", "↓"], description: "Previous / next particle" },
|
||||
{ keys: ["Esc"], description: "Back to network" },
|
||||
{ keys: ['←', '→', '↑', '↓'], description: 'Previous / next particle' },
|
||||
{ keys: ['Esc'], description: 'Back to network' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Playback",
|
||||
label: 'Playback',
|
||||
bindings: [
|
||||
{ keys: ["Space"], description: "Toggle pause" },
|
||||
{ keys: ["Hold", "Space"], description: "Pause while held" },
|
||||
{ keys: ["Hold", "Shift"], description: "1.5× speed" },
|
||||
{ keys: ["Shift", "←", "→"], description: "Seek ±5s" },
|
||||
{ keys: ['Space'], description: 'Toggle pause' },
|
||||
{ keys: ['Hold', 'Space'], description: 'Pause while held' },
|
||||
{ keys: ['Hold', 'Shift'], description: '1.5× speed' },
|
||||
{ keys: ['Shift', '←', '→'], description: 'Seek ±5s' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Compose",
|
||||
label: 'Compose',
|
||||
bindings: [
|
||||
{ keys: ["Hold", "`"], description: "Reply" },
|
||||
{ keys: ["S"], description: "Screen record" },
|
||||
{ keys: ["T"], description: "Text compose" },
|
||||
{ keys: ["V"], description: "Toggle video / audio" },
|
||||
{ keys: ["H"], description: "Join huddle" },
|
||||
{ keys: ['Hold', '`'], description: 'Reply' },
|
||||
{ keys: ['S'], description: 'Screen record' },
|
||||
{ keys: ['T'], description: 'Text compose' },
|
||||
{ keys: ['V'], description: 'Toggle video / audio' },
|
||||
{ keys: ['H'], description: 'Join huddle' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Reactions",
|
||||
label: 'Reactions',
|
||||
bindings: [
|
||||
{ keys: ["1-7"], description: "Toggle emoji reaction" },
|
||||
{ keys: ["R"], description: "Quick text reply" },
|
||||
{ keys: ['1-7'], description: 'Toggle emoji reaction' },
|
||||
{ keys: ['R'], description: 'Quick text reply' },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -132,7 +166,7 @@ const STREAM_VIEW_KEYBINDINGS: KeybindingGroup[] = [
|
||||
// --- StreamView ---
|
||||
|
||||
interface StreamViewProps {
|
||||
streamParticle: Particle & { type: "stream" };
|
||||
streamParticle: Particle & { type: 'stream' };
|
||||
path: ParticlePath;
|
||||
}
|
||||
|
||||
@@ -162,7 +196,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
next,
|
||||
prev,
|
||||
goTo,
|
||||
goToParticle
|
||||
goToParticle,
|
||||
} = useStreamPlayback(streamParticle, path);
|
||||
|
||||
usePrefetchAdjacentMedia(children, currentIndex);
|
||||
@@ -185,30 +219,43 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
|
||||
const mediaRef = useRef<MediaParticleHandle>(null);
|
||||
|
||||
const handleToggleReaction = useCallback((emoji: string) => {
|
||||
if (!authedUser || !currentParticle) return;
|
||||
if (isParticleDeleted(currentParticle)) return;
|
||||
const handleToggleReaction = useCallback(
|
||||
(emoji: string) => {
|
||||
if (!authedUser || !currentParticle) return;
|
||||
if (isParticleDeleted(currentParticle)) return;
|
||||
|
||||
const currentParticleDocPath = currentParticle
|
||||
? toFirestoreDocPath(
|
||||
particlePath(networkId, [streamParticle.id, currentParticle.id]),
|
||||
)
|
||||
: null;
|
||||
if (!currentParticleDocPath) return;
|
||||
|
||||
const currentParticleDocPath = currentParticle
|
||||
? toFirestoreDocPath(particlePath(networkId, [streamParticle.id, currentParticle.id]))
|
||||
: null;
|
||||
if (!currentParticleDocPath) return;
|
||||
|
||||
const reactions = getReactions(currentParticle);
|
||||
toggleParticleReaction(currentParticleDocPath, emoji, authedUser.id, reactions);
|
||||
}, [authedUser, currentParticle]);
|
||||
const reactions = getReactions(currentParticle);
|
||||
toggleParticleReaction(
|
||||
currentParticleDocPath,
|
||||
emoji,
|
||||
authedUser.id,
|
||||
reactions,
|
||||
);
|
||||
},
|
||||
[authedUser, currentParticle, networkId, streamParticle.id],
|
||||
);
|
||||
|
||||
const [composeActive, setComposeActive] = useState(false);
|
||||
const [composeStep, setComposeStep] = useState<ComposeStep>("idle");
|
||||
const [composeStep, setComposeStep] = useState<ComposeStep>('idle');
|
||||
const paused = usePlaybackPauseStore(selectIsPaused);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [prevParticleId, setPrevParticleId] = useState(currentParticle?.id);
|
||||
const [showKeybindings, setShowKeybindings] = useState(false);
|
||||
const [textReactionOpen, setTextReactionOpen] = useState(false);
|
||||
|
||||
const handleSubmitTextReaction = useCallback((text: string) => {
|
||||
handleToggleReaction(text);
|
||||
}, [handleToggleReaction]);
|
||||
const handleSubmitTextReaction = useCallback(
|
||||
(text: string) => {
|
||||
handleToggleReaction(text);
|
||||
},
|
||||
[handleToggleReaction],
|
||||
);
|
||||
|
||||
const { fastPlayback } = usePlaybackKeys({ mediaRef });
|
||||
|
||||
@@ -221,15 +268,17 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
});
|
||||
|
||||
const handleOpenHuddle = useCallback(() => {
|
||||
if (!requireDesktop("Huddle")) return;
|
||||
apiClient.getLivekitToken(networkId, streamParticle.id).then(({ token, server_url }) => {
|
||||
platform.huddle.open({ token, serverUrl: server_url });
|
||||
});
|
||||
if (!requireDesktop('Huddle')) return;
|
||||
apiClient
|
||||
.getLivekitToken(networkId, streamParticle.id)
|
||||
.then(({ token, server_url }) => {
|
||||
platform.huddle.open({ token, serverUrl: server_url });
|
||||
});
|
||||
navigate(`/${networkId}`);
|
||||
}, [networkId, streamParticle.id, navigate]);
|
||||
|
||||
const handleToggleRecordingMode = useCallback(() => {
|
||||
setRecordingMode(recordingMode === "video" ? "audio" : "video");
|
||||
setRecordingMode(recordingMode === 'video' ? 'audio' : 'video');
|
||||
}, [recordingMode, setRecordingMode]);
|
||||
|
||||
const handleToggleKeybindings = useCallback(() => {
|
||||
@@ -249,11 +298,11 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
const stepToMode: Record<string, ComposingMode | null> = {
|
||||
idle: null,
|
||||
submitting: null,
|
||||
recording: "recording",
|
||||
typing: "typing",
|
||||
reviewing: "typing",
|
||||
configuring: "typing",
|
||||
picking: "screen",
|
||||
recording: 'recording',
|
||||
typing: 'typing',
|
||||
reviewing: 'typing',
|
||||
configuring: 'typing',
|
||||
picking: 'screen',
|
||||
};
|
||||
const mode = stepToMode[composeStep] ?? null;
|
||||
if (mode) {
|
||||
@@ -274,35 +323,35 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
useEffect(() => () => clearTimeout(idleTimerRef.current), []);
|
||||
|
||||
// Always show controls when compose is active or exit countdown is visible
|
||||
const controlsVisible = showControls || composeActive || status === "ended";
|
||||
const controlsVisible = showControls || composeActive || status === 'ended';
|
||||
|
||||
const handleExitNavigate = useCallback(() => {
|
||||
navigate(`/${networkId}`);
|
||||
}, [navigate, networkId]);
|
||||
|
||||
const exitRemainingMs = useExitCountdown(
|
||||
status,
|
||||
paused,
|
||||
handleExitNavigate,
|
||||
);
|
||||
const exitRemainingMs = useExitCountdown(status, paused, handleExitNavigate);
|
||||
|
||||
// Reset progress when particle changes
|
||||
useEffect(() => {
|
||||
// Reset progress when the particle changes.
|
||||
if (currentParticle?.id !== prevParticleId) {
|
||||
setPrevParticleId(currentParticle?.id);
|
||||
setProgress(0);
|
||||
}, [currentParticle?.id]);
|
||||
}
|
||||
|
||||
const handleParticleCreated = useCallback((particleId: string) => {
|
||||
if (currentIndex === -1) return;
|
||||
const handleParticleCreated = useCallback(
|
||||
(particleId: string) => {
|
||||
if (currentIndex === -1) return;
|
||||
|
||||
// When local user is at children.length - 1, and they send a new particle,
|
||||
// we want to navigate to the new particle immediately so the user is considered caught up in the stream.
|
||||
// In other cases (e.g. when user is in the middle of the stream and new particles are added),
|
||||
// we don't want to disrupt their current position by jumping them to the end of the stream.
|
||||
// NOTE: at this point, `children` contains stale data from the time when compose was sending, so it doesn't include the new particle yet.
|
||||
if (currentIndex === children.length - 1) {
|
||||
goToParticle(particleId);
|
||||
}
|
||||
}, [children, goToParticle, currentIndex]);
|
||||
// When local user is at children.length - 1, and they send a new particle,
|
||||
// we want to navigate to the new particle immediately so the user is considered caught up in the stream.
|
||||
// In other cases (e.g. when user is in the middle of the stream and new particles are added),
|
||||
// we don't want to disrupt their current position by jumping them to the end of the stream.
|
||||
// NOTE: at this point, `children` contains stale data from the time when compose was sending, so it doesn't include the new particle yet.
|
||||
if (currentIndex === children.length - 1) {
|
||||
goToParticle(particleId);
|
||||
}
|
||||
},
|
||||
[children, goToParticle, currentIndex],
|
||||
);
|
||||
|
||||
if (children.length === 0) {
|
||||
return (
|
||||
@@ -318,7 +367,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
networkId={networkId}
|
||||
targetPath={path}
|
||||
onActiveChange={setComposeActive}
|
||||
disabled={streamParticle.status === "closed"}
|
||||
disabled={streamParticle.status === 'closed'}
|
||||
onParticleCreated={handleParticleCreated}
|
||||
/>
|
||||
</div>
|
||||
@@ -339,7 +388,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
);
|
||||
}
|
||||
switch (particle.type) {
|
||||
case "media":
|
||||
case 'media':
|
||||
return (
|
||||
<MediaParticleView
|
||||
ref={mediaRef}
|
||||
@@ -351,7 +400,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
onProgress={setProgress}
|
||||
/>
|
||||
);
|
||||
case "text":
|
||||
case 'text':
|
||||
return (
|
||||
<TextParticleView
|
||||
key={particle.id}
|
||||
@@ -363,7 +412,9 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <FallbackParticleView particle={particle} networkId={networkId} />;
|
||||
return (
|
||||
<FallbackParticleView particle={particle} networkId={networkId} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,7 +429,11 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
|
||||
{/* TopBar — always visible */}
|
||||
<div className="z-10 absolute left-0 right-0 pt-2">
|
||||
<TopBar networkId={networkId} particle={currentParticle} streamParticle={streamParticle} />
|
||||
<TopBar
|
||||
networkId={networkId}
|
||||
particle={currentParticle}
|
||||
streamParticle={streamParticle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main playback area */}
|
||||
@@ -408,7 +463,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
<div className="absolute right-4 top-1/2 z-10 flex -translate-y-1/2 flex-col items-end gap-2">
|
||||
<ReactionBar
|
||||
reactions={getReactions(currentParticle)}
|
||||
currentHumanId={authedUser?.id ?? ""}
|
||||
currentHumanId={authedUser?.id ?? ''}
|
||||
humans={network?.humans}
|
||||
onToggle={handleToggleReaction}
|
||||
onOpenTextReaction={() => setTextReactionOpen(true)}
|
||||
@@ -422,14 +477,17 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
)}
|
||||
|
||||
{/* Composing indicator — left edge, always visible */}
|
||||
<ComposingIndicator users={composingUsers} networkHumans={network?.humans} />
|
||||
<ComposingIndicator
|
||||
users={composingUsers}
|
||||
networkHumans={network?.humans}
|
||||
/>
|
||||
|
||||
<ComposeOverlay
|
||||
networkId={networkId}
|
||||
targetPath={path}
|
||||
onActiveChange={setComposeActive}
|
||||
onStepChange={setComposeStep}
|
||||
disabled={streamParticle.status === "closed"}
|
||||
disabled={streamParticle.status === 'closed'}
|
||||
onParticleCreated={handleParticleCreated}
|
||||
/>
|
||||
|
||||
@@ -475,16 +533,23 @@ function BottomBar({
|
||||
current: number;
|
||||
progress: number;
|
||||
onGoTo: (index: number) => void;
|
||||
presenceBySegment: Map<number, import("@/hooks/use-presence-positions").HumanPresence[]>;
|
||||
presenceBySegment: Map<
|
||||
number,
|
||||
import('@/hooks/use-presence-positions').HumanPresence[]
|
||||
>;
|
||||
onlineHumanIds: Set<string>;
|
||||
exitRemainingMs: number | null;
|
||||
onOpenKeybindings: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn(
|
||||
"absolute inset-x-0 bottom-0 z-10 transition-all duration-300",
|
||||
visible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-2 pointer-events-none",
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-x-0 bottom-0 z-10 transition-all duration-300',
|
||||
visible
|
||||
? 'opacity-100 translate-y-0'
|
||||
: 'opacity-0 translate-y-2 pointer-events-none',
|
||||
)}
|
||||
>
|
||||
{/* Presence avatars — above the blurred background */}
|
||||
<PlaybackPageIndicator
|
||||
total={total}
|
||||
@@ -536,37 +601,37 @@ function StreamViewControls({
|
||||
<span>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
Esc
|
||||
</kbd>{" "}
|
||||
</kbd>{' '}
|
||||
back
|
||||
</span>
|
||||
)}
|
||||
<VideoAudioToggle />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => requestIntent("record")}
|
||||
onClick={() => requestIntent('record')}
|
||||
className="cursor-pointer rounded transition-colors hover:text-white/80"
|
||||
title="Reply with a recording (or hold `)"
|
||||
>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
Hold `
|
||||
</kbd>{" "}
|
||||
</kbd>{' '}
|
||||
to reply
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => requestIntent("text")}
|
||||
onClick={() => requestIntent('text')}
|
||||
className="cursor-pointer rounded transition-colors hover:text-white/80"
|
||||
title="Reply with text (or press T)"
|
||||
>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
T
|
||||
</kbd>{" "}
|
||||
</kbd>{' '}
|
||||
text
|
||||
</button>
|
||||
<span>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
H
|
||||
</kbd>{" "}
|
||||
</kbd>{' '}
|
||||
huddle
|
||||
</span>
|
||||
<kbd
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { toast } from "sonner";
|
||||
import type { Particle } from "@/api/types";
|
||||
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";
|
||||
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
||||
} from '@/lib/particle-path';
|
||||
import { editTextParticleContent } from '@/lib/firestore-particles';
|
||||
import { TextEditor } from '@/features/compose/text-editor';
|
||||
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
||||
|
||||
type TextParticle = Extract<Particle, { type: "text" }>;
|
||||
type TextParticle = Extract<Particle, { type: 'text' }>;
|
||||
|
||||
interface TextEditOverlayProps {
|
||||
particle: TextParticle;
|
||||
@@ -25,7 +25,7 @@ export function TextEditOverlay({
|
||||
streamPath,
|
||||
onClose,
|
||||
}: TextEditOverlayProps) {
|
||||
useSuspendPlayback(true, "text-edit");
|
||||
useSuspendPlayback(true, 'text-edit');
|
||||
|
||||
const [textContent, setTextContent] = useState(particle.properties.content);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -48,10 +48,17 @@ export function TextEditOverlay({
|
||||
await editTextParticleContent(docPath, trimmed);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to save");
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to save');
|
||||
setSaving(false);
|
||||
}
|
||||
}, [saving, textContent, particle.properties.content, particle.id, streamPath, onClose]);
|
||||
}, [
|
||||
saving,
|
||||
textContent,
|
||||
particle.properties.content,
|
||||
particle.id,
|
||||
streamPath,
|
||||
onClose,
|
||||
]);
|
||||
|
||||
return createPortal(
|
||||
// React synthetic events bubble through the React tree (not the DOM tree),
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
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";
|
||||
import { useAllLinkMetadata, type LinkPreviewEntry } from "@/hooks/use-link-metadata";
|
||||
import { extractUrls } from "@/lib/link-metadata";
|
||||
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';
|
||||
import {
|
||||
useAllLinkMetadata,
|
||||
type LinkPreviewEntry,
|
||||
} from '@/hooks/use-link-metadata';
|
||||
import { extractUrls } from '@/lib/link-metadata';
|
||||
import {
|
||||
LinkPreviewCard,
|
||||
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 { MarkdownEditor } from "@/features/compose/markdown-editor";
|
||||
} 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 { MarkdownEditor } from '@/features/compose/markdown-editor';
|
||||
|
||||
type TextParticle = Extract<Particle, { type: "text" }>;
|
||||
type TextParticle = Extract<Particle, { type: 'text' }>;
|
||||
|
||||
interface TextParticleViewProps {
|
||||
particle: TextParticle;
|
||||
@@ -43,18 +46,21 @@ function computeReadDuration(
|
||||
attachmentCount: number,
|
||||
): number {
|
||||
const base = (text.length / CHARS_PER_MINUTE) * 60;
|
||||
const extra = linkCount * EXTRA_S_PER_LINK + attachmentCount * EXTRA_S_PER_ATTACHMENT;
|
||||
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 getImmersiveTextStyle(length: number) {
|
||||
if (length < 30) return { size: "text-5xl", weight: "font-semibold" };
|
||||
if (length < 70) return { size: "text-3xl", weight: "font-semibold" };
|
||||
return { size: "text-2xl", weight: "font-normal" };
|
||||
if (length < 30) return { size: 'text-5xl', weight: 'font-semibold' };
|
||||
if (length < 70) return { size: 'text-3xl', weight: 'font-semibold' };
|
||||
return { size: 'text-2xl', weight: 'font-normal' };
|
||||
}
|
||||
|
||||
function hasMarkdownFormatting(content: string): boolean {
|
||||
return /^#{1,6} |^\s*[-*+] |^\s*\d+\. |^```|`[^`]+`|\*\*|__|\*[^*]|_[^_]|^>/m.test(content);
|
||||
return /^#{1,6} |^\s*[-*+] |^\s*\d+\. |^```|`[^`]+`|\*\*|__|\*[^*]|_[^_]|^>/m.test(
|
||||
content,
|
||||
);
|
||||
}
|
||||
|
||||
function LinkPreviews({ entries }: { entries: LinkPreviewEntry[] }) {
|
||||
@@ -93,7 +99,11 @@ export function TextParticleView({
|
||||
const hasAttachments = attachments.length > 0;
|
||||
const hasEnrichments = hasLinks || hasAttachments;
|
||||
|
||||
const durationS = computeReadDuration(content, urls.length, attachments.length);
|
||||
const durationS = computeReadDuration(
|
||||
content,
|
||||
urls.length,
|
||||
attachments.length,
|
||||
);
|
||||
const elapsedRef = useRef(0);
|
||||
|
||||
// Reset elapsed when particle changes
|
||||
@@ -120,8 +130,10 @@ export function TextParticleView({
|
||||
|
||||
// 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 linksOnly =
|
||||
hasLinks &&
|
||||
urls.every((url) => contentTrimmed.includes(url)) &&
|
||||
contentTrimmed.replace(/https?:\/\/[^\s<>"')\]]+/g, '').trim() === '';
|
||||
|
||||
const editButton = isCreator && !isEditing && (
|
||||
<button
|
||||
@@ -170,13 +182,17 @@ export function TextParticleView({
|
||||
}
|
||||
|
||||
// Mode 2: short plain text, no enrichments — immersive centered display
|
||||
if (content.length < IMMERSIVE_CHAR_LIMIT && !hasEnrichments && !hasMarkdownFormatting(content)) {
|
||||
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",
|
||||
'max-w-2xl break-words text-center leading-relaxed text-white select-text cursor-text',
|
||||
style.size,
|
||||
style.weight,
|
||||
)}
|
||||
@@ -195,16 +211,21 @@ export function TextParticleView({
|
||||
<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 bg-white/10 p-6 backdrop-blur-md",
|
||||
"[&::-webkit-scrollbar]:w-2",
|
||||
"[&::-webkit-scrollbar]:p-2",
|
||||
"[&::-webkit-scrollbar-track]:bg-transparent",
|
||||
"[&::-webkit-scrollbar-thumb]:rounded-full",
|
||||
"[&::-webkit-scrollbar-thumb]:bg-white/30",
|
||||
"[&::-webkit-scrollbar-thumb]:hover:bg-white/50",
|
||||
'flex max-h-full w-full max-w-2xl flex-col gap-4 overflow-y-auto overscroll-contain rounded bg-white/10 p-6 backdrop-blur-md',
|
||||
'[&::-webkit-scrollbar]:w-2',
|
||||
'[&::-webkit-scrollbar]:p-2',
|
||||
'[&::-webkit-scrollbar-track]:bg-transparent',
|
||||
'[&::-webkit-scrollbar-thumb]:rounded-full',
|
||||
'[&::-webkit-scrollbar-thumb]:bg-white/30',
|
||||
'[&::-webkit-scrollbar-thumb]:hover:bg-white/50',
|
||||
)}
|
||||
>
|
||||
<MarkdownEditor key={content} value={content} readOnly className="select-text pb-3" />
|
||||
<MarkdownEditor
|
||||
key={content}
|
||||
value={content}
|
||||
readOnly
|
||||
className="select-text pb-3"
|
||||
/>
|
||||
|
||||
{hasLinks && <LinkPreviews entries={linkPreviews} />}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Send } from "lucide-react";
|
||||
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
||||
import { sanitizeReactionText } from "@/lib/firestore-particles";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Send } from 'lucide-react';
|
||||
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
||||
import { sanitizeReactionText } from '@/lib/firestore-particles';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const MAX_LENGTH = 40;
|
||||
|
||||
@@ -12,15 +12,28 @@ interface TextReactionInputProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function TextReactionInput({ open, onSubmit, onClose }: TextReactionInputProps) {
|
||||
const [value, setValue] = useState("");
|
||||
export function TextReactionInput({
|
||||
open,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: TextReactionInputProps) {
|
||||
const [value, setValue] = useState('');
|
||||
const [prevOpen, setPrevOpen] = useState(open);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useSuspendPlayback(open, "text-reaction");
|
||||
useSuspendPlayback(open, 'text-reaction');
|
||||
|
||||
if (open !== prevOpen) {
|
||||
setPrevOpen(open);
|
||||
|
||||
// NOTE: perform side effects here when opening
|
||||
if (open) {
|
||||
setValue('')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setValue("");
|
||||
const id = requestAnimationFrame(() => inputRef.current?.focus());
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [open]);
|
||||
@@ -34,6 +47,7 @@ export function TextReactionInput({ open, onSubmit, onClose }: TextReactionInput
|
||||
const handleSubmit = () => {
|
||||
if (!canSubmit) return;
|
||||
onSubmit(trimmed);
|
||||
setValue('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -50,10 +64,10 @@ export function TextReactionInput({ open, onSubmit, onClose }: TextReactionInput
|
||||
}
|
||||
onBlur={onClose}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
} else if (e.key === "Escape") {
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
@@ -64,8 +78,8 @@ export function TextReactionInput({ open, onSubmit, onClose }: TextReactionInput
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-[1.5ch] text-right text-[10px] tabular-nums",
|
||||
remaining <= 8 ? "text-amber-300/80" : "text-white/30",
|
||||
'min-w-[1.5ch] text-right text-[10px] tabular-nums',
|
||||
remaining <= 8 ? 'text-amber-300/80' : 'text-white/30',
|
||||
)}
|
||||
>
|
||||
{remaining}
|
||||
@@ -75,10 +89,10 @@ export function TextReactionInput({ open, onSubmit, onClose }: TextReactionInput
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
className={cn(
|
||||
"ml-1 flex size-6 items-center justify-center rounded-full transition-colors",
|
||||
'ml-1 flex size-6 items-center justify-center rounded-full transition-colors',
|
||||
canSubmit
|
||||
? "bg-white/20 text-white hover:bg-white/30"
|
||||
: "text-white/30",
|
||||
? 'bg-white/20 text-white hover:bg-white/30'
|
||||
: 'text-white/30',
|
||||
)}
|
||||
aria-label="Send reaction"
|
||||
>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useMemo, useRef } from "react";
|
||||
import type { Transcript } from "@/api/types";
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { Transcript } from '@/api/types';
|
||||
|
||||
type Sentence = Transcript["paragraphs"][number]["sentences"][number];
|
||||
type Word = Transcript["words"][number];
|
||||
type Sentence = Transcript['paragraphs'][number]['sentences'][number];
|
||||
type Word = Transcript['words'][number];
|
||||
|
||||
const CHUNK_SIZE = 9;
|
||||
|
||||
@@ -41,42 +41,50 @@ export function TranscriptOverlay({
|
||||
const activeWord =
|
||||
activeWordIndex !== null ? transcript.words[activeWordIndex] : null;
|
||||
|
||||
// Remember the last spoken word so highlights hold during pauses
|
||||
const lastSpokenWordRef = useRef<Word | null>(null);
|
||||
if (activeWord) {
|
||||
lastSpokenWordRef.current = activeWord;
|
||||
// Remember the last spoken word so highlights hold during pauses.
|
||||
const [lastSpokenWord, setLastSpokenWord] = useState<Word | null>(null);
|
||||
if (activeWord && activeWord !== lastSpokenWord) {
|
||||
setLastSpokenWord(activeWord);
|
||||
}
|
||||
const highlightWord = activeWord ?? lastSpokenWordRef.current;
|
||||
const highlightWord = activeWord ?? lastSpokenWord;
|
||||
|
||||
const lastChunkRef = useRef<Word[] | null>(null);
|
||||
|
||||
// Find which chunk contains the active word, holding the last one during pauses
|
||||
const activeChunk = useMemo(() => {
|
||||
if (activeWord) {
|
||||
for (const chunk of chunks) {
|
||||
if (chunk.some((w) => w.start === activeWord.start && w.end === activeWord.end)) {
|
||||
lastChunkRef.current = chunk;
|
||||
return chunk;
|
||||
}
|
||||
}
|
||||
}
|
||||
// No active word (speaker pausing) — hold the last chunk
|
||||
if (lastChunkRef.current && chunks.some((c) => c === lastChunkRef.current)) {
|
||||
return lastChunkRef.current;
|
||||
}
|
||||
// Sentence changed, last chunk no longer valid — use first chunk
|
||||
const fallback = chunks[0] ?? null;
|
||||
lastChunkRef.current = fallback;
|
||||
return fallback;
|
||||
// The chunk currently being spoken (null during a pause or if not found).
|
||||
const spokenChunk = useMemo(() => {
|
||||
if (!activeWord) return null;
|
||||
return (
|
||||
chunks.find((chunk) =>
|
||||
chunk.some(
|
||||
(w) => w.start === activeWord.start && w.end === activeWord.end,
|
||||
),
|
||||
) ?? null
|
||||
);
|
||||
}, [chunks, activeWord]);
|
||||
|
||||
// Resolve which chunk to display: the spoken one, else hold the last one while
|
||||
// it's still part of the current sentence, else fall back to the first chunk.
|
||||
const [lastChunk, setLastChunk] = useState<Word[] | null>(null);
|
||||
let activeChunk: Word[] | null;
|
||||
if (spokenChunk) {
|
||||
activeChunk = spokenChunk;
|
||||
} else if (lastChunk && chunks.includes(lastChunk)) {
|
||||
activeChunk = lastChunk;
|
||||
} else {
|
||||
activeChunk = chunks[0] ?? null;
|
||||
}
|
||||
if (activeChunk !== lastChunk) {
|
||||
setLastChunk(activeChunk);
|
||||
}
|
||||
|
||||
if (!activeSentence || !activeChunk || activeChunk.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={centered
|
||||
? "absolute inset-0 flex items-center justify-center px-6"
|
||||
: "absolute bottom-15 left-0 right-0 flex justify-center px-6"
|
||||
}>
|
||||
<div
|
||||
className={
|
||||
centered
|
||||
? 'absolute inset-0 flex items-center justify-center px-6'
|
||||
: 'absolute bottom-15 left-0 right-0 flex justify-center px-6'
|
||||
}
|
||||
>
|
||||
<p className="rounded-lg px-5 py-3 text-2xl text-center max-w-lg">
|
||||
{activeChunk.map((word, i) => {
|
||||
const isSpoken =
|
||||
@@ -87,11 +95,11 @@ export function TranscriptOverlay({
|
||||
key={`${word.start}-${i}`}
|
||||
className={
|
||||
isSpoken
|
||||
? "text-white font-medium transition-colors duration-150"
|
||||
: "text-white/40 transition-colors duration-150"
|
||||
? 'text-white font-medium transition-colors duration-150'
|
||||
: 'text-white/40 transition-colors duration-150'
|
||||
}
|
||||
>
|
||||
{i > 0 ? " " : ""}
|
||||
{i > 0 ? ' ' : ''}
|
||||
{word.word}
|
||||
</span>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user