feat: add channel creation flow (#8)

This commit is contained in:
Wes
2026-03-09 17:33:13 -07:00
committed by GitHub
parent 774bd62fb3
commit 6f900bcbcc
16 changed files with 582 additions and 78 deletions
+17 -1
View File
@@ -185,7 +185,7 @@ pub struct MemberRecord {
pub removed_at: Option<DateTime<Utc>>,
}
/// Creates a new channel and returns the resulting record.
/// Creates a new channel, bootstraps the creator as owner, and returns the record.
pub async fn create_channel(
pool: &MySqlPool,
name: &str,
@@ -224,6 +224,22 @@ pub async fn create_channel(
.execute(&mut *tx)
.await?;
sqlx::query(
r#"
INSERT INTO channel_members (channel_id, pubkey, role, invited_by)
VALUES (?, ?, 'owner', ?)
ON DUPLICATE KEY UPDATE
removed_at = NULL,
removed_by = NULL,
role = VALUES(role)
"#,
)
.bind(&id_bytes)
.bind(created_by)
.bind(created_by)
.execute(&mut *tx)
.await?;
let row = sqlx::query(
r#"
SELECT id, name, channel_type, visibility, description, canvas,
+2 -6
View File
@@ -174,7 +174,7 @@ impl Db {
// ── Channels ─────────────────────────────────────────────────────────────
/// Creates a new channel and returns the resulting record.
/// Creates a new channel, bootstraps the creator as owner, and returns the record.
pub async fn create_channel(
&self,
name: &str,
@@ -802,11 +802,7 @@ mod tests {
.expect("create");
assert_eq!(channel.name, "test-membership");
assert_eq!(channel.description, Some("desc".to_string()));
// Bootstrap owner
db.add_member(channel.id, &owner, channel::MemberRole::Owner, Some(&owner))
.await
.expect("add owner");
assert!(db.is_member(channel.id, &owner).await.unwrap());
// Add member via owner invite
db.add_member(
+98 -11
View File
@@ -1,19 +1,25 @@
//! GET /api/channels — list accessible channels for the authenticated user.
//! Channel REST API.
//!
//! Endpoints:
//! GET /api/channels — list accessible channels for the authenticated user
//! POST /api/channels — create a new channel for the authenticated user
use std::collections::HashMap;
use std::sync::Arc;
use axum::{
extract::Json as ExtractJson,
extract::State,
http::{HeaderMap, StatusCode},
response::Json,
};
use nostr::util::hex as nostr_hex;
use serde::Deserialize;
use sprout_db::channel::{ChannelRecord, ChannelType, ChannelVisibility};
use crate::state::AppState;
use super::{extract_auth_pubkey, internal_error};
use super::{api_error, extract_auth_pubkey, internal_error};
/// Returns all channels accessible to the authenticated user.
///
@@ -39,19 +45,100 @@ pub async fn channels_handler(
(vec![], vec![])
};
result.push(serde_json::json!({
"id": ch.id.to_string(),
"name": ch.name,
"channel_type": ch.channel_type,
"description": ch.description.clone().unwrap_or_default(),
"participants": participants,
"participant_pubkeys": participant_pubkeys,
}));
result.push(channel_record_to_json(
ch,
participants,
participant_pubkeys,
));
}
Ok(Json(serde_json::json!(result)))
}
/// Request body for creating a new channel.
#[derive(Debug, Deserialize)]
pub struct CreateChannelBody {
/// Human-readable channel name.
pub name: String,
/// Requested channel type (`stream` or `forum`).
pub channel_type: String,
/// Channel visibility (`open` or `private`).
pub visibility: String,
/// Optional channel description.
pub description: Option<String>,
}
/// Creates a new stream or forum channel for the authenticated user.
pub async fn create_channel(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
ExtractJson(body): ExtractJson<CreateChannelBody>,
) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, Json<serde_json::Value>)> {
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
let name = body.name.trim();
if name.is_empty() {
return Err(api_error(
StatusCode::BAD_REQUEST,
"channel name is required",
));
}
let channel_type = match body.channel_type.as_str() {
"stream" => ChannelType::Stream,
"forum" => ChannelType::Forum,
_ => {
return Err(api_error(
StatusCode::BAD_REQUEST,
"channel_type must be 'stream' or 'forum'",
))
}
};
let visibility = match body.visibility.as_str() {
"open" => ChannelVisibility::Open,
"private" => ChannelVisibility::Private,
_ => {
return Err(api_error(
StatusCode::BAD_REQUEST,
"visibility must be 'open' or 'private'",
))
}
};
let description = body
.description
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
let channel = state
.db
.create_channel(name, channel_type, visibility, description, &pubkey_bytes)
.await
.map_err(|e| internal_error(&format!("db error: {e}")))?;
Ok((
StatusCode::CREATED,
Json(channel_record_to_json(&channel, vec![], vec![])),
))
}
fn channel_record_to_json(
channel: &ChannelRecord,
participants: Vec<String>,
participant_pubkeys: Vec<String>,
) -> serde_json::Value {
serde_json::json!({
"id": channel.id.to_string(),
"name": &channel.name,
"channel_type": &channel.channel_type,
"description": channel.description.clone().unwrap_or_default(),
"participants": participants,
"participant_pubkeys": participant_pubkeys,
})
}
/// Fetch DM participants and resolve their display names.
async fn resolve_dm_participants(
state: &AppState,
+2 -2
View File
@@ -1,7 +1,7 @@
//! HTTP REST API handlers for the Sprout relay.
//!
//! Endpoints are split into focused submodules:
//! - `channels` — GET /api/channels
//! - `channels` — GET/POST /api/channels
//! - `search` — GET /api/search
//! - `agents` — GET /api/agents
//! - `presence` — GET /api/presence
@@ -29,7 +29,7 @@ pub mod workflows;
// Re-export all public handlers so router.rs can use `api::*_handler` unchanged.
pub use agents::agents_handler;
pub use approvals::{deny_approval, grant_approval};
pub use channels::channels_handler;
pub use channels::{channels_handler, create_channel};
pub use feed::feed_handler;
pub use presence::presence_handler;
pub use search::search_handler;
+4 -1
View File
@@ -25,7 +25,10 @@ pub fn build_router(state: Arc<AppState>) -> Router {
.route("/info", get(relay_info_handler))
.route("/.well-known/nostr.json", get(nip05_handler))
.route("/health", get(health_handler))
.route("/api/channels", get(api::channels_handler))
.route(
"/api/channels",
get(api::channels_handler).post(api::create_channel),
)
.route("/api/search", get(api::search_handler))
.route("/api/agents", get(api::agents_handler))
.route("/api/presence", get(api::presence_handler))
@@ -20,10 +20,9 @@
//!
//! # Channel setup
//!
//! The relay does not expose a REST endpoint to create channels — channels are
//! created via the DB (seeded at startup). Tests use the pre-seeded open
//! channels (`general`, `agents`, `projects`, etc.) for read operations and
//! send messages via WebSocket to set up search / feed data.
//! The relay exposes REST endpoints to list and create channels. Tests use the
//! pre-seeded open channels (`general`, `agents`, `projects`, etc.) for read
//! operations and create temporary channels for write coverage when needed.
use std::time::Duration;
@@ -63,6 +62,22 @@ async fn authed_get(client: &Client, url: &str, pubkey_hex: &str) -> reqwest::Re
.unwrap_or_else(|e| panic!("HTTP GET {url} failed: {e}"))
}
/// Make an authenticated POST request using the `X-Pubkey` dev-mode header.
async fn authed_post_json(
client: &Client,
url: &str,
pubkey_hex: &str,
body: serde_json::Value,
) -> reqwest::Response {
client
.post(url)
.header("X-Pubkey", pubkey_hex)
.json(&body)
.send()
.await
.unwrap_or_else(|e| panic!("HTTP POST {url} failed: {e}"))
}
/// Known open channel IDs seeded in the dev database.
///
/// These are stable across relay restarts because they are inserted with
@@ -110,6 +125,59 @@ async fn test_list_channels_returns_expected_fields() {
}
}
/// POST /api/channels creates a new channel owned by the requester.
#[tokio::test]
#[ignore]
async fn test_create_channel_returns_channel_record() {
let client = http_client();
let keys = Keys::generate();
let pubkey_hex = keys.public_key().to_hex();
let url = format!("{}/api/channels", relay_http_url());
let channel_name = format!("desktop-create-{}", uuid::Uuid::new_v4());
let resp = authed_post_json(
&client,
&url,
&pubkey_hex,
serde_json::json!({
"name": channel_name,
"channel_type": "stream",
"visibility": "private",
"description": "Created by the REST API test"
}),
)
.await;
assert_eq!(
resp.status(),
201,
"expected 201 Created from POST /api/channels"
);
let created: serde_json::Value = resp.json().await.expect("response must be JSON");
assert!(created.get("id").is_some(), "channel missing 'id' field");
assert_eq!(created["name"].as_str(), Some(channel_name.as_str()));
assert_eq!(created["channel_type"].as_str(), Some("stream"));
assert_eq!(
created["description"].as_str(),
Some("Created by the REST API test")
);
let list_resp = authed_get(&client, &url, &pubkey_hex).await;
assert_eq!(
list_resp.status(),
200,
"expected 200 OK from /api/channels"
);
let channels: Vec<serde_json::Value> = list_resp.json().await.expect("response must be JSON");
assert!(
channels
.iter()
.any(|channel| channel["id"] == created["id"] && channel["name"] == created["name"]),
"newly-created private channel should be visible to its creator"
);
}
/// Open channels are visible to any authenticated user (no prior membership required).
#[tokio::test]
#[ignore]
+63 -5
View File
@@ -24,6 +24,14 @@ pub struct ChannelInfo {
pub participant_pubkeys: Vec<String>,
}
#[derive(Serialize)]
struct CreateChannelBody<'a> {
name: &'a str,
channel_type: &'a str,
visibility: &'a str,
description: Option<&'a str>,
}
fn relay_ws_url() -> String {
std::env::var("SPROUT_RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string())
}
@@ -43,13 +51,30 @@ async fn build_authed_request(
path: &str,
state: &AppState,
) -> Result<reqwest::RequestBuilder, String> {
let keys = state.keys.lock().map_err(|e| e.to_string())?;
let pubkey_hex = keys.public_key().to_hex();
let pubkey_hex = auth_pubkey_header(state)?;
let url = format!("{}{}", relay_api_base_url(), path);
Ok(client.get(url).header("X-Pubkey", pubkey_hex))
}
fn auth_pubkey_header(state: &AppState) -> Result<String, String> {
let keys = state.keys.lock().map_err(|e| e.to_string())?;
Ok(keys.public_key().to_hex())
}
async fn relay_error_message(response: reqwest::Response) -> String {
let status = response.status();
let body = response.text().await.unwrap_or_default();
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&body) {
if let Some(message) = value.get("error").and_then(serde_json::Value::as_str) {
return format!("relay returned {status}: {message}");
}
}
format!("relay returned {status}: {body}")
}
#[tauri::command]
fn get_identity(state: tauri::State<'_, AppState>) -> Result<IdentityInfo, String> {
let keys = state.keys.lock().map_err(|e| e.to_string())?;
@@ -128,9 +153,7 @@ async fn get_channels(state: tauri::State<'_, AppState>) -> Result<Vec<ChannelIn
.map_err(|e| format!("request failed: {e}"))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("relay returned {status}: {body}"));
return Err(relay_error_message(response).await);
}
response
@@ -139,6 +162,40 @@ async fn get_channels(state: tauri::State<'_, AppState>) -> Result<Vec<ChannelIn
.map_err(|e| format!("parse failed: {e}"))
}
#[tauri::command]
async fn create_channel(
name: String,
channel_type: String,
visibility: String,
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)
.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);
}
response
.json::<ChannelInfo>()
.await
.map_err(|e| format!("parse failed: {e}"))
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let app_state = AppState {
@@ -156,6 +213,7 @@ pub fn run() {
sign_event,
create_auth_event,
get_channels,
create_channel,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+13
View File
@@ -2,6 +2,7 @@ import * as React from "react";
import { ChatHeader } from "@/features/chat/ui/ChatHeader";
import {
useCreateChannelMutation,
useChannelsQuery,
useSelectedChannel,
} from "@/features/channels/hooks";
@@ -25,6 +26,7 @@ export function AppShell() {
channels,
null,
);
const createChannelMutation = useCreateChannelMutation();
const messagesQuery = useChannelMessagesQuery(selectedChannel);
useChannelSubscription(selectedChannel);
@@ -60,6 +62,17 @@ export function AppShell() {
: undefined
}
isLoading={channelsQuery.isLoading}
isCreatingChannel={createChannelMutation.isPending}
onCreateChannel={async ({ description, name }) => {
const createdChannel = await createChannelMutation.mutateAsync({
name,
description,
channelType: "stream",
visibility: "open",
});
React.startTransition(() => setSelectedChannelId(createdChannel.id));
}}
onSelectChannel={(channelId) => {
React.startTransition(() => setSelectedChannelId(channelId));
}}
+43 -4
View File
@@ -1,17 +1,56 @@
import * as React from "react";
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getChannels } from "@/shared/api/tauri";
import type { Channel } from "@/shared/api/types";
import { createChannel, getChannels } from "@/shared/api/tauri";
import type { Channel, CreateChannelInput } from "@/shared/api/types";
const channelsQueryKey = ["channels"] as const;
const channelTypeOrder = {
stream: 0,
forum: 1,
dm: 2,
} as const;
function sortChannels(channels: Channel[]) {
return [...channels].sort((left, right) => {
const typeOrder =
channelTypeOrder[left.channelType] - channelTypeOrder[right.channelType];
if (typeOrder !== 0) {
return typeOrder;
}
return left.name.localeCompare(right.name);
});
}
export function useChannelsQuery() {
return useQuery({
queryKey: ["channels"],
queryKey: channelsQueryKey,
queryFn: getChannels,
staleTime: 30_000,
});
}
export function useCreateChannelMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateChannelInput) => createChannel(input),
onSuccess: (createdChannel) => {
queryClient.setQueryData<Channel[]>(channelsQueryKey, (current = []) =>
sortChannels([
...current.filter((channel) => channel.id !== createdChannel.id),
createdChannel,
]),
);
},
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: channelsQueryKey });
},
});
}
export function useSelectedChannel(
channels: Channel[],
preferredChannelId: string | null,
@@ -35,12 +35,6 @@ export function formatTimelineMessages(
return events.map((event) => ({
id: event.id,
author: formatMessageAuthor(event, channel, currentPubkey),
role:
currentPubkey && event.pubkey === currentPubkey
? "Local"
: channel?.channelType === "dm"
? "Participant"
: "Pubkey",
time: new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "2-digit",
+1 -1
View File
@@ -1,7 +1,7 @@
export type TimelineMessage = {
id: string;
author: string;
role: string;
role?: string;
time: string;
body: string;
accent?: boolean;
@@ -20,10 +20,10 @@ function MessageRow({ message }: { message: TimelineMessage }) {
.toUpperCase();
return (
<article className="flex gap-4">
<article className="flex gap-3">
<div
className={cn(
"flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl text-sm font-semibold shadow-sm",
"flex h-9 w-9 shrink-0 items-center justify-center rounded-xl text-xs font-semibold shadow-sm",
message.accent
? "bg-primary text-primary-foreground"
: "bg-secondary text-secondary-foreground",
@@ -32,20 +32,26 @@ function MessageRow({ message }: { message: TimelineMessage }) {
{initials}
</div>
<div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h3 className="font-semibold tracking-tight">{message.author}</h3>
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
{message.role}
</p>
<p className="text-sm text-muted-foreground">{message.time}</p>
{message.pending ? (
<p className="text-xs font-medium uppercase tracking-[0.2em] text-primary/80">
Sending
<div className="min-w-0 flex-1 space-y-1">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="truncate text-sm font-semibold tracking-tight">
{message.author}
</h3>
{message.role ? (
<p className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
{message.role}
</p>
) : null}
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
{message.pending ? (
<p className="font-medium uppercase tracking-[0.14em] text-primary/80">
Sending
</p>
) : null}
<p className="whitespace-nowrap">{message.time}</p>
</div>
</div>
<Markdown className="max-w-3xl" content={message.body} />
<Markdown className="max-w-3xl" compact content={message.body} />
</div>
</article>
);
@@ -57,10 +63,10 @@ function TimelineSkeleton() {
return (
<>
{skeletonRows.map((row) => (
<div className="flex gap-4" key={row}>
<Skeleton className="h-11 w-11 rounded-2xl" />
<div className="min-w-0 flex-1 space-y-2">
<Skeleton className="h-4 w-40" />
<div className="flex gap-3" key={row}>
<Skeleton className="h-9 w-9 rounded-xl" />
<div className="min-w-0 flex-1 space-y-1.5">
<Skeleton className="h-3.5 w-44" />
<Skeleton className="h-4 w-full max-w-2xl" />
<Skeleton className="h-4 w-full max-w-xl" />
</div>
@@ -77,8 +83,8 @@ export function MessageTimeline({
emptyDescription = "Send the first message to start the thread.",
}: MessageTimelineProps) {
return (
<div className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-6 sm:px-6">
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
<div className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-4 sm:px-6">
<div className="mx-auto flex w-full max-w-4xl flex-col gap-4">
<div className="flex items-center gap-4">
<Separator className="flex-1" />
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-muted-foreground">
+206 -8
View File
@@ -1,13 +1,16 @@
import { CircleDot, FileText, Hash } from "lucide-react";
import { CircleDot, FileText, Hash, Plus } from "lucide-react";
import * as React from "react";
import type { Channel } from "@/shared/api/types";
import { ThemeToggle } from "@/shared/theme/ThemeToggle";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
@@ -22,8 +25,13 @@ import {
type AppSidebarProps = {
channels: Channel[];
isLoading: boolean;
isCreatingChannel: boolean;
errorMessage?: string;
selectedChannelId: string | null;
onCreateChannel: (input: {
name: string;
description?: string;
}) => Promise<void>;
onSelectChannel: (channelId: string) => void;
};
@@ -78,16 +86,143 @@ function SidebarSection({
);
}
function StreamsSection({
items,
isCreateOpen,
isCreatingChannel,
draftName,
draftDescription,
createInputRef,
createErrorMessage,
onToggleCreate,
onChangeName,
onChangeDescription,
onCreateChannel,
onCancelCreate,
onSelectChannel,
selectedChannelId,
}: {
items: Channel[];
isCreateOpen: boolean;
isCreatingChannel: boolean;
draftName: string;
draftDescription: string;
createInputRef: React.RefObject<HTMLInputElement | null>;
createErrorMessage?: string;
onToggleCreate: () => void;
onChangeName: (value: string) => void;
onChangeDescription: (value: string) => void;
onCreateChannel: (event: React.FormEvent<HTMLFormElement>) => void;
onCancelCreate: () => void;
onSelectChannel: (channelId: string) => void;
selectedChannelId: string | null;
}) {
return (
<SidebarGroup>
<SidebarGroupLabel>Streams</SidebarGroupLabel>
<SidebarGroupAction
aria-expanded={isCreateOpen}
aria-label={isCreateOpen ? "Close new stream form" : "Create a stream"}
className="top-3 text-sidebar-foreground/50 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground"
onClick={onToggleCreate}
type="button"
>
<Plus
className={
isCreateOpen
? "rotate-45 transition-transform"
: "transition-transform"
}
/>
</SidebarGroupAction>
<SidebarGroupContent>
{isCreateOpen ? (
<form
className="mb-2 space-y-2 rounded-lg border border-sidebar-border/70 bg-sidebar-accent/60 p-2"
onSubmit={onCreateChannel}
>
<Input
autoComplete="off"
className="h-8 bg-background/80"
disabled={isCreatingChannel}
onChange={(event) => onChangeName(event.target.value)}
placeholder="release-notes"
ref={createInputRef}
value={draftName}
/>
<Input
autoComplete="off"
className="h-8 bg-background/80"
disabled={isCreatingChannel}
onChange={(event) => onChangeDescription(event.target.value)}
placeholder="What this stream is for"
value={draftDescription}
/>
<div className="flex items-center gap-2">
<Button
disabled={isCreatingChannel || draftName.trim().length === 0}
size="sm"
type="submit"
>
{isCreatingChannel ? "Creating..." : "Create"}
</Button>
<Button
disabled={isCreatingChannel}
onClick={onCancelCreate}
size="sm"
type="button"
variant="ghost"
>
Cancel
</Button>
</div>
{createErrorMessage ? (
<p className="text-sm text-destructive">{createErrorMessage}</p>
) : null}
</form>
) : null}
{items.length > 0 ? (
<SidebarMenu>
{items.map((channel) => (
<SidebarMenuItem key={channel.id}>
<SidebarMenuButton
isActive={selectedChannelId === channel.id}
onClick={() => onSelectChannel(channel.id)}
tooltip={channel.name}
type="button"
>
<SidebarChannelIcon channel={channel} />
<span>{channel.name}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
) : null}
</SidebarGroupContent>
</SidebarGroup>
);
}
export function AppSidebar({
channels,
isLoading,
isCreatingChannel,
errorMessage,
selectedChannelId,
onCreateChannel,
onSelectChannel,
}: AppSidebarProps) {
const skeletonRows = ["first", "second", "third", "fourth", "fifth", "sixth"];
const [query, setQuery] = React.useState("");
const [isCreateOpen, setIsCreateOpen] = React.useState(false);
const [draftName, setDraftName] = React.useState("");
const [draftDescription, setDraftDescription] = React.useState("");
const [createErrorMessage, setCreateErrorMessage] = React.useState<
string | undefined
>();
const deferredQuery = React.useDeferredValue(query.trim().toLowerCase());
const createInputRef = React.useRef<HTMLInputElement>(null);
const filteredChannels = React.useMemo(() => {
if (!deferredQuery) {
@@ -109,6 +244,41 @@ export function AppSidebar({
(channel) => channel.channelType === "dm",
);
React.useEffect(() => {
if (!isCreateOpen) {
return;
}
createInputRef.current?.focus();
}, [isCreateOpen]);
async function handleCreateChannel(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const name = draftName.trim();
const description = draftDescription.trim();
if (!name) {
return;
}
setCreateErrorMessage(undefined);
try {
await onCreateChannel({
name,
description: description || undefined,
});
setDraftName("");
setDraftDescription("");
setIsCreateOpen(false);
} catch (error) {
setCreateErrorMessage(
error instanceof Error ? error.message : "Failed to create stream.",
);
}
}
return (
<Sidebar collapsible="offcanvas" variant="sidebar">
<SidebarHeader className="gap-3">
@@ -123,11 +293,13 @@ export function AppSidebar({
</p>
</div>
</div>
<SidebarInput
onChange={(event) => setQuery(event.target.value)}
placeholder="Jump to channel"
value={query}
/>
<div className="flex items-center gap-2">
<SidebarInput
onChange={(event) => setQuery(event.target.value)}
placeholder="Jump to channel"
value={query}
/>
</div>
</SidebarHeader>
<SidebarSeparator />
@@ -148,11 +320,37 @@ export function AppSidebar({
{!isLoading ? (
<>
<SidebarSection
<StreamsSection
createErrorMessage={createErrorMessage}
createInputRef={createInputRef}
draftDescription={draftDescription}
draftName={draftName}
isCreateOpen={isCreateOpen}
isCreatingChannel={isCreatingChannel}
items={streamChannels}
onCancelCreate={() => {
setCreateErrorMessage(undefined);
setDraftName("");
setDraftDescription("");
setIsCreateOpen(false);
}}
onChangeDescription={(value) => {
setCreateErrorMessage(undefined);
setDraftDescription(value);
}}
onChangeName={(value) => {
setCreateErrorMessage(undefined);
setDraftName(value);
}}
onCreateChannel={(event) => {
void handleCreateChannel(event);
}}
onSelectChannel={onSelectChannel}
onToggleCreate={() => {
setCreateErrorMessage(undefined);
setIsCreateOpen((current) => !current);
}}
selectedChannelId={selectedChannelId}
title="Streams"
/>
<SidebarSection
items={forumChannels}
+19 -8
View File
@@ -3,6 +3,7 @@ import { invoke } from "@tauri-apps/api/core";
import type {
Channel,
ChannelType,
CreateChannelInput,
Identity,
RelayEvent,
} from "@/shared/api/types";
@@ -21,6 +22,17 @@ type RawChannel = {
participant_pubkeys: string[];
};
function fromRawChannel(channel: RawChannel): Channel {
return {
id: channel.id,
name: channel.name,
channelType: channel.channel_type,
description: channel.description,
participants: channel.participants,
participantPubkeys: channel.participant_pubkeys,
};
}
export async function getIdentity(): Promise<Identity> {
const identity = await invoke<RawIdentity>("get_identity");
@@ -36,15 +48,14 @@ export function getRelayWsUrl(): Promise<string> {
export async function getChannels(): Promise<Channel[]> {
const channels = await invoke<RawChannel[]>("get_channels");
return channels.map(fromRawChannel);
}
return channels.map((channel) => ({
id: channel.id,
name: channel.name,
channelType: channel.channel_type,
description: channel.description,
participants: channel.participants,
participantPubkeys: channel.participant_pubkeys,
}));
export async function createChannel(
input: CreateChannelInput,
): Promise<Channel> {
const channel = await invoke<RawChannel>("create_channel", input);
return fromRawChannel(channel);
}
export async function signRelayEvent(input: {
+8
View File
@@ -1,4 +1,5 @@
export type ChannelType = "stream" | "forum" | "dm";
export type ChannelVisibility = "open" | "private";
export type Channel = {
id: string;
@@ -9,6 +10,13 @@ export type Channel = {
participantPubkeys: string[];
};
export type CreateChannelInput = {
name: string;
channelType: Exclude<ChannelType, "dm">;
visibility: ChannelVisibility;
description?: string;
};
export type Identity = {
pubkey: string;
displayName: string;
+9 -2
View File
@@ -7,6 +7,7 @@ import { cn } from "@/shared/lib/cn";
type MarkdownProps = {
className?: string;
compact?: boolean;
content: string;
};
@@ -121,7 +122,11 @@ const markdownComponents: Components = {
),
};
export function Markdown({ className, content }: MarkdownProps) {
export function Markdown({
className,
compact = false,
content,
}: MarkdownProps) {
let processedContent = content;
if (/^(?:\s{2}\n)+/.test(content)) {
@@ -135,7 +140,9 @@ export function Markdown({ className, content }: MarkdownProps) {
return (
<div
className={cn(
"max-w-none break-words text-sm leading-7 text-foreground/90 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*]:my-3",
compact
? "max-w-none break-words text-[15px] leading-6 text-foreground/90 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*]:my-1.5"
: "max-w-none break-words text-sm leading-7 text-foreground/90 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*]:my-3",
className,
)}
>