fix: Bug - Emojis should be user bound not client bound

Bind custom emoji library membership to the signed-in user instead of the
client. CustomEmojiService now tracks saved emoji ids per user id in
localStorage (metoyou_custom_emoji_saved:<userId>) and the picker only shows
the active user's set, seeded on first load from legacy savedByUser rows the
user created. This stops a second account on the same client (or Electron's
shared SQLite database) from inheriting another user's emoji picker, while
keeping synced assets available for message rendering.

Adds unit coverage for per-user scoping and a single-page-load Playwright e2e
that switches users client-side (second user joins the first user's server)
and asserts no library leak.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-11 04:09:06 +02:00
parent cb386394d0
commit 1671a04f03
6 changed files with 391 additions and 13 deletions

View File

@@ -163,6 +163,62 @@ describe('CustomEmojiService', () => {
]);
});
it('binds the saved library to the active user so switching users on one client does not leak emoji', async () => {
const dataUrl = 'data:image/webp;base64,QUJDRA==';
const hash = await hashText(dataUrl);
const ownerEmoji = customEmoji({
id: 'owner-emoji',
creatorUserId: 'user-1',
dataUrl,
hash,
size: 4,
savedByUser: true
});
// A shared client database (Electron-style) returns user-1's row for everyone.
vi.mocked(db.getCustomEmojis).mockResolvedValue([ownerEmoji]);
const service = createService();
await service.loadForUser('user-1');
expect(service.emojis().map((emoji) => emoji.id)).toEqual(['owner-emoji']);
expect(service.isEmojiInLibrary('owner-emoji')).toBe(true);
await service.loadForUser('user-2');
expect(service.emojis()).toEqual([]);
expect(service.isEmojiInLibrary('owner-emoji')).toBe(false);
await service.loadForUser('user-1');
expect(service.emojis().map((emoji) => emoji.id)).toEqual(['owner-emoji']);
});
it('keeps a peer emoji the active user explicitly saved scoped to that user', async () => {
const dataUrl = 'data:image/webp;base64,QUJDRA==';
const peerEmoji = customEmoji({
id: 'peer-emoji',
creatorUserId: 'peer-9',
dataUrl,
hash: await hashText(dataUrl),
size: 4
});
vi.mocked(db.getCustomEmojis).mockResolvedValue([peerEmoji]);
const service = createService();
await service.loadForUser('user-1');
expect(service.emojis()).toEqual([]);
await service.saveEmojiToLibrary('peer-emoji');
expect(service.isEmojiInLibrary('peer-emoji')).toBe(true);
expect(service.emojis().map((emoji) => emoji.id)).toEqual(['peer-emoji']);
await service.loadForUser('user-2');
expect(service.isEmojiInLibrary('peer-emoji')).toBe(false);
expect(service.emojis()).toEqual([]);
});
it('pushes referenced custom emoji assets to every connected peer without waiting for a request', async () => {
const service = createService();
const dataUrl = 'data:image/webp;base64,QUJDRA==';

View File

@@ -33,6 +33,7 @@ import {
} from '../domain/custom-emoji.rules';
const USAGE_STORAGE_PREFIX = 'metoyou_custom_emoji_usage:';
const SAVED_STORAGE_PREFIX = 'metoyou_custom_emoji_saved:';
interface PendingCustomEmojiTransfer {
chunks: (string | undefined)[];
@@ -46,21 +47,32 @@ export class CustomEmojiService {
private readonly webrtc = inject(RealtimeSessionFacade);
private readonly emojisState = signal<CustomEmoji[]>([]);
private readonly usageState = signal<ReadonlyMap<string, number>>(new Map());
private readonly savedIdsState = signal<ReadonlySet<string>>(new Set());
private readonly pendingTransfers = new Map<string, PendingCustomEmojiTransfer>();
private activeUserId: string | null = null;
private loaded = false;
readonly emojis = computed(() => this.emojisState().filter((emoji) => this.isSavedEmoji(emoji)));
readonly emojis = computed(() => {
const savedIds = this.savedIdsState();
return this.emojisState().filter((emoji) => savedIds.has(emoji.id));
});
readonly shortcutEntries = computed(() => selectEmojiShortcutEntries({
customEmojis: this.emojis(),
usage: this.usageState()
}));
async loadForUser(userId: string | null | undefined): Promise<void> {
this.activeUserId = userId ?? null;
const emojis = await this.db.getCustomEmojis();
const merged = new Map(this.emojisState().map((emoji) => [emoji.id, emoji]));
const validEmojis: CustomEmoji[] = [];
for (const emoji of emojis) {
if (await this.isValidRemoteEmoji(emoji)) {
validEmojis.push(emoji);
const existing = merged.get(emoji.id);
if (!existing || existing.updatedAt < emoji.updatedAt) {
@@ -73,6 +85,7 @@ export class CustomEmojiService {
}
this.emojisState.set([...merged.values()].sort((first, second) => second.updatedAt - first.updatedAt));
this.savedIdsState.set(this.resolveSavedIds(this.activeUserId, validEmojis));
this.usageState.set(this.readUsage(userId));
this.loaded = true;
}
@@ -92,6 +105,8 @@ export class CustomEmojiService {
throw new Error(validation.reason ?? 'Invalid emoji image.');
}
this.activeUserId = userId;
const dataUrl = await this.readFileAsDataUrl(file);
const now = Date.now();
const emoji: CustomEmoji = {
@@ -134,6 +149,14 @@ export class CustomEmojiService {
const nextEmojis = [nextEmoji, ...this.emojisState().filter((entry) => entry.id !== nextEmoji.id)];
this.emojisState.set(nextEmojis.sort((first, second) => second.updatedAt - first.updatedAt));
// Library membership is bound to the active user. The asset is now known to
// everyone on this client, but it only enters *this* user's picker when the
// payload says it is saved (own creation / own broadcast), never when another
// local account synced or received it.
if (emoji.savedByUser === true || existing?.savedByUser) {
this.markEmojiSaved(nextEmoji.id);
}
}
findEmoji(id: string): CustomEmoji | undefined {
@@ -147,15 +170,13 @@ export class CustomEmojiService {
}
isEmojiInLibrary(id: string): boolean {
const emoji = this.findEmoji(id);
return !!emoji && this.isSavedEmoji(emoji);
return this.savedIdsState().has(id);
}
async saveEmojiToLibrary(id: string): Promise<void> {
const emoji = this.findEmoji(id);
if (!emoji || this.isSavedEmoji(emoji)) {
if (!emoji || this.isEmojiInLibrary(id)) {
return;
}
@@ -166,12 +187,13 @@ export class CustomEmojiService {
await this.db.saveCustomEmoji(savedEmoji);
this.emojisState.set(this.emojisState().map((entry) => entry.id === id ? savedEmoji : entry));
this.markEmojiSaved(id);
}
async removeEmojiFromLibrary(id: string): Promise<void> {
const emoji = this.findEmoji(id);
if (!emoji || !this.isSavedEmoji(emoji)) {
if (!emoji || !this.isEmojiInLibrary(id)) {
return;
}
@@ -182,6 +204,7 @@ export class CustomEmojiService {
await this.db.saveCustomEmoji(unsavedEmoji);
this.emojisState.set(this.emojisState().map((entry) => entry.id === id ? unsavedEmoji : entry));
this.unmarkEmojiSaved(id);
}
recordUsage(entry: EmojiShortcutEntry, userId: string | null | undefined): void {
@@ -414,8 +437,95 @@ export class CustomEmojiService {
return CUSTOM_EMOJI_ALLOWED_MIME_TYPES.includes(mime.toLowerCase() as typeof CUSTOM_EMOJI_ALLOWED_MIME_TYPES[number]);
}
private isSavedEmoji(emoji: CustomEmoji): boolean {
return emoji.savedByUser !== false;
/**
* Resolve the active user's saved-library membership. Membership is bound to
* the user (not the client) so a second account on the same device never
* inherits another user's picker. A persisted per-user set wins; on first run
* we seed it from legacy `savedByUser` rows the user actually created, so the
* creator keeps their library after the upgrade while other local accounts
* stay empty.
*/
private resolveSavedIds(userId: string | null, emojis: readonly CustomEmoji[]): ReadonlySet<string> {
const stored = this.readSavedIds(userId);
if (stored) {
return stored;
}
if (!userId) {
return new Set();
}
const seeded = new Set(
emojis
.filter((emoji) => emoji.savedByUser !== false && emoji.creatorUserId === userId)
.map((emoji) => emoji.id)
);
this.writeSavedIds(userId, seeded);
return seeded;
}
private markEmojiSaved(id: string): void {
if (this.savedIdsState().has(id)) {
return;
}
const next = new Set(this.savedIdsState());
next.add(id);
this.savedIdsState.set(next);
this.writeSavedIds(this.activeUserId, next);
}
private unmarkEmojiSaved(id: string): void {
if (!this.savedIdsState().has(id)) {
return;
}
const next = new Set(this.savedIdsState());
next.delete(id);
this.savedIdsState.set(next);
this.writeSavedIds(this.activeUserId, next);
}
private readSavedIds(userId: string | null): ReadonlySet<string> | null {
if (!userId || typeof localStorage === 'undefined') {
return null;
}
try {
const raw = localStorage.getItem(`${SAVED_STORAGE_PREFIX}${userId}`);
if (raw === null) {
return null;
}
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) {
return null;
}
return new Set(parsed.filter((value): value is string => typeof value === 'string'));
} catch {
return null;
}
}
private writeSavedIds(userId: string | null, savedIds: ReadonlySet<string>): void {
if (!userId || typeof localStorage === 'undefined') {
return;
}
try {
localStorage.setItem(`${SAVED_STORAGE_PREFIX}${userId}`, JSON.stringify([...savedIds]));
} catch {
// localStorage may be unavailable (private mode / quota); membership then
// lives in-memory for this session, which is acceptable.
}
}
private readUsage(userId: string | null | undefined): ReadonlyMap<string, number> {