feat(dm): read-only composer notice for Deleted-User 1-on-1 threads (C2)
This commit is contained in:
@@ -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 (
|
||||||
|
<div className="px-4 pb-4 pt-1">
|
||||||
|
<div className="glass-bubble flex items-center gap-2 px-4 py-3 rounded-[14px] text-txt-tertiary text-[14px] justify-center select-none">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" className="opacity-70 flex-shrink-0">
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" />
|
||||||
|
</svg>
|
||||||
|
<span>This user’s account was deleted</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,7 +18,8 @@ import { wsSend } from '../../hooks/useWebSocket';
|
|||||||
import { MemberListToggleButton } from './MemberListToggleButton';
|
import { MemberListToggleButton } from './MemberListToggleButton';
|
||||||
import { TransferIndicator } from './TransferIndicator';
|
import { TransferIndicator } from './TransferIndicator';
|
||||||
import { isSelf, parseFederatedUsername, isFederationGlobeApplicable } from '../../utils/identity';
|
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 { useCanonicalUserView } from '../../utils/userViewLookup';
|
||||||
import type { User } from '@backspace/shared';
|
import type { User } from '@backspace/shared';
|
||||||
import { Tooltip } from '../ui/Tooltip';
|
import { Tooltip } from '../ui/Tooltip';
|
||||||
@@ -179,6 +180,7 @@ export function MainContent() {
|
|||||||
: dmChannel
|
: dmChannel
|
||||||
? `Message @${dmName}`
|
? `Message @${dmName}`
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const dmPartnerDeleted = dmChannel ? isDeletedPartnerDm(dmChannel, authUser) : false;
|
||||||
|
|
||||||
const isInDmCall = activeDmCall?.dmChannelId === currentChannelId;
|
const isInDmCall = activeDmCall?.dmChannelId === currentChannelId;
|
||||||
const isCallingThisDm = outgoingCall?.dmChannelId === currentChannelId;
|
const isCallingThisDm = outgoingCall?.dmChannelId === currentChannelId;
|
||||||
@@ -365,7 +367,9 @@ export function MainContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<MessageList channelId={currentChannelId} jumpToMessageId={jumpToMessageId} onJumpComplete={() => setJumpToMessageId(null)} />
|
<MessageList channelId={currentChannelId} jumpToMessageId={jumpToMessageId} onJumpComplete={() => setJumpToMessageId(null)} />
|
||||||
<MessageInput channelId={currentChannelId} channelName={`@${dmName}`} placeholder={dmInputPlaceholder} />
|
{dmPartnerDeleted
|
||||||
|
? <DmDeletedNotice />
|
||||||
|
: <MessageInput channelId={currentChannelId} channelName={`@${dmName}`} placeholder={dmInputPlaceholder} />}
|
||||||
<SearchPopover
|
<SearchPopover
|
||||||
open={searchOpen}
|
open={searchOpen}
|
||||||
onClose={() => setSearchOpen(false)}
|
onClose={() => setSearchOpen(false)}
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import { MessageList } from '../chat/MessageList';
|
|||||||
import { MessageInput } from '../chat/MessageInput';
|
import { MessageInput } from '../chat/MessageInput';
|
||||||
import { TransferIndicator } from './TransferIndicator';
|
import { TransferIndicator } from './TransferIndicator';
|
||||||
import { parseFederatedUsername } from '../../utils/identity';
|
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 { useCanonicalUserView } from '../../utils/userViewLookup';
|
||||||
import type { User } from '@backspace/shared';
|
import type { User } from '@backspace/shared';
|
||||||
|
|
||||||
@@ -47,6 +48,7 @@ export function MobileChatScreen({ params }: MobileChatScreenProps) {
|
|||||||
const isGroup = !!dm?.ownerId;
|
const isGroup = !!dm?.ownerId;
|
||||||
const rawMainOther = !isGroup ? otherMembers[0] : undefined;
|
const rawMainOther = !isGroup ? otherMembers[0] : undefined;
|
||||||
const canonicalMainOther = useCanonicalUserView((rawMainOther as unknown as User) ?? FALLBACK_USER);
|
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
|
// Resolve channel/DM name. Group DMs route through `formatDmHeaderName` so
|
||||||
// a renamed group shows `dm.name` (previously this surface silently dropped
|
// 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. */}
|
`absolute bottom-full` to the bubble), so we don't render it here. */}
|
||||||
<div className="relative flex-1 min-h-0 flex flex-col overflow-hidden">
|
<div className="relative flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||||
{channelId && <MessageList channelId={channelId} />}
|
{channelId && <MessageList channelId={channelId} />}
|
||||||
{channelId && <MessageInput channelId={channelId} channelName={channelName} placeholder={inputPlaceholder} />}
|
{channelId && (dmPartnerDeleted
|
||||||
|
? <DmDeletedNotice />
|
||||||
|
: <MessageInput channelId={channelId} channelName={channelName} placeholder={inputPlaceholder} />)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
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';
|
import type { DmChannel, DmLastMessagePreview, User } from '@backspace/shared';
|
||||||
|
|
||||||
/** Build a local-time Date: new Date(year, month-1, day, hour, minute) as a timestamp. */
|
/** 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');
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -314,3 +314,14 @@ export function formatDmInputLabel(dm: DmChannel, currentUser: AuthLike): string
|
|||||||
if (!partner) return '@unknown';
|
if (!partner) return '@unknown';
|
||||||
return `@${memberDisplayName(partner) || '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<DmChannel, 'ownerId' | 'members'>, 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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user