Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/sprout-db/src/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,7 @@ pub async fn get_accessible_channels(
ON c.id = cm.channel_id AND cm.pubkey = $1 AND cm.removed_at IS NULL
WHERE c.deleted_at IS NULL
{membership_clause}
AND (c.channel_type != 'dm' OR cm.hidden_at IS NULL)
"#
);

Expand Down
53 changes: 53 additions & 0 deletions crates/sprout-db/src/dm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ pub async fn list_dms_for_user(
ON c.id = cm.channel_id
AND cm.pubkey = $1
AND cm.removed_at IS NULL
AND cm.hidden_at IS NULL
WHERE c.channel_type = 'dm'
AND c.deleted_at IS NULL
AND c.updated_at < $2
Expand All @@ -264,6 +265,7 @@ pub async fn list_dms_for_user(
ON c.id = cm.channel_id
AND cm.pubkey = $1
AND cm.removed_at IS NULL
AND cm.hidden_at IS NULL
WHERE c.channel_type = 'dm'
AND c.deleted_at IS NULL
ORDER BY c.updated_at DESC
Expand Down Expand Up @@ -350,6 +352,8 @@ pub async fn open_dm(

// Check for existing DM first (fast path, no transaction).
if let Some(existing) = find_dm_by_participants(pool, &hash).await? {
// Clear hidden_at for the caller so the DM reappears in their sidebar.
unhide_dm(pool, existing.id, created_by).await?;
return Ok((existing, false));
}

Expand All @@ -359,6 +363,55 @@ pub async fn open_dm(
Ok((channel, true))
}

// -- Hide / unhide ------------------------------------------------------------

/// Hide a DM for a specific user by setting `hidden_at = NOW()`.
///
/// The DM is not deleted — it can be restored by opening a new DM with the
/// same participants (which clears `hidden_at`). Returns an error if the user
/// is not an active member of the channel.
pub async fn hide_dm(pool: &PgPool, channel_id: Uuid, pubkey: &[u8]) -> Result<()> {
let result = sqlx::query(
r#"
UPDATE channel_members
SET hidden_at = NOW()
WHERE channel_id = $1 AND pubkey = $2 AND removed_at IS NULL
"#,
)
.bind(channel_id)
.bind(pubkey)
.execute(pool)
.await?;

if result.rows_affected() == 0 {
return Err(DbError::NotFound(format!(
"no active membership for channel {channel_id}"
)));
}

Ok(())
}

/// Unhide a DM for a specific user by clearing `hidden_at`.
///
/// This is called automatically when a user re-opens a DM via [`open_dm`].
/// It is a no-op if the membership is not currently hidden.
pub async fn unhide_dm(pool: &PgPool, channel_id: Uuid, pubkey: &[u8]) -> Result<()> {
sqlx::query(
r#"
UPDATE channel_members
SET hidden_at = NULL
WHERE channel_id = $1 AND pubkey = $2 AND removed_at IS NULL
"#,
)
.bind(channel_id)
.bind(pubkey)
.execute(pool)
.await?;

Ok(())
}

// -- Row mapping --------------------------------------------------------------

fn row_to_channel_record(row: sqlx::postgres::PgRow) -> Result<ChannelRecord> {
Expand Down
13 changes: 13 additions & 0 deletions crates/sprout-db/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,19 @@ impl Db {
dm::open_dm(&self.pool, pubkeys, created_by).await
}

/// Hide a DM channel for a specific user.
///
/// The DM is not deleted — it can be restored by opening a new DM with
/// the same participants.
pub async fn hide_dm(&self, channel_id: Uuid, pubkey: &[u8]) -> Result<()> {
dm::hide_dm(&self.pool, channel_id, pubkey).await
}

/// Unhide a DM channel for a specific user.
pub async fn unhide_dm(&self, channel_id: Uuid, pubkey: &[u8]) -> Result<()> {
dm::unhide_dm(&self.pool, channel_id, pubkey).await
}

// ── Threads ──────────────────────────────────────────────────────────────

/// Insert thread metadata.
Expand Down
35 changes: 35 additions & 0 deletions crates/sprout-mcp/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,13 @@ pub struct AddDmMemberParams {
pub pubkey: String,
}

/// Parameters for the `hide_dm` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct HideDmParams {
/// UUID of the DM channel to hide.
pub channel_id: String,
}

// ── Reaction tool parameter structs ──────────────────────────────────────────

/// Parameters for the `add_reaction` tool.
Expand Down Expand Up @@ -1662,6 +1669,34 @@ with kind:45003 comments)."
}
}

/// Hide a DM channel from the agent's DM list.
#[tool(
name = "hide_dm",
description = "Hide a direct message channel from the agent's DM list. The DM can be restored by opening a new DM with the same participants."
)]
pub async fn hide_dm(&self, Parameters(p): Parameters<HideDmParams>) -> String {
if let Err(e) = validate_uuid(&p.channel_id) {
return format!("Error: {e}");
}
match self
.client
.post(
&format!("/api/dms/{}/hide", p.channel_id),
&serde_json::json!({}),
)
.await
{
Ok(b) => {
if b.is_empty() {
"DM hidden successfully.".to_string()
} else {
b
}
}
Err(e) => format!("Error: {e}"),
}
}

