feat: system messages, group DM welcome header, and dm_owner_updated event
- Render system messages (member_added, member_removed, owner_changed) inline in MessageList with icon + human-readable text; system messages never group with adjacent user messages - Rewrite WelcomeHeader to branch on ownerId: group DMs show overlapping avatars, group name, creator attribution, federated privacy note, and a Leave Group button - Add dm_owner_updated ServerEvent; broadcast from dm.ts leave handler and federation processOwnershipTransferEvent so all clients update ownerId in real-time - Add updateDmOwner action to spaceStore and handle dm_owner_updated in useWebSocket - Add leaveGroup alias to API client dm namespace
This commit is contained in:
@@ -1214,6 +1214,15 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
.where(eq(schema.dmChannels.id, id))
|
.where(eq(schema.dmChannels.id, id))
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
|
// Broadcast ownership change via dedicated event
|
||||||
|
for (const member of remainingMembers) {
|
||||||
|
connectionManager.sendToUser(member.userId, {
|
||||||
|
type: 'dm_owner_updated',
|
||||||
|
dmChannelId: id,
|
||||||
|
newOwnerId: nextOwner.userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Query new owner user outside federation block so it's available for system message
|
// Query new owner user outside federation block so it's available for system message
|
||||||
const newOwnerUser = db.select().from(schema.users).where(eq(schema.users.id, nextOwner.userId)).get();
|
const newOwnerUser = db.select().from(schema.users).where(eq(schema.users.id, nextOwner.userId)).get();
|
||||||
|
|
||||||
|
|||||||
@@ -1972,6 +1972,12 @@ function processOwnershipTransferEvent(
|
|||||||
.where(eq(schema.dmChannels.id, channel.id))
|
.where(eq(schema.dmChannels.id, channel.id))
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
|
connectionManager.sendToDmMembers(channel.id, {
|
||||||
|
type: 'dm_owner_updated',
|
||||||
|
dmChannelId: channel.id,
|
||||||
|
newOwnerId: newOwnerLocal?.id ?? channel.ownerId!,
|
||||||
|
});
|
||||||
|
|
||||||
const prevOwnerLocal = event.ownership.previousOwner
|
const prevOwnerLocal = event.ownership.previousOwner
|
||||||
? resolveLocalUser(event.ownership.previousOwner.homeUserId, db)
|
? resolveLocalUser(event.ownership.previousOwner.homeUserId, db)
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
@@ -413,6 +413,7 @@ export type ServerEvent =
|
|||||||
| { type: 'embeds_resolved'; messageId: string; channelId: string; embeds: Embed[] }
|
| { type: 'embeds_resolved'; messageId: string; channelId: string; embeds: Embed[] }
|
||||||
| { type: 'dm_embeds_resolved'; messageId: string; dmChannelId: string; embeds: Embed[] }
|
| { type: 'dm_embeds_resolved'; messageId: string; dmChannelId: string; embeds: Embed[] }
|
||||||
| { type: 'federation_file_rejected'; messageId: string; dmChannelId: string; attachmentId: string; affectedUsers: Array<{ userId: string; username: string; limit: number }> }
|
| { type: 'federation_file_rejected'; messageId: string; dmChannelId: string; attachmentId: string; affectedUsers: Array<{ userId: string; username: string; limit: number }> }
|
||||||
|
| { type: 'dm_owner_updated'; dmChannelId: string; newOwnerId: string }
|
||||||
| { type: 'pong' }
|
| { type: 'pong' }
|
||||||
| { type: 'error'; message: string };
|
| { type: 'error'; message: string };
|
||||||
|
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ export class BackspaceApiClient {
|
|||||||
deleteMessage: (id: string) => Promise<{ success: boolean }>;
|
deleteMessage: (id: string) => Promise<{ success: boolean }>;
|
||||||
addMember: (dmChannelId: string, data: { userId: string }) => Promise<DmChannel>;
|
addMember: (dmChannelId: string, data: { userId: string }) => Promise<DmChannel>;
|
||||||
leave: (dmChannelId: string) => Promise<{ success: boolean }>;
|
leave: (dmChannelId: string) => Promise<{ success: boolean }>;
|
||||||
|
leaveGroup: (dmChannelId: string) => Promise<{ success: boolean }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
readonly social: {
|
readonly social: {
|
||||||
@@ -505,6 +506,8 @@ export class BackspaceApiClient {
|
|||||||
request<DmChannel>('POST', `/dm/${dmChannelId}/members`, data),
|
request<DmChannel>('POST', `/dm/${dmChannelId}/members`, data),
|
||||||
leave: (dmChannelId: string) =>
|
leave: (dmChannelId: string) =>
|
||||||
request<{ success: boolean }>('DELETE', `/dm/${dmChannelId}/members`),
|
request<{ success: boolean }>('DELETE', `/dm/${dmChannelId}/members`),
|
||||||
|
leaveGroup: (dmChannelId: string) =>
|
||||||
|
request<{ success: boolean }>('DELETE', `/dm/${dmChannelId}/members`),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.social = {
|
this.social = {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import React, { useEffect, useRef, useCallback, useState, useMemo } from 'react';
|
import React, { useEffect, useRef, useCallback, useState, useMemo } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { api } from '../../api/client';
|
||||||
import { Message } from './Message';
|
import { Message } from './Message';
|
||||||
import { useChatStore } from '../../stores/chatStore';
|
import { useChatStore } from '../../stores/chatStore';
|
||||||
import { useSpaceStore, isDmChannel } from '../../stores/spaceStore';
|
import { useSpaceStore, isDmChannel } from '../../stores/spaceStore';
|
||||||
@@ -19,6 +21,7 @@ interface MessageListProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isSameGroup(prev: MessageWithUser, curr: MessageWithUser): boolean {
|
function isSameGroup(prev: MessageWithUser, curr: MessageWithUser): boolean {
|
||||||
|
if ((prev as any).type === 'system' || (curr as any).type === 'system') return false;
|
||||||
if (prev.userId !== curr.userId) return false;
|
if (prev.userId !== curr.userId) return false;
|
||||||
const timeDiff = curr.createdAt - prev.createdAt;
|
const timeDiff = curr.createdAt - prev.createdAt;
|
||||||
return timeDiff < 5 * 60 * 1000; // 5 minutes
|
return timeDiff < 5 * 60 * 1000; // 5 minutes
|
||||||
@@ -335,12 +338,16 @@ export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: Mess
|
|||||||
<div className="flex-1 h-[1px] bg-border-hard" />
|
<div className="flex-1 h-[1px] bg-border-hard" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{(msg as any).type === 'system' ? (
|
||||||
|
<SystemMessage message={msg} />
|
||||||
|
) : (
|
||||||
<Message
|
<Message
|
||||||
message={msg}
|
message={msg}
|
||||||
isCompact={!isFirstInGroup}
|
isCompact={!isFirstInGroup}
|
||||||
isFirstInGroup={isFirstInGroup}
|
isFirstInGroup={isFirstInGroup}
|
||||||
previousMessageId={messages[i - 1]?.id ?? null}
|
previousMessageId={messages[i - 1]?.id ?? null}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -364,17 +371,126 @@ export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: Mess
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SystemMessage({ message }: { message: MessageWithUser }) {
|
||||||
|
let text = '';
|
||||||
|
let icon = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(message.content ?? '{}');
|
||||||
|
const actorName = message.user?.displayName ?? message.user?.username ?? 'Someone';
|
||||||
|
|
||||||
|
switch (data.event) {
|
||||||
|
case 'member_added':
|
||||||
|
icon = '\u2192'; // →
|
||||||
|
text = `${actorName} added ${data.targetDisplayName} to the group`;
|
||||||
|
break;
|
||||||
|
case 'member_removed':
|
||||||
|
if (data.reason === 'leave') {
|
||||||
|
icon = '\u2190'; // ←
|
||||||
|
text = `${data.targetDisplayName} left the group`;
|
||||||
|
} else {
|
||||||
|
icon = '\u2190';
|
||||||
|
text = `${actorName} removed ${data.targetDisplayName} from the group`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'owner_changed':
|
||||||
|
icon = '\u265B'; // ♛
|
||||||
|
text = `${data.newOwnerDisplayName} is now the group owner`;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
text = 'Unknown event';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
text = message.content ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-1 px-4 select-none">
|
||||||
|
<span className="text-xs text-txt-tertiary">
|
||||||
|
<span className="mr-1.5">{icon}</span>
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function WelcomeHeader({ channelId }: { channelId: string }) {
|
function WelcomeHeader({ channelId }: { channelId: string }) {
|
||||||
const dmChannels = useSpaceStore((s) => s.dmChannels);
|
const dmChannels = useSpaceStore((s) => s.dmChannels);
|
||||||
const authUser = useAuthStore((s) => s.user);
|
const authUser = useAuthStore((s) => s.user);
|
||||||
const removeFriend = useSocialStore((s) => s.removeFriend);
|
const removeFriend = useSocialStore((s) => s.removeFriend);
|
||||||
const friends = useSocialStore((s) => s.friends);
|
const friends = useSocialStore((s) => s.friends);
|
||||||
const isDm = isDmChannel(channelId);
|
const isDm = isDmChannel(channelId);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
if (isDm) {
|
if (isDm) {
|
||||||
const dm = dmChannels.find(d => d.id === channelId);
|
const dm = dmChannels.find(d => d.id === channelId);
|
||||||
if (!dm) return null; // DM data not yet loaded (WebSocket ready pending)
|
if (!dm) return null; // DM data not yet loaded (WebSocket ready pending)
|
||||||
const otherUser = dm.members.find(m => !isSelf(m, authUser));
|
const otherMembers = dm.members.filter(m => !isSelf(m, authUser));
|
||||||
|
const isGroupDm = !!dm.ownerId;
|
||||||
|
|
||||||
|
if (isGroupDm) {
|
||||||
|
const groupName = otherMembers
|
||||||
|
.map(m => m.displayName ?? (m.username?.includes('@') ? m.username.split('@')[0] : m.username))
|
||||||
|
.join(', ');
|
||||||
|
const ownerMember = dm.members.find(m => m.id === dm.ownerId);
|
||||||
|
const ownerName = ownerMember?.displayName ?? ownerMember?.username ?? 'Unknown';
|
||||||
|
const hasFederated = dm.members.some(m => m.homeInstance);
|
||||||
|
|
||||||
|
const handleLeaveGroup = async () => {
|
||||||
|
try {
|
||||||
|
await api.dm.leaveGroup(channelId);
|
||||||
|
navigate('/channels/@me');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to leave group:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-4 pt-8 pb-4">
|
||||||
|
<div className="mb-2 relative" style={{ width: 80, height: 80 }}>
|
||||||
|
{otherMembers.slice(0, 2).map((m, idx) => (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
className="absolute rounded-full overflow-hidden border-2 border-surface-chat"
|
||||||
|
style={{
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
left: idx * 28,
|
||||||
|
top: idx * 12,
|
||||||
|
zIndex: 2 - idx,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar src={m.avatar} name={m.displayName ?? m.username ?? ''} size={56} user={m} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-[32px] leading-10 font-bold text-txt-primary mt-2">{groupName}</h3>
|
||||||
|
<p className="text-txt-secondary text-[14px] mt-1">
|
||||||
|
This is the beginning of your group conversation.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-txt-tertiary mt-1">
|
||||||
|
Group created by <strong>@{ownerName}</strong>
|
||||||
|
</p>
|
||||||
|
{hasFederated && (
|
||||||
|
<p className="text-xs text-txt-tertiary mt-1">
|
||||||
|
Messages are stored on your and your recipients' home instances. They are not end-to-end encrypted.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-4">
|
||||||
|
<button
|
||||||
|
onClick={handleLeaveGroup}
|
||||||
|
className="px-4 py-1.5 bg-surface-elevated hover:bg-surface-elevated text-[14px] font-medium text-txt-primary rounded-[3px] transition-colors"
|
||||||
|
>
|
||||||
|
Leave Group
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 border-b border-interactive-muted" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1-on-1 DM welcome header
|
||||||
|
const otherUser = otherMembers[0];
|
||||||
const { baseName } = parseFederatedUsername(otherUser?.username ?? '');
|
const { baseName } = parseFederatedUsername(otherUser?.username ?? '');
|
||||||
const displayName = otherUser?.displayName ?? (baseName || 'Direct Message');
|
const displayName = otherUser?.displayName ?? (baseName || 'Direct Message');
|
||||||
const mentionName = otherUser?.displayName ?? baseName;
|
const mentionName = otherUser?.displayName ?? baseName;
|
||||||
|
|||||||
@@ -814,6 +814,12 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'dm_owner_updated': {
|
||||||
|
const { updateDmOwner } = useSpaceStore.getState();
|
||||||
|
updateDmOwner(event.dmChannelId, event.newOwnerId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Channel/space events (all origins) ─────────────────────────────────
|
// ─── Channel/space events (all origins) ─────────────────────────────────
|
||||||
|
|
||||||
case 'channel_created': {
|
case 'channel_created': {
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ interface SpaceState {
|
|||||||
removeDmChannel: (id: string) => void;
|
removeDmChannel: (id: string) => void;
|
||||||
addDmMember: (dmChannelId: string, user: User) => void;
|
addDmMember: (dmChannelId: string, user: User) => void;
|
||||||
removeDmMember: (dmChannelId: string, userId: string) => void;
|
removeDmMember: (dmChannelId: string, userId: string) => void;
|
||||||
|
updateDmOwner: (dmChannelId: string, newOwnerId: string) => void;
|
||||||
closeDm: (id: string) => Promise<void>;
|
closeDm: (id: string) => Promise<void>;
|
||||||
leaveDm: (id: string) => Promise<void>;
|
leaveDm: (id: string) => Promise<void>;
|
||||||
loadSpaces: () => Promise<void>;
|
loadSpaces: () => Promise<void>;
|
||||||
@@ -201,6 +202,12 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
),
|
),
|
||||||
})),
|
})),
|
||||||
|
|
||||||
|
updateDmOwner: (dmChannelId, newOwnerId) => set((state) => ({
|
||||||
|
dmChannels: state.dmChannels.map(dm =>
|
||||||
|
dm.id === dmChannelId ? { ...dm, ownerId: newOwnerId } : dm
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
|
||||||
closeDm: async (id) => {
|
closeDm: async (id) => {
|
||||||
const origin = get().channelOriginMap.get(id) || '';
|
const origin = get().channelOriginMap.get(id) || '';
|
||||||
const targetApi = getApiForOrigin(origin);
|
const targetApi = getApiForOrigin(origin);
|
||||||
|
|||||||
Reference in New Issue
Block a user