mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Add channel management e2e coverage (#24)
This commit is contained in:
+274
-72
@@ -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<String>,
|
||||
pub purpose: Option<String>,
|
||||
pub member_count: i64,
|
||||
pub last_message_at: Option<String>,
|
||||
pub archived_at: Option<String>,
|
||||
pub participants: Vec<String>,
|
||||
pub participant_pubkeys: Vec<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub topic_set_by: Option<String>,
|
||||
pub topic_set_at: Option<String>,
|
||||
pub purpose: Option<String>,
|
||||
pub purpose_set_by: Option<String>,
|
||||
pub purpose_set_at: Option<String>,
|
||||
pub created_by: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub archived_at: Option<String>,
|
||||
pub member_count: i64,
|
||||
pub topic_required: bool,
|
||||
pub max_members: Option<i32>,
|
||||
pub nip29_group_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ChannelMemberInfo {
|
||||
pub pubkey: String,
|
||||
pub role: String,
|
||||
pub joined_at: String,
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ChannelMembersResponse {
|
||||
pub members: Vec<ChannelMemberInfo>,
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct AddMembersResponse {
|
||||
pub added: Vec<String>,
|
||||
pub errors: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[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<reqwest::RequestBuilder, String> {
|
||||
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<String, String> {
|
||||
@@ -145,6 +221,38 @@ async fn relay_error_message(response: reqwest::Response) -> String {
|
||||
format!("relay returned {status}: {body}")
|
||||
}
|
||||
|
||||
async fn send_json_request<T>(request: reqwest::RequestBuilder) -> Result<T, String>
|
||||
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::<T>()
|
||||
.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<IdentityInfo, String> {
|
||||
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<Vec<ChannelInfo>, 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::<Vec<ChannelInfo>>()
|
||||
.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<String>,
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<ChannelInfo, String> {
|
||||
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::<ChannelInfo>()
|
||||
.await
|
||||
.map_err(|e| format!("parse failed: {e}"))
|
||||
#[tauri::command]
|
||||
async fn get_channel_details(
|
||||
channel_id: String,
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<ChannelDetailInfo, String> {
|
||||
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<ChannelMembersResponse, String> {
|
||||
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<String>,
|
||||
description: Option<String>,
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<ChannelDetailInfo, String> {
|
||||
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<String>,
|
||||
role: Option<String>,
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<AddMembersResponse, String> {
|
||||
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<String>,
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<FeedResponse, String> {
|
||||
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::<FeedResponse>()
|
||||
.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<u32>,
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<SearchResponse, String> {
|
||||
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::<SearchResponse>()
|
||||
.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<String, String> {
|
||||
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,
|
||||
|
||||
@@ -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<AppView>("home");
|
||||
const [isChannelManagementOpen, setIsChannelManagementOpen] =
|
||||
React.useState(false);
|
||||
const [isSearchOpen, setIsSearchOpen] = React.useState(false);
|
||||
const [searchAnchor, setSearchAnchor] = React.useState<SearchHit | null>(
|
||||
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() {
|
||||
/>
|
||||
) : (
|
||||
<ChatHeader
|
||||
actions={
|
||||
activeChannel ? (
|
||||
<Button
|
||||
data-testid="channel-management-trigger"
|
||||
onClick={() => {
|
||||
setIsChannelManagementOpen(true);
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
Manage
|
||||
</Button>
|
||||
) : 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}
|
||||
/>
|
||||
|
||||
<ChannelManagementSheet
|
||||
channel={activeChannel}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
onDeleted={() => {
|
||||
React.startTransition(() => {
|
||||
setIsChannelManagementOpen(false);
|
||||
setSelectedView("home");
|
||||
});
|
||||
}}
|
||||
onOpenChange={setIsChannelManagementOpen}
|
||||
open={isChannelManagementOpen && activeChannel !== null}
|
||||
/>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
|
||||
@@ -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<typeof useQueryClient>,
|
||||
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<UpdateChannelInput, "channelId">) => {
|
||||
if (!channelId) {
|
||||
throw new Error("No channel selected.");
|
||||
}
|
||||
|
||||
return updateChannel({ ...input, channelId });
|
||||
},
|
||||
onSuccess: (updatedChannel) => {
|
||||
if (!channelId) {
|
||||
return;
|
||||
}
|
||||
|
||||
queryClient.setQueryData<ChannelDetail>(
|
||||
channelDetailQueryKey(channelId),
|
||||
updatedChannel,
|
||||
);
|
||||
queryClient.setQueryData<Channel[]>(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<SetChannelTopicInput, "channelId">) => {
|
||||
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<SetChannelPurposeInput, "channelId">) => {
|
||||
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<Channel[]>(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<AddChannelMembersInput, "channelId">) => {
|
||||
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,
|
||||
|
||||
@@ -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<Exclude<ChannelMember["role"], "owner">> = [
|
||||
"member",
|
||||
"admin",
|
||||
"guest",
|
||||
"bot",
|
||||
];
|
||||
|
||||
const roleOrder: Record<ChannelMember["role"], number> = {
|
||||
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 (
|
||||
<section className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-sm font-semibold tracking-tight">{title}</h2>
|
||||
{description ? (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function MetadataPill({
|
||||
icon: Icon,
|
||||
label,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-border/80 bg-muted/40 px-3 py-1 text-xs font-medium text-muted-foreground">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<Exclude<ChannelMember["role"], "owner">>("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 (
|
||||
<Sheet onOpenChange={onOpenChange} open={open}>
|
||||
<SheetContent
|
||||
className="flex w-full flex-col gap-0 overflow-hidden border-l border-border/80 bg-background p-0 sm:max-w-xl"
|
||||
data-testid="channel-management-sheet"
|
||||
side="right"
|
||||
>
|
||||
<SheetHeader className="space-y-4 border-b border-border/80 bg-muted/20 px-6 py-6 text-left">
|
||||
<div className="space-y-2">
|
||||
<SheetTitle className="pr-8">{channel.name}</SheetTitle>
|
||||
<SheetDescription>
|
||||
Manage channel settings, membership, and access.
|
||||
</SheetDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<MetadataPill
|
||||
icon={
|
||||
channel.channelType === "forum"
|
||||
? FileText
|
||||
: channel.channelType === "dm"
|
||||
? MessageSquare
|
||||
: Hash
|
||||
}
|
||||
label={channel.channelType}
|
||||
/>
|
||||
<MetadataPill
|
||||
icon={channel.visibility === "private" ? Lock : DoorOpen}
|
||||
label={channel.visibility}
|
||||
/>
|
||||
<MetadataPill
|
||||
icon={Users}
|
||||
label={`${resolvedChannel.memberCount} members`}
|
||||
/>
|
||||
{isArchived ? (
|
||||
<MetadataPill icon={Archive} label="archived" />
|
||||
) : null}
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 space-y-6 overflow-y-auto px-6 py-6">
|
||||
{detailsQuery.error instanceof Error ? (
|
||||
<p className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{detailsQuery.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{membersQuery.error instanceof Error ? (
|
||||
<p className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{membersQuery.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Section
|
||||
description="Open channels stay visible to everyone. Private channels require an invite."
|
||||
title="Access"
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{canJoin ? (
|
||||
<Button
|
||||
data-testid="channel-management-join"
|
||||
disabled={joinChannelMutation.isPending}
|
||||
onClick={() => {
|
||||
void joinChannelMutation.mutateAsync();
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
>
|
||||
<DoorOpen className="h-4 w-4" />
|
||||
{joinChannelMutation.isPending
|
||||
? "Joining..."
|
||||
: "Join channel"}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{canLeave ? (
|
||||
<Button
|
||||
data-testid="channel-management-leave"
|
||||
disabled={leaveChannelMutation.isPending}
|
||||
onClick={() => {
|
||||
void leaveChannelMutation.mutateAsync().then(() => {
|
||||
onOpenChange(false);
|
||||
});
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<DoorClosed className="h-4 w-4" />
|
||||
{leaveChannelMutation.isPending
|
||||
? "Leaving..."
|
||||
: "Leave channel"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{joinChannelMutation.error instanceof Error ? (
|
||||
<p className="text-sm text-destructive">
|
||||
{joinChannelMutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
{leaveChannelMutation.error instanceof Error ? (
|
||||
<p className="text-sm text-destructive">
|
||||
{leaveChannelMutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
</Section>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Section
|
||||
description="Name and description are owner/admin actions."
|
||||
title="Details"
|
||||
>
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void updateChannelMutation.mutateAsync({
|
||||
description: descriptionDraft.trim() || undefined,
|
||||
name: nameDraft.trim() || undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="channel-name">
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
data-testid="channel-management-name"
|
||||
disabled={
|
||||
!canManageChannel || updateChannelMutation.isPending
|
||||
}
|
||||
id="channel-name"
|
||||
onChange={(event) => setNameDraft(event.target.value)}
|
||||
value={nameDraft}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor="channel-description"
|
||||
>
|
||||
Description
|
||||
</label>
|
||||
<Textarea
|
||||
className="min-h-24"
|
||||
data-testid="channel-management-description"
|
||||
disabled={
|
||||
!canManageChannel || updateChannelMutation.isPending
|
||||
}
|
||||
id="channel-description"
|
||||
onChange={(event) => setDescriptionDraft(event.target.value)}
|
||||
value={descriptionDraft}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
data-testid="channel-management-save-details"
|
||||
disabled={!canManageChannel || updateChannelMutation.isPending}
|
||||
size="sm"
|
||||
type="submit"
|
||||
>
|
||||
{updateChannelMutation.isPending ? "Saving..." : "Save details"}
|
||||
</Button>
|
||||
{updateChannelMutation.error instanceof Error ? (
|
||||
<p className="text-sm text-destructive">
|
||||
{updateChannelMutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
</Section>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Section
|
||||
description="Topic and purpose show the current context for the channel."
|
||||
title="Context"
|
||||
>
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void setTopicMutation.mutateAsync({
|
||||
topic: topicDraft.trim(),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="channel-topic">
|
||||
Topic
|
||||
</label>
|
||||
<Input
|
||||
data-testid="channel-management-topic"
|
||||
disabled={!canEditNarrative || setTopicMutation.isPending}
|
||||
id="channel-topic"
|
||||
onChange={(event) => setTopicDraft(event.target.value)}
|
||||
value={topicDraft}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
data-testid="channel-management-save-topic"
|
||||
disabled={!canEditNarrative || setTopicMutation.isPending}
|
||||
size="sm"
|
||||
type="submit"
|
||||
variant="outline"
|
||||
>
|
||||
{setTopicMutation.isPending ? "Saving..." : "Save topic"}
|
||||
</Button>
|
||||
{setTopicMutation.error instanceof Error ? (
|
||||
<p className="text-sm text-destructive">
|
||||
{setTopicMutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void setPurposeMutation.mutateAsync({
|
||||
purpose: purposeDraft.trim(),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor="channel-purpose"
|
||||
>
|
||||
Purpose
|
||||
</label>
|
||||
<Textarea
|
||||
className="min-h-24"
|
||||
data-testid="channel-management-purpose"
|
||||
disabled={!canEditNarrative || setPurposeMutation.isPending}
|
||||
id="channel-purpose"
|
||||
onChange={(event) => setPurposeDraft(event.target.value)}
|
||||
value={purposeDraft}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
data-testid="channel-management-save-purpose"
|
||||
disabled={!canEditNarrative || setPurposeMutation.isPending}
|
||||
size="sm"
|
||||
type="submit"
|
||||
variant="outline"
|
||||
>
|
||||
{setPurposeMutation.isPending ? "Saving..." : "Save purpose"}
|
||||
</Button>
|
||||
{setPurposeMutation.error instanceof Error ? (
|
||||
<p className="text-sm text-destructive">
|
||||
{setPurposeMutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
</Section>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Section
|
||||
description="Owners and admins can invite members or remove them."
|
||||
title="Members"
|
||||
>
|
||||
{canManageChannel && resolvedChannel.channelType !== "dm" ? (
|
||||
<form
|
||||
className="space-y-3 rounded-2xl border border-border/80 bg-muted/20 p-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void addMembersMutation
|
||||
.mutateAsync({
|
||||
pubkeys: parsedInvitePubkeys,
|
||||
role: inviteRole,
|
||||
})
|
||||
.then((result) => {
|
||||
if (result.errors.length === 0) {
|
||||
setInvitePubkeys("");
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<UserPlus className="h-4 w-4" />
|
||||
Invite members
|
||||
</div>
|
||||
<Textarea
|
||||
className="min-h-24"
|
||||
data-testid="channel-management-add-pubkeys"
|
||||
disabled={addMembersMutation.isPending}
|
||||
onChange={(event) => setInvitePubkeys(event.target.value)}
|
||||
placeholder="Paste one or more pubkeys, separated by spaces, commas, or new lines."
|
||||
value={invitePubkeys}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||
htmlFor="channel-member-role"
|
||||
>
|
||||
Role
|
||||
</label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||
data-testid="channel-management-add-role"
|
||||
disabled={addMembersMutation.isPending}
|
||||
id="channel-member-role"
|
||||
onChange={(event) =>
|
||||
setInviteRole(
|
||||
event.target.value as Exclude<
|
||||
ChannelMember["role"],
|
||||
"owner"
|
||||
>,
|
||||
)
|
||||
}
|
||||
value={inviteRole}
|
||||
>
|
||||
{roleOptions.map((role) => (
|
||||
<option key={role} value={role}>
|
||||
{role}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
data-testid="channel-management-add-members"
|
||||
disabled={
|
||||
addMembersMutation.isPending ||
|
||||
parsedInvitePubkeys.length === 0
|
||||
}
|
||||
size="sm"
|
||||
type="submit"
|
||||
>
|
||||
{addMembersMutation.isPending
|
||||
? "Inviting..."
|
||||
: "Add members"}
|
||||
</Button>
|
||||
</div>
|
||||
{addMembersMutation.error instanceof Error ? (
|
||||
<p className="text-sm text-destructive">
|
||||
{addMembersMutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
{addMembersMutation.data &&
|
||||
addMembersMutation.data.errors.length > 0 ? (
|
||||
<div className="space-y-1 text-sm text-destructive">
|
||||
{addMembersMutation.data.errors.map((error) => (
|
||||
<p key={`${error.pubkey}-${error.error}`}>
|
||||
{formatPubkey(error.pubkey)}: {error.error}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2" data-testid="channel-members-list">
|
||||
{members.length > 0 ? (
|
||||
members.map((member) => {
|
||||
const Icon = roleIcon(member.role);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-start justify-between gap-3 rounded-2xl border border-border/80 bg-background px-4 py-3"
|
||||
data-testid={`channel-member-${member.pubkey}`}
|
||||
key={member.pubkey}
|
||||
>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="truncate text-sm font-medium">
|
||||
{formatMemberName(member, currentPubkey)}
|
||||
</p>
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
{member.role}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{member.pubkey}
|
||||
</p>
|
||||
</div>
|
||||
{canManageChannel ||
|
||||
(currentPubkey && member.pubkey === currentPubkey) ? (
|
||||
<Button
|
||||
data-testid={`remove-member-${member.pubkey}`}
|
||||
disabled={
|
||||
removeMemberMutation.isPending || isArchived
|
||||
}
|
||||
onClick={() => {
|
||||
void removeMemberMutation
|
||||
.mutateAsync(member.pubkey)
|
||||
.then(() => {
|
||||
if (member.pubkey === currentPubkey) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
});
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{membersQuery.isLoading
|
||||
? "Loading members..."
|
||||
: "No active members found."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{removeMemberMutation.error instanceof Error ? (
|
||||
<p className="text-sm text-destructive">
|
||||
{removeMemberMutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
</Section>
|
||||
|
||||
{resolvedChannel.channelType !== "dm" ? (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
<Section
|
||||
description="Archiving keeps history but blocks new changes."
|
||||
title="Channel state"
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{isArchived ? (
|
||||
<Button
|
||||
data-testid="channel-management-unarchive"
|
||||
disabled={
|
||||
!canManageChannel || unarchiveChannelMutation.isPending
|
||||
}
|
||||
onClick={() => {
|
||||
void unarchiveChannelMutation.mutateAsync();
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
>
|
||||
<ArchiveRestore className="h-4 w-4" />
|
||||
{unarchiveChannelMutation.isPending
|
||||
? "Restoring..."
|
||||
: "Unarchive channel"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
data-testid="channel-management-archive"
|
||||
disabled={
|
||||
!canManageChannel || archiveChannelMutation.isPending
|
||||
}
|
||||
onClick={() => {
|
||||
void archiveChannelMutation.mutateAsync();
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Archive className="h-4 w-4" />
|
||||
{archiveChannelMutation.isPending
|
||||
? "Archiving..."
|
||||
: "Archive channel"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{archiveChannelMutation.error instanceof Error ? (
|
||||
<p className="text-sm text-destructive">
|
||||
{archiveChannelMutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
{unarchiveChannelMutation.error instanceof Error ? (
|
||||
<p className="text-sm text-destructive">
|
||||
{unarchiveChannelMutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
</Section>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{isOwner && resolvedChannel.channelType !== "dm" ? (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
<Section
|
||||
description="Deleting removes the channel from the workspace list."
|
||||
title="Danger zone"
|
||||
>
|
||||
<Button
|
||||
data-testid="channel-management-delete"
|
||||
disabled={deleteChannelMutation.isPending}
|
||||
onClick={() => {
|
||||
if (!window.confirm(`Delete ${resolvedChannel.name}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
void deleteChannelMutation.mutateAsync().then(() => {
|
||||
onOpenChange(false);
|
||||
onDeleted?.();
|
||||
});
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="destructive"
|
||||
>
|
||||
{deleteChannelMutation.isPending
|
||||
? "Deleting..."
|
||||
: "Delete channel"}
|
||||
</Button>
|
||||
{deleteChannelMutation.error instanceof Error ? (
|
||||
<p className="text-sm text-destructive">
|
||||
{deleteChannelMutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
</Section>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { CircleDot, FileText, Hash, Home } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
|
||||
import type { ChannelType } from "@/shared/api/types";
|
||||
import { SidebarTrigger } from "@/shared/ui/sidebar";
|
||||
|
||||
type ChatHeaderProps = {
|
||||
actions?: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
channelType?: ChannelType;
|
||||
@@ -33,6 +35,7 @@ function ChannelIcon({
|
||||
}
|
||||
|
||||
export function ChatHeader({
|
||||
actions,
|
||||
title,
|
||||
description,
|
||||
channelType,
|
||||
@@ -62,6 +65,8 @@ export function ChatHeader({
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{actions ? <div className="shrink-0">{actions}</div> : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import type {
|
||||
AddChannelMembersInput,
|
||||
AddChannelMembersResult,
|
||||
Channel,
|
||||
ChannelDetail,
|
||||
ChannelMember,
|
||||
ChannelType,
|
||||
CreateChannelInput,
|
||||
GetHomeFeedInput,
|
||||
@@ -10,6 +14,9 @@ import type {
|
||||
RelayEvent,
|
||||
SearchMessagesInput,
|
||||
SearchMessagesResponse,
|
||||
SetChannelPurposeInput,
|
||||
SetChannelTopicInput,
|
||||
UpdateChannelInput,
|
||||
} from "@/shared/api/types";
|
||||
|
||||
type RawIdentity = {
|
||||
@@ -21,11 +28,50 @@ type RawChannel = {
|
||||
id: string;
|
||||
name: string;
|
||||
channel_type: ChannelType;
|
||||
visibility: "open" | "private";
|
||||
description: string;
|
||||
topic: string | null;
|
||||
purpose: string | null;
|
||||
member_count: number;
|
||||
last_message_at: string | null;
|
||||
archived_at: string | null;
|
||||
participants: string[];
|
||||
participant_pubkeys: string[];
|
||||
};
|
||||
|
||||
type RawChannelDetail = RawChannel & {
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
topic_set_by: string | null;
|
||||
topic_set_at: string | null;
|
||||
purpose_set_by: string | null;
|
||||
purpose_set_at: string | null;
|
||||
topic_required: boolean;
|
||||
max_members: number | null;
|
||||
nip29_group_id: string | null;
|
||||
};
|
||||
|
||||
type RawChannelMember = {
|
||||
pubkey: string;
|
||||
role: ChannelMember["role"];
|
||||
joined_at: string;
|
||||
display_name: string | null;
|
||||
};
|
||||
|
||||
type RawChannelMembersResponse = {
|
||||
members: RawChannelMember[];
|
||||
next_cursor: string | null;
|
||||
};
|
||||
|
||||
type RawAddChannelMembersResult = {
|
||||
added: string[];
|
||||
errors: Array<{
|
||||
pubkey: string;
|
||||
error: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type RawFeedItem = {
|
||||
id: string;
|
||||
kind: number;
|
||||
@@ -73,12 +119,43 @@ function fromRawChannel(channel: RawChannel): Channel {
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
channelType: channel.channel_type,
|
||||
visibility: channel.visibility,
|
||||
description: channel.description,
|
||||
topic: channel.topic,
|
||||
purpose: channel.purpose,
|
||||
memberCount: channel.member_count,
|
||||
lastMessageAt: channel.last_message_at,
|
||||
archivedAt: channel.archived_at,
|
||||
participants: channel.participants,
|
||||
participantPubkeys: channel.participant_pubkeys,
|
||||
};
|
||||
}
|
||||
|
||||
function fromRawChannelDetail(channel: RawChannelDetail): ChannelDetail {
|
||||
return {
|
||||
...fromRawChannel(channel),
|
||||
createdBy: channel.created_by,
|
||||
createdAt: channel.created_at,
|
||||
updatedAt: channel.updated_at,
|
||||
topicSetBy: channel.topic_set_by,
|
||||
topicSetAt: channel.topic_set_at,
|
||||
purposeSetBy: channel.purpose_set_by,
|
||||
purposeSetAt: channel.purpose_set_at,
|
||||
topicRequired: channel.topic_required,
|
||||
maxMembers: channel.max_members,
|
||||
nip29GroupId: channel.nip29_group_id,
|
||||
};
|
||||
}
|
||||
|
||||
function fromRawChannelMember(member: RawChannelMember): ChannelMember {
|
||||
return {
|
||||
pubkey: member.pubkey,
|
||||
role: member.role,
|
||||
joinedAt: member.joined_at,
|
||||
displayName: member.display_name,
|
||||
};
|
||||
}
|
||||
|
||||
function fromRawFeedItem(item: RawFeedItem) {
|
||||
return {
|
||||
id: item.id,
|
||||
@@ -131,6 +208,79 @@ export async function createChannel(
|
||||
return fromRawChannel(channel);
|
||||
}
|
||||
|
||||
export async function getChannelDetails(
|
||||
channelId: string,
|
||||
): Promise<ChannelDetail> {
|
||||
const channel = await invoke<RawChannelDetail>("get_channel_details", {
|
||||
channelId,
|
||||
});
|
||||
return fromRawChannelDetail(channel);
|
||||
}
|
||||
|
||||
export async function getChannelMembers(
|
||||
channelId: string,
|
||||
): Promise<ChannelMember[]> {
|
||||
const response = await invoke<RawChannelMembersResponse>(
|
||||
"get_channel_members",
|
||||
{
|
||||
channelId,
|
||||
},
|
||||
);
|
||||
return response.members.map(fromRawChannelMember);
|
||||
}
|
||||
|
||||
export async function updateChannel(
|
||||
input: UpdateChannelInput,
|
||||
): Promise<ChannelDetail> {
|
||||
const channel = await invoke<RawChannelDetail>("update_channel", input);
|
||||
return fromRawChannelDetail(channel);
|
||||
}
|
||||
|
||||
export async function setChannelTopic(
|
||||
input: SetChannelTopicInput,
|
||||
): Promise<void> {
|
||||
await invoke("set_channel_topic", input);
|
||||
}
|
||||
|
||||
export async function setChannelPurpose(
|
||||
input: SetChannelPurposeInput,
|
||||
): Promise<void> {
|
||||
await invoke("set_channel_purpose", input);
|
||||
}
|
||||
|
||||
export async function archiveChannel(channelId: string): Promise<void> {
|
||||
await invoke("archive_channel", { channelId });
|
||||
}
|
||||
|
||||
export async function unarchiveChannel(channelId: string): Promise<void> {
|
||||
await invoke("unarchive_channel", { channelId });
|
||||
}
|
||||
|
||||
export async function deleteChannel(channelId: string): Promise<void> {
|
||||
await invoke("delete_channel", { channelId });
|
||||
}
|
||||
|
||||
export async function addChannelMembers(
|
||||
input: AddChannelMembersInput,
|
||||
): Promise<AddChannelMembersResult> {
|
||||
return invoke<RawAddChannelMembersResult>("add_channel_members", input);
|
||||
}
|
||||
|
||||
export async function removeChannelMember(
|
||||
channelId: string,
|
||||
pubkey: string,
|
||||
): Promise<void> {
|
||||
await invoke("remove_channel_member", { channelId, pubkey });
|
||||
}
|
||||
|
||||
export async function joinChannel(channelId: string): Promise<void> {
|
||||
await invoke("join_channel", { channelId });
|
||||
}
|
||||
|
||||
export async function leaveChannel(channelId: string): Promise<void> {
|
||||
await invoke("leave_channel", { channelId });
|
||||
}
|
||||
|
||||
export async function getHomeFeed(
|
||||
input: GetHomeFeedInput = {},
|
||||
): Promise<HomeFeedResponse> {
|
||||
|
||||
@@ -1,15 +1,42 @@
|
||||
export type ChannelType = "stream" | "forum" | "dm";
|
||||
export type ChannelVisibility = "open" | "private";
|
||||
export type ChannelRole = "owner" | "admin" | "member" | "guest" | "bot";
|
||||
|
||||
export type Channel = {
|
||||
id: string;
|
||||
name: string;
|
||||
channelType: ChannelType;
|
||||
visibility: ChannelVisibility;
|
||||
description: string;
|
||||
topic: string | null;
|
||||
purpose: string | null;
|
||||
memberCount: number;
|
||||
lastMessageAt: string | null;
|
||||
archivedAt: string | null;
|
||||
participants: string[];
|
||||
participantPubkeys: string[];
|
||||
};
|
||||
|
||||
export type ChannelDetail = Channel & {
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
topicSetBy: string | null;
|
||||
topicSetAt: string | null;
|
||||
purposeSetBy: string | null;
|
||||
purposeSetAt: string | null;
|
||||
topicRequired: boolean;
|
||||
maxMembers: number | null;
|
||||
nip29GroupId: string | null;
|
||||
};
|
||||
|
||||
export type ChannelMember = {
|
||||
pubkey: string;
|
||||
role: ChannelRole;
|
||||
joinedAt: string;
|
||||
displayName: string | null;
|
||||
};
|
||||
|
||||
export type CreateChannelInput = {
|
||||
name: string;
|
||||
channelType: Exclude<ChannelType, "dm">;
|
||||
@@ -17,6 +44,36 @@ export type CreateChannelInput = {
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type UpdateChannelInput = {
|
||||
channelId: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type SetChannelTopicInput = {
|
||||
channelId: string;
|
||||
topic: string;
|
||||
};
|
||||
|
||||
export type SetChannelPurposeInput = {
|
||||
channelId: string;
|
||||
purpose: string;
|
||||
};
|
||||
|
||||
export type AddChannelMembersInput = {
|
||||
channelId: string;
|
||||
pubkeys: string[];
|
||||
role?: Exclude<ChannelRole, "owner">;
|
||||
};
|
||||
|
||||
export type AddChannelMembersResult = {
|
||||
added: string[];
|
||||
errors: Array<{
|
||||
pubkey: string;
|
||||
error: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type Identity = {
|
||||
pubkey: string;
|
||||
displayName: string;
|
||||
|
||||
@@ -21,11 +21,54 @@ type RawChannel = {
|
||||
id: string;
|
||||
name: string;
|
||||
channel_type: "stream" | "forum" | "dm";
|
||||
visibility: "open" | "private";
|
||||
description: string;
|
||||
topic: string | null;
|
||||
purpose: string | null;
|
||||
member_count: number;
|
||||
last_message_at: string | null;
|
||||
archived_at: string | null;
|
||||
participants: string[];
|
||||
participant_pubkeys: string[];
|
||||
};
|
||||
|
||||
type RawChannelDetail = RawChannel & {
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
topic_set_by: string | null;
|
||||
topic_set_at: string | null;
|
||||
purpose_set_by: string | null;
|
||||
purpose_set_at: string | null;
|
||||
topic_required: boolean;
|
||||
max_members: number | null;
|
||||
nip29_group_id: string | null;
|
||||
};
|
||||
|
||||
type RawChannelMember = {
|
||||
pubkey: string;
|
||||
role: "owner" | "admin" | "member" | "guest" | "bot";
|
||||
joined_at: string;
|
||||
display_name: string | null;
|
||||
};
|
||||
|
||||
type RawChannelMembersResponse = {
|
||||
members: RawChannelMember[];
|
||||
next_cursor: string | null;
|
||||
};
|
||||
|
||||
type RawAddChannelMembersResponse = {
|
||||
added: string[];
|
||||
errors: Array<{
|
||||
pubkey: string;
|
||||
error: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type MockChannel = RawChannelDetail & {
|
||||
members: RawChannelMember[];
|
||||
};
|
||||
|
||||
type RawFeedItem = {
|
||||
id: string;
|
||||
kind: number;
|
||||
@@ -95,77 +138,373 @@ const DEFAULT_REAL_IDENTITY = {
|
||||
username: "tyler",
|
||||
} satisfies TestIdentity;
|
||||
|
||||
const mockChannels: RawChannel[] = [
|
||||
{
|
||||
const ALICE_PUBKEY =
|
||||
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f";
|
||||
const BOB_PUBKEY =
|
||||
"bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260";
|
||||
const CHARLIE_PUBKEY =
|
||||
"554cef57437abac34522ac2c9f0490d685b72c80478cf9f7ed6f9570ee8624ea";
|
||||
const OUTSIDER_PUBKEY =
|
||||
"df8e91b86fda13a9a67896df77232f7bdab2ba9c3e165378e1ba3d24c13a328e";
|
||||
const MOCK_IDENTITY_PUBKEY = DEFAULT_MOCK_IDENTITY.pubkey;
|
||||
|
||||
const mockDisplayNames = new Map<string, string>([
|
||||
[MOCK_IDENTITY_PUBKEY, DEFAULT_MOCK_IDENTITY.display_name],
|
||||
[ALICE_PUBKEY, "alice"],
|
||||
[BOB_PUBKEY, "bob"],
|
||||
[CHARLIE_PUBKEY, "charlie"],
|
||||
[OUTSIDER_PUBKEY, "outsider"],
|
||||
[DEFAULT_REAL_IDENTITY.pubkey, DEFAULT_REAL_IDENTITY.username],
|
||||
]);
|
||||
|
||||
function isoMinutesAgo(minutesAgo: number): string {
|
||||
return new Date(Date.now() - minutesAgo * 60_000).toISOString();
|
||||
}
|
||||
|
||||
function cloneMembers(members: RawChannelMember[]): RawChannelMember[] {
|
||||
return members.map((member) => ({ ...member }));
|
||||
}
|
||||
|
||||
function toRawChannel(channel: MockChannel): RawChannel {
|
||||
return {
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
channel_type: channel.channel_type,
|
||||
visibility: channel.visibility,
|
||||
description: channel.description,
|
||||
topic: channel.topic,
|
||||
purpose: channel.purpose,
|
||||
member_count: channel.member_count,
|
||||
last_message_at: channel.last_message_at,
|
||||
archived_at: channel.archived_at,
|
||||
participants: [...channel.participants],
|
||||
participant_pubkeys: [...channel.participant_pubkeys],
|
||||
};
|
||||
}
|
||||
|
||||
function toRawChannelDetail(channel: MockChannel): RawChannelDetail {
|
||||
return {
|
||||
...toRawChannel(channel),
|
||||
created_by: channel.created_by,
|
||||
created_at: channel.created_at,
|
||||
updated_at: channel.updated_at,
|
||||
topic_set_by: channel.topic_set_by,
|
||||
topic_set_at: channel.topic_set_at,
|
||||
purpose_set_by: channel.purpose_set_by,
|
||||
purpose_set_at: channel.purpose_set_at,
|
||||
topic_required: channel.topic_required,
|
||||
max_members: channel.max_members,
|
||||
nip29_group_id: channel.nip29_group_id,
|
||||
};
|
||||
}
|
||||
|
||||
function createMockMember(
|
||||
pubkey: string,
|
||||
role: RawChannelMember["role"],
|
||||
joinedMinutesAgo: number,
|
||||
): RawChannelMember {
|
||||
return {
|
||||
pubkey,
|
||||
role,
|
||||
joined_at: isoMinutesAgo(joinedMinutesAgo),
|
||||
display_name: mockDisplayNames.get(pubkey) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function createMockChannel(
|
||||
seed: Omit<
|
||||
MockChannel,
|
||||
| "created_at"
|
||||
| "member_count"
|
||||
| "members"
|
||||
| "updated_at"
|
||||
| "participant_pubkeys"
|
||||
| "participants"
|
||||
> & {
|
||||
created_minutes_ago: number;
|
||||
members: RawChannelMember[];
|
||||
participant_pubkeys?: string[];
|
||||
participants?: string[];
|
||||
updated_minutes_ago?: number;
|
||||
},
|
||||
): MockChannel {
|
||||
return {
|
||||
...seed,
|
||||
created_at: isoMinutesAgo(seed.created_minutes_ago),
|
||||
member_count: seed.members.length,
|
||||
members: cloneMembers(seed.members),
|
||||
participant_pubkeys: [...(seed.participant_pubkeys ?? [])],
|
||||
participants: [...(seed.participants ?? [])],
|
||||
updated_at: isoMinutesAgo(
|
||||
seed.updated_minutes_ago ?? seed.created_minutes_ago,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function syncMockChannel(channel: MockChannel) {
|
||||
channel.member_count = channel.members.length;
|
||||
|
||||
if (channel.channel_type !== "dm") {
|
||||
return;
|
||||
}
|
||||
|
||||
channel.participant_pubkeys = channel.members.map((member) => member.pubkey);
|
||||
channel.participants = channel.members.map(
|
||||
(member) => member.display_name ?? member.pubkey.slice(0, 8),
|
||||
);
|
||||
}
|
||||
|
||||
function touchMockChannel(channel: MockChannel) {
|
||||
channel.updated_at = new Date().toISOString();
|
||||
}
|
||||
|
||||
function getMockIdentity() {
|
||||
return {
|
||||
pubkey: MOCK_IDENTITY_PUBKEY,
|
||||
displayName: DEFAULT_MOCK_IDENTITY.display_name,
|
||||
};
|
||||
}
|
||||
|
||||
function listMockChannels(): RawChannel[] {
|
||||
return mockChannels.map(toRawChannel);
|
||||
}
|
||||
|
||||
function getMockChannel(channelId: string): MockChannel {
|
||||
const channel = mockChannels.find((candidate) => candidate.id === channelId);
|
||||
if (!channel) {
|
||||
throw new Error(`Channel ${channelId} not found.`);
|
||||
}
|
||||
|
||||
return channel;
|
||||
}
|
||||
|
||||
function getMockMemberPubkey(config: E2eConfig | undefined): string {
|
||||
return getIdentity(config)?.pubkey ?? getMockIdentity().pubkey;
|
||||
}
|
||||
|
||||
function getMockMemberDisplayName(config: E2eConfig | undefined): string {
|
||||
return getIdentity(config)?.username ?? getMockIdentity().displayName;
|
||||
}
|
||||
|
||||
function createCurrentMember(
|
||||
config: E2eConfig | undefined,
|
||||
role: RawChannelMember["role"],
|
||||
): RawChannelMember {
|
||||
return {
|
||||
pubkey: getMockMemberPubkey(config),
|
||||
role,
|
||||
joined_at: new Date().toISOString(),
|
||||
display_name: getMockMemberDisplayName(config),
|
||||
};
|
||||
}
|
||||
|
||||
const mockChannels: MockChannel[] = [
|
||||
createMockChannel({
|
||||
id: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
|
||||
name: "general",
|
||||
channel_type: "stream",
|
||||
visibility: "open",
|
||||
description: "General discussion for everyone",
|
||||
participants: [],
|
||||
participant_pubkeys: [],
|
||||
},
|
||||
{
|
||||
topic: "Company-wide updates",
|
||||
purpose: "Coordinate day-to-day work and unblock the team.",
|
||||
last_message_at: isoMinutesAgo(5),
|
||||
archived_at: null,
|
||||
created_by: MOCK_IDENTITY_PUBKEY,
|
||||
topic_set_by: MOCK_IDENTITY_PUBKEY,
|
||||
topic_set_at: isoMinutesAgo(90),
|
||||
purpose_set_by: MOCK_IDENTITY_PUBKEY,
|
||||
purpose_set_at: isoMinutesAgo(80),
|
||||
topic_required: false,
|
||||
max_members: null,
|
||||
nip29_group_id: null,
|
||||
created_minutes_ago: 1440,
|
||||
updated_minutes_ago: 5,
|
||||
members: [
|
||||
createMockMember(MOCK_IDENTITY_PUBKEY, "owner", 1440),
|
||||
createMockMember(ALICE_PUBKEY, "admin", 1200),
|
||||
createMockMember(BOB_PUBKEY, "member", 960),
|
||||
],
|
||||
}),
|
||||
createMockChannel({
|
||||
id: "9dae0116-799b-5071-a0a8-fdd30a91a35d",
|
||||
name: "random",
|
||||
channel_type: "stream",
|
||||
visibility: "open",
|
||||
description: "Off-topic, fun stuff",
|
||||
participants: [],
|
||||
participant_pubkeys: [],
|
||||
},
|
||||
{
|
||||
topic: null,
|
||||
purpose: null,
|
||||
last_message_at: null,
|
||||
archived_at: null,
|
||||
created_by: ALICE_PUBKEY,
|
||||
topic_set_by: null,
|
||||
topic_set_at: null,
|
||||
purpose_set_by: null,
|
||||
purpose_set_at: null,
|
||||
topic_required: false,
|
||||
max_members: null,
|
||||
nip29_group_id: null,
|
||||
created_minutes_ago: 1400,
|
||||
updated_minutes_ago: 1400,
|
||||
members: [
|
||||
createMockMember(ALICE_PUBKEY, "owner", 1400),
|
||||
createMockMember(BOB_PUBKEY, "member", 1000),
|
||||
],
|
||||
}),
|
||||
createMockChannel({
|
||||
id: "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9",
|
||||
name: "engineering",
|
||||
channel_type: "stream",
|
||||
visibility: "open",
|
||||
description: "Engineering discussions",
|
||||
participants: [],
|
||||
participant_pubkeys: [],
|
||||
},
|
||||
{
|
||||
topic: "Desktop release train",
|
||||
purpose: "Track implementation details and release readiness.",
|
||||
last_message_at: isoMinutesAgo(42),
|
||||
archived_at: null,
|
||||
created_by: ALICE_PUBKEY,
|
||||
topic_set_by: ALICE_PUBKEY,
|
||||
topic_set_at: isoMinutesAgo(120),
|
||||
purpose_set_by: ALICE_PUBKEY,
|
||||
purpose_set_at: isoMinutesAgo(130),
|
||||
topic_required: false,
|
||||
max_members: null,
|
||||
nip29_group_id: null,
|
||||
created_minutes_ago: 1320,
|
||||
updated_minutes_ago: 42,
|
||||
members: [
|
||||
createMockMember(ALICE_PUBKEY, "owner", 1320),
|
||||
createMockMember(MOCK_IDENTITY_PUBKEY, "member", 1180),
|
||||
createMockMember(BOB_PUBKEY, "member", 900),
|
||||
],
|
||||
}),
|
||||
createMockChannel({
|
||||
id: "94a444a4-c0a3-5966-ab05-530c6ddc2301",
|
||||
name: "agents",
|
||||
channel_type: "stream",
|
||||
visibility: "open",
|
||||
description: "AI agent testing and collaboration",
|
||||
participants: [],
|
||||
participant_pubkeys: [],
|
||||
},
|
||||
{
|
||||
topic: "Coordination board",
|
||||
purpose: "Track agent work and relay activity.",
|
||||
last_message_at: isoMinutesAgo(15),
|
||||
archived_at: null,
|
||||
created_by: MOCK_IDENTITY_PUBKEY,
|
||||
topic_set_by: MOCK_IDENTITY_PUBKEY,
|
||||
topic_set_at: isoMinutesAgo(60),
|
||||
purpose_set_by: MOCK_IDENTITY_PUBKEY,
|
||||
purpose_set_at: isoMinutesAgo(65),
|
||||
topic_required: false,
|
||||
max_members: null,
|
||||
nip29_group_id: null,
|
||||
created_minutes_ago: 1000,
|
||||
updated_minutes_ago: 15,
|
||||
members: [
|
||||
createMockMember(MOCK_IDENTITY_PUBKEY, "owner", 1000),
|
||||
createMockMember(CHARLIE_PUBKEY, "bot", 800),
|
||||
],
|
||||
}),
|
||||
createMockChannel({
|
||||
id: "a27e1ee9-76a6-5bdf-a5d5-1d85610dad11",
|
||||
name: "watercooler",
|
||||
channel_type: "forum",
|
||||
visibility: "open",
|
||||
description: "Casual forum for async discussions",
|
||||
participants: [],
|
||||
participant_pubkeys: [],
|
||||
},
|
||||
{
|
||||
topic: null,
|
||||
purpose: null,
|
||||
last_message_at: null,
|
||||
archived_at: null,
|
||||
created_by: ALICE_PUBKEY,
|
||||
topic_set_by: null,
|
||||
topic_set_at: null,
|
||||
purpose_set_by: null,
|
||||
purpose_set_at: null,
|
||||
topic_required: false,
|
||||
max_members: null,
|
||||
nip29_group_id: null,
|
||||
created_minutes_ago: 900,
|
||||
updated_minutes_ago: 900,
|
||||
members: [
|
||||
createMockMember(ALICE_PUBKEY, "owner", 900),
|
||||
createMockMember(MOCK_IDENTITY_PUBKEY, "member", 750),
|
||||
],
|
||||
}),
|
||||
createMockChannel({
|
||||
id: "1be1dcdb-4c31-5a8c-81de-ac102552ca10",
|
||||
name: "announcements",
|
||||
channel_type: "forum",
|
||||
visibility: "private",
|
||||
description: "Company announcements",
|
||||
participants: [],
|
||||
participant_pubkeys: [],
|
||||
},
|
||||
{
|
||||
topic: "Leadership updates",
|
||||
purpose: "Read-only announcements for the workspace.",
|
||||
last_message_at: null,
|
||||
archived_at: null,
|
||||
created_by: ALICE_PUBKEY,
|
||||
topic_set_by: ALICE_PUBKEY,
|
||||
topic_set_at: isoMinutesAgo(200),
|
||||
purpose_set_by: ALICE_PUBKEY,
|
||||
purpose_set_at: isoMinutesAgo(210),
|
||||
topic_required: false,
|
||||
max_members: null,
|
||||
nip29_group_id: null,
|
||||
created_minutes_ago: 880,
|
||||
updated_minutes_ago: 200,
|
||||
members: [
|
||||
createMockMember(ALICE_PUBKEY, "owner", 880),
|
||||
createMockMember(MOCK_IDENTITY_PUBKEY, "guest", 700),
|
||||
],
|
||||
}),
|
||||
createMockChannel({
|
||||
id: "f48efb06-0c93-5025-aac9-2e646bb6bfa8",
|
||||
name: "alice-tyler",
|
||||
channel_type: "dm",
|
||||
visibility: "private",
|
||||
description: "DM between alice and tyler",
|
||||
topic: null,
|
||||
purpose: null,
|
||||
last_message_at: null,
|
||||
archived_at: null,
|
||||
created_by: ALICE_PUBKEY,
|
||||
topic_set_by: null,
|
||||
topic_set_at: null,
|
||||
purpose_set_by: null,
|
||||
purpose_set_at: null,
|
||||
topic_required: false,
|
||||
max_members: 2,
|
||||
nip29_group_id: null,
|
||||
created_minutes_ago: 720,
|
||||
updated_minutes_ago: 720,
|
||||
participants: ["alice", "tyler"],
|
||||
participant_pubkeys: [
|
||||
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f",
|
||||
"e5ebc6cdb579be112e336cc319b5989b4bb6af11786ea90dbe52b5f08d741b34",
|
||||
participant_pubkeys: [ALICE_PUBKEY, DEFAULT_REAL_IDENTITY.pubkey],
|
||||
members: [
|
||||
createMockMember(ALICE_PUBKEY, "member", 720),
|
||||
createMockMember(DEFAULT_REAL_IDENTITY.pubkey, "member", 720),
|
||||
],
|
||||
},
|
||||
{
|
||||
}),
|
||||
createMockChannel({
|
||||
id: "7eb9f239-9393-50b0-bd76-d85eef0511c7",
|
||||
name: "bob-tyler",
|
||||
channel_type: "dm",
|
||||
visibility: "private",
|
||||
description: "DM between bob and tyler",
|
||||
topic: null,
|
||||
purpose: null,
|
||||
last_message_at: null,
|
||||
archived_at: null,
|
||||
created_by: BOB_PUBKEY,
|
||||
topic_set_by: null,
|
||||
topic_set_at: null,
|
||||
purpose_set_by: null,
|
||||
purpose_set_at: null,
|
||||
topic_required: false,
|
||||
max_members: 2,
|
||||
nip29_group_id: null,
|
||||
created_minutes_ago: 700,
|
||||
updated_minutes_ago: 700,
|
||||
participants: ["bob", "tyler"],
|
||||
participant_pubkeys: [
|
||||
"bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260",
|
||||
"e5ebc6cdb579be112e336cc319b5989b4bb6af11786ea90dbe52b5f08d741b34",
|
||||
participant_pubkeys: [BOB_PUBKEY, DEFAULT_REAL_IDENTITY.pubkey],
|
||||
members: [
|
||||
createMockMember(BOB_PUBKEY, "member", 700),
|
||||
createMockMember(DEFAULT_REAL_IDENTITY.pubkey, "member", 700),
|
||||
],
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const mockMessages = new Map<string, RelayEvent[]>();
|
||||
@@ -322,19 +661,63 @@ async function assertOk(response: Response) {
|
||||
throw new Error(body || `Request failed with ${response.status}`);
|
||||
}
|
||||
|
||||
function getRelayIdentity(config: E2eConfig | undefined): TestIdentity {
|
||||
const identity = getIdentity(config);
|
||||
if (!identity) {
|
||||
throw new Error("Relay identity required.");
|
||||
}
|
||||
|
||||
return identity;
|
||||
}
|
||||
|
||||
async function relayJsonRequest<T>(
|
||||
config: E2eConfig | undefined,
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const identity = getRelayIdentity(config);
|
||||
const headers = new Headers(init.headers);
|
||||
|
||||
headers.set("X-Pubkey", identity.pubkey);
|
||||
if (init.body && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
const response = await fetch(`${getRelayHttpUrl(config)}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
});
|
||||
await assertOk(response);
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function relayEmptyRequest(
|
||||
config: E2eConfig | undefined,
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
) {
|
||||
const identity = getRelayIdentity(config);
|
||||
const headers = new Headers(init.headers);
|
||||
|
||||
headers.set("X-Pubkey", identity.pubkey);
|
||||
if (init.body && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
const response = await fetch(`${getRelayHttpUrl(config)}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
});
|
||||
await assertOk(response);
|
||||
}
|
||||
|
||||
async function handleGetChannels(config: E2eConfig | undefined) {
|
||||
const identity = getIdentity(config);
|
||||
if (!identity) {
|
||||
return mockChannels;
|
||||
return listMockChannels();
|
||||
}
|
||||
|
||||
const response = await fetch(`${getRelayHttpUrl(config)}/api/channels`, {
|
||||
headers: {
|
||||
"X-Pubkey": identity.pubkey,
|
||||
},
|
||||
});
|
||||
await assertOk(response);
|
||||
return response.json();
|
||||
return relayJsonRequest<RawChannel[]>(config, "/api/channels");
|
||||
}
|
||||
|
||||
async function handleCreateChannel(
|
||||
@@ -348,24 +731,35 @@ async function handleCreateChannel(
|
||||
) {
|
||||
const identity = getIdentity(config);
|
||||
if (!identity) {
|
||||
const channel: RawChannel = {
|
||||
const owner = createCurrentMember(config, "owner");
|
||||
const channel = createMockChannel({
|
||||
id: crypto.randomUUID(),
|
||||
name: args.name,
|
||||
channel_type: args.channelType,
|
||||
visibility: args.visibility,
|
||||
description: args.description ?? "",
|
||||
participants: [],
|
||||
participant_pubkeys: [],
|
||||
};
|
||||
topic: null,
|
||||
purpose: null,
|
||||
last_message_at: null,
|
||||
archived_at: null,
|
||||
created_by: owner.pubkey,
|
||||
topic_set_by: null,
|
||||
topic_set_at: null,
|
||||
purpose_set_by: null,
|
||||
purpose_set_at: null,
|
||||
topic_required: false,
|
||||
max_members: null,
|
||||
nip29_group_id: null,
|
||||
created_minutes_ago: 0,
|
||||
updated_minutes_ago: 0,
|
||||
members: [owner],
|
||||
});
|
||||
mockChannels.push(channel);
|
||||
return channel;
|
||||
return toRawChannel(channel);
|
||||
}
|
||||
|
||||
const response = await fetch(`${getRelayHttpUrl(config)}/api/channels`, {
|
||||
return relayJsonRequest<RawChannel>(config, "/api/channels", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Pubkey": identity.pubkey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: args.name,
|
||||
channel_type: args.channelType,
|
||||
@@ -373,8 +767,315 @@ async function handleCreateChannel(
|
||||
description: args.description,
|
||||
}),
|
||||
});
|
||||
await assertOk(response);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function handleGetChannelDetails(
|
||||
args: { channelId: string },
|
||||
config: E2eConfig | undefined,
|
||||
) {
|
||||
const identity = getIdentity(config);
|
||||
if (!identity) {
|
||||
return toRawChannelDetail(getMockChannel(args.channelId));
|
||||
}
|
||||
|
||||
return relayJsonRequest<RawChannelDetail>(
|
||||
config,
|
||||
`/api/channels/${args.channelId}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function handleGetChannelMembers(
|
||||
args: { channelId: string },
|
||||
config: E2eConfig | undefined,
|
||||
): Promise<RawChannelMembersResponse> {
|
||||
const identity = getIdentity(config);
|
||||
if (!identity) {
|
||||
const channel = getMockChannel(args.channelId);
|
||||
return {
|
||||
members: cloneMembers(channel.members),
|
||||
next_cursor: null,
|
||||
};
|
||||
}
|
||||
|
||||
return relayJsonRequest<RawChannelMembersResponse>(
|
||||
config,
|
||||
`/api/channels/${args.channelId}/members`,
|
||||
);
|
||||
}
|
||||
|
||||
async function handleUpdateChannel(
|
||||
args: {
|
||||
channelId: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
},
|
||||
config: E2eConfig | undefined,
|
||||
) {
|
||||
const identity = getIdentity(config);
|
||||
if (!identity) {
|
||||
const channel = getMockChannel(args.channelId);
|
||||
if (args.name !== undefined) {
|
||||
channel.name = args.name;
|
||||
}
|
||||
if (args.description !== undefined) {
|
||||
channel.description = args.description;
|
||||
}
|
||||
touchMockChannel(channel);
|
||||
return toRawChannelDetail(channel);
|
||||
}
|
||||
|
||||
return relayJsonRequest<RawChannelDetail>(
|
||||
config,
|
||||
`/api/channels/${args.channelId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
name: args.name,
|
||||
description: args.description,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSetChannelTopic(
|
||||
args: {
|
||||
channelId: string;
|
||||
topic: string;
|
||||
},
|
||||
config: E2eConfig | undefined,
|
||||
) {
|
||||
const identity = getIdentity(config);
|
||||
if (!identity) {
|
||||
const channel = getMockChannel(args.channelId);
|
||||
const nextTopic = args.topic.trim();
|
||||
|
||||
channel.topic = nextTopic.length > 0 ? nextTopic : null;
|
||||
channel.topic_set_by = getMockMemberPubkey(config);
|
||||
channel.topic_set_at = new Date().toISOString();
|
||||
touchMockChannel(channel);
|
||||
return;
|
||||
}
|
||||
|
||||
await relayEmptyRequest(config, `/api/channels/${args.channelId}/topic`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
topic: args.topic,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSetChannelPurpose(
|
||||
args: {
|
||||
channelId: string;
|
||||
purpose: string;
|
||||
},
|
||||
config: E2eConfig | undefined,
|
||||
) {
|
||||
const identity = getIdentity(config);
|
||||
if (!identity) {
|
||||
const channel = getMockChannel(args.channelId);
|
||||
const nextPurpose = args.purpose.trim();
|
||||
|
||||
channel.purpose = nextPurpose.length > 0 ? nextPurpose : null;
|
||||
channel.purpose_set_by = getMockMemberPubkey(config);
|
||||
channel.purpose_set_at = new Date().toISOString();
|
||||
touchMockChannel(channel);
|
||||
return;
|
||||
}
|
||||
|
||||
await relayEmptyRequest(config, `/api/channels/${args.channelId}/purpose`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
purpose: args.purpose,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function handleArchiveChannel(
|
||||
args: { channelId: string },
|
||||
config: E2eConfig | undefined,
|
||||
) {
|
||||
const identity = getIdentity(config);
|
||||
if (!identity) {
|
||||
const channel = getMockChannel(args.channelId);
|
||||
channel.archived_at = new Date().toISOString();
|
||||
touchMockChannel(channel);
|
||||
return;
|
||||
}
|
||||
|
||||
await relayEmptyRequest(config, `/api/channels/${args.channelId}/archive`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
async function handleUnarchiveChannel(
|
||||
args: { channelId: string },
|
||||
config: E2eConfig | undefined,
|
||||
) {
|
||||
const identity = getIdentity(config);
|
||||
if (!identity) {
|
||||
const channel = getMockChannel(args.channelId);
|
||||
channel.archived_at = null;
|
||||
touchMockChannel(channel);
|
||||
return;
|
||||
}
|
||||
|
||||
await relayEmptyRequest(config, `/api/channels/${args.channelId}/unarchive`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDeleteChannel(
|
||||
args: { channelId: string },
|
||||
config: E2eConfig | undefined,
|
||||
) {
|
||||
const identity = getIdentity(config);
|
||||
if (!identity) {
|
||||
const index = mockChannels.findIndex(
|
||||
(channel) => channel.id === args.channelId,
|
||||
);
|
||||
if (index === -1) {
|
||||
throw new Error(`Channel ${args.channelId} not found.`);
|
||||
}
|
||||
|
||||
mockChannels.splice(index, 1);
|
||||
mockMessages.delete(args.channelId);
|
||||
return;
|
||||
}
|
||||
|
||||
await relayEmptyRequest(config, `/api/channels/${args.channelId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAddChannelMembers(
|
||||
args: {
|
||||
channelId: string;
|
||||
pubkeys: string[];
|
||||
role?: RawChannelMember["role"];
|
||||
},
|
||||
config: E2eConfig | undefined,
|
||||
): Promise<RawAddChannelMembersResponse> {
|
||||
const identity = getIdentity(config);
|
||||
if (!identity) {
|
||||
const channel = getMockChannel(args.channelId);
|
||||
const added: string[] = [];
|
||||
const errors: RawAddChannelMembersResponse["errors"] = [];
|
||||
|
||||
for (const pubkey of args.pubkeys) {
|
||||
if (channel.members.some((member) => member.pubkey === pubkey)) {
|
||||
errors.push({
|
||||
pubkey,
|
||||
error: "Already a member.",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
channel.members.push({
|
||||
pubkey,
|
||||
role: args.role ?? "member",
|
||||
joined_at: new Date().toISOString(),
|
||||
display_name: mockDisplayNames.get(pubkey) ?? null,
|
||||
});
|
||||
added.push(pubkey);
|
||||
}
|
||||
|
||||
syncMockChannel(channel);
|
||||
touchMockChannel(channel);
|
||||
return {
|
||||
added,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
return relayJsonRequest<RawAddChannelMembersResponse>(
|
||||
config,
|
||||
`/api/channels/${args.channelId}/members`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
pubkeys: args.pubkeys,
|
||||
role: args.role,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function handleRemoveChannelMember(
|
||||
args: {
|
||||
channelId: string;
|
||||
pubkey: string;
|
||||
},
|
||||
config: E2eConfig | undefined,
|
||||
) {
|
||||
const identity = getIdentity(config);
|
||||
if (!identity) {
|
||||
const channel = getMockChannel(args.channelId);
|
||||
channel.members = channel.members.filter(
|
||||
(member) => member.pubkey !== args.pubkey,
|
||||
);
|
||||
syncMockChannel(channel);
|
||||
touchMockChannel(channel);
|
||||
return;
|
||||
}
|
||||
|
||||
await relayEmptyRequest(
|
||||
config,
|
||||
`/api/channels/${args.channelId}/members/${args.pubkey}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function handleJoinChannel(
|
||||
args: {
|
||||
channelId: string;
|
||||
},
|
||||
config: E2eConfig | undefined,
|
||||
) {
|
||||
const identity = getIdentity(config);
|
||||
if (!identity) {
|
||||
const channel = getMockChannel(args.channelId);
|
||||
const currentPubkey = getMockMemberPubkey(config);
|
||||
|
||||
if (channel.members.some((member) => member.pubkey === currentPubkey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
channel.members.push(createCurrentMember(config, "member"));
|
||||
syncMockChannel(channel);
|
||||
touchMockChannel(channel);
|
||||
return;
|
||||
}
|
||||
|
||||
await relayEmptyRequest(config, `/api/channels/${args.channelId}/join`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
async function handleLeaveChannel(
|
||||
args: {
|
||||
channelId: string;
|
||||
},
|
||||
config: E2eConfig | undefined,
|
||||
) {
|
||||
const identity = getIdentity(config);
|
||||
if (!identity) {
|
||||
const channel = getMockChannel(args.channelId);
|
||||
const currentPubkey = getMockMemberPubkey(config);
|
||||
|
||||
channel.members = channel.members.filter(
|
||||
(member) => member.pubkey !== currentPubkey,
|
||||
);
|
||||
syncMockChannel(channel);
|
||||
touchMockChannel(channel);
|
||||
return;
|
||||
}
|
||||
|
||||
await relayEmptyRequest(config, `/api/channels/${args.channelId}/leave`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
async function handleGetFeed(
|
||||
@@ -837,6 +1538,66 @@ export function maybeInstallE2eTauriMocks() {
|
||||
payload as Parameters<typeof handleCreateChannel>[0],
|
||||
activeConfig,
|
||||
);
|
||||
case "get_channel_details":
|
||||
return handleGetChannelDetails(
|
||||
payload as Parameters<typeof handleGetChannelDetails>[0],
|
||||
activeConfig,
|
||||
);
|
||||
case "get_channel_members":
|
||||
return handleGetChannelMembers(
|
||||
payload as Parameters<typeof handleGetChannelMembers>[0],
|
||||
activeConfig,
|
||||
);
|
||||
case "update_channel":
|
||||
return handleUpdateChannel(
|
||||
payload as Parameters<typeof handleUpdateChannel>[0],
|
||||
activeConfig,
|
||||
);
|
||||
case "set_channel_topic":
|
||||
return handleSetChannelTopic(
|
||||
payload as Parameters<typeof handleSetChannelTopic>[0],
|
||||
activeConfig,
|
||||
);
|
||||
case "set_channel_purpose":
|
||||
return handleSetChannelPurpose(
|
||||
payload as Parameters<typeof handleSetChannelPurpose>[0],
|
||||
activeConfig,
|
||||
);
|
||||
case "archive_channel":
|
||||
return handleArchiveChannel(
|
||||
payload as Parameters<typeof handleArchiveChannel>[0],
|
||||
activeConfig,
|
||||
);
|
||||
case "unarchive_channel":
|
||||
return handleUnarchiveChannel(
|
||||
payload as Parameters<typeof handleUnarchiveChannel>[0],
|
||||
activeConfig,
|
||||
);
|
||||
case "delete_channel":
|
||||
return handleDeleteChannel(
|
||||
payload as Parameters<typeof handleDeleteChannel>[0],
|
||||
activeConfig,
|
||||
);
|
||||
case "add_channel_members":
|
||||
return handleAddChannelMembers(
|
||||
payload as Parameters<typeof handleAddChannelMembers>[0],
|
||||
activeConfig,
|
||||
);
|
||||
case "remove_channel_member":
|
||||
return handleRemoveChannelMember(
|
||||
payload as Parameters<typeof handleRemoveChannelMember>[0],
|
||||
activeConfig,
|
||||
);
|
||||
case "join_channel":
|
||||
return handleJoinChannel(
|
||||
payload as Parameters<typeof handleJoinChannel>[0],
|
||||
activeConfig,
|
||||
);
|
||||
case "leave_channel":
|
||||
return handleLeaveChannel(
|
||||
payload as Parameters<typeof handleLeaveChannel>[0],
|
||||
activeConfig,
|
||||
);
|
||||
case "search_messages":
|
||||
return handleSearchMessages(
|
||||
payload as Parameters<typeof handleSearchMessages>[0],
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge";
|
||||
|
||||
const MOCK_IDENTITY_PUBKEY = "deadbeef".repeat(8);
|
||||
|
||||
async function openChannelManagement(
|
||||
page: import("@playwright/test").Page,
|
||||
channelName: string,
|
||||
) {
|
||||
await page.getByTestId(`channel-${channelName}`).click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
|
||||
await page.getByTestId("channel-management-trigger").click();
|
||||
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
|
||||
}
|
||||
|
||||
async function closeChannelManagement(page: import("@playwright/test").Page) {
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("channel-management-sheet")).not.toBeVisible();
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await installMockBridge(page);
|
||||
@@ -123,3 +140,155 @@ test("sidebar persists after channel switch", async ({ page }) => {
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("watercooler");
|
||||
await expect(page.getByTestId("app-sidebar")).toBeVisible();
|
||||
});
|
||||
|
||||
test("manage channel updates details and context", async ({ page }) => {
|
||||
const stamp = Date.now();
|
||||
const newName = `release-hub-${stamp}`;
|
||||
const newDescription = `Release coordination ${stamp}`;
|
||||
const newTopic = `Launch plan ${stamp}`;
|
||||
const newPurpose = `Track blockers and owners ${stamp}`;
|
||||
|
||||
await page.goto("/");
|
||||
await openChannelManagement(page, "general");
|
||||
|
||||
await page.getByTestId("channel-management-name").fill(newName);
|
||||
await page.getByTestId("channel-management-description").fill(newDescription);
|
||||
await page.getByTestId("channel-management-save-details").click();
|
||||
|
||||
await expect(page.getByTestId("chat-title")).toHaveText(newName);
|
||||
await expect(page.getByTestId("stream-list")).toContainText(newName);
|
||||
|
||||
const saveTopicButton = page.getByTestId("channel-management-save-topic");
|
||||
const savePurposeButton = page.getByTestId("channel-management-save-purpose");
|
||||
|
||||
await page.getByTestId("channel-management-topic").fill(newTopic);
|
||||
await saveTopicButton.click();
|
||||
await expect(saveTopicButton).toHaveText("Save topic");
|
||||
await expect(page.getByTestId("channel-management-topic")).toHaveValue(
|
||||
newTopic,
|
||||
);
|
||||
|
||||
await page.getByTestId("channel-management-purpose").fill(newPurpose);
|
||||
await savePurposeButton.click();
|
||||
await expect(savePurposeButton).toHaveText("Save purpose");
|
||||
await expect(page.getByTestId("channel-management-purpose")).toHaveValue(
|
||||
newPurpose,
|
||||
);
|
||||
|
||||
await closeChannelManagement(page);
|
||||
|
||||
await page.getByTestId("channel-random").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("random");
|
||||
|
||||
await page.getByTestId("stream-list").getByText(newName).click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText(newName);
|
||||
await page.getByTestId("channel-management-trigger").click();
|
||||
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId("channel-management-name")).toHaveValue(
|
||||
newName,
|
||||
);
|
||||
await expect(page.getByTestId("channel-management-description")).toHaveValue(
|
||||
newDescription,
|
||||
);
|
||||
await expect(page.getByTestId("channel-management-topic")).toHaveValue(
|
||||
newTopic,
|
||||
);
|
||||
await expect(page.getByTestId("channel-management-purpose")).toHaveValue(
|
||||
newPurpose,
|
||||
);
|
||||
});
|
||||
|
||||
test("manage channel can invite and remove members", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await openChannelManagement(page, "general");
|
||||
|
||||
await page
|
||||
.getByTestId("channel-management-add-pubkeys")
|
||||
.fill(TEST_IDENTITIES.charlie.pubkey);
|
||||
await page.getByTestId("channel-management-add-role").selectOption("admin");
|
||||
await page.getByTestId("channel-management-add-members").click();
|
||||
|
||||
await expect(page.getByTestId("channel-management-add-pubkeys")).toHaveValue(
|
||||
"",
|
||||
);
|
||||
await expect(
|
||||
page.getByTestId(`channel-member-${TEST_IDENTITIES.charlie.pubkey}`),
|
||||
).toContainText("charlie");
|
||||
await expect(
|
||||
page.getByTestId(`channel-member-${TEST_IDENTITIES.charlie.pubkey}`),
|
||||
).toContainText("admin");
|
||||
|
||||
await page
|
||||
.getByTestId(`remove-member-${TEST_IDENTITIES.charlie.pubkey}`)
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByTestId(`channel-member-${TEST_IDENTITIES.charlie.pubkey}`),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("open channel management supports join and leave", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await openChannelManagement(page, "random");
|
||||
|
||||
await expect(page.getByTestId("channel-management-join")).toBeVisible();
|
||||
await expect(page.getByTestId("channel-management-leave")).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("channel-management-join").click();
|
||||
|
||||
await expect(page.getByTestId("channel-management-join")).toHaveCount(0);
|
||||
await expect(page.getByTestId("channel-management-leave")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId(`channel-member-${MOCK_IDENTITY_PUBKEY}`),
|
||||
).toContainText("You");
|
||||
|
||||
await page.getByTestId("channel-management-leave").click();
|
||||
await expect(page.getByTestId("channel-management-sheet")).not.toBeVisible();
|
||||
|
||||
await page.getByTestId("channel-management-trigger").click();
|
||||
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
|
||||
await expect(page.getByTestId("channel-management-join")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId(`channel-member-${MOCK_IDENTITY_PUBKEY}`),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("manage channel can archive and unarchive a stream", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await openChannelManagement(page, "general");
|
||||
|
||||
await page.getByTestId("channel-management-archive").click();
|
||||
await expect(page.getByTestId("channel-management-unarchive")).toBeVisible();
|
||||
|
||||
await closeChannelManagement(page);
|
||||
await expect(page.getByTestId("message-input")).toBeDisabled();
|
||||
await expect(page.getByTestId("send-message")).toBeDisabled();
|
||||
|
||||
await page.getByTestId("channel-management-trigger").click();
|
||||
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
|
||||
await page.getByTestId("channel-management-unarchive").click();
|
||||
await expect(page.getByTestId("channel-management-archive")).toBeVisible();
|
||||
|
||||
await closeChannelManagement(page);
|
||||
await expect(page.getByTestId("message-input")).toBeEnabled();
|
||||
});
|
||||
|
||||
test("manage channel can delete an owned stream", async ({ page }) => {
|
||||
const channelName = `delete-me-${Date.now()}`;
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Create a stream" }).click();
|
||||
await page.getByTestId("create-stream-name").fill(channelName);
|
||||
await page.getByRole("button", { name: "Create" }).click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
|
||||
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
|
||||
await page.getByTestId("channel-management-trigger").click();
|
||||
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
|
||||
await page.getByTestId("channel-management-delete").click();
|
||||
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("Home");
|
||||
await expect(page.getByTestId("stream-list")).not.toContainText(channelName);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,32 @@ import { expect, test, type Browser } from "@playwright/test";
|
||||
import { installRelayBridge } from "../helpers/bridge";
|
||||
import { assertRelaySeeded } from "../helpers/seed";
|
||||
|
||||
async function createStream(
|
||||
page: import("@playwright/test").Page,
|
||||
channelName: string,
|
||||
description?: string,
|
||||
) {
|
||||
await page.getByRole("button", { name: "Create a stream" }).click();
|
||||
await page.getByTestId("create-stream-name").fill(channelName);
|
||||
if (description !== undefined) {
|
||||
await page.getByTestId("create-stream-description").fill(description);
|
||||
}
|
||||
await page.getByRole("button", { name: "Create" }).click();
|
||||
|
||||
await expect(page.getByTestId("stream-list")).toContainText(channelName);
|
||||
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
|
||||
}
|
||||
|
||||
async function openChannelManagement(page: import("@playwright/test").Page) {
|
||||
await page.getByTestId("channel-management-trigger").click();
|
||||
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
|
||||
}
|
||||
|
||||
async function closeChannelManagement(page: import("@playwright/test").Page) {
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("channel-management-sheet")).not.toBeVisible();
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await assertRelaySeeded();
|
||||
});
|
||||
@@ -118,13 +144,8 @@ test("create channel with description", async ({ page }) => {
|
||||
|
||||
await installRelayBridge(page, "tyler");
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Create a stream" }).click();
|
||||
await page.getByTestId("create-stream-name").fill(channelName);
|
||||
await page.getByTestId("create-stream-description").fill(description);
|
||||
await page.getByRole("button", { name: "Create" }).click();
|
||||
await createStream(page, channelName, description);
|
||||
|
||||
await expect(page.getByTestId("stream-list")).toContainText(channelName);
|
||||
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
|
||||
await expect(page.getByTestId("chat-description")).toContainText(description);
|
||||
});
|
||||
|
||||
@@ -162,3 +183,106 @@ test("multiple channels independent", async ({ page }) => {
|
||||
messageA,
|
||||
);
|
||||
});
|
||||
|
||||
test("manage sheet updates channel details and context through the relay", async ({
|
||||
page,
|
||||
}) => {
|
||||
const stamp = Date.now();
|
||||
const initialName = `manage-integration-${stamp}`;
|
||||
const renamedChannel = `manage-renamed-${stamp}`;
|
||||
const initialDescription = `Initial description ${stamp}`;
|
||||
const updatedDescription = `Updated description ${stamp}`;
|
||||
const updatedTopic = `Updated topic ${stamp}`;
|
||||
const updatedPurpose = `Updated purpose ${stamp}`;
|
||||
|
||||
await installRelayBridge(page, "tyler");
|
||||
await page.goto("/");
|
||||
await createStream(page, initialName, initialDescription);
|
||||
|
||||
await openChannelManagement(page);
|
||||
await page.getByTestId("channel-management-name").fill(renamedChannel);
|
||||
await page
|
||||
.getByTestId("channel-management-description")
|
||||
.fill(updatedDescription);
|
||||
await page.getByTestId("channel-management-save-details").click();
|
||||
|
||||
await expect(page.getByTestId("chat-title")).toHaveText(renamedChannel);
|
||||
await expect(page.getByTestId("stream-list")).toContainText(renamedChannel);
|
||||
|
||||
const saveTopicButton = page.getByTestId("channel-management-save-topic");
|
||||
const savePurposeButton = page.getByTestId("channel-management-save-purpose");
|
||||
|
||||
await page.getByTestId("channel-management-topic").fill(updatedTopic);
|
||||
await saveTopicButton.click();
|
||||
await expect(saveTopicButton).toHaveText("Save topic");
|
||||
await expect(page.getByTestId("channel-management-topic")).toHaveValue(
|
||||
updatedTopic,
|
||||
);
|
||||
|
||||
await page.getByTestId("channel-management-purpose").fill(updatedPurpose);
|
||||
await savePurposeButton.click();
|
||||
await expect(savePurposeButton).toHaveText("Save purpose");
|
||||
await expect(page.getByTestId("channel-management-purpose")).toHaveValue(
|
||||
updatedPurpose,
|
||||
);
|
||||
|
||||
await closeChannelManagement(page);
|
||||
await page.reload();
|
||||
|
||||
await page.getByTestId(`channel-${renamedChannel}`).click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText(renamedChannel);
|
||||
await expect(page.getByTestId("chat-description")).toContainText(
|
||||
updatedTopic,
|
||||
);
|
||||
await expect(page.getByTestId("chat-description")).toContainText(
|
||||
updatedDescription,
|
||||
);
|
||||
await expect(page.getByTestId("chat-description")).toContainText(
|
||||
updatedPurpose,
|
||||
);
|
||||
|
||||
await openChannelManagement(page);
|
||||
await expect(page.getByTestId("channel-management-name")).toHaveValue(
|
||||
renamedChannel,
|
||||
);
|
||||
await expect(page.getByTestId("channel-management-description")).toHaveValue(
|
||||
updatedDescription,
|
||||
);
|
||||
await expect(page.getByTestId("channel-management-topic")).toHaveValue(
|
||||
updatedTopic,
|
||||
);
|
||||
await expect(page.getByTestId("channel-management-purpose")).toHaveValue(
|
||||
updatedPurpose,
|
||||
);
|
||||
});
|
||||
|
||||
test("manage sheet archive and unarchive survives a reload through the relay", async ({
|
||||
page,
|
||||
}) => {
|
||||
const channelName = `archive-integration-${Date.now()}`;
|
||||
|
||||
await installRelayBridge(page, "tyler");
|
||||
await page.goto("/");
|
||||
await createStream(page, channelName, "Archive integration channel");
|
||||
|
||||
await openChannelManagement(page);
|
||||
await page.getByTestId("channel-management-archive").click();
|
||||
await expect(page.getByTestId("channel-management-unarchive")).toBeVisible();
|
||||
await closeChannelManagement(page);
|
||||
|
||||
await expect(page.getByTestId("message-input")).toBeDisabled();
|
||||
await expect(page.getByTestId("send-message")).toBeDisabled();
|
||||
|
||||
await page.reload();
|
||||
|
||||
await page.getByTestId(`channel-${channelName}`).click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
|
||||
await expect(page.getByTestId("message-input")).toBeDisabled();
|
||||
|
||||
await openChannelManagement(page);
|
||||
await page.getByTestId("channel-management-unarchive").click();
|
||||
await expect(page.getByTestId("channel-management-archive")).toBeVisible();
|
||||
await closeChannelManagement(page);
|
||||
|
||||
await expect(page.getByTestId("message-input")).toBeEnabled();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user