mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat: add hide/close DM support (Slack-style DM management) (#157)
Co-authored-by: Tyler Longwell <tlongwell@squareup.com>
This commit is contained in:
@@ -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)
|
||||
"#
|
||||
);
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -209,6 +209,7 @@ pub fn run() {
|
||||
get_channels,
|
||||
create_channel,
|
||||
open_dm,
|
||||
hide_dm,
|
||||
get_channel_details,
|
||||
get_channel_members,
|
||||
update_channel,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user