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); +}