From ae07ae66e4c8603aaaf6b05d09e025fd45a2c554 Mon Sep 17 00:00:00 2001
From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com>
Date: Thu, 2 Jul 2026 16:20:38 +0200
Subject: [PATCH] feat(dm): read-only composer notice for Deleted-User 1-on-1
threads (C2)
---
.../src/components/chat/DmDeletedNotice.tsx | 16 ++++++++++++++++
.../web/src/components/layout/MainContent.tsx | 8 ++++++--
.../src/components/layout/MobileChatScreen.tsx | 8 ++++++--
packages/web/src/utils/dmFormatters.test.ts | 18 +++++++++++++++++-
packages/web/src/utils/dmFormatters.ts | 11 +++++++++++
5 files changed, 56 insertions(+), 5 deletions(-)
create mode 100644 packages/web/src/components/chat/DmDeletedNotice.tsx
diff --git a/packages/web/src/components/chat/DmDeletedNotice.tsx b/packages/web/src/components/chat/DmDeletedNotice.tsx
new file mode 100644
index 00000000..0bb658ea
--- /dev/null
+++ b/packages/web/src/components/chat/DmDeletedNotice.tsx
@@ -0,0 +1,16 @@
+/**
+ * Read-only composer replacement shown in a 1-on-1 DM whose partner's account
+ * was deleted. Occupies the same bottom slot as the MessageInput glass bubble.
+ */
+export function DmDeletedNotice() {
+ return (
+
+
+
+
This user’s account was deleted
+
+
+ );
+}
diff --git a/packages/web/src/components/layout/MainContent.tsx b/packages/web/src/components/layout/MainContent.tsx
index 3914d102..7ea4ac40 100644
--- a/packages/web/src/components/layout/MainContent.tsx
+++ b/packages/web/src/components/layout/MainContent.tsx
@@ -18,7 +18,8 @@ import { wsSend } from '../../hooks/useWebSocket';
import { MemberListToggleButton } from './MemberListToggleButton';
import { TransferIndicator } from './TransferIndicator';
import { isSelf, parseFederatedUsername, isFederationGlobeApplicable } from '../../utils/identity';
-import { formatDmHeaderName, formatDmInputLabel } from '../../utils/dmFormatters';
+import { formatDmHeaderName, formatDmInputLabel, isDeletedPartnerDm } from '../../utils/dmFormatters';
+import { DmDeletedNotice } from '../chat/DmDeletedNotice';
import { useCanonicalUserView } from '../../utils/userViewLookup';
import type { User } from '@backspace/shared';
import { Tooltip } from '../ui/Tooltip';
@@ -179,6 +180,7 @@ export function MainContent() {
: dmChannel
? `Message @${dmName}`
: undefined;
+ const dmPartnerDeleted = dmChannel ? isDeletedPartnerDm(dmChannel, authUser) : false;
const isInDmCall = activeDmCall?.dmChannelId === currentChannelId;
const isCallingThisDm = outgoingCall?.dmChannelId === currentChannelId;
@@ -365,7 +367,9 @@ export function MainContent() {
setJumpToMessageId(null)} />
-
+ {dmPartnerDeleted
+ ?
+ : }
setSearchOpen(false)}
diff --git a/packages/web/src/components/layout/MobileChatScreen.tsx b/packages/web/src/components/layout/MobileChatScreen.tsx
index 8786c860..2f35b545 100644
--- a/packages/web/src/components/layout/MobileChatScreen.tsx
+++ b/packages/web/src/components/layout/MobileChatScreen.tsx
@@ -7,7 +7,8 @@ import { MessageList } from '../chat/MessageList';
import { MessageInput } from '../chat/MessageInput';
import { TransferIndicator } from './TransferIndicator';
import { parseFederatedUsername } from '../../utils/identity';
-import { formatDmHeaderName, formatDmInputLabel } from '../../utils/dmFormatters';
+import { formatDmHeaderName, formatDmInputLabel, isDeletedPartnerDm } from '../../utils/dmFormatters';
+import { DmDeletedNotice } from '../chat/DmDeletedNotice';
import { useCanonicalUserView } from '../../utils/userViewLookup';
import type { User } from '@backspace/shared';
@@ -47,6 +48,7 @@ export function MobileChatScreen({ params }: MobileChatScreenProps) {
const isGroup = !!dm?.ownerId;
const rawMainOther = !isGroup ? otherMembers[0] : undefined;
const canonicalMainOther = useCanonicalUserView((rawMainOther as unknown as User) ?? FALLBACK_USER);
+ const dmPartnerDeleted = dm ? isDeletedPartnerDm(dm, authUser) : false;
// Resolve channel/DM name. Group DMs route through `formatDmHeaderName` so
// a renamed group shows `dm.name` (previously this surface silently dropped
@@ -127,7 +129,9 @@ export function MobileChatScreen({ params }: MobileChatScreenProps) {
`absolute bottom-full` to the bubble), so we don't render it here. */}
{channelId && }
- {channelId && }
+ {channelId && (dmPartnerDeleted
+ ?
+ : )}
);
diff --git a/packages/web/src/utils/dmFormatters.test.ts b/packages/web/src/utils/dmFormatters.test.ts
index ecec5236..a2e70011 100644
--- a/packages/web/src/utils/dmFormatters.test.ts
+++ b/packages/web/src/utils/dmFormatters.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
-import { formatDmTimestamp, formatDmPreview, formatDmSidebarPreview, formatDmHeaderName, formatDmInputLabel } from './dmFormatters';
+import { formatDmTimestamp, formatDmPreview, formatDmSidebarPreview, formatDmHeaderName, formatDmInputLabel, isDeletedPartnerDm } from './dmFormatters';
import type { DmChannel, DmLastMessagePreview, User } from '@backspace/shared';
/** Build a local-time Date: new Date(year, month-1, day, hour, minute) as a timestamp. */
@@ -417,3 +417,19 @@ describe('formatDmSidebarPreview — icon_changed system message', () => {
expect(formatDmSidebarPreview(dm, { id: 'OTHER', username: 'other' })).toBe('Unknown updated the group icon');
});
});
+
+describe('isDeletedPartnerDm', () => {
+ const me = { id: 'me', username: 'me' } as User;
+ it('true for a 1-on-1 whose only other member is deleted', () => {
+ const dm = { ownerId: null, members: [me, { id: 'x', username: 'Deleted User', isDeleted: true } as User] };
+ expect(isDeletedPartnerDm(dm as any, me)).toBe(true);
+ });
+ it('false for a live partner', () => {
+ const dm = { ownerId: null, members: [me, { id: 'x', username: 'p', isDeleted: false } as User] };
+ expect(isDeletedPartnerDm(dm as any, me)).toBe(false);
+ });
+ it('false for a group even with a deleted member', () => {
+ const dm = { ownerId: 'me', members: [me, { id: 'x', isDeleted: true } as User] };
+ expect(isDeletedPartnerDm(dm as any, me)).toBe(false);
+ });
+});
diff --git a/packages/web/src/utils/dmFormatters.ts b/packages/web/src/utils/dmFormatters.ts
index d60d6f50..5681bfce 100644
--- a/packages/web/src/utils/dmFormatters.ts
+++ b/packages/web/src/utils/dmFormatters.ts
@@ -314,3 +314,14 @@ export function formatDmInputLabel(dm: DmChannel, currentUser: AuthLike): string
if (!partner) return '@unknown';
return `@${memberDisplayName(partner) || 'unknown'}`;
}
+
+/**
+ * True when `dm` is a 1-on-1 whose only other participant(s) are tombstoned.
+ * Drives the read-only composer — you cannot message a deleted user.
+ */
+export function isDeletedPartnerDm(dm: Pick, currentUser: AuthLike): boolean {
+ if (dm.ownerId) return false; // group
+ const others = dm.members.filter(m => !isSelf(m, currentUser));
+ if (others.length === 0) return false;
+ return others.every(m => m.isDeleted === true);
+}