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:
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -56,6 +56,7 @@ import { hasDedicatedChatEmbed } from '../../../../domain/rules/link-embed.rules
|
|||||||
import { shouldShowMessageEditedLabel } from '../../../../domain/rules/message.rules';
|
import { shouldShowMessageEditedLabel } from '../../../../domain/rules/message.rules';
|
||||||
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../../../core/i18n';
|
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../../../core/i18n';
|
||||||
import { Message, User } from '../../../../../../shared-kernel';
|
import { Message, User } from '../../../../../../shared-kernel';
|
||||||
|
import { resolveUserByIdentity } from '../../../../../../store/users/user-identity-lookup.rules';
|
||||||
import { ThemeNodeDirective } from '../../../../../theme';
|
import { ThemeNodeDirective } from '../../../../../theme';
|
||||||
import { PluginRenderHostComponent } from '../../../../../plugins/feature/plugin-render-host/plugin-render-host.component';
|
import { PluginRenderHostComponent } from '../../../../../plugins/feature/plugin-render-host/plugin-render-host.component';
|
||||||
import { PluginRequirementStateService, PluginUiRegistryService } from '../../../../../plugins';
|
import { PluginRequirementStateService, PluginUiRegistryService } from '../../../../../plugins';
|
||||||
@@ -221,7 +222,7 @@ export class ChatMessageItemComponent implements OnDestroy {
|
|||||||
readonly showEmojiPicker = signal(false);
|
readonly showEmojiPicker = signal(false);
|
||||||
readonly senderUser = computed<User>(() => {
|
readonly senderUser = computed<User>(() => {
|
||||||
const msg = this.message();
|
const msg = this.message();
|
||||||
const found = this.userLookup().get(msg.senderId);
|
const found = resolveUserByIdentity(this.userLookup(), msg.senderId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
found ?? {
|
found ?? {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
ChatMessageReplyEvent
|
ChatMessageReplyEvent
|
||||||
} from '../../models/chat-messages.model';
|
} from '../../models/chat-messages.model';
|
||||||
import { selectAllUsers } from '../../../../../../store/users/users.selectors';
|
import { selectAllUsers } from '../../../../../../store/users/users.selectors';
|
||||||
|
import { buildUserIdentityLookup } from '../../../../../../store/users/user-identity-lookup.rules';
|
||||||
import { APP_TRANSLATE_IMPORTS } from '../../../../../../core/i18n';
|
import { APP_TRANSLATE_IMPORTS } from '../../../../../../core/i18n';
|
||||||
import { ThemeNodeDirective } from '../../../../../theme';
|
import { ThemeNodeDirective } from '../../../../../theme';
|
||||||
import { ChatMessageItemComponent } from '../message-item/chat-message-item.component';
|
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>>(() => {
|
readonly userLookup = computed<ReadonlyMap<string, User>>(() => {
|
||||||
const lookup = new Map<string, User>();
|
const lookup = new Map(buildUserIdentityLookup(this.allUsers()));
|
||||||
|
|
||||||
for (const user of this.allUsers()) {
|
|
||||||
lookup.set(user.id, user);
|
|
||||||
|
|
||||||
if (user.oderId && user.oderId !== user.id) {
|
|
||||||
lookup.set(user.oderId, user);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const user of this.userLookupOverrides()) {
|
for (const user of this.userLookupOverrides()) {
|
||||||
lookup.set(user.id, user);
|
for (const [key, value] of buildUserIdentityLookup([user])) {
|
||||||
|
lookup.set(key, value);
|
||||||
if (user.oderId && user.oderId !== user.id) {
|
|
||||||
lookup.set(user.oderId, user);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { ProfileSignalServerTagComponent } from './profile-signal-server-tag.com
|
|||||||
import { ThemeNodeDirective } from '../../../domains/theme';
|
import { ThemeNodeDirective } from '../../../domains/theme';
|
||||||
import { User, UserStatus } from '../../../shared-kernel';
|
import { User, UserStatus } from '../../../shared-kernel';
|
||||||
import { selectCurrentUser, selectUsersEntities } from '../../../store/users/users.selectors';
|
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 { UsersActions } from '../../../store/users/users.actions';
|
||||||
import { DirectMessageService } from '../../../domains/direct-message/application/services/direct-message.service';
|
import { DirectMessageService } from '../../../domains/direct-message/application/services/direct-message.service';
|
||||||
import { FriendService } from '../../../domains/direct-message/application/services/friend.service';
|
import { FriendService } from '../../../domains/direct-message/application/services/friend.service';
|
||||||
@@ -108,7 +109,9 @@ export class ProfileCardMobileComponent implements OnDestroy {
|
|||||||
readonly displayedUser = computed(() => {
|
readonly displayedUser = computed(() => {
|
||||||
const snapshot = this.user();
|
const snapshot = this.user();
|
||||||
const entities = this.users();
|
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;
|
return liveUser ? { ...snapshot, ...liveUser } : snapshot;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
} from '../../../domains/profile-avatar';
|
} from '../../../domains/profile-avatar';
|
||||||
import { UsersActions } from '../../../store/users/users.actions';
|
import { UsersActions } from '../../../store/users/users.actions';
|
||||||
import { selectUsersEntities } from '../../../store/users/users.selectors';
|
import { selectUsersEntities } from '../../../store/users/users.selectors';
|
||||||
|
import { findUserEntityByIdentity } from '../../../store/users/user-identity-lookup.rules';
|
||||||
import { ThemeNodeDirective } from '../../../domains/theme';
|
import { ThemeNodeDirective } from '../../../domains/theme';
|
||||||
import { formatGameActivityElapsed } from '../../../domains/game-activity';
|
import { formatGameActivityElapsed } from '../../../domains/game-activity';
|
||||||
import { ExternalLinkService } from '../../../core/platform/external-link.service';
|
import { ExternalLinkService } from '../../../core/platform/external-link.service';
|
||||||
@@ -67,7 +68,9 @@ export class ProfileCardComponent {
|
|||||||
readonly displayedUser = computed(() => {
|
readonly displayedUser = computed(() => {
|
||||||
const snapshot = this.user();
|
const snapshot = this.user();
|
||||||
const entities = this.users();
|
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;
|
return liveUser ? { ...snapshot, ...liveUser } : snapshot;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -68,6 +68,35 @@ describe('dispatchIncomingMessage multi-device sync', () => {
|
|||||||
expect(action).not.toBeNull();
|
expect(action).not.toBeNull();
|
||||||
expect(saveMessage).toHaveBeenCalled();
|
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', () => {
|
describe('dispatchIncomingMessage room-scoped sync', () => {
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
mergeIncomingRevision
|
mergeIncomingRevision
|
||||||
} from './messages.helpers';
|
} from './messages.helpers';
|
||||||
import { MessageRevisionService } from '../../domains/chat/application/services/message-revision.service';
|
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 AnnouncedAttachment = Pick<AttachmentMeta, 'id' | 'filename' | 'size' | 'mime' | 'isImage' | 'uploaderPeerId'>;
|
||||||
type AttachmentMetaMap = Record<string, AttachmentMeta[]>;
|
type AttachmentMetaMap = Record<string, AttachmentMeta[]>;
|
||||||
@@ -362,30 +363,39 @@ function handleChatMessage(
|
|||||||
if (!isKnownRoomId(msg.roomId, ctx))
|
if (!isKnownRoomId(msg.roomId, ctx))
|
||||||
return EMPTY;
|
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.
|
// Skip only messages that originated on this client instance.
|
||||||
const isOwnMessageOnThisClient =
|
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());
|
&& (!msg.clientInstanceId || msg.clientInstanceId === ctx.getClientInstanceId());
|
||||||
|
|
||||||
if (isOwnMessageOnThisClient)
|
if (isOwnMessageOnThisClient)
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
|
|
||||||
attachments.rememberMessageRoom(msg.id, msg.roomId);
|
attachments.rememberMessageRoom(normalizedMessage.id, normalizedMessage.roomId);
|
||||||
|
|
||||||
trackBackgroundOperation(
|
trackBackgroundOperation(
|
||||||
db.saveMessage(msg),
|
db.saveMessage(normalizedMessage),
|
||||||
debugging,
|
debugging,
|
||||||
'Failed to persist incoming chat message',
|
'Failed to persist incoming chat message',
|
||||||
{
|
{
|
||||||
channelId: msg.channelId || 'general',
|
channelId: normalizedMessage.channelId || 'general',
|
||||||
fromPeerId: event.fromPeerId ?? null,
|
fromPeerId: event.fromPeerId ?? null,
|
||||||
messageId: msg.id,
|
messageId: normalizedMessage.id,
|
||||||
roomId: msg.roomId,
|
roomId: normalizedMessage.roomId,
|
||||||
senderId: msg.senderId
|
senderId: normalizedMessage.senderId
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return of(MessagesActions.receiveMessage({ message: msg }));
|
return of(MessagesActions.receiveMessage({ message: normalizedMessage }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMessageRevision(
|
function handleMessageRevision(
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
* The giant `incomingMessages$` switch-case has been replaced by a
|
* The giant `incomingMessages$` switch-case has been replaced by a
|
||||||
* handler registry in `messages-incoming.handlers.ts`.
|
* handler registry in `messages-incoming.handlers.ts`.
|
||||||
*/
|
*/
|
||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import {
|
import {
|
||||||
Actions,
|
Actions,
|
||||||
@@ -52,7 +52,9 @@ import {
|
|||||||
} from '../../shared-kernel';
|
} from '../../shared-kernel';
|
||||||
import { hydrateMessages } from './messages.helpers';
|
import { hydrateMessages } from './messages.helpers';
|
||||||
import { canEditMessage } from '../../domains/chat/domain/rules/message.rules';
|
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 { resolveRoomPermission } from '../../domains/access-control';
|
||||||
|
import { SignalServerAuthService } from '../../domains/authentication/application/services/signal-server-auth.service';
|
||||||
import { dispatchIncomingMessage, IncomingMessageContext } from './messages-incoming.handlers';
|
import { dispatchIncomingMessage, IncomingMessageContext } from './messages-incoming.handlers';
|
||||||
import { MessageRevisionService } from '../../domains/chat/application/services/message-revision.service';
|
import { MessageRevisionService } from '../../domains/chat/application/services/message-revision.service';
|
||||||
import { materializeMessageFromRevision } from '../../domains/chat/domain/rules/message-revision.builder.rules';
|
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 platform = inject(PlatformService);
|
||||||
private readonly i18n = inject(AppI18nService);
|
private readonly i18n = inject(AppI18nService);
|
||||||
private readonly messageRevisions = inject(MessageRevisionService);
|
private readonly messageRevisions = inject(MessageRevisionService);
|
||||||
|
private readonly signalServerAuth = inject(SignalServerAuthService);
|
||||||
|
|
||||||
/** Loads messages for a room from the local database, hydrating reactions. */
|
/** Loads messages for a room from the local database, hydrating reactions. */
|
||||||
loadMessages$ = createEffect(() =>
|
loadMessages$ = createEffect(() =>
|
||||||
@@ -238,11 +241,16 @@ export class MessagesEffects {
|
|||||||
return of(MessagesActions.sendMessageFailure({ error: this.i18n.instant('chat.effects.notConnectedToRoom') }));
|
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 = {
|
const draftMessage: Message = {
|
||||||
id: id ?? uuidv4(),
|
id: id ?? uuidv4(),
|
||||||
roomId: currentRoom.id,
|
roomId: currentRoom.id,
|
||||||
channelId: channelId || 'general',
|
channelId: channelId || 'general',
|
||||||
senderId: currentUser.id,
|
senderId,
|
||||||
senderName: currentUser.displayName || currentUser.username,
|
senderName: currentUser.displayName || currentUser.username,
|
||||||
content,
|
content,
|
||||||
timestamp: this.timeSync.now(),
|
timestamp: this.timeSync.now(),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
68
toju-app/src/app/store/users/user-identity-lookup.rules.ts
Normal file
68
toju-app/src/app/store/users/user-identity-lookup.rules.ts
Normal 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)
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user