mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat: add channel creation flow (#8)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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));
|
||||
}}
|
||||
|
||||
@@ -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,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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user