+9
-2
@@ -152,6 +152,13 @@ export const PaperPropertiesSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type PaperProperties = z.infer<typeof PaperPropertiesSchema>;
|
export type PaperProperties = z.infer<typeof PaperPropertiesSchema>;
|
||||||
|
|
||||||
|
// --- Reactions ---
|
||||||
|
|
||||||
|
export const ReactionsSchema = z.record(z.string(), z.array(z.string())).optional();
|
||||||
|
export type Reactions = z.infer<typeof ReactionsSchema>;
|
||||||
|
|
||||||
|
export const REACTION_EMOJIS = ["\u{1F44D}", "\u{2764}\u{FE0F}", "\u{1F525}", "\u{1F440}", "\u{2705}", "\u{2753}"] as const;
|
||||||
|
|
||||||
export interface ParticlePropertiesMap {
|
export interface ParticlePropertiesMap {
|
||||||
stream: StreamProperties;
|
stream: StreamProperties;
|
||||||
folder: FolderProperties;
|
folder: FolderProperties;
|
||||||
@@ -193,9 +200,9 @@ export const ParticleSchema = z.discriminatedUnion("type", [
|
|||||||
// e.g. ["network:123"] - visible to everyone in the network
|
// e.g. ["network:123"] - visible to everyone in the network
|
||||||
visible_to: z.array(z.string()),
|
visible_to: z.array(z.string()),
|
||||||
}),
|
}),
|
||||||
ParticleBaseSchema.extend({ type: z.literal("media"), properties: MediaPropertiesSchema }),
|
ParticleBaseSchema.extend({ type: z.literal("media"), properties: MediaPropertiesSchema, reactions: ReactionsSchema }),
|
||||||
ParticleBaseSchema.extend({ type: z.literal("file"), properties: FilePropertiesSchema }),
|
ParticleBaseSchema.extend({ type: z.literal("file"), properties: FilePropertiesSchema }),
|
||||||
ParticleBaseSchema.extend({ type: z.literal("text"), properties: TextPropertiesSchema }),
|
ParticleBaseSchema.extend({ type: z.literal("text"), properties: TextPropertiesSchema, reactions: ReactionsSchema }),
|
||||||
ParticleBaseSchema.extend({ type: z.literal("quest"), properties: QuestPropertiesSchema }),
|
ParticleBaseSchema.extend({ type: z.literal("quest"), properties: QuestPropertiesSchema }),
|
||||||
ParticleBaseSchema.extend({ type: z.literal("paper"), properties: PaperPropertiesSchema }),
|
ParticleBaseSchema.extend({ type: z.literal("paper"), properties: PaperPropertiesSchema }),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Plus, X } from "lucide-react";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { REACTION_EMOJIS, type Reactions } from "@/api/types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { Human } from "@/api/types";
|
||||||
|
|
||||||
|
interface ReactionBarProps {
|
||||||
|
reactions: Reactions;
|
||||||
|
currentHumanId: string;
|
||||||
|
humans?: Human[];
|
||||||
|
onToggle: (emoji: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReactorNames(
|
||||||
|
humanIds: string[],
|
||||||
|
humans?: Human[],
|
||||||
|
): string {
|
||||||
|
return humanIds
|
||||||
|
.map((id) => {
|
||||||
|
const human = humans?.find((h) => h.id === id);
|
||||||
|
return human?.email_prefix ?? id;
|
||||||
|
})
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReactionBar({ reactions, currentHumanId, humans, onToggle }: ReactionBarProps) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
const activeEmojis = REACTION_EMOJIS.filter(
|
||||||
|
(emoji) => reactions?.[emoji] && reactions[emoji].length > 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleToggle = (emoji: string) => {
|
||||||
|
onToggle(emoji);
|
||||||
|
setExpanded(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{/* Existing reaction pills */}
|
||||||
|
{activeEmojis.map((emoji) => {
|
||||||
|
const reactors = reactions![emoji];
|
||||||
|
const isMine = reactors.includes(currentHumanId);
|
||||||
|
return (
|
||||||
|
<Tooltip key={emoji}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
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",
|
||||||
|
isMine
|
||||||
|
? "bg-white/20 ring-1 ring-white/40"
|
||||||
|
: "bg-black/40 hover:bg-black/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-sm">{emoji}</span>
|
||||||
|
<span className="text-white/80">{reactors.length}</span>
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="text-xs">
|
||||||
|
{getReactorNames(reactors, humans)}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Expand / picker toggle */}
|
||||||
|
{expanded ? (
|
||||||
|
<div className="flex items-center gap-0.5 rounded-full bg-black/40 px-1.5 py-0.5 backdrop-blur-sm">
|
||||||
|
{REACTION_EMOJIS.map((emoji) => {
|
||||||
|
// Skip emojis that already have pills
|
||||||
|
if (activeEmojis.includes(emoji)) return null;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={emoji}
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleToggle(emoji); }}
|
||||||
|
className="rounded-full px-1 py-0.5 text-sm transition-colors hover:bg-white/15"
|
||||||
|
>
|
||||||
|
{emoji}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
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" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
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" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { useState, useEffect, useEffectEvent, useCallback, useRef } from "react"
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { apiClient } from "@/api/client";
|
import { apiClient } from "@/api/client";
|
||||||
import type { Particle } from "@/api/types";
|
import { type Particle, REACTION_EMOJIS } from "@/api/types";
|
||||||
import { parseParticlePath, particlePath, toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path";
|
import { parseParticlePath, particlePath, toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path";
|
||||||
import { ComposeOverlay } from "@/features/compose/compose-overlay";
|
import { ComposeOverlay } from "@/features/compose/compose-overlay";
|
||||||
import { PlaybackPageIndicator } from "@/features/particles/playback-page-indicator";
|
import { PlaybackPageIndicator } from "@/features/particles/playback-page-indicator";
|
||||||
@@ -22,7 +22,8 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Settings, CircleCheckBig, CircleDot, EllipsisVertical } from "lucide-react";
|
import { Settings, CircleCheckBig, CircleDot, EllipsisVertical } from "lucide-react";
|
||||||
import { updateStreamStatus } from "@/lib/firestore-particles";
|
import { updateStreamStatus, toggleParticleReaction } from "@/lib/firestore-particles";
|
||||||
|
import { ReactionBar } from "@/features/particles/reaction-bar";
|
||||||
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb";
|
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb";
|
||||||
import { WindowControls } from "@/components/window-controls";
|
import { WindowControls } from "@/components/window-controls";
|
||||||
import { RelativeTimestamp } from "@/components/relative-timestamp";
|
import { RelativeTimestamp } from "@/components/relative-timestamp";
|
||||||
@@ -32,6 +33,11 @@ import { usePresencePositions } from "@/hooks/use-presence-positions";
|
|||||||
import { cn, getInitials } from "@/lib/utils";
|
import { cn, getInitials } from "@/lib/utils";
|
||||||
import { useMount } from "react-use";
|
import { useMount } from "react-use";
|
||||||
|
|
||||||
|
function getReactions(particle: Particle): Record<string, string[]> | undefined {
|
||||||
|
if (particle.type === "media" || particle.type === "text") return particle.reactions;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function getParticleDisplayName(particle: Particle): string {
|
function getParticleDisplayName(particle: Particle): string {
|
||||||
switch (particle.type) {
|
switch (particle.type) {
|
||||||
case "stream":
|
case "stream":
|
||||||
@@ -128,6 +134,12 @@ const STREAM_VIEW_KEYBINDINGS: KeybindingGroup[] = [
|
|||||||
{ keys: ["H"], description: "Join huddle" },
|
{ keys: ["H"], description: "Join huddle" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Reactions",
|
||||||
|
bindings: [
|
||||||
|
{ keys: ["1-6"], description: "Toggle emoji reaction" },
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// --- StreamView ---
|
// --- StreamView ---
|
||||||
@@ -171,6 +183,19 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
|
|||||||
|
|
||||||
const mediaRef = useRef<MediaParticleHandle>(null);
|
const mediaRef = useRef<MediaParticleHandle>(null);
|
||||||
|
|
||||||
|
const handleToggleReaction = useCallback((emoji: string) => {
|
||||||
|
if (!authedUser || !currentParticle) 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 [composeActive, setComposeActive] = useState(false);
|
const [composeActive, setComposeActive] = useState(false);
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
const [fastPlayback, setFastPlayback] = useState(false);
|
const [fastPlayback, setFastPlayback] = useState(false);
|
||||||
@@ -262,6 +287,10 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setShowKeybindings((v) => !v);
|
setShowKeybindings((v) => !v);
|
||||||
break;
|
break;
|
||||||
|
case "1": case "2": case "3": case "4": case "5": case "6":
|
||||||
|
e.preventDefault();
|
||||||
|
handleToggleReaction(REACTION_EMOJIS[parseInt(e.key) - 1]);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -286,7 +315,7 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
|
|||||||
window.removeEventListener("keyup", handleKeyUp);
|
window.removeEventListener("keyup", handleKeyUp);
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
[composeActive, next, prev, pause, resume, navigate, networkId, streamParticle.id, setShowKeybindings],
|
[composeActive, next, prev, pause, resume, navigate, networkId, streamParticle.id, setShowKeybindings, handleToggleReaction],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Click-to-navigate: left 30% = prev, right 70% = next
|
// Click-to-navigate: left 30% = prev, right 70% = next
|
||||||
@@ -387,6 +416,18 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Reaction bar — always visible */}
|
||||||
|
{currentParticle && (
|
||||||
|
<div className="absolute bottom-16 left-4 z-10">
|
||||||
|
<ReactionBar
|
||||||
|
reactions={getReactions(currentParticle)}
|
||||||
|
currentHumanId={authedUser?.id ?? ""}
|
||||||
|
humans={network?.humans}
|
||||||
|
onToggle={handleToggleReaction}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<ComposeOverlay
|
<ComposeOverlay
|
||||||
networkId={networkId}
|
networkId={networkId}
|
||||||
targetPath={path}
|
targetPath={path}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
serverTimestamp,
|
serverTimestamp,
|
||||||
where,
|
where,
|
||||||
Timestamp,
|
Timestamp,
|
||||||
|
arrayUnion,
|
||||||
|
arrayRemove,
|
||||||
type DocumentData,
|
type DocumentData,
|
||||||
type FirestoreDataConverter,
|
type FirestoreDataConverter,
|
||||||
type QueryDocumentSnapshot,
|
type QueryDocumentSnapshot,
|
||||||
@@ -21,7 +23,7 @@ import {
|
|||||||
} from "firebase/firestore";
|
} from "firebase/firestore";
|
||||||
import { firestoreDb } from "@/firebase";
|
import { firestoreDb } from "@/firebase";
|
||||||
import { isContainerType, ParticleSchema } from "@/api/types";
|
import { isContainerType, ParticleSchema } from "@/api/types";
|
||||||
import type { Particle, ParticleType, ParticlePropertiesMap } from "@/api/types";
|
import type { Particle, ParticleType, ParticlePropertiesMap, Reactions } from "@/api/types";
|
||||||
|
|
||||||
// --- Converter ---
|
// --- Converter ---
|
||||||
|
|
||||||
@@ -87,6 +89,7 @@ const particleConverter: FirestoreDataConverter<Particle> = {
|
|||||||
created_at: (raw.created_at as Timestamp).toDate(),
|
created_at: (raw.created_at as Timestamp).toDate(),
|
||||||
created_by_human_id: raw.created_by_human_id,
|
created_by_human_id: raw.created_by_human_id,
|
||||||
updated_at: raw.updated_at ? (raw.updated_at as Timestamp).toDate() : undefined,
|
updated_at: raw.updated_at ? (raw.updated_at as Timestamp).toDate() : undefined,
|
||||||
|
reactions: raw.reactions ?? undefined,
|
||||||
});
|
});
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown particle type: ${type}`);
|
throw new Error(`Unknown particle type: ${type}`);
|
||||||
@@ -342,3 +345,18 @@ export async function updateStreamPlaybackMarker(
|
|||||||
updated_at: serverTimestamp(),
|
updated_at: serverTimestamp(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function toggleParticleReaction(
|
||||||
|
docPath: string,
|
||||||
|
emoji: string,
|
||||||
|
humanId: string,
|
||||||
|
currentReactions?: Reactions,
|
||||||
|
): Promise<void> {
|
||||||
|
const particleRef = typedDoc(docPath);
|
||||||
|
const field = `reactions.${emoji}`;
|
||||||
|
const alreadyReacted = currentReactions?.[emoji]?.includes(humanId) ?? false;
|
||||||
|
await updateDoc(particleRef, {
|
||||||
|
[field]: alreadyReacted ? arrayRemove(humanId) : arrayUnion(humanId),
|
||||||
|
updated_at: serverTimestamp(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user