feat: order streams by last child creation
This commit is contained in:
@@ -138,6 +138,9 @@ export const ParticleSchema = z.discriminatedUnion("type", [
|
|||||||
visible_to: z.array(z.string()),
|
visible_to: z.array(z.string()),
|
||||||
// Marks emails to their `playback_position_at`: where they left off in a conversation
|
// Marks emails to their `playback_position_at`: where they left off in a conversation
|
||||||
markers: z.record(z.string(), z.coerce.date()).optional(),
|
markers: z.record(z.string(), z.coerce.date()).optional(),
|
||||||
|
// Timestamp of the most recent child particle
|
||||||
|
// used for sorting streams by recent activity without needing to query subcollections
|
||||||
|
last_child_created_at: z.coerce.date().optional(),
|
||||||
}),
|
}),
|
||||||
ParticleBaseSchema.extend({
|
ParticleBaseSchema.extend({
|
||||||
type: z.literal("folder"), properties: FolderPropertiesSchema,
|
type: z.literal("folder"), properties: FolderPropertiesSchema,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useEffectEvent, useRef, useState } from "react";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useCreateParticle, useCreateStreamParticle } from "@/hooks/use-create-particle";
|
import { useCreateParticle, useCreateStreamParticle } from "@/hooks/use-create-particle";
|
||||||
import { useRecordingMode } from "@/hooks/use-recording-mode";
|
import { useRecordingMode } from "@/hooks/use-recording-mode";
|
||||||
import { useRecorder } from "@/features/compose/use-recorder";
|
import { useRecorder } from "@/features/compose/use-recorder";
|
||||||
import { particlePath, toFirestoreChildrenPath } from "@/lib/particle-path";
|
import { particlePath } from "@/lib/particle-path";
|
||||||
import type { ParticlePath } from "@/lib/particle-path";
|
import type { ParticlePath } from "@/lib/particle-path";
|
||||||
import { RecordingOverlay } from "@/features/compose/recording-overlay";
|
import { RecordingOverlay } from "@/features/compose/recording-overlay";
|
||||||
import { TextComposeStep } from "@/features/compose/text-compose-step";
|
import { TextComposeStep } from "@/features/compose/text-compose-step";
|
||||||
@@ -14,6 +14,7 @@ type ComposeStep = "idle" | "recording" | "reviewing" | "typing" | "configuring"
|
|||||||
|
|
||||||
interface ComposeOverlayProps {
|
interface ComposeOverlayProps {
|
||||||
networkId: string;
|
networkId: string;
|
||||||
|
// Optional target path for reply mode. If not provided, compose creates a new stream.
|
||||||
targetPath?: ParticlePath;
|
targetPath?: ParticlePath;
|
||||||
onActiveChange?: (active: boolean) => void;
|
onActiveChange?: (active: boolean) => void;
|
||||||
}
|
}
|
||||||
@@ -101,12 +102,12 @@ export function ComposeOverlay({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const createChildParticle = useCallback(
|
const createChildParticle = useCallback(
|
||||||
async (collectionPath: string) => {
|
async (path: ParticlePath) => {
|
||||||
if (!userEmail) return;
|
if (!userEmail) return;
|
||||||
|
|
||||||
if (textContent.trim()) {
|
if (textContent.trim()) {
|
||||||
await createParticle.mutateAsync({
|
await createParticle.mutateAsync({
|
||||||
collectionPath,
|
path,
|
||||||
type: "text",
|
type: "text",
|
||||||
properties: { content: textContent },
|
properties: { content: textContent },
|
||||||
createdByEmail: userEmail,
|
createdByEmail: userEmail,
|
||||||
@@ -118,7 +119,7 @@ export function ComposeOverlay({
|
|||||||
);
|
);
|
||||||
|
|
||||||
await createParticle.mutateAsync({
|
await createParticle.mutateAsync({
|
||||||
collectionPath,
|
path,
|
||||||
type: "media",
|
type: "media",
|
||||||
properties: {
|
properties: {
|
||||||
object_id,
|
object_id,
|
||||||
@@ -142,25 +143,19 @@ export function ComposeOverlay({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Reply mode: create particle directly under targetPath
|
// Reply mode: create particle directly under targetPath
|
||||||
const submitReply = useCallback(async () => {
|
const onSubmitReply = useEffectEvent(async () => {
|
||||||
if (!targetPath || !userEmail) return;
|
if (!targetPath || !userEmail) return;
|
||||||
const collectionPath = toFirestoreChildrenPath(targetPath);
|
await createChildParticle(targetPath);
|
||||||
await createChildParticle(collectionPath);
|
|
||||||
cancel();
|
cancel();
|
||||||
}, [targetPath, userEmail, createChildParticle, cancel]);
|
});
|
||||||
|
|
||||||
const submitReplyRef = useRef(submitReply);
|
|
||||||
submitReplyRef.current = submitReply;
|
|
||||||
|
|
||||||
// New stream mode: create stream + first child
|
// New stream mode: create stream + first child
|
||||||
const handleStreamSubmit = useCallback(
|
const handleStreamSubmit = useCallback(
|
||||||
async (streamName: string, visibleTo: string[]) => {
|
async (streamName: string, visibleTo: string[]) => {
|
||||||
if (!userEmail) return;
|
if (!userEmail) return;
|
||||||
|
|
||||||
const collectionPath = toFirestoreChildrenPath(particlePath(networkId));
|
|
||||||
|
|
||||||
const streamId = await createStream.mutateAsync({
|
const streamId = await createStream.mutateAsync({
|
||||||
collectionPath,
|
networkId,
|
||||||
properties: {
|
properties: {
|
||||||
name: streamName,
|
name: streamName,
|
||||||
status: "open",
|
status: "open",
|
||||||
@@ -169,11 +164,9 @@ export function ComposeOverlay({
|
|||||||
visibleTo,
|
visibleTo,
|
||||||
});
|
});
|
||||||
|
|
||||||
const streamChildrenPath = toFirestoreChildrenPath(
|
const streamChildrenPath = particlePath(networkId, [streamId]);
|
||||||
particlePath(networkId, [streamId]),
|
|
||||||
);
|
|
||||||
|
|
||||||
await createChildParticle(streamChildrenPath);
|
await createChildParticle(streamChildrenPath);
|
||||||
|
|
||||||
cancel();
|
cancel();
|
||||||
},
|
},
|
||||||
[networkId, userEmail, createParticle, createChildParticle, cancel],
|
[networkId, userEmail, createParticle, createChildParticle, cancel],
|
||||||
@@ -226,7 +219,7 @@ export function ComposeOverlay({
|
|||||||
} else if (e.key === "Enter") {
|
} else if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (targetPath) {
|
if (targetPath) {
|
||||||
submitReplyRef.current();
|
onSubmitReply();
|
||||||
} else {
|
} else {
|
||||||
setStep("configuring");
|
setStep("configuring");
|
||||||
}
|
}
|
||||||
@@ -256,7 +249,7 @@ export function ComposeOverlay({
|
|||||||
if (step === "idle") return null;
|
if (step === "idle") return null;
|
||||||
|
|
||||||
const handleTextAdvance = targetPath
|
const handleTextAdvance = targetPath
|
||||||
? submitReply
|
? onSubmitReply
|
||||||
: () => setStep("configuring");
|
: () => setStep("configuring");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ interface ParticleListViewProps {
|
|||||||
* List of stream particles for a container (network root, folder, etc.).
|
* List of stream particles for a container (network root, folder, etc.).
|
||||||
*/
|
*/
|
||||||
export function ParticleListView({ path }: ParticleListViewProps) {
|
export function ParticleListView({ path }: ParticleListViewProps) {
|
||||||
const { children, isLoading } = useLiveParticleChildren(path);
|
const { children, isLoading } = useLiveParticleChildren(path, "last_child_created_at", "desc");
|
||||||
const { networkId } = parseParticlePath(path);
|
const { networkId } = parseParticlePath(path);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ interface StreamViewProps {
|
|||||||
export function StreamView({ path, streamParticle }: StreamViewProps) {
|
export function StreamView({ path, streamParticle }: StreamViewProps) {
|
||||||
const { networkId } = parseParticlePath(path);
|
const { networkId } = parseParticlePath(path);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { children } = useLiveParticleChildren(path);
|
const { children } = useLiveParticleChildren(path, "created_at", "asc");
|
||||||
|
|
||||||
const [state, dispatch] = useReducer(playbackReducer, initialState);
|
const [state, dispatch] = useReducer(playbackReducer, initialState);
|
||||||
const [composeActive, setComposeActive] = useState(false);
|
const [composeActive, setComposeActive] = useState(false);
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { createParticle } from "@/lib/firestore-particles";
|
import { createParticle, updateStreamParticleLastChildParticle } from "@/lib/firestore-particles";
|
||||||
import type { ParticleType, ParticlePropertiesMap } from "@/api/types";
|
import type { ParticleType, ParticlePropertiesMap } from "@/api/types";
|
||||||
|
import { particlePath, ParticlePath, toFirestoreChildrenPath, toFirestoreDocPath } from "@/lib/particle-path";
|
||||||
|
|
||||||
interface CreateParticleParams<T extends ParticleType = ParticleType> {
|
interface CreateParticleParams<T extends ParticleType = ParticleType> {
|
||||||
collectionPath: string;
|
// Path to which the new particle will be added as a child
|
||||||
|
path: ParticlePath;
|
||||||
type: T;
|
type: T;
|
||||||
properties: ParticlePropertiesMap[T];
|
properties: ParticlePropertiesMap[T];
|
||||||
createdByEmail: string;
|
createdByEmail: string;
|
||||||
@@ -11,29 +13,41 @@ interface CreateParticleParams<T extends ParticleType = ParticleType> {
|
|||||||
|
|
||||||
export function useCreateParticle() {
|
export function useCreateParticle() {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (params: CreateParticleParams) =>
|
mutationFn: async (params: CreateParticleParams) => {
|
||||||
createParticle(
|
const collectionPath = toFirestoreChildrenPath(params.path);
|
||||||
params.collectionPath,
|
const result = await createParticle(
|
||||||
|
collectionPath,
|
||||||
params.type,
|
params.type,
|
||||||
params.properties,
|
params.properties,
|
||||||
params.createdByEmail,
|
params.createdByEmail,
|
||||||
),
|
);
|
||||||
|
|
||||||
|
const streamDocPath = toFirestoreDocPath(params.path);
|
||||||
|
await updateStreamParticleLastChildParticle(streamDocPath);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateStreamParticleParams = Omit<CreateParticleParams<"stream">, "type"> & {
|
type CreateStreamParticleParams = {
|
||||||
|
networkId: string;
|
||||||
|
properties: ParticlePropertiesMap["stream"];
|
||||||
|
createdByEmail: string;
|
||||||
visibleTo?: string[];
|
visibleTo?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useCreateStreamParticle() {
|
export function useCreateStreamParticle() {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (params: CreateStreamParticleParams) =>
|
mutationFn: async (params: CreateStreamParticleParams) => {
|
||||||
createParticle(
|
const path = particlePath(params.networkId, []);
|
||||||
params.collectionPath,
|
const networkCollectionPath = toFirestoreChildrenPath(path);
|
||||||
|
return await createParticle(
|
||||||
|
networkCollectionPath,
|
||||||
"stream",
|
"stream",
|
||||||
params.properties,
|
params.properties,
|
||||||
params.createdByEmail,
|
params.createdByEmail,
|
||||||
params.visibleTo,
|
params.visibleTo,
|
||||||
),
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ interface UseLiveParticleChildrenResult {
|
|||||||
|
|
||||||
export function useLiveParticleChildren(
|
export function useLiveParticleChildren(
|
||||||
path: ParticlePath,
|
path: ParticlePath,
|
||||||
|
orderByField: string = "created_at",
|
||||||
|
orderDirection: "asc" | "desc" = "desc",
|
||||||
): UseLiveParticleChildrenResult {
|
): UseLiveParticleChildrenResult {
|
||||||
const [children, setChildren] = useState<Particle[]>([]);
|
const [children, setChildren] = useState<Particle[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
@@ -79,6 +81,8 @@ export function useLiveParticleChildren(
|
|||||||
setError(err);
|
setError(err);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
},
|
},
|
||||||
|
orderByField,
|
||||||
|
orderDirection,
|
||||||
);
|
);
|
||||||
|
|
||||||
return unsubscribe;
|
return unsubscribe;
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ const particleConverter: FirestoreDataConverter<Particle> = {
|
|||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
last_child_created_at: raw.last_child_created_at ? (raw.last_child_created_at as Timestamp).toDate() : undefined,
|
||||||
});
|
});
|
||||||
case "folder":
|
case "folder":
|
||||||
return ParticleSchema.parse({
|
return ParticleSchema.parse({
|
||||||
@@ -127,8 +128,10 @@ export function subscribeToParticleChildren(
|
|||||||
collectionPath: string,
|
collectionPath: string,
|
||||||
onData: (children: Particle[]) => void,
|
onData: (children: Particle[]) => void,
|
||||||
onError: (error: Error) => void,
|
onError: (error: Error) => void,
|
||||||
|
orderByField: string = "created_at",
|
||||||
|
orderDirection: "asc" | "desc" = "desc",
|
||||||
): Unsubscribe {
|
): Unsubscribe {
|
||||||
const q = query(typedCollection(collectionPath), orderBy("created_at"));
|
const q = query(typedCollection(collectionPath), orderBy(orderByField, orderDirection));
|
||||||
return onSnapshot(
|
return onSnapshot(
|
||||||
q,
|
q,
|
||||||
(snap) => {
|
(snap) => {
|
||||||
@@ -222,3 +225,27 @@ export async function updateParticleVisibleTo(
|
|||||||
updated_at: serverTimestamp(),
|
updated_at: serverTimestamp(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CAUTION: use the other type safe update functions in most cases
|
||||||
|
// There is no checking whether this field actually exists on the particle type, so it can lead to inconsistent data if used incorrectly
|
||||||
|
export async function updateParticle(
|
||||||
|
docPath: string,
|
||||||
|
fieldName: string,
|
||||||
|
value: any,
|
||||||
|
): Promise<void> {
|
||||||
|
const particleRef = typedDoc(docPath);
|
||||||
|
await updateDoc(particleRef, {
|
||||||
|
[fieldName]: value,
|
||||||
|
updated_at: serverTimestamp(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateStreamParticleLastChildParticle(
|
||||||
|
docPath: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const particleRef = typedDoc(docPath);
|
||||||
|
await updateDoc(particleRef, {
|
||||||
|
last_child_created_at: serverTimestamp(),
|
||||||
|
updated_at: serverTimestamp(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user