// ── Reaction tools ────────────────────────────────────────────────────────

/// Add an emoji reaction to a message.
Expand Down
9 changes: 5 additions & 4 deletions crates/sprout-mcp/src/toolsets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
//! |-----------------|-------|
//! | `default` | 25 |
//! | `channel_admin` | 6 |
//! | `dms` | 2 |
//! | `dms` | 3 |
//! | `canvas` | 2 |
//! | `workflow_admin`| 5 |
//! | `identity` | 1 |
Expand All @@ -40,7 +40,7 @@ use std::sync::LazyLock;
/// classification. `is_read = true` means the tool is safe to include under
/// a `:ro` (read-only) mode restriction.
///
/// 42 tools total. See [`DEFERRED_TOOLS`] for tools planned but not yet implemented.
/// 43 tools total. See [`DEFERRED_TOOLS`] for tools planned but not yet implemented.
pub const ALL_TOOLS: &[(&str, &str, bool)] = &[
// ── default ─────────────────────────────────────────────────────────────
("send_message", "default", false),
Expand Down Expand Up @@ -77,6 +77,7 @@ pub const ALL_TOOLS: &[(&str, &str, bool)] = &[
("list_channel_members", "channel_admin", true),
// ── dms ──────────────────────────────────────────────────────────────────
("add_dm_member", "dms", false),
("hide_dm", "dms", false),
("list_dms", "dms", true),
// ── canvas ───────────────────────────────────────────────────────────────
("get_canvas", "canvas", true),
Expand Down Expand Up @@ -370,8 +371,8 @@ mod tests {
}

#[test]
fn all_tools_count_is_42() {
assert_eq!(ALL_TOOLS.len(), 42);
fn all_tools_count_is_43() {
assert_eq!(ALL_TOOLS.len(), 43);
}

#[test]
Expand Down
49 changes: 49 additions & 0 deletions crates/sprout-relay/src/api/dms.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
//! Endpoints:
//! POST /api/dms — Open or create a DM (idempotent)
//! POST /api/dms/{channel_id}/members — Add member to group DM (creates new DM)
//! POST /api/dms/{channel_id}/hide — Hide a DM from the user's sidebar
//! GET /api/dms — List user's DM conversations

use std::sync::Arc;
Expand Down Expand Up @@ -362,6 +363,54 @@ pub async fn list_dms_handler(
})))
}

/// `POST /api/dms/{channel_id}/hide` — Hide a DM from the caller's sidebar.
///
/// The DM is not deleted — it can be restored by opening a new DM with the
/// same participants. Returns 204 No Content on success.
pub async fn hide_dm_handler(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(channel_id_str): Path<String>,
) -> Result<StatusCode, (StatusCode, Json<serde_json::Value>)> {
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesWrite)
.map_err(super::scope_error)?;

let channel_id: Uuid = channel_id_str
.parse()
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel_id format"))?;

// Verify the channel exists and is a DM.
let channel = state
.db
.get_channel(channel_id)
.await
.map_err(|_| super::not_found("DM not found"))?;

if channel.channel_type != "dm" {
return Err(api_error(StatusCode::BAD_REQUEST, "channel is not a DM"));
}

// Verify caller is a member.
let is_member = state
.db
.is_member(channel_id, &ctx.pubkey_bytes)
.await
.map_err(|e| internal_error(&format!("db error: {e}")))?;

if !is_member {
return Err(super::forbidden("not a member of this DM"));
}

state
.db
.hide_dm(channel_id, &ctx.pubkey_bytes)
.await
.map_err(|e| internal_error(&format!("db error: {e}")))?;

Ok(StatusCode::NO_CONTENT)
}

// ── Helpers ───────────────────────────────────────────────────────────────────

