fix: Bug - Users appear as both online and offline

Align chat message sender ids with per-server presence identities so profile cards opened from message authors resolve the same live user state as the members panel.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-13 20:55:13 +02:00
parent c3c2f01cc6
commit b2a2d9d770
11 changed files with 274 additions and 26 deletions

View File

@@ -0,0 +1,49 @@
import type { Message } from '../../../../shared-kernel';
import { resolveIncomingChatMessageSenderId, resolveRoomMessageSenderId } from './message-sender-identity.rules';
function createMessage(overrides: Partial<Message> = {}): 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');
});
});

View File

@@ -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<Message, 'senderId'>,
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;
}

View File

@@ -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<User>(() => {
const msg = this.message();
const found = this.userLookup().get(msg.senderId);
const found = resolveUserByIdentity(this.userLookup(), msg.senderId);
return (
found ?? {

View File

@@ -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<ReadonlyMap<string, User>>(() => {
const lookup = new Map<string, User>();
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);
}
}

View File

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

View File

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

View File

@@ -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', () => {

View File

@@ -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<AttachmentMeta, 'id' | 'filename' | 'size' | 'mime' | 'isImage' | 'uploaderPeerId'>;
type AttachmentMetaMap = Record<string, AttachmentMeta[]>;
@@ -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(

View File

@@ -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(),

View File

@@ -0,0 +1,49 @@
import type { User } from '../../shared-kernel';
import {
buildUserIdentityLookup,
findUserEntityByIdentity,
resolveUserByIdentity
} from './user-identity-lookup.rules';
function createUser(overrides: Partial<User> = {}): 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();
});
});

View File

@@ -0,0 +1,68 @@
import type { User } from '../../shared-kernel';
type UserIdentityFields = Pick<User, 'id' | 'oderId' | 'peerId'>;
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<string, User> {
const lookup = new Map<string, User>();
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<string, User>,
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<string, User | undefined>,
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)
);
}