@@ -177,6 +177,15 @@ class ApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
async setMessageRetentionHours(networkId: string, hours: number) {
|
||||
return this.request(
|
||||
NetworkSchema,
|
||||
"PUT",
|
||||
`/networks/${networkId}/message-retention`,
|
||||
{ hours },
|
||||
);
|
||||
}
|
||||
|
||||
// --- Invitations ---
|
||||
|
||||
async listNetworkInvitations(networkId: string) {
|
||||
|
||||
@@ -14,6 +14,7 @@ export const NetworkSchema = z.object({
|
||||
name: z.string(),
|
||||
admin_human: HumanSchema,
|
||||
humans: z.array(HumanSchema),
|
||||
message_retention_hours: z.number(),
|
||||
created_at: z.coerce.date(),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import * as React from "react"
|
||||
import { Slider as SliderPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Slider({
|
||||
className,
|
||||
defaultValue,
|
||||
value,
|
||||
min = 0,
|
||||
max = 100,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
||||
const _values = React.useMemo(
|
||||
() =>
|
||||
Array.isArray(value)
|
||||
? value
|
||||
: Array.isArray(defaultValue)
|
||||
? defaultValue
|
||||
: [min, max],
|
||||
[value, defaultValue, min, max]
|
||||
)
|
||||
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
data-slot="slider"
|
||||
defaultValue={defaultValue}
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:min-h-40 data-vertical:w-auto data-vertical:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track
|
||||
data-slot="slider-track"
|
||||
className="relative grow overflow-hidden rounded-full bg-muted data-horizontal:h-1 data-horizontal:w-full data-vertical:h-full data-vertical:w-1"
|
||||
>
|
||||
<SliderPrimitive.Range
|
||||
data-slot="slider-range"
|
||||
className="absolute bg-primary select-none data-horizontal:h-full data-vertical:w-full"
|
||||
/>
|
||||
</SliderPrimitive.Track>
|
||||
{Array.from({ length: _values.length }, (_, index) => (
|
||||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
key={index}
|
||||
className="relative block size-3 shrink-0 rounded-full border border-ring bg-white ring-ring/50 transition-[color,box-shadow] select-none after:absolute after:-inset-2 hover:ring-3 focus-visible:ring-3 focus-visible:outline-hidden active:ring-3 disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
))}
|
||||
</SliderPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Slider }
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, Mail, Shield, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
@@ -8,14 +8,17 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Muted } from "@/components/ui/typography";
|
||||
import { WindowControls } from "@/components/window-controls";
|
||||
import { useNetworks } from "@/hooks/use-networks";
|
||||
import { useSetMessageRetention } from "@/hooks/use-network-settings";
|
||||
import {
|
||||
useNetworkInvitations,
|
||||
useInviteMembers,
|
||||
useRevokeInvitation,
|
||||
} from "@/hooks/use-invitations";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import type { Human } from "@/api/types";
|
||||
|
||||
function MemberRow({
|
||||
@@ -147,12 +150,62 @@ function SettingsGroup({
|
||||
);
|
||||
}
|
||||
|
||||
function formatRetentionDays(hours: number): string {
|
||||
const days = Math.round(hours / 24);
|
||||
return days === 1 ? "1 day" : `${days} days`;
|
||||
}
|
||||
|
||||
function EphemeralitySettings({ networkId, retentionHours }: { networkId: string; retentionHours: number }) {
|
||||
const setRetention = useSetMessageRetention(networkId);
|
||||
const [days, setDays] = useState(Math.round(retentionHours / 24));
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
// Sync local state if server value changes externally
|
||||
useEffect(() => {
|
||||
setDays(Math.round(retentionHours / 24));
|
||||
}, [retentionHours]);
|
||||
|
||||
const handleChange = useCallback((value: number[]) => {
|
||||
const newDays = value[0];
|
||||
setDays(newDays);
|
||||
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
setRetention.mutate(newDays * 24, {
|
||||
onSuccess: () => toast.success("Retention window updated"),
|
||||
onError: (err) => toast.error(err.message || "Failed to update retention"),
|
||||
});
|
||||
}, 500);
|
||||
}, [setRetention]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 px-4 py-3">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<p className="text-sm font-medium">Messages disappear after</p>
|
||||
<p className="text-sm font-semibold">{formatRetentionDays(days * 24)}</p>
|
||||
</div>
|
||||
<Slider
|
||||
min={1}
|
||||
max={14}
|
||||
step={1}
|
||||
value={[days]}
|
||||
onValueChange={handleChange}
|
||||
/>
|
||||
<Muted className="text-xs">
|
||||
Older messages are no longer visible to anyone.
|
||||
</Muted>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NetworkSettingsPage() {
|
||||
const navigate = useNavigate();
|
||||
const { networkId } = useParams<{ networkId: string }>();
|
||||
const { data: networks } = useNetworks();
|
||||
const network = networks?.find((n) => n.id === networkId);
|
||||
const { data: invitations } = useNetworkInvitations(networkId!);
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
const isAdmin = currentUser?.id === network?.admin_human.id;
|
||||
|
||||
const networkName = network?.name ?? "Network";
|
||||
|
||||
@@ -187,33 +240,49 @@ export default function NetworkSettingsPage() {
|
||||
))}
|
||||
</SettingsGroup>
|
||||
|
||||
<Separator className="mt-4" />
|
||||
|
||||
<SettingsGroup title="Invite">
|
||||
<InviteForm networkId={networkId!} />
|
||||
</SettingsGroup>
|
||||
{isAdmin && network && (
|
||||
<>
|
||||
<Separator className="mt-4" />
|
||||
<SettingsGroup title="Ephemerality">
|
||||
<EphemeralitySettings
|
||||
networkId={networkId!}
|
||||
retentionHours={network.message_retention_hours}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator className="mt-4" />
|
||||
|
||||
<SettingsGroup title="Pending Invitations">
|
||||
{invitations && invitations.length > 0 ? (
|
||||
invitations.map((inv, index) => (
|
||||
<div key={inv.email}>
|
||||
<PendingInvitationRow
|
||||
email={inv.email}
|
||||
networkId={networkId!}
|
||||
/>
|
||||
{index < invitations.length - 1 && (
|
||||
<Separator className="mx-4" />
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-muted-foreground px-4 py-3 text-sm">
|
||||
No pending invitations
|
||||
</p>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
{isAdmin && network && (
|
||||
<>
|
||||
<SettingsGroup title="Invite">
|
||||
<InviteForm networkId={networkId!} />
|
||||
</SettingsGroup>
|
||||
|
||||
<Separator className="mt-4" />
|
||||
|
||||
<SettingsGroup title="Pending Invitations">
|
||||
{invitations && invitations.length > 0 ? (
|
||||
invitations.map((inv, index) => (
|
||||
<div key={inv.email}>
|
||||
<PendingInvitationRow
|
||||
email={inv.email}
|
||||
networkId={networkId!}
|
||||
/>
|
||||
{index < invitations.length - 1 && (
|
||||
<Separator className="mx-4" />
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-muted-foreground px-4 py-3 text-sm">
|
||||
No pending invitations
|
||||
</p>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
</>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiClient } from "@/api/client";
|
||||
|
||||
export function useSetMessageRetention(networkId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (hours: number) =>
|
||||
apiClient.setMessageRetentionHours(networkId, hours),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["networks"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useAuthStore } from "@/stores/auth-store";
|
||||
import { parseParticlePath, type ParticlePath } from "@/lib/particle-path";
|
||||
import type { Particle, StreamProperties } from "@/api/types";
|
||||
import { where, Timestamp } from "firebase/firestore";
|
||||
import { RECENCY_WINDOW_HOURS } from "@/lib/constants";
|
||||
import { useNetwork } from "@/hooks/use-networks";
|
||||
|
||||
type StreamParticle = Particle & { type: "stream"; properties: StreamProperties };
|
||||
|
||||
@@ -27,21 +27,28 @@ export function useStreamParticles(path: ParticlePath): UseStreamParticlesResult
|
||||
const { networkId } = parseParticlePath(path);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const visibilityScopes = useVisibilityScopes(user?.id, networkId);
|
||||
const network = useNetwork(networkId);
|
||||
const retentionHours = network?.message_retention_hours ?? 24;
|
||||
|
||||
const [recencyCutoff, setRecencyCutoff] = useState(() => {
|
||||
const d = new Date();
|
||||
d.setHours(d.getHours() - RECENCY_WINDOW_HOURS);
|
||||
d.setHours(d.getHours() - retentionHours);
|
||||
return Timestamp.fromDate(d);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Recalculate immediately when retention changes
|
||||
const d = new Date();
|
||||
d.setHours(d.getHours() - retentionHours);
|
||||
setRecencyCutoff(Timestamp.fromDate(d));
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const d = new Date();
|
||||
d.setHours(d.getHours() - RECENCY_WINDOW_HOURS);
|
||||
d.setHours(d.getHours() - retentionHours);
|
||||
setRecencyCutoff(Timestamp.fromDate(d));
|
||||
}, 60 * 60 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
}, [retentionHours]);
|
||||
|
||||
const { children, isLoading } = useLiveParticleChildren(
|
||||
path,
|
||||
|
||||
@@ -2,10 +2,10 @@ import { useCallback, useEffect, useEffectEvent, useMemo, useReducer, useRef, us
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import type { Particle } from "@/api/types";
|
||||
import { useLiveParticleChildren } from "@/hooks/use-particle";
|
||||
import { toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path";
|
||||
import { parseParticlePath, toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path";
|
||||
import { updateStreamPlaybackMarker } from "@/lib/firestore-particles";
|
||||
import { where, Timestamp } from "firebase/firestore";
|
||||
import { RECENCY_WINDOW_HOURS } from "@/lib/constants";
|
||||
import { useNetwork } from "@/hooks/use-networks";
|
||||
|
||||
// --- Playback reducer (ID-based) ---
|
||||
|
||||
@@ -96,6 +96,9 @@ export function useStreamPlayback(
|
||||
path: ParticlePath,
|
||||
): UseStreamPlaybackResult {
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
const { networkId } = parseParticlePath(path);
|
||||
const network = useNetwork(networkId);
|
||||
const retentionHours = network?.message_retention_hours ?? 24;
|
||||
const [state, dispatch] = useReducer(playbackReducer, initialState);
|
||||
// Track the stream ID we've initialized for, to reset when navigating between streams
|
||||
const initializedForRef = useRef<string | null>(null);
|
||||
@@ -117,7 +120,7 @@ export function useStreamPlayback(
|
||||
|
||||
const [recencyCutoff] = useState(() => {
|
||||
const d = new Date();
|
||||
d.setHours(d.getHours() - RECENCY_WINDOW_HOURS);
|
||||
d.setHours(d.getHours() - retentionHours);
|
||||
return Timestamp.fromDate(d);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
/** How far back to look when filtering particles by recency. */
|
||||
export const RECENCY_WINDOW_HOURS = 24;
|
||||
|
||||
/** Maximum file size for attachments (25 MB). */
|
||||
export const MAX_ATTACHMENT_SIZE_BYTES = 25 * 1024 * 1024;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user