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:
@@ -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==';
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -45,7 +45,7 @@ Both backends store the same entity types:
|
||||
| `users` | `oderId` | | User profiles |
|
||||
| `rooms` | `id` | | Server/room metadata |
|
||||
| `reactions` | `oderId-emoji-messageId` | | Emoji reactions, deduplicated per user |
|
||||
| `customEmojis` / `custom_emojis` | `id` | `updatedAt`, `creatorUserId` | Known custom emoji image assets synced over peer data channels; `savedByUser` controls picker/library membership |
|
||||
| `customEmojis` / `custom_emojis` | `id` | `updatedAt`, `creatorUserId` | Known custom emoji image assets synced over peer data channels. Asset store only (the Electron table is shared across local accounts); picker/library membership is **user-bound**, tracked per user id in `localStorage` (`metoyou_custom_emoji_saved:<userId>`), not by the row's legacy `savedByUser` flag |
|
||||
| `bans` | `oderId` | | Active bans per room |
|
||||
| `attachments` | `id` | | File/image metadata tied to messages |
|
||||
| `meta` | `key` | | Key-value pairs (e.g. `currentUserId`) |
|
||||
|
||||
Reference in New Issue
Block a user