feat: add hide/close DM support (Slack-style DM management) (#157)

Co-authored-by: Tyler Longwell <tlongwell@squareup.com>
This commit is contained in:
Wes
2026-03-22 09:00:45 -07:00
committed by GitHub
co-authored by Tyler Longwell
parent 9d6e82b173
commit dfe32bfb2c
19 changed files with 272 additions and 14 deletions
+1
View File
@@ -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)
"#
);
+53
View File
@@ -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
@@ -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
@@ -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));
}
@@ -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> {
+13
View File
@@ -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.
+35
View File
@@ -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.
@@ -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.
+5 -4
View File
@@ -21,7 +21,7 @@
//! |-----------------|-------|
//! | `default` | 25 |
//! | `channel_admin` | 6 |
//! | `dms` | 2 |
//! | `dms` | 3 |
//! | `canvas` | 2 |
//! | `workflow_admin`| 5 |
//! | `identity` | 1 |
@@ -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),
@@ -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),
@@ -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]
+49
View File
@@ -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;
@@ -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.
+1 -1
View File
@@ -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};
+1
View File
@@ -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}",
+5 -4
View File
@@ -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.
@@ -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() {
@@ -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()
@@ -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",
+1 -1
View File
@@ -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],
+11 -1
View File
@@ -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]
@@ -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
View File
@@ -209,6 +209,7 @@ pub fn run() {
get_channels,
create_channel,
open_dm,
hide_dm,
get_channel_details,
get_channel_members,
update_channel,
+20
View File
@@ -10,6 +10,7 @@ import { ChatHeader } from "@/features/chat/ui/ChatHeader";
import {
channelsQueryKey,
useCreateChannelMutation,
useHideDmMutation,
useOpenDmMutation,
useChannelsQuery,
useSelectedChannel,
@@ -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);
@@ -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);
@@ -576,6 +595,7 @@ export function AppShell() {
setIsSearchOpen(true);
void refetchChannels();
}}
onHideDm={handleHideDm}
onOpenDm={async ({ pubkeys }) => {
const directMessage = await openDmMutation.mutateAsync({
pubkeys,
+25
View File
@@ -15,6 +15,7 @@ import {
getChannelDetails,
getChannelMembers,
getChannels,
hideDm,
joinChannel,
leaveChannel,
openDm,
@@ -197,6 +198,30 @@ export function useOpenDmMutation() {
});
}
export function useHideDmMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (channelId: string) => hideDm(channelId),
onMutate: async (channelId) => {
await queryClient.cancelQueries({ queryKey: channelsQueryKey });
const previous = queryClient.getQueryData<Channel[]>(channelsQueryKey);
queryClient.setQueryData<Channel[]>(channelsQueryKey, (current = []) =>
current.filter((channel) => channel.id !== channelId),
);
return { previous };
},
onError: (_error, _channelId, context) => {
if (context?.previous) {
queryClient.setQueryData(channelsQueryKey, context.previous);
}
},
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: channelsQueryKey });
},
});
}
export function useChannelDetailsQuery(
channelId: string | null,
enabled = true,
@@ -77,6 +77,7 @@ type AppSidebarProps = {
onOpenBrowseChannels: () => void;
onOpenBrowseForums: () => void;
onOpenSearch: () => void;
onHideDm: (channelId: string) => void;
onOpenDm: (input: { pubkeys: string[] }) => Promise<void>;
onSelectAgents: () => void;
onSelectHome: () => void;
@@ -440,6 +441,7 @@ export function AppSidebar({
onOpenBrowseChannels,
onOpenBrowseForums,
onOpenSearch,
onHideDm,
onOpenDm,
onSelectAgents,
onSelectHome,
@@ -640,6 +642,7 @@ export function AppSidebar({
isActiveChannel={selectedView === "channel"}
items={directMessages}
channelLabels={dmChannelLabels}
onHideDm={onHideDm}
onSelectChannel={onSelectChannel}
presenceByChannelId={dmPresenceByChannelId}
selectedChannelId={selectedChannelId}
@@ -1,5 +1,5 @@
import type * as React from "react";
import { CircleDot, FileText, Hash, Lock } from "lucide-react";
import { CircleDot, FileText, Hash, Lock, X } from "lucide-react";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import type { Channel, PresenceStatus } from "@/shared/api/types";
@@ -9,6 +9,7 @@ import {
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
} from "@/shared/ui/sidebar";
@@ -161,7 +162,7 @@ export function ChannelMenuButton({
{hasUnread && !isActive ? (
<span
aria-hidden="true"
className="ml-auto h-2.5 w-2.5 shrink-0 rounded-full bg-primary"
className="ml-auto h-2.5 w-2.5 shrink-0 rounded-full bg-primary group-hover/menu-item:hidden"
data-testid={`channel-unread-${channel.name}`}
/>
) : null}
@@ -181,6 +182,7 @@ export function SidebarSection({
title,
testId,
unreadChannelIds,
onHideDm,
onSelectChannel,
}: {
action?: React.ReactNode;
@@ -194,6 +196,7 @@ export function SidebarSection({
title: string;
testId: string;
unreadChannelIds: Set<string>;
onHideDm?: (channelId: string) => void;
onSelectChannel: (channelId: string) => void;
}) {
if (items.length === 0 && !action && !emptyState) {
@@ -208,7 +211,7 @@ export function SidebarSection({
{items.length > 0 ? (
<SidebarMenu data-testid={testId}>
{items.map((channel) => (
<SidebarMenuItem key={channel.id}>
<SidebarMenuItem className="group/menu-item" key={channel.id}>
<ChannelMenuButton
channel={channel}
dmParticipants={dmParticipantsByChannelId?.[channel.id]}
@@ -218,6 +221,16 @@ export function SidebarSection({
presenceStatus={presenceByChannelId?.[channel.id]}
onSelectChannel={onSelectChannel}
/>
{channel.channelType === "dm" && onHideDm ? (
<SidebarMenuAction
aria-label="Close direct message"
data-testid={`hide-dm-${channel.name}`}
onClick={() => onHideDm(channel.id)}
showOnHover
>
<X />
</SidebarMenuAction>
) : null}
</SidebarMenuItem>
))}
</SidebarMenu>
+4
View File
@@ -535,6 +535,10 @@ export async function openDm(input: OpenDmInput): Promise<Channel> {
return fromRawChannel(await invokeTauri<RawChannel>("open_dm", input));
}
export async function hideDm(channelId: string): Promise<void> {
await invokeTauri<void>("hide_dm", { channelId });
}
export async function getChannelDetails(
channelId: string,
): Promise<ChannelDetail> {
+27
View File
@@ -1715,6 +1715,28 @@ async function handleOpenDm(
);
}
async function handleHideDm(
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(`DM ${args.channelId} not found.`);
}
// Remove from mock list (simulates hiding from sidebar).
mockChannels.splice(index, 1);
return;
}
await relayEmptyRequest(config, `/api/dms/${args.channelId}/hide`, {
method: "POST",
});
}
async function handleGetChannelDetails(
args: { channelId: string },
config: E2eConfig | undefined,
@@ -3286,6 +3308,11 @@ export function maybeInstallE2eTauriMocks() {
payload as Parameters<typeof handleOpenDm>[0],
activeConfig,
);
case "hide_dm":
return handleHideDm(
payload as Parameters<typeof handleHideDm>[0],
activeConfig,
);
case "get_channel_details":
return handleGetChannelDetails(
payload as Parameters<typeof handleGetChannelDetails>[0],
+1
View File
@@ -57,6 +57,7 @@ CREATE TABLE channel_members (
invited_by BYTEA,
removed_at TIMESTAMPTZ,
removed_by BYTEA,
hidden_at TIMESTAMPTZ,
PRIMARY KEY (channel_id, pubkey)
);