diff --git a/toju-app/src/app/domains/chat/domain/rules/message-sender-identity.rules.spec.ts b/toju-app/src/app/domains/chat/domain/rules/message-sender-identity.rules.spec.ts new file mode 100644 index 0000000..a2db5f7 --- /dev/null +++ b/toju-app/src/app/domains/chat/domain/rules/message-sender-identity.rules.spec.ts @@ -0,0 +1,49 @@ +import type { Message } from '../../../../shared-kernel'; +import { resolveIncomingChatMessageSenderId, resolveRoomMessageSenderId } from './message-sender-identity.rules'; + +function createMessage(overrides: Partial = {}): Message { + return { + id: 'message-1', + roomId: 'room-1', + senderId: 'home-user-1', + senderName: 'Alice', + content: 'hello', + timestamp: 1, + reactions: [], + isDeleted: false, + ...overrides + }; +} + +describe('message-sender-identity.rules', () => { + it('resolveRoomMessageSenderId uses the per-server actor id', () => { + const senderId = resolveRoomMessageSenderId( + { id: 'home-user-1', oderId: 'home-oder-1' }, + 'https://signal.example.com', + (_serverUrl, fallback) => fallback === 'home-oder-1' ? 'server-user-1' : fallback + ); + + expect(senderId).toBe('server-user-1'); + }); + + it('resolveIncomingChatMessageSenderId prefers relay sender identity over message senderId', () => { + const senderId = resolveIncomingChatMessageSenderId( + createMessage({ senderId: 'home-user-1' }), + { + senderId: 'server-user-1', + fromPeerId: 'peer-1' + } + ); + + expect(senderId).toBe('server-user-1'); + }); + + it('resolveIncomingChatMessageSenderId falls back to fromPeerId for P2P chat', () => { + const senderId = resolveIncomingChatMessageSenderId( + createMessage({ senderId: 'home-user-1' }), + { fromPeerId: 'server-user-1' } + ); + + expect(senderId).toBe('server-user-1'); + }); +}); diff --git a/toju-app/src/app/domains/chat/domain/rules/message-sender-identity.rules.ts b/toju-app/src/app/domains/chat/domain/rules/message-sender-identity.rules.ts new file mode 100644 index 0000000..e708e5b --- /dev/null +++ b/toju-app/src/app/domains/chat/domain/rules/message-sender-identity.rules.ts @@ -0,0 +1,37 @@ +import type { Message } from '../../../../shared-kernel'; + +/** Resolve the sender id that should be stored for a room chat message. */ +export function resolveRoomMessageSenderId( + currentUser: Pick<{ id: string; oderId: string }, 'id' | 'oderId'>, + roomSourceUrl: string | undefined, + resolveActorUserId: (serverUrl: string | undefined, fallbackUserId: string) => string +): string { + const homeUserKey = currentUser.oderId || currentUser.id; + + return resolveActorUserId(roomSourceUrl, homeUserKey); +} + +interface IncomingChatMessageEnvelope { + senderId?: string; + fromPeerId?: string; + fromUserId?: string; +} + +/** Normalize incoming chat sender ids to the per-server identity used by presence. */ +export function resolveIncomingChatMessageSenderId( + message: Pick, + envelope: IncomingChatMessageEnvelope +): string { + const relayIdentity = [envelope.senderId, envelope.fromUserId] + .find((value): value is string => typeof value === 'string' && value.trim().length > 0); + + if (relayIdentity) { + return relayIdentity.trim(); + } + + if (envelope.fromPeerId?.trim()) { + return envelope.fromPeerId.trim(); + } + + return message.senderId; +} diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts index 8d9881f..ebb57b1 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts @@ -56,6 +56,7 @@ import { hasDedicatedChatEmbed } from '../../../../domain/rules/link-embed.rules import { shouldShowMessageEditedLabel } from '../../../../domain/rules/message.rules'; import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../../../core/i18n'; import { Message, User } from '../../../../../../shared-kernel'; +import { resolveUserByIdentity } from '../../../../../../store/users/user-identity-lookup.rules'; import { ThemeNodeDirective } from '../../../../../theme'; import { PluginRenderHostComponent } from '../../../../../plugins/feature/plugin-render-host/plugin-render-host.component'; import { PluginRequirementStateService, PluginUiRegistryService } from '../../../../../plugins'; @@ -221,7 +222,7 @@ export class ChatMessageItemComponent implements OnDestroy { readonly showEmojiPicker = signal(false); readonly senderUser = computed(() => { const msg = this.message(); - const found = this.userLookup().get(msg.senderId); + const found = resolveUserByIdentity(this.userLookup(), msg.senderId); return ( found ?? { diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.ts b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.ts index dfda817..1cc90f9 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.ts +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.ts @@ -34,6 +34,7 @@ import { ChatMessageReplyEvent } from '../../models/chat-messages.model'; import { selectAllUsers } from '../../../../../../store/users/users.selectors'; +import { buildUserIdentityLookup } from '../../../../../../store/users/user-identity-lookup.rules'; import { APP_TRANSLATE_IMPORTS } from '../../../../../../core/i18n'; import { ThemeNodeDirective } from '../../../../../theme'; import { ChatMessageItemComponent } from '../message-item/chat-message-item.component'; @@ -146,21 +147,11 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy { }); readonly userLookup = computed>(() => { - const lookup = new Map(); - - for (const user of this.allUsers()) { - lookup.set(user.id, user); - - if (user.oderId && user.oderId !== user.id) { - lookup.set(user.oderId, user); - } - } + const lookup = new Map(buildUserIdentityLookup(this.allUsers())); for (const user of this.userLookupOverrides()) { - lookup.set(user.id, user); - - if (user.oderId && user.oderId !== user.id) { - lookup.set(user.oderId, user); + for (const [key, value] of buildUserIdentityLookup([user])) { + lookup.set(key, value); } } diff --git a/toju-app/src/app/shared/components/profile-card/profile-card-mobile.component.ts b/toju-app/src/app/shared/components/profile-card/profile-card-mobile.component.ts index 15b8a11..3a17277 100644 --- a/toju-app/src/app/shared/components/profile-card/profile-card-mobile.component.ts +++ b/toju-app/src/app/shared/components/profile-card/profile-card-mobile.component.ts @@ -27,6 +27,7 @@ import { ProfileSignalServerTagComponent } from './profile-signal-server-tag.com import { ThemeNodeDirective } from '../../../domains/theme'; import { User, UserStatus } from '../../../shared-kernel'; import { selectCurrentUser, selectUsersEntities } from '../../../store/users/users.selectors'; +import { findUserEntityByIdentity } from '../../../store/users/user-identity-lookup.rules'; import { UsersActions } from '../../../store/users/users.actions'; import { DirectMessageService } from '../../../domains/direct-message/application/services/direct-message.service'; import { FriendService } from '../../../domains/direct-message/application/services/friend.service'; @@ -108,7 +109,9 @@ export class ProfileCardMobileComponent implements OnDestroy { readonly displayedUser = computed(() => { const snapshot = this.user(); const entities = this.users(); - const liveUser = entities[snapshot.id] ?? entities[snapshot.oderId]; + const liveUser = findUserEntityByIdentity(entities, snapshot.id) + ?? findUserEntityByIdentity(entities, snapshot.oderId) + ?? findUserEntityByIdentity(entities, snapshot.peerId); return liveUser ? { ...snapshot, ...liveUser } : snapshot; }); diff --git a/toju-app/src/app/shared/components/profile-card/profile-card.component.ts b/toju-app/src/app/shared/components/profile-card/profile-card.component.ts index 1dd2ba8..55a9c3f 100644 --- a/toju-app/src/app/shared/components/profile-card/profile-card.component.ts +++ b/toju-app/src/app/shared/components/profile-card/profile-card.component.ts @@ -32,6 +32,7 @@ import { } from '../../../domains/profile-avatar'; import { UsersActions } from '../../../store/users/users.actions'; import { selectUsersEntities } from '../../../store/users/users.selectors'; +import { findUserEntityByIdentity } from '../../../store/users/user-identity-lookup.rules'; import { ThemeNodeDirective } from '../../../domains/theme'; import { formatGameActivityElapsed } from '../../../domains/game-activity'; import { ExternalLinkService } from '../../../core/platform/external-link.service'; @@ -67,7 +68,9 @@ export class ProfileCardComponent { readonly displayedUser = computed(() => { const snapshot = this.user(); const entities = this.users(); - const liveUser = entities[snapshot.id] ?? entities[snapshot.oderId]; + const liveUser = findUserEntityByIdentity(entities, snapshot.id) + ?? findUserEntityByIdentity(entities, snapshot.oderId) + ?? findUserEntityByIdentity(entities, snapshot.peerId); return liveUser ? { ...snapshot, ...liveUser } : snapshot; }); diff --git a/toju-app/src/app/store/messages/messages-incoming.handlers.spec.ts b/toju-app/src/app/store/messages/messages-incoming.handlers.spec.ts index ddfb664..e835cd2 100644 --- a/toju-app/src/app/store/messages/messages-incoming.handlers.spec.ts +++ b/toju-app/src/app/store/messages/messages-incoming.handlers.spec.ts @@ -68,6 +68,35 @@ describe('dispatchIncomingMessage multi-device sync', () => { expect(action).not.toBeNull(); expect(saveMessage).toHaveBeenCalled(); }); + + it('normalizes relayed chat sender ids to the per-server identity', async () => { + const saveMessage = vi.fn(async () => undefined); + const rememberMessageRoom = vi.fn(); + const context = createContext({ + db: { saveMessage }, + attachments: { rememberMessageRoom }, + currentUser: { id: 'viewer-home', oderId: 'viewer-home' }, + currentRoom: { id: 'room-a' }, + savedRooms: [{ id: 'room-a' }] + }); + const action = await firstValueFrom( + dispatchIncomingMessage( + { + type: 'chat-message', + senderId: 'server-user-1', + fromPeerId: 'server-user-1', + message: createMessage({ + senderId: 'home-user-1', + roomId: 'room-a' + }) + } as never, + context as never + ).pipe(defaultIfEmpty(null)) + ); + + expect(action).not.toBeNull(); + expect(saveMessage).toHaveBeenCalledWith(expect.objectContaining({ senderId: 'server-user-1' })); + }); }); describe('dispatchIncomingMessage room-scoped sync', () => { diff --git a/toju-app/src/app/store/messages/messages-incoming.handlers.ts b/toju-app/src/app/store/messages/messages-incoming.handlers.ts index 35079f2..5b1109d 100644 --- a/toju-app/src/app/store/messages/messages-incoming.handlers.ts +++ b/toju-app/src/app/store/messages/messages-incoming.handlers.ts @@ -45,6 +45,7 @@ import { mergeIncomingRevision } from './messages.helpers'; import { MessageRevisionService } from '../../domains/chat/application/services/message-revision.service'; +import { resolveIncomingChatMessageSenderId } from '../../domains/chat/domain/rules/message-sender-identity.rules'; type AnnouncedAttachment = Pick; type AttachmentMetaMap = Record; @@ -362,30 +363,39 @@ function handleChatMessage( if (!isKnownRoomId(msg.roomId, ctx)) return EMPTY; + const senderId = resolveIncomingChatMessageSenderId(msg, { + senderId: (event as { senderId?: string }).senderId, + fromPeerId: event.fromPeerId, + fromUserId: (event as { fromUserId?: string }).fromUserId + }); + const normalizedMessage = senderId === msg.senderId ? msg : { ...msg, senderId }; // Skip only messages that originated on this client instance. const isOwnMessageOnThisClient = - (msg.senderId === currentUser?.id || msg.senderId === currentUser?.oderId) + (normalizedMessage.senderId === currentUser?.id + || normalizedMessage.senderId === currentUser?.oderId + || msg.senderId === currentUser?.id + || msg.senderId === currentUser?.oderId) && (!msg.clientInstanceId || msg.clientInstanceId === ctx.getClientInstanceId()); if (isOwnMessageOnThisClient) return EMPTY; - attachments.rememberMessageRoom(msg.id, msg.roomId); + attachments.rememberMessageRoom(normalizedMessage.id, normalizedMessage.roomId); trackBackgroundOperation( - db.saveMessage(msg), + db.saveMessage(normalizedMessage), debugging, 'Failed to persist incoming chat message', { - channelId: msg.channelId || 'general', + channelId: normalizedMessage.channelId || 'general', fromPeerId: event.fromPeerId ?? null, - messageId: msg.id, - roomId: msg.roomId, - senderId: msg.senderId + messageId: normalizedMessage.id, + roomId: normalizedMessage.roomId, + senderId: normalizedMessage.senderId } ); - return of(MessagesActions.receiveMessage({ message: msg })); + return of(MessagesActions.receiveMessage({ message: normalizedMessage })); } function handleMessageRevision( diff --git a/toju-app/src/app/store/messages/messages.effects.ts b/toju-app/src/app/store/messages/messages.effects.ts index a491fd4..d6f76c5 100644 --- a/toju-app/src/app/store/messages/messages.effects.ts +++ b/toju-app/src/app/store/messages/messages.effects.ts @@ -8,7 +8,7 @@ * The giant `incomingMessages$` switch-case has been replaced by a * handler registry in `messages-incoming.handlers.ts`. */ -/* eslint-disable @typescript-eslint/member-ordering */ + import { Injectable, inject } from '@angular/core'; import { Actions, @@ -52,7 +52,9 @@ import { } from '../../shared-kernel'; import { hydrateMessages } from './messages.helpers'; import { canEditMessage } from '../../domains/chat/domain/rules/message.rules'; +import { resolveRoomMessageSenderId } from '../../domains/chat/domain/rules/message-sender-identity.rules'; import { resolveRoomPermission } from '../../domains/access-control'; +import { SignalServerAuthService } from '../../domains/authentication/application/services/signal-server-auth.service'; import { dispatchIncomingMessage, IncomingMessageContext } from './messages-incoming.handlers'; import { MessageRevisionService } from '../../domains/chat/application/services/message-revision.service'; import { materializeMessageFromRevision } from '../../domains/chat/domain/rules/message-revision.builder.rules'; @@ -76,6 +78,7 @@ export class MessagesEffects { private readonly platform = inject(PlatformService); private readonly i18n = inject(AppI18nService); private readonly messageRevisions = inject(MessageRevisionService); + private readonly signalServerAuth = inject(SignalServerAuthService); /** Loads messages for a room from the local database, hydrating reactions. */ loadMessages$ = createEffect(() => @@ -238,11 +241,16 @@ export class MessagesEffects { return of(MessagesActions.sendMessageFailure({ error: this.i18n.instant('chat.effects.notConnectedToRoom') })); } + const senderId = resolveRoomMessageSenderId( + currentUser, + currentRoom.sourceUrl, + (serverUrl, fallbackUserId) => this.signalServerAuth.resolveActorUserIdForServer(serverUrl, fallbackUserId) + ); const draftMessage: Message = { id: id ?? uuidv4(), roomId: currentRoom.id, channelId: channelId || 'general', - senderId: currentUser.id, + senderId, senderName: currentUser.displayName || currentUser.username, content, timestamp: this.timeSync.now(), diff --git a/toju-app/src/app/store/users/user-identity-lookup.rules.spec.ts b/toju-app/src/app/store/users/user-identity-lookup.rules.spec.ts new file mode 100644 index 0000000..40cd1fd --- /dev/null +++ b/toju-app/src/app/store/users/user-identity-lookup.rules.spec.ts @@ -0,0 +1,49 @@ +import type { User } from '../../shared-kernel'; +import { + buildUserIdentityLookup, + findUserEntityByIdentity, + resolveUserByIdentity +} from './user-identity-lookup.rules'; + +function createUser(overrides: Partial = {}): User { + return { + id: 'server-user-1', + oderId: 'server-user-1', + username: 'alice', + displayName: 'Alice', + status: 'online', + role: 'member', + joinedAt: 1, + presenceServerIds: ['room-1'], + isOnline: true, + ...overrides + }; +} + +describe('user-identity-lookup.rules', () => { + it('indexes id, oderId, and peerId aliases in buildUserIdentityLookup', () => { + const user = createUser({ + id: 'server-user-1', + oderId: 'oder-1', + peerId: 'peer-1' + }); + const lookup = buildUserIdentityLookup([user]); + + expect(resolveUserByIdentity(lookup, 'server-user-1')).toBe(user); + expect(resolveUserByIdentity(lookup, 'oder-1')).toBe(user); + expect(resolveUserByIdentity(lookup, 'peer-1')).toBe(user); + }); + + it('findUserEntityByIdentity resolves users keyed by a different entity id', () => { + const user = createUser({ + id: 'server-user-1', + oderId: 'oder-1' + }); + const entities = { + 'server-user-1': user + }; + + expect(findUserEntityByIdentity(entities, 'oder-1')).toBe(user); + expect(findUserEntityByIdentity(entities, 'home-user-1')).toBeUndefined(); + }); +}); diff --git a/toju-app/src/app/store/users/user-identity-lookup.rules.ts b/toju-app/src/app/store/users/user-identity-lookup.rules.ts new file mode 100644 index 0000000..9dc5aae --- /dev/null +++ b/toju-app/src/app/store/users/user-identity-lookup.rules.ts @@ -0,0 +1,68 @@ +import type { User } from '../../shared-kernel'; + +type UserIdentityFields = Pick; + +function collectUserIdentityKeys(user: UserIdentityFields): string[] { + const keys: string[] = []; + + if (user.id?.trim()) { + keys.push(user.id.trim()); + } + + if (user.oderId?.trim() && user.oderId !== user.id) { + keys.push(user.oderId.trim()); + } + + if (user.peerId?.trim() && user.peerId !== user.id && user.peerId !== user.oderId) { + keys.push(user.peerId.trim()); + } + + return keys; +} + +/** Build a lookup map keyed by every known identity alias for each user. */ +export function buildUserIdentityLookup(users: readonly User[]): ReadonlyMap { + const lookup = new Map(); + + for (const user of users) { + for (const key of collectUserIdentityKeys(user)) { + lookup.set(key, user); + } + } + + return lookup; +} + +/** Resolve a user from a pre-built identity lookup map. */ +export function resolveUserByIdentity( + lookup: ReadonlyMap, + identity: string | undefined +): User | undefined { + if (!identity?.trim()) { + return undefined; + } + + return lookup.get(identity.trim()); +} + +/** Resolve a user entity when NgRx entity keys may not match the queried identity. */ +export function findUserEntityByIdentity( + entities: Record, + identity: string | undefined +): User | undefined { + if (!identity?.trim()) { + return undefined; + } + + const trimmed = identity.trim(); + const direct = entities[trimmed]; + + if (direct) { + return direct; + } + + return Object.values(entities).find((user): user is User => + !!user + && (user.id === trimmed || user.oderId === trimmed || user.peerId === trimmed) + ); +}