From c3191be4e717041b67b770c14213cc6a65bca0d8 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Fri, 27 Mar 2026 05:43:30 +0100 Subject: [PATCH] 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 --- packages/server/src/routes/dm.ts | 9 ++ packages/server/src/routes/federation.ts | 6 + packages/shared/src/types.ts | 1 + packages/web/src/api/client.ts | 3 + .../web/src/components/chat/MessageList.tsx | 130 +++++++++++++++++- packages/web/src/hooks/useWebSocket.ts | 6 + packages/web/src/stores/spaceStore.ts | 7 + 7 files changed, 155 insertions(+), 7 deletions(-) diff --git a/packages/server/src/routes/dm.ts b/packages/server/src/routes/dm.ts index efe49eab..c13077bd 100644 --- a/packages/server/src/routes/dm.ts +++ b/packages/server/src/routes/dm.ts @@ -1214,6 +1214,15 @@ export async function dmRoutes(app: FastifyInstance): Promise { .where(eq(schema.dmChannels.id, id)) .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 const newOwnerUser = db.select().from(schema.users).where(eq(schema.users.id, nextOwner.userId)).get(); diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index 68816ae0..97bfa5bd 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -1972,6 +1972,12 @@ function processOwnershipTransferEvent( .where(eq(schema.dmChannels.id, channel.id)) .run(); + connectionManager.sendToDmMembers(channel.id, { + type: 'dm_owner_updated', + dmChannelId: channel.id, + newOwnerId: newOwnerLocal?.id ?? channel.ownerId!, + }); + const prevOwnerLocal = event.ownership.previousOwner ? resolveLocalUser(event.ownership.previousOwner.homeUserId, db) : null; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 6118fb0a..9f5bba94 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -413,6 +413,7 @@ export type ServerEvent = | { type: 'embeds_resolved'; messageId: string; channelId: 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: 'dm_owner_updated'; dmChannelId: string; newOwnerId: string } | { type: 'pong' } | { type: 'error'; message: string }; diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index dd585dfe..3320a798 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -145,6 +145,7 @@ export class BackspaceApiClient { deleteMessage: (id: string) => Promise<{ success: boolean }>; addMember: (dmChannelId: string, data: { userId: string }) => Promise; leave: (dmChannelId: string) => Promise<{ success: boolean }>; + leaveGroup: (dmChannelId: string) => Promise<{ success: boolean }>; }; readonly social: { @@ -505,6 +506,8 @@ export class BackspaceApiClient { request('POST', `/dm/${dmChannelId}/members`, data), leave: (dmChannelId: string) => request<{ success: boolean }>('DELETE', `/dm/${dmChannelId}/members`), + leaveGroup: (dmChannelId: string) => + request<{ success: boolean }>('DELETE', `/dm/${dmChannelId}/members`), }; this.social = { diff --git a/packages/web/src/components/chat/MessageList.tsx b/packages/web/src/components/chat/MessageList.tsx index fd7bcb8d..d96c99fd 100644 --- a/packages/web/src/components/chat/MessageList.tsx +++ b/packages/web/src/components/chat/MessageList.tsx @@ -1,4 +1,6 @@ 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 { useChatStore } from '../../stores/chatStore'; import { useSpaceStore, isDmChannel } from '../../stores/spaceStore'; @@ -19,6 +21,7 @@ interface MessageListProps { } 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; const timeDiff = curr.createdAt - prev.createdAt; return timeDiff < 5 * 60 * 1000; // 5 minutes @@ -335,12 +338,16 @@ export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: Mess
)} - + {(msg as any).type === 'system' ? ( + + ) : ( + + )} ); })} @@ -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 ( +
+ + {icon} + {text} + +
+ ); +} + function WelcomeHeader({ channelId }: { channelId: string }) { const dmChannels = useSpaceStore((s) => s.dmChannels); const authUser = useAuthStore((s) => s.user); const removeFriend = useSocialStore((s) => s.removeFriend); const friends = useSocialStore((s) => s.friends); const isDm = isDmChannel(channelId); + const navigate = useNavigate(); if (isDm) { const dm = dmChannels.find(d => d.id === channelId); 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 ( +
+
+ {otherMembers.slice(0, 2).map((m, idx) => ( +
+ +
+ ))} +
+

{groupName}

+

+ This is the beginning of your group conversation. +

+

+ Group created by @{ownerName} +

+ {hasFederated && ( +

+ Messages are stored on your and your recipients' home instances. They are not end-to-end encrypted. +

+ )} +
+ +
+
+
+ ); + } + + // 1-on-1 DM welcome header + const otherUser = otherMembers[0]; const { baseName } = parseFederatedUsername(otherUser?.username ?? ''); const displayName = otherUser?.displayName ?? (baseName || 'Direct Message'); const mentionName = otherUser?.displayName ?? baseName; diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index 71e6baa8..dbdff132 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -814,6 +814,12 @@ function handleEvent(origin: string, event: ServerEvent): void { break; } + case 'dm_owner_updated': { + const { updateDmOwner } = useSpaceStore.getState(); + updateDmOwner(event.dmChannelId, event.newOwnerId); + break; + } + // ─── Channel/space events (all origins) ───────────────────────────────── case 'channel_created': { diff --git a/packages/web/src/stores/spaceStore.ts b/packages/web/src/stores/spaceStore.ts index 6a2df4b0..43f9b10e 100644 --- a/packages/web/src/stores/spaceStore.ts +++ b/packages/web/src/stores/spaceStore.ts @@ -53,6 +53,7 @@ interface SpaceState { removeDmChannel: (id: string) => void; addDmMember: (dmChannelId: string, user: User) => void; removeDmMember: (dmChannelId: string, userId: string) => void; + updateDmOwner: (dmChannelId: string, newOwnerId: string) => void; closeDm: (id: string) => Promise; leaveDm: (id: string) => Promise; loadSpaces: () => Promise; @@ -201,6 +202,12 @@ export const useSpaceStore = create((set, get) => ({ ), })), + updateDmOwner: (dmChannelId, newOwnerId) => set((state) => ({ + dmChannels: state.dmChannels.map(dm => + dm.id === dmChannelId ? { ...dm, ownerId: newOwnerId } : dm + ), + })), + closeDm: async (id) => { const origin = get().channelOriginMap.get(id) || ''; const targetApi = getApiForOrigin(origin);