diff --git a/go/internal/handler/handler.go b/go/internal/handler/handler.go index cbb15a0..c67a32a 100644 --- a/go/internal/handler/handler.go +++ b/go/internal/handler/handler.go @@ -87,9 +87,10 @@ type MembersRequest struct { } type Invitation struct { - NetworkId string `json:"network_id"` - Email string `json:"email"` - CreatedAt time.Time `json:"created_at"` + NetworkId string `json:"network_id"` + NetworkName string `json:"network_name"` + Email string `json:"email"` + CreatedAt time.Time `json:"created_at"` } type AcceptInvitationRequest struct { @@ -527,9 +528,10 @@ func (h *Handler) ListInvitationsForNetwork(w http.ResponseWriter, r *http.Reque resp := make([]Invitation, 0, len(invitations)) for _, inv := range invitations { resp = append(resp, Invitation{ - NetworkId: inv.NetworkID, - Email: inv.Email, - CreatedAt: inv.CreatedAt, + NetworkId: inv.NetworkID, + NetworkName: inv.NetworkName, + Email: inv.Email, + CreatedAt: inv.CreatedAt, }) } @@ -555,9 +557,10 @@ func (h *Handler) ListMyInvitations(w http.ResponseWriter, r *http.Request) { resp := make([]Invitation, 0, len(invitations)) for _, inv := range invitations { resp = append(resp, Invitation{ - NetworkId: inv.NetworkID, - Email: inv.Email, - CreatedAt: inv.CreatedAt, + NetworkId: inv.NetworkID, + NetworkName: inv.NetworkName, + Email: inv.Email, + CreatedAt: inv.CreatedAt, }) } diff --git a/go/internal/network/models.go b/go/internal/network/models.go index 6eedbd7..3483023 100644 --- a/go/internal/network/models.go +++ b/go/internal/network/models.go @@ -13,7 +13,8 @@ type Network struct { } type Invitation struct { - NetworkID string - Email string - CreatedAt time.Time + NetworkID string + NetworkName string + Email string + CreatedAt time.Time } diff --git a/go/internal/network/repository.go b/go/internal/network/repository.go index e846309..8839a01 100644 --- a/go/internal/network/repository.go +++ b/go/internal/network/repository.go @@ -215,7 +215,10 @@ func (r *repositoryImpl) createInvitation(ctx context.Context, networkID, email func (r *repositoryImpl) getInvitationsByEmail(ctx context.Context, email string) ([]*Invitation, error) { rows, err := r.pool.Query(ctx, - `SELECT network_id, email, created_at FROM network_invitations WHERE email = $1`, + `SELECT ni.network_id, n.name, ni.email, ni.created_at + FROM network_invitations ni + JOIN networks n ON n.id = ni.network_id + WHERE ni.email = $1`, email, ) if err != nil { @@ -226,7 +229,7 @@ func (r *repositoryImpl) getInvitationsByEmail(ctx context.Context, email string var invitations []*Invitation for rows.Next() { var inv Invitation - if err := rows.Scan(&inv.NetworkID, &inv.Email, &inv.CreatedAt); err != nil { + if err := rows.Scan(&inv.NetworkID, &inv.NetworkName, &inv.Email, &inv.CreatedAt); err != nil { return nil, err } invitations = append(invitations, &inv) @@ -236,7 +239,10 @@ func (r *repositoryImpl) getInvitationsByEmail(ctx context.Context, email string func (r *repositoryImpl) getInvitationsByNetwork(ctx context.Context, networkID string) ([]*Invitation, error) { rows, err := r.pool.Query(ctx, - `SELECT network_id, email, created_at FROM network_invitations WHERE network_id = $1`, + `SELECT ni.network_id, n.name, ni.email, ni.created_at + FROM network_invitations ni + JOIN networks n ON n.id = ni.network_id + WHERE ni.network_id = $1`, networkID, ) if err != nil { @@ -247,7 +253,7 @@ func (r *repositoryImpl) getInvitationsByNetwork(ctx context.Context, networkID var invitations []*Invitation for rows.Next() { var inv Invitation - if err := rows.Scan(&inv.NetworkID, &inv.Email, &inv.CreatedAt); err != nil { + if err := rows.Scan(&inv.NetworkID, &inv.NetworkName, &inv.Email, &inv.CreatedAt); err != nil { return nil, err } invitations = append(invitations, &inv) diff --git a/js/package.json b/js/package.json index c53f28a..c6da2ca 100644 --- a/js/package.json +++ b/js/package.json @@ -54,11 +54,13 @@ "electron-squirrel-startup": "^1.0.1", "firebase": "^12.10.0", "lucide-react": "^0.575.0", + "next-themes": "^0.4.6", "radix-ui": "^1.4.3", "react": "^19.2.4", "react-dom": "^19.2.4", "react-router-dom": "^7.13.0", "shadcn": "^3.8.5", + "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.0", "tw-animate-css": "^1.4.0", diff --git a/js/src/App.tsx b/js/src/App.tsx index d83c8b4..a7d3f43 100644 --- a/js/src/App.tsx +++ b/js/src/App.tsx @@ -13,7 +13,9 @@ import NetworkSelector from "@/features/network-selector"; import NetworkRoot from "@/features/network-root"; import ParticleViewResolver from "@/features/particles/particle-view-resolver"; import Layout from "@/features/layout"; +import NetworkSettingsPage from "@/features/network-settings"; import { Button } from "@/components/ui/button"; +import { Toaster } from "@/components/ui/sonner"; const queryClient = new QueryClient(); @@ -50,6 +52,7 @@ function AuthenticatedApp() { } /> } /> + } /> } /> @@ -62,6 +65,7 @@ const AppWithProviders = () => ( + ); diff --git a/js/src/api/client.ts b/js/src/api/client.ts index 9794b76..772537a 100644 --- a/js/src/api/client.ts +++ b/js/src/api/client.ts @@ -3,16 +3,19 @@ import type { z } from "zod"; import { DepotObjectSchema, HumanSchema, + ListInvitationsResponseSchema, ListNetworksResponseSchema, NetworkSchema, PrepareUploadResponseSchema, SignInResponseSchema, } from "./types"; import type { + AcceptInvitationRequest, AddMembersRequest, CreateNetworkRequest, PrepareUploadRequest, RequestCodeRequest, + RevokeInvitationRequest, SignInRequest, } from "./types"; @@ -173,6 +176,32 @@ class ApiClient { `/networks/${networkId}/members/${email}`, ); } + + // --- Invitations --- + + async listNetworkInvitations(networkId: string) { + return this.request( + ListInvitationsResponseSchema, + "GET", + `/networks/${networkId}/invitations`, + ); + } + + async listMyInvitations() { + return this.request( + ListInvitationsResponseSchema, + "GET", + "/invitations", + ); + } + + async acceptInvitation(data: AcceptInvitationRequest): Promise { + await this.requestVoid("POST", "/invitations/accept", data); + } + + async revokeInvitation(networkId: string, data: RevokeInvitationRequest): Promise { + await this.requestVoid("DELETE", `/networks/${networkId}/invitations`, data); + } } export const apiClient = new ApiClient({ diff --git a/js/src/api/types.ts b/js/src/api/types.ts index 22ce879..7eaf334 100644 --- a/js/src/api/types.ts +++ b/js/src/api/types.ts @@ -34,6 +34,21 @@ const AddMembersRequestSchema = z.object({ }); export type AddMembersRequest = z.infer; +// --- Invitation types --- + +export const InvitationSchema = z.object({ + network_id: z.string(), + network_name: z.string(), + email: z.string(), + created_at: z.coerce.date(), +}); +export type Invitation = z.infer; + +export const ListInvitationsResponseSchema = z.array(InvitationSchema); + +export type AcceptInvitationRequest = { network_id: string }; +export type RevokeInvitationRequest = { email: string }; + // --- Depot types --- const PrepareUploadRequestSchema = z.object({ diff --git a/js/src/components/ui/sonner.tsx b/js/src/components/ui/sonner.tsx new file mode 100644 index 0000000..30b5a7c --- /dev/null +++ b/js/src/components/ui/sonner.tsx @@ -0,0 +1,44 @@ +import { Toaster as Sonner, type ToasterProps } from "sonner" +import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react" + +const Toaster = ({ ...props }: ToasterProps) => { + return ( + + ), + info: ( + + ), + warning: ( + + ), + error: ( + + ), + loading: ( + + ), + }} + style={ + { + "--normal-bg": "var(--popover)", + "--normal-text": "var(--popover-foreground)", + "--normal-border": "var(--border)", + "--border-radius": "var(--radius)", + } as React.CSSProperties + } + toastOptions={{ + classNames: { + toast: "cn-toast", + }, + }} + {...props} + /> + ) +} + +export { Toaster } diff --git a/js/src/features/layout.tsx b/js/src/features/layout.tsx index ad3e6ec..12f99f8 100644 --- a/js/src/features/layout.tsx +++ b/js/src/features/layout.tsx @@ -1,7 +1,7 @@ import { WindowControls } from "@/components/window-controls"; import { Button } from "@/components/ui/button"; import { useNavigate, useParams } from "react-router-dom"; -import { Home, Settings, Volume2, VolumeOff } from "lucide-react"; +import { Home, Settings, Users, Volume2, VolumeOff } from "lucide-react"; import { Toggle } from "@/components/ui/toggle"; import { useAutoplayStore } from "@/stores/autoplay-store"; import { @@ -137,6 +137,17 @@ function TopBar() { + {networkId && ( + + )} + ); } +function InvitationRow({ invitation }: { invitation: Invitation }) { + const acceptInvitation = useAcceptInvitation(); + const initials = invitation.network_name.slice(0, 2).toUpperCase(); + + const handleAccept = () => { + acceptInvitation.mutate(invitation.network_id, { + onSuccess: () => { + toast.success(`Joined ${invitation.network_name}`); + }, + onError: (err) => { + toast.error(err.message || "Failed to accept invitation"); + }, + }); + }; + + return ( +
+ + + {initials} + + +
+

+ {invitation.network_name} +

+ You've been invited +
+ +
+ ); +} + +function CreateNetworkDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const [name, setName] = useState(""); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const createNetwork = useMutation({ + mutationFn: (networkName: string) => + apiClient.createNetwork({ name: networkName }), + onSuccess: (network) => { + queryClient.invalidateQueries({ queryKey: ["networks"] }); + toast.success(`Created ${network.name}`); + onOpenChange(false); + setName(""); + navigate(`/${network.id}/settings`); + }, + onError: (err) => { + toast.error(err.message || "Failed to create network"); + }, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = name.trim(); + if (!trimmed) return; + createNetwork.mutate(trimmed); + }; + + return ( + + + + Create Network + +
+
+ setName(e.target.value)} + autoFocus + /> +
+ + + + +
+
+
+ ); +} + export default function NetworkSelector() { const navigate = useNavigate(); + const [createDialogOpen, setCreateDialogOpen] = useState(false); - const { data, isPending, error } = useNetworks(); + const { data: networks, isPending, error } = useNetworks(); + const { data: invitations } = useMyInvitations(); if (isPending) { return ; @@ -60,33 +205,96 @@ export default function NetworkSelector() { ); } - if (data?.length === 0) { + const hasInvitations = invitations && invitations.length > 0; + const hasNetworks = networks && networks.length > 0; + + if (!hasNetworks && !hasInvitations) { return (

- You don't have access to any networks yet. Please email us to get - started. + You don't have access to any networks yet. Create one or ask your admin for an invite.

- - team@flowylabs.ai - + +
); } return ( - -
- {data?.map((network, index) => ( -
- navigate(`/${network.id}`)} - /> - {index < data.length - 1 && } + <> + +
+ {hasInvitations && ( + <> +
+

+ Pending Invitations +

+ + {invitations.length} + +
+ {invitations.map((inv, index) => ( +
+ + {index < invitations.length - 1 && ( + + )} +
+ ))} + + + )} + + {hasNetworks && ( + <> + {hasInvitations && ( +

+ Your Networks +

+ )} + {networks.map((network, index) => ( +
+ navigate(`/${network.id}`)} + onSettingsClick={() => + navigate(`/${network.id}/settings`) + } + /> + {index < networks.length - 1 && ( + + )} +
+ ))} + + )} + + +
+
- ))} -
-
+
+ + + + ); } diff --git a/js/src/features/network-settings.tsx b/js/src/features/network-settings.tsx new file mode 100644 index 0000000..f96e90b --- /dev/null +++ b/js/src/features/network-settings.tsx @@ -0,0 +1,220 @@ +import { useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { ArrowLeft, Mail, Shield, X } from "lucide-react"; +import { toast } from "sonner"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +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 { Muted } from "@/components/ui/typography"; +import { WindowControls } from "@/components/window-controls"; +import { useNetworks } from "@/hooks/use-networks"; +import { + useNetworkInvitations, + useInviteMembers, + useRevokeInvitation, +} from "@/hooks/use-invitations"; +import type { Human } from "@/api/types"; + +function MemberRow({ + human, + isAdmin, +}: { + human: Human; + isAdmin: boolean; +}) { + const initials = human.email_prefix.slice(0, 2).toUpperCase(); + + return ( +
+ + + {initials} + + +
+

{human.email_prefix}

+ {human.email} +
+ {isAdmin && ( + + + Admin + + )} +
+ ); +} + +function InviteForm({ networkId }: { networkId: string }) { + const [email, setEmail] = useState(""); + const inviteMembers = useInviteMembers(networkId); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = email.trim(); + if (!trimmed) return; + + inviteMembers.mutate([trimmed], { + onSuccess: () => { + toast.success(`Invitation sent to ${trimmed}`); + setEmail(""); + }, + onError: (err) => { + toast.error(err.message || "Failed to send invitation"); + }, + }); + }; + + return ( +
+ setEmail(e.target.value)} + className="flex-1" + /> + +
+ ); +} + +function PendingInvitationRow({ + email, + networkId, +}: { + email: string; + networkId: string; +}) { + const revokeInvitation = useRevokeInvitation(networkId); + + const handleRevoke = () => { + revokeInvitation.mutate(email, { + onSuccess: () => { + toast.success(`Invitation to ${email} revoked`); + }, + onError: (err) => { + toast.error(err.message || "Failed to revoke invitation"); + }, + }); + }; + + return ( +
+ + + +
+

{email}

+ Pending +
+ +
+ ); +} + +function SettingsGroup({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( +
+

+ {title} +

+
{children}
+
+ ); +} + +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 networkName = network?.name ?? "Network"; + + return ( +
+
+ + + {networkName} +
+
+ + + + {network?.humans.map((human, index) => ( +
+ + {index < network.humans.length - 1 && ( + + )} +
+ ))} +
+ + + + + + + + + + + {invitations && invitations.length > 0 ? ( + invitations.map((inv, index) => ( +
+ + {index < invitations.length - 1 && ( + + )} +
+ )) + ) : ( +

+ No pending invitations +

+ )} +
+
+
+ ); +} diff --git a/js/src/features/particles/particle-list-view.tsx b/js/src/features/particles/particle-list-view.tsx index 1e300d9..daf120c 100644 --- a/js/src/features/particles/particle-list-view.tsx +++ b/js/src/features/particles/particle-list-view.tsx @@ -295,6 +295,17 @@ export function ParticleListView({ path }: ParticleListViewProps) { return ; } + if (streams.length === 0) { + return ( +
+ +

+ No recent streams yet. Start a conversation using the keyboard shortcuts below. +

+
+ ); + } + return (
diff --git a/js/src/hooks/use-invitations.ts b/js/src/hooks/use-invitations.ts new file mode 100644 index 0000000..dbf1c42 --- /dev/null +++ b/js/src/hooks/use-invitations.ts @@ -0,0 +1,52 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { apiClient } from "@/api/client"; + +export function useMyInvitations() { + return useQuery({ + queryKey: ["my-invitations"], + queryFn: () => apiClient.listMyInvitations(), + refetchInterval: 10_000, + }); +} + +export function useNetworkInvitations(networkId: string) { + return useQuery({ + queryKey: ["network-invitations", networkId], + queryFn: () => apiClient.listNetworkInvitations(networkId), + }); +} + +export function useInviteMembers(networkId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (emailAddresses: string[]) => + apiClient.addMembers(networkId, { email_addresses: emailAddresses }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["network-invitations", networkId] }); + queryClient.invalidateQueries({ queryKey: ["networks"] }); + }, + }); +} + +export function useAcceptInvitation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (networkId: string) => + apiClient.acceptInvitation({ network_id: networkId }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["my-invitations"] }); + queryClient.invalidateQueries({ queryKey: ["networks"] }); + }, + }); +} + +export function useRevokeInvitation(networkId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (email: string) => + apiClient.revokeInvitation(networkId, { email }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["network-invitations", networkId] }); + }, + }); +} diff --git a/js/yarn.lock b/js/yarn.lock index 16553ea..9cc740c 100644 --- a/js/yarn.lock +++ b/js/yarn.lock @@ -6723,6 +6723,11 @@ neo-async@^2.6.2: resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== +next-themes@^0.4.6: + version "0.4.6" + resolved "https://registry.yarnpkg.com/next-themes/-/next-themes-0.4.6.tgz#8d7e92d03b8fea6582892a50a928c9b23502e8b6" + integrity sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA== + nice-try@^1.0.4: version "1.0.5" resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" @@ -8042,6 +8047,11 @@ socks@^2.6.2: ip-address "^10.0.1" smart-buffer "^4.2.0" +sonner@^2.0.7: + version "2.0.7" + resolved "https://registry.yarnpkg.com/sonner/-/sonner-2.0.7.tgz#810c1487a67ec3370126e0f400dfb9edddc3e4f6" + integrity sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w== + source-map-js@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"