format
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
import { useMemo, useRef, useEffect, useCallback, memo, createElement } 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,24 +19,28 @@ import {
|
||||
Headphones,
|
||||
Trash2,
|
||||
type LucideIcon,
|
||||
} 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";
|
||||
} 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,
|
||||
@@ -42,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 && (
|
||||
@@ -63,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;
|
||||
@@ -84,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;
|
||||
@@ -116,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;
|
||||
@@ -125,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) {
|
||||
@@ -144,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;
|
||||
@@ -170,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]);
|
||||
|
||||
@@ -193,8 +215,8 @@ const StreamRow = memo(function StreamRow({
|
||||
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;
|
||||
|
||||
@@ -203,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 && (
|
||||
@@ -218,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>
|
||||
@@ -228,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}
|
||||
@@ -240,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} />
|
||||
@@ -258,16 +284,16 @@ const StreamRow = memo(function StreamRow({
|
||||
<div className="flex items-center gap-1">
|
||||
{createElement(typeIcon, {
|
||||
className: cn(
|
||||
"size-3.5 shrink-0",
|
||||
isUnseen ? "text-foreground" : "text-muted-foreground",
|
||||
'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 && (
|
||||
@@ -277,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>
|
||||
);
|
||||
});
|
||||
@@ -314,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]);
|
||||
|
||||
@@ -328,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>
|
||||
);
|
||||
@@ -337,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}
|
||||
|
||||
Reference in New Issue
Block a user