From d932984047bc016777f3ce8d51dbef9d64624215 Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 10 Mar 2026 17:11:49 -0700 Subject: [PATCH] Add channel management e2e coverage (#24) --- desktop/src-tauri/src/lib.rs | 346 +++++-- desktop/src/app/AppShell.tsx | 61 +- desktop/src/features/channels/hooks.ts | 289 +++++- .../channels/ui/ChannelManagementSheet.tsx | 738 +++++++++++++++ desktop/src/features/chat/ui/ChatHeader.tsx | 5 + desktop/src/shared/api/tauri.ts | 150 +++ desktop/src/shared/api/types.ts | 57 ++ desktop/src/testing/e2eBridge.ts | 871 ++++++++++++++++-- desktop/tests/e2e/channels.spec.ts | 171 +++- desktop/tests/e2e/integration.spec.ts | 136 ++- 10 files changed, 2680 insertions(+), 144 deletions(-) create mode 100644 desktop/src/features/channels/ui/ChannelManagementSheet.tsx diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 0f0b442ef..e709babbe 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1,7 +1,8 @@ use std::sync::Mutex; use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, ToBech32}; -use serde::{Deserialize, Serialize}; +use reqwest::Method; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; use tauri_plugin_window_state::StateFlags; pub struct AppState { @@ -20,11 +21,60 @@ pub struct ChannelInfo { pub id: String, pub name: String, pub channel_type: String, + pub visibility: String, pub description: String, + pub topic: Option, + pub purpose: Option, + pub member_count: i64, + pub last_message_at: Option, + pub archived_at: Option, pub participants: Vec, pub participant_pubkeys: Vec, } +#[derive(Serialize, Deserialize)] +pub struct ChannelDetailInfo { + pub id: String, + pub name: String, + pub channel_type: String, + pub visibility: String, + pub description: String, + pub topic: Option, + pub topic_set_by: Option, + pub topic_set_at: Option, + pub purpose: Option, + pub purpose_set_by: Option, + pub purpose_set_at: Option, + pub created_by: String, + pub created_at: String, + pub updated_at: String, + pub archived_at: Option, + pub member_count: i64, + pub topic_required: bool, + pub max_members: Option, + pub nip29_group_id: Option, +} + +#[derive(Serialize, Deserialize)] +pub struct ChannelMemberInfo { + pub pubkey: String, + pub role: String, + pub joined_at: String, + pub display_name: Option, +} + +#[derive(Serialize, Deserialize)] +pub struct ChannelMembersResponse { + pub members: Vec, + pub next_cursor: Option, +} + +#[derive(Serialize, Deserialize)] +pub struct AddMembersResponse { + pub added: Vec, + pub errors: Vec, +} + #[derive(Serialize)] struct CreateChannelBody<'a> { name: &'a str, @@ -33,6 +83,31 @@ struct CreateChannelBody<'a> { description: Option<&'a str>, } +#[derive(Serialize)] +struct UpdateChannelBody<'a> { + #[serde(skip_serializing_if = "Option::is_none")] + name: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option<&'a str>, +} + +#[derive(Serialize)] +struct SetTopicBody<'a> { + topic: &'a str, +} + +#[derive(Serialize)] +struct SetPurposeBody<'a> { + purpose: &'a str, +} + +#[derive(Serialize)] +struct AddMembersBody<'a> { + pubkeys: &'a [String], + #[serde(skip_serializing_if = "Option::is_none")] + role: Option<&'a str>, +} + #[derive(Serialize)] struct GetFeedQuery<'a> { #[serde(skip_serializing_if = "Option::is_none")] @@ -116,15 +191,16 @@ fn relay_api_base_url() -> String { .replace("ws://", "http://") } -async fn build_authed_request( +fn build_authed_request( client: &reqwest::Client, + method: Method, path: &str, state: &AppState, ) -> Result { let pubkey_hex = auth_pubkey_header(state)?; let url = format!("{}{}", relay_api_base_url(), path); - Ok(client.get(url).header("X-Pubkey", pubkey_hex)) + Ok(client.request(method, url).header("X-Pubkey", pubkey_hex)) } fn auth_pubkey_header(state: &AppState) -> Result { @@ -145,6 +221,38 @@ async fn relay_error_message(response: reqwest::Response) -> String { format!("relay returned {status}: {body}") } +async fn send_json_request(request: reqwest::RequestBuilder) -> Result +where + T: DeserializeOwned, +{ + let response = request + .send() + .await + .map_err(|e| format!("request failed: {e}"))?; + + if !response.status().is_success() { + return Err(relay_error_message(response).await); + } + + response + .json::() + .await + .map_err(|e| format!("parse failed: {e}")) +} + +async fn send_empty_request(request: reqwest::RequestBuilder) -> Result<(), String> { + let response = request + .send() + .await + .map_err(|e| format!("request failed: {e}"))?; + + if !response.status().is_success() { + return Err(relay_error_message(response).await); + } + + Ok(()) +} + #[tauri::command] fn get_identity(state: tauri::State<'_, AppState>) -> Result { let keys = state.keys.lock().map_err(|e| e.to_string())?; @@ -216,20 +324,8 @@ fn create_auth_event( #[tauri::command] async fn get_channels(state: tauri::State<'_, AppState>) -> Result, String> { - let request = build_authed_request(&state.http_client, "/api/channels", &state).await?; - let response = request - .send() - .await - .map_err(|e| format!("request failed: {e}"))?; - - if !response.status().is_success() { - return Err(relay_error_message(response).await); - } - - response - .json::>() - .await - .map_err(|e| format!("parse failed: {e}")) + let request = build_authed_request(&state.http_client, Method::GET, "/api/channels", &state)?; + send_json_request(request).await } #[tauri::command] @@ -240,30 +336,154 @@ async fn create_channel( description: Option, state: tauri::State<'_, AppState>, ) -> Result { - let pubkey_hex = auth_pubkey_header(&state)?; - let url = format!("{}{}", relay_api_base_url(), "/api/channels"); - let response = state - .http_client - .post(url) - .header("X-Pubkey", pubkey_hex) + let request = build_authed_request(&state.http_client, Method::POST, "/api/channels", &state)? .json(&CreateChannelBody { name: &name, channel_type: &channel_type, visibility: &visibility, description: description.as_deref(), - }) - .send() - .await - .map_err(|e| format!("request failed: {e}"))?; + }); - if !response.status().is_success() { - return Err(relay_error_message(response).await); - } + send_json_request(request).await +} - response - .json::() - .await - .map_err(|e| format!("parse failed: {e}")) +#[tauri::command] +async fn get_channel_details( + channel_id: String, + state: tauri::State<'_, AppState>, +) -> Result { + let path = format!("/api/channels/{channel_id}"); + let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?; + send_json_request(request).await +} + +#[tauri::command] +async fn get_channel_members( + channel_id: String, + state: tauri::State<'_, AppState>, +) -> Result { + let path = format!("/api/channels/{channel_id}/members"); + let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?; + send_json_request(request).await +} + +#[tauri::command] +async fn update_channel( + channel_id: String, + name: Option, + description: Option, + state: tauri::State<'_, AppState>, +) -> Result { + let path = format!("/api/channels/{channel_id}"); + let request = build_authed_request(&state.http_client, Method::PUT, &path, &state)? + .json(&UpdateChannelBody { + name: name.as_deref(), + description: description.as_deref(), + }); + + send_json_request(request).await +} + +#[tauri::command] +async fn set_channel_topic( + channel_id: String, + topic: String, + state: tauri::State<'_, AppState>, +) -> Result<(), String> { + let path = format!("/api/channels/{channel_id}/topic"); + let request = build_authed_request(&state.http_client, Method::PUT, &path, &state)? + .json(&SetTopicBody { topic: &topic }); + send_empty_request(request).await +} + +#[tauri::command] +async fn set_channel_purpose( + channel_id: String, + purpose: String, + state: tauri::State<'_, AppState>, +) -> Result<(), String> { + let path = format!("/api/channels/{channel_id}/purpose"); + let request = build_authed_request(&state.http_client, Method::PUT, &path, &state)? + .json(&SetPurposeBody { purpose: &purpose }); + send_empty_request(request).await +} + +#[tauri::command] +async fn archive_channel( + channel_id: String, + state: tauri::State<'_, AppState>, +) -> Result<(), String> { + let path = format!("/api/channels/{channel_id}/archive"); + let request = build_authed_request(&state.http_client, Method::POST, &path, &state)?; + send_empty_request(request).await +} + +#[tauri::command] +async fn unarchive_channel( + channel_id: String, + state: tauri::State<'_, AppState>, +) -> Result<(), String> { + let path = format!("/api/channels/{channel_id}/unarchive"); + let request = build_authed_request(&state.http_client, Method::POST, &path, &state)?; + send_empty_request(request).await +} + +#[tauri::command] +async fn delete_channel( + channel_id: String, + state: tauri::State<'_, AppState>, +) -> Result<(), String> { + let path = format!("/api/channels/{channel_id}"); + let request = build_authed_request(&state.http_client, Method::DELETE, &path, &state)?; + send_empty_request(request).await +} + +#[tauri::command] +async fn add_channel_members( + channel_id: String, + pubkeys: Vec, + role: Option, + state: tauri::State<'_, AppState>, +) -> Result { + let path = format!("/api/channels/{channel_id}/members"); + let request = build_authed_request(&state.http_client, Method::POST, &path, &state)? + .json(&AddMembersBody { + pubkeys: &pubkeys, + role: role.as_deref(), + }); + + send_json_request(request).await +} + +#[tauri::command] +async fn remove_channel_member( + channel_id: String, + pubkey: String, + state: tauri::State<'_, AppState>, +) -> Result<(), String> { + let path = format!("/api/channels/{channel_id}/members/{pubkey}"); + let request = build_authed_request(&state.http_client, Method::DELETE, &path, &state)?; + send_empty_request(request).await +} + +#[tauri::command] +async fn join_channel( + channel_id: String, + state: tauri::State<'_, AppState>, +) -> Result<(), String> { + let path = format!("/api/channels/{channel_id}/join"); + let request = build_authed_request(&state.http_client, Method::POST, &path, &state)?; + send_empty_request(request).await +} + +#[tauri::command] +async fn leave_channel( + channel_id: String, + state: tauri::State<'_, AppState>, +) -> Result<(), String> { + let path = format!("/api/channels/{channel_id}/leave"); + let request = build_authed_request(&state.http_client, Method::POST, &path, &state)?; + send_empty_request(request).await } #[tauri::command] @@ -273,29 +493,14 @@ async fn get_feed( types: Option, state: tauri::State<'_, AppState>, ) -> Result { - let pubkey_hex = auth_pubkey_header(&state)?; - let url = format!("{}{}", relay_api_base_url(), "/api/feed"); - let response = state - .http_client - .get(url) - .header("X-Pubkey", pubkey_hex) + let request = build_authed_request(&state.http_client, Method::GET, "/api/feed", &state)? .query(&GetFeedQuery { since, limit, types: types.as_deref(), - }) - .send() - .await - .map_err(|e| format!("request failed: {e}"))?; + }); - if !response.status().is_success() { - return Err(relay_error_message(response).await); - } - - response - .json::() - .await - .map_err(|e| format!("parse failed: {e}")) + send_json_request(request).await } #[tauri::command] @@ -304,38 +509,23 @@ async fn search_messages( limit: Option, state: tauri::State<'_, AppState>, ) -> Result { - let pubkey_hex = auth_pubkey_header(&state)?; - let url = format!("{}{}", relay_api_base_url(), "/api/search"); - let response = state - .http_client - .get(url) - .header("X-Pubkey", pubkey_hex) + let request = build_authed_request(&state.http_client, Method::GET, "/api/search", &state)? .query(&SearchQueryParams { q: q.trim(), limit, - }) - .send() - .await - .map_err(|e| format!("request failed: {e}"))?; + }); - if !response.status().is_success() { - return Err(relay_error_message(response).await); - } - - response - .json::() - .await - .map_err(|e| format!("parse failed: {e}")) + send_json_request(request).await } #[tauri::command] async fn get_event(event_id: String, state: tauri::State<'_, AppState>) -> Result { let request = build_authed_request( &state.http_client, + Method::GET, &format!("/api/events/{event_id}"), &state, - ) - .await?; + )?; let response = request .send() .await @@ -373,6 +563,18 @@ pub fn run() { create_auth_event, get_channels, create_channel, + get_channel_details, + get_channel_members, + update_channel, + set_channel_topic, + set_channel_purpose, + archive_channel, + unarchive_channel, + delete_channel, + add_channel_members, + remove_channel_member, + join_channel, + leave_channel, get_feed, search_messages, get_event, diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index d452c86ac..17e3a4426 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { Settings2 } from "lucide-react"; import { ChatHeader } from "@/features/chat/ui/ChatHeader"; import { @@ -6,6 +7,7 @@ import { useChannelsQuery, useSelectedChannel, } from "@/features/channels/hooks"; +import { ChannelManagementSheet } from "@/features/channels/ui/ChannelManagementSheet"; import { useHomeFeedQuery } from "@/features/home/hooks"; import { HomeView } from "@/features/home/ui/HomeView"; import { @@ -22,6 +24,7 @@ import { AppSidebar } from "@/features/sidebar/ui/AppSidebar"; import { getEventById } from "@/shared/api/tauri"; import { useIdentityQuery } from "@/shared/api/hooks"; import type { RelayEvent, SearchHit } from "@/shared/api/types"; +import { Button } from "@/shared/ui/button"; import { SidebarInset, SidebarProvider } from "@/shared/ui/sidebar"; type AppView = "home" | "channel"; @@ -40,6 +43,8 @@ function createSearchAnchorEvent(hit: SearchHit): RelayEvent { export function AppShell() { const [selectedView, setSelectedView] = React.useState("home"); + const [isChannelManagementOpen, setIsChannelManagementOpen] = + React.useState(false); const [isSearchOpen, setIsSearchOpen] = React.useState(false); const [searchAnchor, setSearchAnchor] = React.useState( null, @@ -104,9 +109,17 @@ export function AppShell() { ); const channelDescription = activeChannel - ? activeChannel.channelType === "forum" - ? `${activeChannel.description} Forum channels are listed, but this first pass only wires message streams and DMs.` - : activeChannel.description + ? [ + activeChannel.archivedAt ? "Archived." : null, + activeChannel.topic, + activeChannel.description, + activeChannel.purpose, + activeChannel.channelType === "forum" + ? "Forum channels are listed, but this first pass only wires message streams and DMs." + : null, + ] + .filter((value) => value && value.trim().length > 0) + .join(" ") || "Channel details and activity." : "Connect to the relay to browse channels and read messages."; const contentPaneKey = selectedView === "home" ? "home" : `channel:${activeChannel?.id ?? "none"}`; @@ -203,6 +216,22 @@ export function AppShell() { /> ) : ( { + setIsChannelManagementOpen(true); + }} + size="sm" + type="button" + variant="outline" + > + + Manage + + ) : null + } channelType={activeChannel?.channelType} description={channelDescription} title={activeChannel?.name ?? "Channels"} @@ -260,6 +289,7 @@ export function AppShell() { channelName={activeChannel?.name ?? "channel"} disabled={ !activeChannel || + activeChannel.archivedAt !== null || activeChannel.channelType === "forum" || sendMessageMutation.isPending } @@ -269,11 +299,13 @@ export function AppShell() { await sendMessageMutation.mutateAsync(content); }} placeholder={ - activeChannel?.channelType === "forum" - ? "Forum posting is not wired in this pass." - : activeChannel - ? `Message #${activeChannel.name}` - : "Select a channel" + activeChannel?.archivedAt + ? "Archived channels are read-only." + : activeChannel?.channelType === "forum" + ? "Forum posting is not wired in this pass." + : activeChannel + ? `Message #${activeChannel.name}` + : "Select a channel" } /> @@ -286,6 +318,19 @@ export function AppShell() { onOpenChange={setIsSearchOpen} open={isSearchOpen} /> + + { + React.startTransition(() => { + setIsChannelManagementOpen(false); + setSelectedView("home"); + }); + }} + onOpenChange={setIsChannelManagementOpen} + open={isChannelManagementOpen && activeChannel !== null} + /> ); diff --git a/desktop/src/features/channels/hooks.ts b/desktop/src/features/channels/hooks.ts index e69774928..0ba7d5d8f 100644 --- a/desktop/src/features/channels/hooks.ts +++ b/desktop/src/features/channels/hooks.ts @@ -1,10 +1,37 @@ import * as React from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { createChannel, getChannels } from "@/shared/api/tauri"; -import type { Channel, CreateChannelInput } from "@/shared/api/types"; +import { + addChannelMembers, + archiveChannel, + createChannel, + deleteChannel, + getChannelDetails, + getChannelMembers, + getChannels, + joinChannel, + leaveChannel, + removeChannelMember, + setChannelPurpose, + setChannelTopic, + unarchiveChannel, + updateChannel, +} from "@/shared/api/tauri"; +import type { + AddChannelMembersInput, + Channel, + ChannelDetail, + CreateChannelInput, + SetChannelPurposeInput, + SetChannelTopicInput, + UpdateChannelInput, +} from "@/shared/api/types"; const channelsQueryKey = ["channels"] as const; +const channelDetailQueryKey = (channelId: string) => + ["channels", channelId, "detail"] as const; +const channelMembersQueryKey = (channelId: string) => + ["channels", channelId, "members"] as const; const channelTypeOrder = { stream: 0, forum: 1, @@ -24,6 +51,26 @@ function sortChannels(channels: Channel[]) { }); } +async function invalidateChannelState( + queryClient: ReturnType, + channelId: string | null | undefined, +) { + await queryClient.invalidateQueries({ queryKey: channelsQueryKey }); + + if (!channelId) { + return; + } + + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: channelDetailQueryKey(channelId), + }), + queryClient.invalidateQueries({ + queryKey: channelMembersQueryKey(channelId), + }), + ]); +} + export function useChannelsQuery() { return useQuery({ queryKey: channelsQueryKey, @@ -51,6 +98,244 @@ export function useCreateChannelMutation() { }); } +export function useChannelDetailsQuery( + channelId: string | null, + enabled = true, +) { + return useQuery({ + enabled: enabled && channelId !== null, + queryKey: ["channels", channelId ?? "none", "detail"], + queryFn: async () => { + if (!channelId) { + throw new Error("No channel selected."); + } + + return getChannelDetails(channelId); + }, + staleTime: 30_000, + }); +} + +export function useChannelMembersQuery( + channelId: string | null, + enabled = true, +) { + return useQuery({ + enabled: enabled && channelId !== null, + queryKey: ["channels", channelId ?? "none", "members"], + queryFn: async () => { + if (!channelId) { + throw new Error("No channel selected."); + } + + return getChannelMembers(channelId); + }, + staleTime: 30_000, + }); +} + +export function useUpdateChannelMutation(channelId: string | null) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input: Omit) => { + if (!channelId) { + throw new Error("No channel selected."); + } + + return updateChannel({ ...input, channelId }); + }, + onSuccess: (updatedChannel) => { + if (!channelId) { + return; + } + + queryClient.setQueryData( + channelDetailQueryKey(channelId), + updatedChannel, + ); + queryClient.setQueryData(channelsQueryKey, (current = []) => + sortChannels( + current.map((channel) => + channel.id === updatedChannel.id ? updatedChannel : channel, + ), + ), + ); + }, + onSettled: async () => { + await invalidateChannelState(queryClient, channelId); + }, + }); +} + +export function useSetChannelTopicMutation(channelId: string | null) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input: Omit) => { + if (!channelId) { + throw new Error("No channel selected."); + } + + return setChannelTopic({ ...input, channelId }); + }, + onSettled: async () => { + await invalidateChannelState(queryClient, channelId); + }, + }); +} + +export function useSetChannelPurposeMutation(channelId: string | null) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input: Omit) => { + if (!channelId) { + throw new Error("No channel selected."); + } + + return setChannelPurpose({ ...input, channelId }); + }, + onSettled: async () => { + await invalidateChannelState(queryClient, channelId); + }, + }); +} + +export function useArchiveChannelMutation(channelId: string | null) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async () => { + if (!channelId) { + throw new Error("No channel selected."); + } + + await archiveChannel(channelId); + }, + onSettled: async () => { + await invalidateChannelState(queryClient, channelId); + }, + }); +} + +export function useUnarchiveChannelMutation(channelId: string | null) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async () => { + if (!channelId) { + throw new Error("No channel selected."); + } + + await unarchiveChannel(channelId); + }, + onSettled: async () => { + await invalidateChannelState(queryClient, channelId); + }, + }); +} + +export function useDeleteChannelMutation(channelId: string | null) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async () => { + if (!channelId) { + throw new Error("No channel selected."); + } + + await deleteChannel(channelId); + }, + onSuccess: () => { + if (!channelId) { + return; + } + + queryClient.setQueryData(channelsQueryKey, (current = []) => + current.filter((channel) => channel.id !== channelId), + ); + queryClient.removeQueries({ + queryKey: channelDetailQueryKey(channelId), + }); + queryClient.removeQueries({ + queryKey: channelMembersQueryKey(channelId), + }); + }, + onSettled: async () => { + await queryClient.invalidateQueries({ queryKey: channelsQueryKey }); + }, + }); +} + +export function useAddChannelMembersMutation(channelId: string | null) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input: Omit) => { + if (!channelId) { + throw new Error("No channel selected."); + } + + return addChannelMembers({ ...input, channelId }); + }, + onSettled: async () => { + await invalidateChannelState(queryClient, channelId); + }, + }); +} + +export function useRemoveChannelMemberMutation(channelId: string | null) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (pubkey: string) => { + if (!channelId) { + throw new Error("No channel selected."); + } + + await removeChannelMember(channelId, pubkey); + }, + onSettled: async () => { + await invalidateChannelState(queryClient, channelId); + }, + }); +} + +export function useJoinChannelMutation(channelId: string | null) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async () => { + if (!channelId) { + throw new Error("No channel selected."); + } + + await joinChannel(channelId); + }, + onSettled: async () => { + await invalidateChannelState(queryClient, channelId); + }, + }); +} + +export function useLeaveChannelMutation(channelId: string | null) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async () => { + if (!channelId) { + throw new Error("No channel selected."); + } + + await leaveChannel(channelId); + }, + onSettled: async () => { + await invalidateChannelState(queryClient, channelId); + }, + }); +} + export function useSelectedChannel( channels: Channel[], preferredChannelId: string | null, diff --git a/desktop/src/features/channels/ui/ChannelManagementSheet.tsx b/desktop/src/features/channels/ui/ChannelManagementSheet.tsx new file mode 100644 index 000000000..5cb77c1fd --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelManagementSheet.tsx @@ -0,0 +1,738 @@ +import { + Archive, + ArchiveRestore, + Crown, + DoorClosed, + DoorOpen, + FileText, + Hash, + Lock, + MessageSquare, + Shield, + User, + UserPlus, + Users, +} from "lucide-react"; +import * as React from "react"; + +import { + useAddChannelMembersMutation, + useArchiveChannelMutation, + useChannelDetailsQuery, + useChannelMembersQuery, + useDeleteChannelMutation, + useJoinChannelMutation, + useLeaveChannelMutation, + useRemoveChannelMemberMutation, + useSetChannelPurposeMutation, + useSetChannelTopicMutation, + useUnarchiveChannelMutation, + useUpdateChannelMutation, +} from "@/features/channels/hooks"; +import type { Channel, ChannelMember } from "@/shared/api/types"; +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; +import { Separator } from "@/shared/ui/separator"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/shared/ui/sheet"; +import { Textarea } from "@/shared/ui/textarea"; + +type ChannelManagementSheetProps = { + channel: Channel | null; + currentPubkey?: string; + onDeleted?: () => void; + onOpenChange: (open: boolean) => void; + open: boolean; +}; + +const roleOptions: Array> = [ + "member", + "admin", + "guest", + "bot", +]; + +const roleOrder: Record = { + owner: 0, + admin: 1, + member: 2, + guest: 3, + bot: 4, +}; + +function formatPubkey(pubkey: string) { + return `${pubkey.slice(0, 8)}…${pubkey.slice(-4)}`; +} + +function formatMemberName(member: ChannelMember, currentPubkey?: string) { + if (currentPubkey && member.pubkey === currentPubkey) { + return "You"; + } + + return member.displayName ?? formatPubkey(member.pubkey); +} + +function Section({ + title, + description, + children, +}: React.PropsWithChildren<{ + title: string; + description?: string; +}>) { + return ( +
+
+

{title}

+ {description ? ( +

{description}

+ ) : null} +
+ {children} +
+ ); +} + +function MetadataPill({ + icon: Icon, + label, +}: { + icon: React.ComponentType<{ className?: string }>; + label: string; +}) { + return ( +
+ + {label} +
+ ); +} + +function roleIcon(role: ChannelMember["role"]) { + switch (role) { + case "owner": + return Crown; + case "admin": + return Shield; + default: + return User; + } +} + +export function ChannelManagementSheet({ + channel, + currentPubkey, + onDeleted, + onOpenChange, + open, +}: ChannelManagementSheetProps) { + const channelId = channel?.id ?? null; + const detailsQuery = useChannelDetailsQuery(channelId, open); + const membersQuery = useChannelMembersQuery(channelId, open); + const updateChannelMutation = useUpdateChannelMutation(channelId); + const setTopicMutation = useSetChannelTopicMutation(channelId); + const setPurposeMutation = useSetChannelPurposeMutation(channelId); + const archiveChannelMutation = useArchiveChannelMutation(channelId); + const unarchiveChannelMutation = useUnarchiveChannelMutation(channelId); + const deleteChannelMutation = useDeleteChannelMutation(channelId); + const addMembersMutation = useAddChannelMembersMutation(channelId); + const removeMemberMutation = useRemoveChannelMemberMutation(channelId); + const joinChannelMutation = useJoinChannelMutation(channelId); + const leaveChannelMutation = useLeaveChannelMutation(channelId); + + const detail = detailsQuery.data ?? channel; + const members = React.useMemo(() => { + const currentMembers = membersQuery.data ?? []; + return [...currentMembers].sort((left, right) => { + if (currentPubkey && left.pubkey === currentPubkey) { + return -1; + } + + if (currentPubkey && right.pubkey === currentPubkey) { + return 1; + } + + const roleDelta = roleOrder[left.role] - roleOrder[right.role]; + if (roleDelta !== 0) { + return roleDelta; + } + + return formatMemberName(left).localeCompare(formatMemberName(right)); + }); + }, [currentPubkey, membersQuery.data]); + + const selfMember = + members.find((member) => member.pubkey === currentPubkey) ?? null; + const hasResolvedMembership = membersQuery.data !== undefined; + const isOwner = selfMember?.role === "owner"; + const canManageChannel = + selfMember?.role === "owner" || selfMember?.role === "admin"; + const canEditNarrative = selfMember !== null && detail?.channelType !== "dm"; + const isArchived = + detail?.archivedAt !== null && detail?.archivedAt !== undefined; + const canJoin = + hasResolvedMembership && + detail?.channelType !== "dm" && + detail?.visibility === "open" && + !isArchived && + selfMember === null; + const canLeave = + hasResolvedMembership && + detail?.channelType !== "dm" && + !isArchived && + selfMember !== null; + + const [nameDraft, setNameDraft] = React.useState(""); + const [descriptionDraft, setDescriptionDraft] = React.useState(""); + const [topicDraft, setTopicDraft] = React.useState(""); + const [purposeDraft, setPurposeDraft] = React.useState(""); + const [invitePubkeys, setInvitePubkeys] = React.useState(""); + const [inviteRole, setInviteRole] = + React.useState>("member"); + + React.useEffect(() => { + if (!detail) { + return; + } + + setNameDraft(detail.name); + setDescriptionDraft(detail.description); + setTopicDraft(detail.topic ?? ""); + setPurposeDraft(detail.purpose ?? ""); + }, [detail]); + + if (!channel) { + return null; + } + + const resolvedChannel = detail ?? channel; + + const parsedInvitePubkeys = invitePubkeys + .split(/[\s,]+/) + .map((value) => value.trim()) + .filter((value) => value.length > 0); + + return ( + + + +
+ {channel.name} + + Manage channel settings, membership, and access. + +
+
+ + + + {isArchived ? ( + + ) : null} +
+
+ +
+ {detailsQuery.error instanceof Error ? ( +

+ {detailsQuery.error.message} +

+ ) : null} + + {membersQuery.error instanceof Error ? ( +

+ {membersQuery.error.message} +

+ ) : null} + +
+
+ {canJoin ? ( + + ) : null} + + {canLeave ? ( + + ) : null} +
+ {joinChannelMutation.error instanceof Error ? ( +

+ {joinChannelMutation.error.message} +

+ ) : null} + {leaveChannelMutation.error instanceof Error ? ( +

+ {leaveChannelMutation.error.message} +

+ ) : null} +
+ + + +
+
{ + event.preventDefault(); + void updateChannelMutation.mutateAsync({ + description: descriptionDraft.trim() || undefined, + name: nameDraft.trim() || undefined, + }); + }} + > +
+ + setNameDraft(event.target.value)} + value={nameDraft} + /> +
+
+ +