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