@@ -114,6 +114,7 @@ func main() {
|
|||||||
mux.Handle("GET /networks", withAuth(h.ListNetworks))
|
mux.Handle("GET /networks", withAuth(h.ListNetworks))
|
||||||
mux.Handle("GET /networks/{id}", withAuth(h.GetNetwork))
|
mux.Handle("GET /networks/{id}", withAuth(h.GetNetwork))
|
||||||
mux.Handle("POST /networks/{id}/members", withAuth(h.AddMembersToNetwork))
|
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))
|
// mux.Handle("DELETE /networks/{id}/members/{humanId}", withAuth(h.RemoveMemberFromNetwork))
|
||||||
|
|
||||||
// Network Invitations
|
// Network Invitations
|
||||||
|
|||||||
@@ -48,11 +48,12 @@ type Human struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Network struct {
|
type Network struct {
|
||||||
Id string `json:"id"`
|
Id string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
AdminHuman Human `json:"admin_human"`
|
AdminHuman Human `json:"admin_human"`
|
||||||
Humans []Human `json:"humans"`
|
Humans []Human `json:"humans"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
MessageRetentionHours int `json:"message_retention_hours"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth Request/Response DTOs
|
// Auth Request/Response DTOs
|
||||||
@@ -85,6 +86,10 @@ type SetOpenStreamCapacityRequest struct {
|
|||||||
Capacity int `json:"capacity"`
|
Capacity int `json:"capacity"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SetMessageRetentionHoursRequest struct {
|
||||||
|
Hours int `json:"hours"`
|
||||||
|
}
|
||||||
|
|
||||||
type MembersRequest struct {
|
type MembersRequest struct {
|
||||||
Emails []string `json:"emails"`
|
Emails []string `json:"emails"`
|
||||||
}
|
}
|
||||||
@@ -649,6 +654,72 @@ func (h *Handler) RevokeInvitation(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
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
|
// DownloadParticleMedia returns a fresh signed download URL for media/file particles
|
||||||
func (h *Handler) DownloadParticleMedia(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) DownloadParticleMedia(w http.ResponseWriter, r *http.Request) {
|
||||||
_, ok := middleware.EmailFromContext(r.Context())
|
_, ok := middleware.EmailFromContext(r.Context())
|
||||||
@@ -960,11 +1031,12 @@ func (h *Handler) networkToDTO(ctx context.Context, n *network.Network) (Network
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Network{
|
return Network{
|
||||||
Id: n.ID,
|
Id: n.ID,
|
||||||
Name: n.Name,
|
Name: n.Name,
|
||||||
AdminHuman: humanToDTO(adminHuman),
|
AdminHuman: humanToDTO(adminHuman),
|
||||||
Humans: humans,
|
Humans: humans,
|
||||||
CreatedAt: n.CreatedAt,
|
MessageRetentionHours: n.MessageRetentionHours,
|
||||||
|
CreatedAt: n.CreatedAt,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ package network
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
type Network struct {
|
type Network struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
AdminHumanId string
|
AdminHumanId string
|
||||||
MemberHumanIds []string
|
MemberHumanIds []string
|
||||||
OpenStreamCapacity int
|
OpenStreamCapacity int
|
||||||
OpenStreamCount int
|
OpenStreamCount int
|
||||||
CreatedAt time.Time
|
MessageRetentionHours int
|
||||||
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type Invitation struct {
|
type Invitation struct {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ type repository interface {
|
|||||||
getMemberHumanIds(ctx context.Context, networkID string) ([]string, error)
|
getMemberHumanIds(ctx context.Context, networkID string) ([]string, error)
|
||||||
getNetworksForHuman(ctx context.Context, humanId string) ([]*Network, error)
|
getNetworksForHuman(ctx context.Context, humanId string) ([]*Network, error)
|
||||||
isMember(ctx context.Context, networkID, humanId string) (bool, error)
|
isMember(ctx context.Context, networkID, humanId string) (bool, error)
|
||||||
|
updateMessageRetentionHours(ctx context.Context, id string, hours int) error
|
||||||
|
|
||||||
// Invitations
|
// Invitations
|
||||||
createInvitation(ctx context.Context, networkID, email string) error
|
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
|
var n Network
|
||||||
err = r.pool.QueryRow(ctx,
|
err = r.pool.QueryRow(ctx,
|
||||||
`INSERT INTO networks (id, name, admin_human_id) VALUES ($1, $2, $3)
|
`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,
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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) {
|
func (r *repositoryImpl) getByID(ctx context.Context, id string) (*Network, error) {
|
||||||
var n Network
|
var n Network
|
||||||
err := r.pool.QueryRow(ctx,
|
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,
|
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 err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, errNotFound
|
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) {
|
func (r *repositoryImpl) getNetworksForHuman(ctx context.Context, humanId string) ([]*Network, error) {
|
||||||
rows, err := r.pool.Query(ctx,
|
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
|
FROM networks n
|
||||||
WHERE n.admin_human_id = $1
|
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)`,
|
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
|
var networks []*Network
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var n Network
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
networks = append(networks, &n)
|
networks = append(networks, &n)
|
||||||
@@ -268,3 +269,17 @@ func (r *repositoryImpl) deleteInvitation(ctx context.Context, networkID, email
|
|||||||
)
|
)
|
||||||
return err
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
var ErrNotFound = errors.New("network not found")
|
var ErrNotFound = errors.New("network not found")
|
||||||
var ErrInvalidName = errors.New("name cannot be empty")
|
var ErrInvalidName = errors.New("name cannot be empty")
|
||||||
var ErrCapacityExceeded = errors.New("active stream capacity exceeded")
|
var ErrCapacityExceeded = errors.New("active stream capacity exceeded")
|
||||||
|
var ErrInvalidRetentionHours = errors.New("message retention hours must be between 24 and 336")
|
||||||
|
|
||||||
type Service interface {
|
type Service interface {
|
||||||
// Create creates a network and adds adminHumanId as the first member. Returns ErrInvalidName if name is empty.
|
// 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
|
RemoveMember(ctx context.Context, networkID, humanId string) error
|
||||||
ListForHuman(ctx context.Context, humanId string) ([]*Network, error)
|
ListForHuman(ctx context.Context, humanId string) ([]*Network, error)
|
||||||
IsMember(ctx context.Context, networkID, humanId string) (bool, 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)
|
// Invitations (email-based, for users who haven't registered yet)
|
||||||
InviteByEmail(ctx context.Context, networkID string, emails []string) error
|
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)
|
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
|
// Invitation methods
|
||||||
|
|
||||||
func (s *serviceImpl) InviteByEmail(ctx context.Context, networkID string, emails []string) error {
|
func (s *serviceImpl) InviteByEmail(ctx context.Context, networkID string, emails []string) error {
|
||||||
|
|||||||
@@ -49,9 +49,9 @@ func NewSpeechService(ctx context.Context) SpeechService {
|
|||||||
|
|
||||||
func (s *speechServiceImpl) Transcribe(ctx context.Context, mediaUrl string) (*TranscriptResult, error) {
|
func (s *speechServiceImpl) Transcribe(ctx context.Context, mediaUrl string) (*TranscriptResult, error) {
|
||||||
options := &interfaces.PreRecordedTranscriptionOptions{
|
options := &interfaces.PreRecordedTranscriptionOptions{
|
||||||
Model: "nova-3",
|
Model: "nova-3",
|
||||||
SmartFormat: true,
|
SmartFormat: true,
|
||||||
Paragraphs: true,
|
Paragraphs: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
response, err := s.deepgramClient.FromURL(ctx, mediaUrl, options)
|
response, err := s.deepgramClient.FromURL(ctx, mediaUrl, options)
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE networks
|
||||||
|
DROP COLUMN IF EXISTS message_retention_hours;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE networks
|
||||||
|
ADD COLUMN message_retention_hours INTEGER NOT NULL DEFAULT 24;
|
||||||
@@ -177,6 +177,15 @@ class ApiClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setMessageRetentionHours(networkId: string, hours: number) {
|
||||||
|
return this.request(
|
||||||
|
NetworkSchema,
|
||||||
|
"PUT",
|
||||||
|
`/networks/${networkId}/message-retention`,
|
||||||
|
{ hours },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Invitations ---
|
// --- Invitations ---
|
||||||
|
|
||||||
async listNetworkInvitations(networkId: string) {
|
async listNetworkInvitations(networkId: string) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const NetworkSchema = z.object({
|
|||||||
name: z.string(),
|
name: z.string(),
|
||||||
admin_human: HumanSchema,
|
admin_human: HumanSchema,
|
||||||
humans: z.array(HumanSchema),
|
humans: z.array(HumanSchema),
|
||||||
|
message_retention_hours: z.number(),
|
||||||
created_at: z.coerce.date(),
|
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 { useNavigate, useParams } from "react-router-dom";
|
||||||
import { ArrowLeft, Mail, Shield, X } from "lucide-react";
|
import { ArrowLeft, Mail, Shield, X } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -8,14 +8,17 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Slider } from "@/components/ui/slider";
|
||||||
import { Muted } from "@/components/ui/typography";
|
import { Muted } from "@/components/ui/typography";
|
||||||
import { WindowControls } from "@/components/window-controls";
|
import { WindowControls } from "@/components/window-controls";
|
||||||
import { useNetworks } from "@/hooks/use-networks";
|
import { useNetworks } from "@/hooks/use-networks";
|
||||||
|
import { useSetMessageRetention } from "@/hooks/use-network-settings";
|
||||||
import {
|
import {
|
||||||
useNetworkInvitations,
|
useNetworkInvitations,
|
||||||
useInviteMembers,
|
useInviteMembers,
|
||||||
useRevokeInvitation,
|
useRevokeInvitation,
|
||||||
} from "@/hooks/use-invitations";
|
} from "@/hooks/use-invitations";
|
||||||
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import type { Human } from "@/api/types";
|
import type { Human } from "@/api/types";
|
||||||
|
|
||||||
function MemberRow({
|
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() {
|
export default function NetworkSettingsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { networkId } = useParams<{ networkId: string }>();
|
const { networkId } = useParams<{ networkId: string }>();
|
||||||
const { data: networks } = useNetworks();
|
const { data: networks } = useNetworks();
|
||||||
const network = networks?.find((n) => n.id === networkId);
|
const network = networks?.find((n) => n.id === networkId);
|
||||||
const { data: invitations } = useNetworkInvitations(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";
|
const networkName = network?.name ?? "Network";
|
||||||
|
|
||||||
@@ -187,33 +240,49 @@ export default function NetworkSettingsPage() {
|
|||||||
))}
|
))}
|
||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
|
|
||||||
<Separator className="mt-4" />
|
{isAdmin && network && (
|
||||||
|
<>
|
||||||
<SettingsGroup title="Invite">
|
<Separator className="mt-4" />
|
||||||
<InviteForm networkId={networkId!} />
|
<SettingsGroup title="Ephemerality">
|
||||||
</SettingsGroup>
|
<EphemeralitySettings
|
||||||
|
networkId={networkId!}
|
||||||
|
retentionHours={network.message_retention_hours}
|
||||||
|
/>
|
||||||
|
</SettingsGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Separator className="mt-4" />
|
<Separator className="mt-4" />
|
||||||
|
|
||||||
<SettingsGroup title="Pending Invitations">
|
{isAdmin && network && (
|
||||||
{invitations && invitations.length > 0 ? (
|
<>
|
||||||
invitations.map((inv, index) => (
|
<SettingsGroup title="Invite">
|
||||||
<div key={inv.email}>
|
<InviteForm networkId={networkId!} />
|
||||||
<PendingInvitationRow
|
</SettingsGroup>
|
||||||
email={inv.email}
|
|
||||||
networkId={networkId!}
|
<Separator className="mt-4" />
|
||||||
/>
|
|
||||||
{index < invitations.length - 1 && (
|
<SettingsGroup title="Pending Invitations">
|
||||||
<Separator className="mx-4" />
|
{invitations && invitations.length > 0 ? (
|
||||||
)}
|
invitations.map((inv, index) => (
|
||||||
</div>
|
<div key={inv.email}>
|
||||||
))
|
<PendingInvitationRow
|
||||||
) : (
|
email={inv.email}
|
||||||
<p className="text-muted-foreground px-4 py-3 text-sm">
|
networkId={networkId!}
|
||||||
No pending invitations
|
/>
|
||||||
</p>
|
{index < invitations.length - 1 && (
|
||||||
)}
|
<Separator className="mx-4" />
|
||||||
</SettingsGroup>
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground px-4 py-3 text-sm">
|
||||||
|
No pending invitations
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</SettingsGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</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 { parseParticlePath, type ParticlePath } from "@/lib/particle-path";
|
||||||
import type { Particle, StreamProperties } from "@/api/types";
|
import type { Particle, StreamProperties } from "@/api/types";
|
||||||
import { where, Timestamp } from "firebase/firestore";
|
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 };
|
type StreamParticle = Particle & { type: "stream"; properties: StreamProperties };
|
||||||
|
|
||||||
@@ -27,21 +27,28 @@ export function useStreamParticles(path: ParticlePath): UseStreamParticlesResult
|
|||||||
const { networkId } = parseParticlePath(path);
|
const { networkId } = parseParticlePath(path);
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const visibilityScopes = useVisibilityScopes(user?.id, networkId);
|
const visibilityScopes = useVisibilityScopes(user?.id, networkId);
|
||||||
|
const network = useNetwork(networkId);
|
||||||
|
const retentionHours = network?.message_retention_hours ?? 24;
|
||||||
|
|
||||||
const [recencyCutoff, setRecencyCutoff] = useState(() => {
|
const [recencyCutoff, setRecencyCutoff] = useState(() => {
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
d.setHours(d.getHours() - RECENCY_WINDOW_HOURS);
|
d.setHours(d.getHours() - retentionHours);
|
||||||
return Timestamp.fromDate(d);
|
return Timestamp.fromDate(d);
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Recalculate immediately when retention changes
|
||||||
|
const d = new Date();
|
||||||
|
d.setHours(d.getHours() - retentionHours);
|
||||||
|
setRecencyCutoff(Timestamp.fromDate(d));
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
d.setHours(d.getHours() - RECENCY_WINDOW_HOURS);
|
d.setHours(d.getHours() - retentionHours);
|
||||||
setRecencyCutoff(Timestamp.fromDate(d));
|
setRecencyCutoff(Timestamp.fromDate(d));
|
||||||
}, 60 * 60 * 1000);
|
}, 60 * 60 * 1000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, []);
|
}, [retentionHours]);
|
||||||
|
|
||||||
const { children, isLoading } = useLiveParticleChildren(
|
const { children, isLoading } = useLiveParticleChildren(
|
||||||
path,
|
path,
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import { useCallback, useEffect, useEffectEvent, useMemo, useReducer, useRef, us
|
|||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import type { Particle } from "@/api/types";
|
import type { Particle } from "@/api/types";
|
||||||
import { useLiveParticleChildren } from "@/hooks/use-particle";
|
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 { updateStreamPlaybackMarker } from "@/lib/firestore-particles";
|
||||||
import { where, Timestamp } from "firebase/firestore";
|
import { where, Timestamp } from "firebase/firestore";
|
||||||
import { RECENCY_WINDOW_HOURS } from "@/lib/constants";
|
import { useNetwork } from "@/hooks/use-networks";
|
||||||
|
|
||||||
// --- Playback reducer (ID-based) ---
|
// --- Playback reducer (ID-based) ---
|
||||||
|
|
||||||
@@ -96,6 +96,9 @@ export function useStreamPlayback(
|
|||||||
path: ParticlePath,
|
path: ParticlePath,
|
||||||
): UseStreamPlaybackResult {
|
): UseStreamPlaybackResult {
|
||||||
const userId = useAuthStore((s) => s.user?.id);
|
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);
|
const [state, dispatch] = useReducer(playbackReducer, initialState);
|
||||||
// Track the stream ID we've initialized for, to reset when navigating between streams
|
// Track the stream ID we've initialized for, to reset when navigating between streams
|
||||||
const initializedForRef = useRef<string | null>(null);
|
const initializedForRef = useRef<string | null>(null);
|
||||||
@@ -117,7 +120,7 @@ export function useStreamPlayback(
|
|||||||
|
|
||||||
const [recencyCutoff] = useState(() => {
|
const [recencyCutoff] = useState(() => {
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
d.setHours(d.getHours() - RECENCY_WINDOW_HOURS);
|
d.setHours(d.getHours() - retentionHours);
|
||||||
return Timestamp.fromDate(d);
|
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). */
|
/** Maximum file size for attachments (25 MB). */
|
||||||
export const MAX_ATTACHMENT_SIZE_BYTES = 25 * 1024 * 1024;
|
export const MAX_ATTACHMENT_SIZE_BYTES = 25 * 1024 * 1024;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user