diff --git a/go/cmd/orion/main.go b/go/cmd/orion/main.go index 9c4d229..df35195 100644 --- a/go/cmd/orion/main.go +++ b/go/cmd/orion/main.go @@ -114,6 +114,7 @@ func main() { mux.Handle("GET /networks", withAuth(h.ListNetworks)) mux.Handle("GET /networks/{id}", withAuth(h.GetNetwork)) mux.Handle("POST /networks/{id}/members", withAuth(h.AddMembersToNetwork)) + mux.Handle("PUT /networks/{id}/message-retention", withAuth(h.SetMessageRetentionHours)) // mux.Handle("DELETE /networks/{id}/members/{humanId}", withAuth(h.RemoveMemberFromNetwork)) // Network Invitations diff --git a/go/internal/handler/handler.go b/go/internal/handler/handler.go index 6f5a34f..c59687c 100644 --- a/go/internal/handler/handler.go +++ b/go/internal/handler/handler.go @@ -48,11 +48,12 @@ type Human struct { } type Network struct { - Id string `json:"id"` - Name string `json:"name"` - AdminHuman Human `json:"admin_human"` - Humans []Human `json:"humans"` - CreatedAt time.Time `json:"created_at"` + Id string `json:"id"` + Name string `json:"name"` + AdminHuman Human `json:"admin_human"` + Humans []Human `json:"humans"` + MessageRetentionHours int `json:"message_retention_hours"` + CreatedAt time.Time `json:"created_at"` } // Auth Request/Response DTOs @@ -85,6 +86,10 @@ type SetOpenStreamCapacityRequest struct { Capacity int `json:"capacity"` } +type SetMessageRetentionHoursRequest struct { + Hours int `json:"hours"` +} + type MembersRequest struct { Emails []string `json:"emails"` } @@ -649,6 +654,72 @@ func (h *Handler) RevokeInvitation(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +// SetMessageRetentionHours updates the message retention window for a network (admin-only) +func (h *Handler) SetMessageRetentionHours(w http.ResponseWriter, r *http.Request) { + humanId, ok := middleware.HumanIdFromContext(r.Context()) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + networkID := r.PathValue("id") + if networkID == "" { + http.Error(w, "network id is required", http.StatusBadRequest) + return + } + + // Fetch network to verify admin + net, err := h.networkSvc.GetByID(r.Context(), networkID) + if err != nil { + if errors.Is(err, network.ErrNotFound) { + http.Error(w, "network not found", http.StatusNotFound) + return + } + slog.Error("failed to get network", "error", err, "network_id", networkID) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + if net.AdminHumanId != humanId { + http.Error(w, "only the network admin can change this setting", http.StatusForbidden) + return + } + + var req SetMessageRetentionHoursRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + if err := h.networkSvc.SetMessageRetentionHours(r.Context(), networkID, req.Hours); err != nil { + if errors.Is(err, network.ErrInvalidRetentionHours) { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + slog.Error("failed to set message retention hours", "error", err, "network_id", networkID) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + // Return updated network + updatedNet, err := h.networkSvc.GetByID(r.Context(), networkID) + if err != nil { + slog.Error("failed to get network after update", "error", err, "network_id", networkID) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + resp, err := h.networkToDTO(r.Context(), updatedNet) + if err != nil { + slog.Error("failed to convert network to DTO", "error", err, "network_id", networkID) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + // DownloadParticleMedia returns a fresh signed download URL for media/file particles func (h *Handler) DownloadParticleMedia(w http.ResponseWriter, r *http.Request) { _, ok := middleware.EmailFromContext(r.Context()) @@ -960,11 +1031,12 @@ func (h *Handler) networkToDTO(ctx context.Context, n *network.Network) (Network } return Network{ - Id: n.ID, - Name: n.Name, - AdminHuman: humanToDTO(adminHuman), - Humans: humans, - CreatedAt: n.CreatedAt, + Id: n.ID, + Name: n.Name, + AdminHuman: humanToDTO(adminHuman), + Humans: humans, + MessageRetentionHours: n.MessageRetentionHours, + CreatedAt: n.CreatedAt, }, nil } diff --git a/go/internal/network/models.go b/go/internal/network/models.go index 3483023..7753400 100644 --- a/go/internal/network/models.go +++ b/go/internal/network/models.go @@ -3,13 +3,14 @@ package network import "time" type Network struct { - ID string - Name string - AdminHumanId string - MemberHumanIds []string - OpenStreamCapacity int - OpenStreamCount int - CreatedAt time.Time + ID string + Name string + AdminHumanId string + MemberHumanIds []string + OpenStreamCapacity int + OpenStreamCount int + MessageRetentionHours int + CreatedAt time.Time } type Invitation struct { diff --git a/go/internal/network/repository.go b/go/internal/network/repository.go index 8839a01..6c4fb6b 100644 --- a/go/internal/network/repository.go +++ b/go/internal/network/repository.go @@ -35,6 +35,7 @@ type repository interface { getMemberHumanIds(ctx context.Context, networkID string) ([]string, error) getNetworksForHuman(ctx context.Context, humanId string) ([]*Network, error) isMember(ctx context.Context, networkID, humanId string) (bool, error) + updateMessageRetentionHours(ctx context.Context, id string, hours int) error // Invitations createInvitation(ctx context.Context, networkID, email string) error @@ -60,9 +61,9 @@ func (r *repositoryImpl) create(ctx context.Context, name, adminHumanId string) var n Network err = r.pool.QueryRow(ctx, `INSERT INTO networks (id, name, admin_human_id) VALUES ($1, $2, $3) - RETURNING id, name, admin_human_id, open_stream_capacity, open_stream_count, created_at`, + RETURNING id, name, admin_human_id, open_stream_capacity, open_stream_count, message_retention_hours, created_at`, id.String(), name, adminHumanId, - ).Scan(&n.ID, &n.Name, &n.AdminHumanId, &n.OpenStreamCapacity, &n.OpenStreamCount, &n.CreatedAt) + ).Scan(&n.ID, &n.Name, &n.AdminHumanId, &n.OpenStreamCapacity, &n.OpenStreamCount, &n.MessageRetentionHours, &n.CreatedAt) if err != nil { return nil, err } @@ -74,9 +75,9 @@ func (r *repositoryImpl) create(ctx context.Context, name, adminHumanId string) func (r *repositoryImpl) getByID(ctx context.Context, id string) (*Network, error) { var n Network err := r.pool.QueryRow(ctx, - `SELECT id, name, admin_human_id, open_stream_capacity, open_stream_count, created_at FROM networks WHERE id = $1`, + `SELECT id, name, admin_human_id, open_stream_capacity, open_stream_count, message_retention_hours, created_at FROM networks WHERE id = $1`, id, - ).Scan(&n.ID, &n.Name, &n.AdminHumanId, &n.OpenStreamCapacity, &n.OpenStreamCount, &n.CreatedAt) + ).Scan(&n.ID, &n.Name, &n.AdminHumanId, &n.OpenStreamCapacity, &n.OpenStreamCount, &n.MessageRetentionHours, &n.CreatedAt) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, errNotFound @@ -157,7 +158,7 @@ func (r *repositoryImpl) getMemberHumanIds(ctx context.Context, networkID string func (r *repositoryImpl) getNetworksForHuman(ctx context.Context, humanId string) ([]*Network, error) { rows, err := r.pool.Query(ctx, - `SELECT n.id, n.name, n.admin_human_id, n.open_stream_capacity, n.open_stream_count, n.created_at + `SELECT n.id, n.name, n.admin_human_id, n.open_stream_capacity, n.open_stream_count, n.message_retention_hours, n.created_at FROM networks n WHERE n.admin_human_id = $1 OR EXISTS (SELECT 1 FROM network_members nm WHERE nm.network_id = n.id AND nm.human_id = $1)`, @@ -171,7 +172,7 @@ func (r *repositoryImpl) getNetworksForHuman(ctx context.Context, humanId string var networks []*Network for rows.Next() { var n Network - if err := rows.Scan(&n.ID, &n.Name, &n.AdminHumanId, &n.OpenStreamCapacity, &n.OpenStreamCount, &n.CreatedAt); err != nil { + if err := rows.Scan(&n.ID, &n.Name, &n.AdminHumanId, &n.OpenStreamCapacity, &n.OpenStreamCount, &n.MessageRetentionHours, &n.CreatedAt); err != nil { return nil, err } networks = append(networks, &n) @@ -268,3 +269,17 @@ func (r *repositoryImpl) deleteInvitation(ctx context.Context, networkID, email ) return err } + +func (r *repositoryImpl) updateMessageRetentionHours(ctx context.Context, id string, hours int) error { + result, err := r.pool.Exec(ctx, + `UPDATE networks SET message_retention_hours = $1 WHERE id = $2`, + hours, id, + ) + if err != nil { + return err + } + if result.RowsAffected() == 0 { + return errNotFound + } + return nil +} diff --git a/go/internal/network/service.go b/go/internal/network/service.go index 37715d7..cefae1f 100644 --- a/go/internal/network/service.go +++ b/go/internal/network/service.go @@ -13,6 +13,7 @@ import ( var ErrNotFound = errors.New("network not found") var ErrInvalidName = errors.New("name cannot be empty") var ErrCapacityExceeded = errors.New("active stream capacity exceeded") +var ErrInvalidRetentionHours = errors.New("message retention hours must be between 24 and 336") type Service interface { // Create creates a network and adds adminHumanId as the first member. Returns ErrInvalidName if name is empty. @@ -25,6 +26,8 @@ type Service interface { RemoveMember(ctx context.Context, networkID, humanId string) error ListForHuman(ctx context.Context, humanId string) ([]*Network, error) IsMember(ctx context.Context, networkID, humanId string) (bool, error) + // SetMessageRetentionHours sets how long messages remain visible (24–336 hours). + SetMessageRetentionHours(ctx context.Context, id string, hours int) error // Invitations (email-based, for users who haven't registered yet) InviteByEmail(ctx context.Context, networkID string, emails []string) error @@ -115,6 +118,17 @@ func (s *serviceImpl) IsMember(ctx context.Context, networkID, humanId string) ( return s.repo.isMember(ctx, networkID, humanId) } +func (s *serviceImpl) SetMessageRetentionHours(ctx context.Context, id string, hours int) error { + if hours < 24 || hours > 336 { + return ErrInvalidRetentionHours + } + err := s.repo.updateMessageRetentionHours(ctx, id, hours) + if errors.Is(err, errNotFound) { + return ErrNotFound + } + return err +} + // Invitation methods func (s *serviceImpl) InviteByEmail(ctx context.Context, networkID string, emails []string) error { diff --git a/go/internal/speech/service.go b/go/internal/speech/service.go index d14ea66..0a88c75 100644 --- a/go/internal/speech/service.go +++ b/go/internal/speech/service.go @@ -49,9 +49,9 @@ func NewSpeechService(ctx context.Context) SpeechService { func (s *speechServiceImpl) Transcribe(ctx context.Context, mediaUrl string) (*TranscriptResult, error) { options := &interfaces.PreRecordedTranscriptionOptions{ - Model: "nova-3", + Model: "nova-3", SmartFormat: true, - Paragraphs: true, + Paragraphs: true, } response, err := s.deepgramClient.FromURL(ctx, mediaUrl, options) diff --git a/go/migrations/000011_message_retention.down.sql b/go/migrations/000011_message_retention.down.sql new file mode 100644 index 0000000..fb13dc1 --- /dev/null +++ b/go/migrations/000011_message_retention.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE networks + DROP COLUMN IF EXISTS message_retention_hours; diff --git a/go/migrations/000011_message_retention.up.sql b/go/migrations/000011_message_retention.up.sql new file mode 100644 index 0000000..765cb14 --- /dev/null +++ b/go/migrations/000011_message_retention.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE networks + ADD COLUMN message_retention_hours INTEGER NOT NULL DEFAULT 24; diff --git a/js/src/api/client.ts b/js/src/api/client.ts index 772537a..0adcd26 100644 --- a/js/src/api/client.ts +++ b/js/src/api/client.ts @@ -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) { diff --git a/js/src/api/types.ts b/js/src/api/types.ts index 748d9a4..61edebf 100644 --- a/js/src/api/types.ts +++ b/js/src/api/types.ts @@ -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(), }); diff --git a/js/src/components/ui/slider.tsx b/js/src/components/ui/slider.tsx new file mode 100644 index 0000000..5fa21f0 --- /dev/null +++ b/js/src/components/ui/slider.tsx @@ -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) { + const _values = React.useMemo( + () => + Array.isArray(value) + ? value + : Array.isArray(defaultValue) + ? defaultValue + : [min, max], + [value, defaultValue, min, max] + ) + + return ( + + + + + {Array.from({ length: _values.length }, (_, index) => ( + + ))} + + ) +} + +export { Slider } diff --git a/js/src/features/network-settings.tsx b/js/src/features/network-settings.tsx index f96e90b..0368fb8 100644 --- a/js/src/features/network-settings.tsx +++ b/js/src/features/network-settings.tsx @@ -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>(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 ( +
+
+

Messages disappear after

+

{formatRetentionDays(days * 24)}

+
+ + + Older messages are no longer visible to anyone. + +
+ ); +} + 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() { ))} - - - - - + {isAdmin && network && ( + <> + + + + + + )} - - {invitations && invitations.length > 0 ? ( - invitations.map((inv, index) => ( -
- - {index < invitations.length - 1 && ( - - )} -
- )) - ) : ( -

- No pending invitations -

- )} -
+ {isAdmin && network && ( + <> + + + + + + + + {invitations && invitations.length > 0 ? ( + invitations.map((inv, index) => ( +
+ + {index < invitations.length - 1 && ( + + )} +
+ )) + ) : ( +

+ No pending invitations +

+ )} +
+ + )} ); diff --git a/js/src/hooks/use-network-settings.ts b/js/src/hooks/use-network-settings.ts new file mode 100644 index 0000000..002c936 --- /dev/null +++ b/js/src/hooks/use-network-settings.ts @@ -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"] }); + }, + }); +} diff --git a/js/src/hooks/use-stream-particles.ts b/js/src/hooks/use-stream-particles.ts index c2c408b..dac1544 100644 --- a/js/src/hooks/use-stream-particles.ts +++ b/js/src/hooks/use-stream-particles.ts @@ -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, diff --git a/js/src/hooks/use-stream-playback.ts b/js/src/hooks/use-stream-playback.ts index 1393d79..6abd9f6 100644 --- a/js/src/hooks/use-stream-playback.ts +++ b/js/src/hooks/use-stream-playback.ts @@ -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(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); }); diff --git a/js/src/lib/constants.ts b/js/src/lib/constants.ts index c92668a..6a50eee 100644 --- a/js/src/lib/constants.ts +++ b/js/src/lib/constants.ts @@ -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;