/// Fetch and format participant info for a DM channel.
Expand Down
2 changes: 1 addition & 1 deletion crates/sprout-relay/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ pub use channels_metadata::{
archive_channel_handler, delete_channel_handler, get_channel_handler, set_purpose_handler,
set_topic_handler, unarchive_channel_handler, update_channel_handler,
};
pub use dms::{add_dm_member_handler, list_dms_handler, open_dm_handler};
pub use dms::{add_dm_member_handler, hide_dm_handler, list_dms_handler, open_dm_handler};
pub use events::get_event;
pub use feed::feed_handler;
pub use members::{add_members, join_channel, leave_channel, list_members, remove_member};
Expand Down
1 change: 1 addition & 0 deletions crates/sprout-relay/src/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ pub fn build_router(state: Arc<AppState>) -> Router {
"/api/dms/{channel_id}/members",
post(api::add_dm_member_handler),
)
.route("/api/dms/{channel_id}/hide", post(api::hide_dm_handler))
// Message delete + edit routes
.route(
"/api/messages/{event_id}",
Expand Down
9 changes: 5 additions & 4 deletions crates/sprout-test-client/tests/e2e_mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ fn spawn_mcp_server(keys: &Keys) -> Child {
])
.env("SPROUT_RELAY_URL", relay_ws_url())
.env("SPROUT_PRIVATE_KEY", &nsec)
// Tests exercise all 42 tools — enable every toolset.
// Tests exercise all 43 tools — enable every toolset.
.env("SPROUT_TOOLSETS", "all")
// Prevent a stale SPROUT_API_TOKEN from the host .env leaking into
// the subprocess and causing NIP-42 auth failures against a fresh DB.
Expand Down Expand Up @@ -251,7 +251,7 @@ impl McpSession {
// ── Tests ─────────────────────────────────────────────────────────────────────

/// Spawn the MCP server, complete the initialize handshake, and verify that
/// all 42 expected tools are listed by `tools/list`.
/// all 43 expected tools are listed by `tools/list`.
#[tokio::test]
#[ignore]
async fn test_mcp_initialize_and_list_tools() {
Expand Down Expand Up @@ -300,8 +300,8 @@ async fn test_mcp_initialize_and_list_tools() {

assert_eq!(
tools.len(),
42,
"expected exactly 42 tools, got {}. Tools: {:?}",
43,
"expected exactly 43 tools, got {}. Tools: {:?}",
tools.len(),
tools
.iter()
Expand Down Expand Up @@ -332,6 +332,7 @@ async fn test_mcp_initialize_and_list_tools() {
"get_thread",
"get_users",
"get_workflow_runs",
"hide_dm",
"join_channel",
"leave_channel",
"list_channel_members",
Expand Down
2 changes: 1 addition & 1 deletion desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const overrides = new Map([
["src-tauri/src/managed_agents/persona_card.rs", 700], // PNG/ZIP persona card codec + 21 unit tests (~300 lines of tests)
["src/app/AppShell.tsx", 775],
["src/features/agents/ui/AgentsView.tsx", 625], // persona/team orchestration plus import/export wiring
["src/features/channels/hooks.ts", 525], // canvas query + mutation hooks
["src/features/channels/hooks.ts", 550], // canvas query + mutation hooks + DM hide mutation
["src/features/channels/ui/ChannelManagementSheet.tsx", 800],
["src/features/messages/ui/MessageComposer.tsx", 665], // media upload handlers (paste, drop, dialog) + channelId reset effect
["src/features/settings/ui/SettingsView.tsx", 600],
Expand Down
12 changes: 11 additions & 1 deletion desktop/src-tauri/src/commands/dms.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use tauri::State;
use crate::{
app_state::AppState,
models::{ChannelInfo, OpenDmBody, OpenDmResponse},
relay::{build_authed_request, send_json_request},
relay::{build_authed_request, send_empty_request, send_json_request},
};

#[tauri::command]
Expand All @@ -20,3 +20,13 @@ pub async fn open_dm(
let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?;
send_json_request(request).await
}

#[tauri::command]
pub async fn hide_dm(
channel_id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let path = format!("/api/dms/{channel_id}/hide");
let request = build_authed_request(&state.http_client, Method::POST, &path, &state)?;
send_empty_request(request).await
}
1 change: 1 addition & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ pub fn run() {
get_channels,
create_channel,
open_dm,
hide_dm,
get_channel_details,
get_channel_members,
update_channel,
Expand Down
20 changes: 20 additions & 0 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { ChatHeader } from "@/features/chat/ui/ChatHeader";
import {
channelsQueryKey,
useCreateChannelMutation,
useHideDmMutation,
useOpenDmMutation,
useChannelsQuery,
useSelectedChannel,
Expand Down Expand Up @@ -107,6 +108,7 @@ export function AppShell() {
const createChannelMutation = useCreateChannelMutation();
const createForumMutation = useCreateChannelMutation();
const openDmMutation = useOpenDmMutation();
const hideDmMutation = useHideDmMutation();
const activeChannel = selectedView === "channel" ? selectedChannel : null;
const activeChannelId = activeChannel?.id ?? null;
const messagesQuery = useChannelMessagesQuery(activeChannel);
Expand Down Expand Up @@ -305,6 +307,23 @@ export function AppShell() {
[queryClient],
);

const handleHideDm = React.useCallback(
async (channelId: string) => {
try {
await hideDmMutation.mutateAsync(channelId);
} catch {
// Optimistic rollback handled by onError in the mutation hook.
return;
}
if (selectedChannel?.id === channelId) {
React.startTransition(() => {
setSelectedView("home");
});
}
},
[hideDmMutation, selectedChannel?.id],
);

const handleOpenSettings = React.useCallback(
(section: SettingsSection = DEFAULT_SETTINGS_SECTION) => {
setIsSearchOpen(false);
Expand Down Expand Up @@ -576,6 +595,7 @@ export function AppShell() {
setIsSearchOpen(true);
void refetchChannels();
}}
onHideDm={handleHideDm}
onOpenDm={async ({ pubkeys }) => {
const directMessage = await openDmMutation.mutateAsync({
pubkeys,
Expand Down
Loading
Loading