mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat: add channel canvas frontend support (#130)
This commit is contained in:
@@ -31,13 +31,14 @@ const rules = [
|
||||
// Exceptions should stay rare and temporary. Prefer splitting files instead.
|
||||
const overrides = new Map([
|
||||
["src/app/AppShell.tsx", 750],
|
||||
["src/features/channels/hooks.ts", 525], // canvas query + mutation hooks
|
||||
["src/features/channels/ui/ChannelManagementSheet.tsx", 800],
|
||||
["src/features/messages/ui/MessageComposer.tsx", 650], // media upload handlers (paste, drop, dialog)
|
||||
["src/features/settings/ui/SettingsView.tsx", 600],
|
||||
["src/features/sidebar/ui/AppSidebar.tsx", 850], // channels + forums creation forms
|
||||
["src/features/tokens/ui/TokenSettingsCard.tsx", 800],
|
||||
["src/shared/api/relayClientSession.ts", 725], // durable websocket session manager with reconnect/replay/recovery state
|
||||
["src/shared/api/tauri.ts", 975],
|
||||
["src/shared/api/tauri.ts", 1025], // canvas API functions
|
||||
]);
|
||||
|
||||
async function walkFiles(directory) {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
use reqwest::Method;
|
||||
use tauri::State;
|
||||
|
||||
use crate::{
|
||||
app_state::AppState,
|
||||
models::SetCanvasBody,
|
||||
relay::{build_authed_request, send_json_request},
|
||||
};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_canvas(
|
||||
channel_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let path = format!("/api/channels/{channel_id}/canvas");
|
||||
let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?;
|
||||
send_json_request(request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_canvas(
|
||||
channel_id: String,
|
||||
content: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let path = format!("/api/channels/{channel_id}/canvas");
|
||||
let request = build_authed_request(&state.http_client, Method::PUT, &path, &state)?
|
||||
.json(&SetCanvasBody { content: &content });
|
||||
send_json_request(request).await
|
||||
}
|
||||
@@ -2,6 +2,7 @@ mod agent_discovery;
|
||||
mod agent_models;
|
||||
mod agent_settings;
|
||||
mod agents;
|
||||
mod canvas;
|
||||
mod channels;
|
||||
mod dms;
|
||||
mod identity;
|
||||
@@ -15,6 +16,7 @@ pub use agent_discovery::*;
|
||||
pub use agent_models::*;
|
||||
pub use agent_settings::*;
|
||||
pub use agents::*;
|
||||
pub use canvas::*;
|
||||
pub use channels::*;
|
||||
pub use dms::*;
|
||||
pub use identity::*;
|
||||
|
||||
@@ -141,6 +141,8 @@ pub fn run() {
|
||||
remove_channel_member,
|
||||
join_channel,
|
||||
leave_channel,
|
||||
get_canvas,
|
||||
set_canvas,
|
||||
get_feed,
|
||||
search_messages,
|
||||
send_channel_message,
|
||||
|
||||
@@ -392,6 +392,11 @@ pub struct GetForumThreadQuery {
|
||||
pub cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SetCanvasBody<'a> {
|
||||
pub content: &'a str,
|
||||
}
|
||||
|
||||
fn deserialize_null_string_as_empty<'de, D>(deserializer: D) -> Result<String, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
archiveChannel,
|
||||
createChannel,
|
||||
deleteChannel,
|
||||
getCanvas,
|
||||
getChannelDetails,
|
||||
getChannelMembers,
|
||||
getChannels,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
leaveChannel,
|
||||
openDm,
|
||||
removeChannelMember,
|
||||
setCanvas,
|
||||
setChannelPurpose,
|
||||
setChannelTopic,
|
||||
unarchiveChannel,
|
||||
@@ -474,3 +476,38 @@ export function useSelectedChannel(
|
||||
setSelectedChannelId,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Canvas ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useCanvasQuery(channelId: string | null, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ["channel-canvas", channelId],
|
||||
queryFn: () => {
|
||||
if (!channelId) {
|
||||
return Promise.reject(new Error("No channel selected"));
|
||||
}
|
||||
return getCanvas(channelId);
|
||||
},
|
||||
enabled: enabled && channelId !== null,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetCanvasMutation(channelId: string | null) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (content: string) => {
|
||||
if (!channelId) {
|
||||
return Promise.reject(new Error("No channel selected"));
|
||||
}
|
||||
return setCanvas({ channelId, content });
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (channelId) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["channel-canvas", channelId],
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { Pencil, Save, X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
useCanvasQuery,
|
||||
useSetCanvasMutation,
|
||||
} from "@/features/channels/hooks";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
|
||||
type ChannelCanvasProps = {
|
||||
channelId: string | null;
|
||||
canEdit: boolean;
|
||||
isArchived: boolean;
|
||||
};
|
||||
|
||||
export function ChannelCanvas({
|
||||
channelId,
|
||||
canEdit,
|
||||
isArchived,
|
||||
}: ChannelCanvasProps) {
|
||||
const canvasQuery = useCanvasQuery(channelId, channelId !== null);
|
||||
const setCanvasMutation = useSetCanvasMutation(channelId);
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const [draft, setDraft] = React.useState("");
|
||||
|
||||
const canvasContent = canvasQuery.data?.content ?? null;
|
||||
|
||||
function handleStartEditing() {
|
||||
setDraft(canvasContent ?? "");
|
||||
setIsEditing(true);
|
||||
}
|
||||
|
||||
function handleCancelEditing() {
|
||||
setIsEditing(false);
|
||||
setDraft("");
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
await setCanvasMutation.mutateAsync(draft);
|
||||
setIsEditing(false);
|
||||
}
|
||||
|
||||
if (canvasQuery.isLoading) {
|
||||
return <p className="text-sm text-muted-foreground">Loading canvas…</p>;
|
||||
}
|
||||
|
||||
if (canvasQuery.error instanceof Error) {
|
||||
return (
|
||||
<p className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{canvasQuery.error.message}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Textarea
|
||||
aria-label="Canvas content"
|
||||
className="min-h-48 font-mono text-sm"
|
||||
data-testid="channel-canvas-editor"
|
||||
disabled={setCanvasMutation.isPending}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
placeholder="Write your canvas content in Markdown…"
|
||||
value={draft}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
data-testid="channel-canvas-save"
|
||||
disabled={setCanvasMutation.isPending}
|
||||
onClick={() => {
|
||||
void handleSave().catch(() => {
|
||||
// Error is already surfaced via setCanvasMutation.error
|
||||
});
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{setCanvasMutation.isPending ? "Saving…" : "Save canvas"}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="channel-canvas-cancel"
|
||||
disabled={setCanvasMutation.isPending}
|
||||
onClick={handleCancelEditing}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
{setCanvasMutation.error instanceof Error ? (
|
||||
<p className="text-sm text-destructive">
|
||||
{setCanvasMutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{canvasContent ? (
|
||||
<div
|
||||
className="rounded-2xl border border-border/70 bg-muted/20 px-4 py-3"
|
||||
data-testid="channel-canvas-content"
|
||||
>
|
||||
<Markdown compact content={canvasContent} />
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No canvas set for this channel.
|
||||
</p>
|
||||
)}
|
||||
{canEdit && !isArchived ? (
|
||||
<Button
|
||||
data-testid="channel-canvas-edit"
|
||||
onClick={handleStartEditing}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
{canvasContent ? "Edit canvas" : "Create canvas"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
SheetTitle,
|
||||
} from "@/shared/ui/sheet";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import { ChannelCanvas } from "./ChannelCanvas";
|
||||
import { ChannelMemberInviteCard } from "./ChannelMemberInviteCard";
|
||||
|
||||
type ChannelManagementSheetProps = {
|
||||
@@ -516,6 +517,19 @@ export function ChannelManagementSheet({
|
||||
|
||||
<Separator />
|
||||
|
||||
<Section
|
||||
description="A shared Markdown document for the channel."
|
||||
title="Canvas"
|
||||
>
|
||||
<ChannelCanvas
|
||||
canEdit={canEditNarrative}
|
||||
channelId={channelId}
|
||||
isArchived={isArchived}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Section
|
||||
description="Owners and admins can invite members or remove them."
|
||||
title="Members"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { invoke as tauriInvoke } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
AddChannelMembersInput,
|
||||
AddChannelMembersResult,
|
||||
CanvasResponse,
|
||||
Channel,
|
||||
ChannelDetail,
|
||||
ChannelMember,
|
||||
@@ -23,6 +24,8 @@ import type {
|
||||
SearchMessagesInput,
|
||||
SearchMessagesResponse,
|
||||
SendChannelMessageResult,
|
||||
SetCanvasInput,
|
||||
SetCanvasResult,
|
||||
SetPresenceResult,
|
||||
SetChannelPurposeInput,
|
||||
SetChannelTopicInput,
|
||||
@@ -280,6 +283,17 @@ type RawManagedAgentPrereqs = {
|
||||
mcp: RawCommandAvailability;
|
||||
};
|
||||
|
||||
type RawCanvasResponse = {
|
||||
content: string | null;
|
||||
updated_at: number | null;
|
||||
author: string | null;
|
||||
};
|
||||
|
||||
type RawSetCanvasResult = {
|
||||
ok: boolean;
|
||||
event_id: string;
|
||||
};
|
||||
|
||||
function toTauriError(error: unknown): Error {
|
||||
if (error instanceof Error) {
|
||||
return error;
|
||||
@@ -588,6 +602,30 @@ export async function leaveChannel(channelId: string): Promise<void> {
|
||||
await invokeTauri("leave_channel", { channelId });
|
||||
}
|
||||
|
||||
export async function getCanvas(channelId: string): Promise<CanvasResponse> {
|
||||
const response = await invokeTauri<RawCanvasResponse>("get_canvas", {
|
||||
channelId,
|
||||
});
|
||||
return {
|
||||
content: response.content,
|
||||
updatedAt: response.updated_at,
|
||||
author: response.author,
|
||||
};
|
||||
}
|
||||
|
||||
export async function setCanvas(
|
||||
input: SetCanvasInput,
|
||||
): Promise<SetCanvasResult> {
|
||||
const response = await invokeTauri<RawSetCanvasResult>("set_canvas", {
|
||||
channelId: input.channelId,
|
||||
content: input.content,
|
||||
});
|
||||
return {
|
||||
ok: response.ok,
|
||||
eventId: response.event_id,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getHomeFeed(
|
||||
input: GetHomeFeedInput = {},
|
||||
): Promise<HomeFeedResponse> {
|
||||
|
||||
@@ -65,6 +65,22 @@ export type SetChannelPurposeInput = {
|
||||
purpose: string;
|
||||
};
|
||||
|
||||
export type CanvasResponse = {
|
||||
content: string | null;
|
||||
updatedAt: number | null;
|
||||
author: string | null;
|
||||
};
|
||||
|
||||
export type SetCanvasInput = {
|
||||
channelId: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type SetCanvasResult = {
|
||||
ok: boolean;
|
||||
eventId: string;
|
||||
};
|
||||
|
||||
export type AddChannelMembersInput = {
|
||||
channelId: string;
|
||||
pubkeys: string[];
|
||||
|
||||
Reference in New Issue
Block a user