emoji picker

This commit is contained in:
2026-04-02 21:03:07 -04:00
parent ed36488c46
commit 996b72f7d7
9 changed files with 1578 additions and 54 deletions
+44 -4
View File
@@ -340,7 +340,22 @@ const message_reaction = table(
id: t.u64().primaryKey().autoInc(),
message_id: t.u64(),
identity: t.identity(),
emoji: t.string(),
emoji: t.string().optional(),
custom_emoji_id: t.u64().optional(),
},
);
const custom_emoji = table(
{
name: "custom_emoji",
public: true,
indexes: [{ accessor: "by_name", algorithm: "btree", columns: ["name"] }],
},
{
id: t.u64().primaryKey().autoInc(),
name: t.string(),
category: t.string(),
data: t.byteArray(),
},
);
@@ -375,6 +390,7 @@ const spacetimedb = schema({
message,
message_image,
message_reaction,
custom_emoji,
image,
});
export default spacetimedb;
@@ -394,9 +410,27 @@ export const upload_image = spacetimedb.reducer(
},
);
export const upload_custom_emoji = spacetimedb.reducer(
{ name: t.string(), category: t.string(), data: t.byteArray() },
(ctx, { name, category, data }) => {
if (data.length > 256 * 1024) {
throw new SenderError("Emoji image exceeds 256KB limit");
}
ctx.db.custom_emoji.insert({ id: 0n, name, category, data });
},
);
export const toggle_reaction = spacetimedb.reducer(
{ messageId: t.u64(), emoji: t.string() },
(ctx, { messageId, emoji }) => {
{
messageId: t.u64(),
emoji: t.string().optional(),
customEmojiId: t.u64().optional(),
},
(ctx, { messageId, emoji, customEmojiId }) => {
if (!emoji && !customEmojiId) {
throw new SenderError("Emoji or CustomEmojiId required");
}
const user = ctx.db.user.identity.find(ctx.sender);
if (!user || !user.subject) {
throw new SenderError("You must be logged in via OIDC to react");
@@ -404,7 +438,12 @@ export const toggle_reaction = spacetimedb.reducer(
const existing = [
...ctx.db.message_reaction.by_message_id.filter(messageId),
].find((r) => r.identity.isEqual(ctx.sender) && r.emoji === emoji);
].find((r) => {
if (!r.identity.isEqual(ctx.sender)) return false;
if (emoji && r.emoji === emoji) return true;
if (customEmojiId && r.custom_emoji_id === customEmojiId) return true;
return false;
});
if (existing) {
ctx.db.message_reaction.id.delete(existing.id);
@@ -414,6 +453,7 @@ export const toggle_reaction = spacetimedb.reducer(
message_id: messageId,
identity: ctx.sender,
emoji,
custom_emoji_id: customEmojiId,
});
}
},
+311
View File
@@ -0,0 +1,311 @@
<script lang="ts">
import { getContext, onMount } from "svelte";
import { EMOJIS, type EmojiInfo } from "../emojis";
import type { ChatService } from "../services/chat.svelte";
import type * as Types from "../../module_bindings/types";
let { onSelect, onClose } = $props<{
onSelect: (emoji?: string, customEmojiId?: bigint) => void;
onClose: () => void;
}>();
const chat = getContext<ChatService>("chat");
let searchTerm = $state("");
let recentEmojis = $state<(string | bigint)[]>([]);
onMount(() => {
const saved = localStorage.getItem("recent_emojis");
if (saved) {
try {
recentEmojis = JSON.parse(saved).map((id: any) =>
typeof id === 'string' && id.startsWith('custom:') ? BigInt(id.split(':')[1]) : id
);
} catch (e) {
recentEmojis = [];
}
}
});
function saveRecent(emoji: string | bigint) {
let newRecent = [emoji, ...recentEmojis.filter(e => e !== emoji)];
newRecent = newRecent.slice(0, 7);
recentEmojis = newRecent;
localStorage.setItem("recent_emojis", JSON.stringify(newRecent.map(e =>
typeof e === 'bigint' ? `custom:${e.toString()}` : e
)));
}
const filteredEmojis = $derived.by(() => {
const lowerSearch = searchTerm.toLowerCase();
const standard = EMOJIS.filter(e =>
e.name.toLowerCase().includes(lowerSearch) ||
e.char.includes(lowerSearch)
);
const custom = chat.customEmojis.filter(e =>
e.name.toLowerCase().includes(lowerSearch)
);
return { standard, custom };
});
const categories = [
{ id: 'smileys', name: 'Smileys', icon: '😀' },
{ id: 'people', name: 'People', icon: '👋' },
{ id: 'animals', name: 'Animals', icon: '🐶' },
{ id: 'food', name: 'Food', icon: '🍎' },
{ id: 'activities', name: 'Activities', icon: '⚽' },
{ id: 'travel', name: 'Travel', icon: '🚗' },
{ id: 'objects', name: 'Objects', icon: '💡' },
{ id: 'symbols', name: 'Symbols', icon: '⚠️' },
{ id: 'flags', name: 'Flags', icon: '🏁' },
{ id: 'custom', name: 'Custom', icon: '✨' },
];
async function handleCustomUpload(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
// We'll assume the user provides a name or we use the filename
const name = prompt("Enter emoji name:", file.name.split('.')[0]) || "custom";
const category = "custom";
const reader = new FileReader();
reader.onload = async () => {
const data = new Uint8Array(reader.result as ArrayBuffer);
await chat.uploadCustomEmoji(name, category, data);
};
reader.readAsArrayBuffer(file);
}
function getCustomEmojiUrl(data: Uint8Array) {
return URL.createObjectURL(new Blob([data], { type: "image/webp" }));
}
</script>
<div class="emoji-picker" onclick={(e) => e.stopPropagation()}>
<div class="picker-header">
<input
type="text"
placeholder="Search emojis..."
bind:value={searchTerm}
class="search-input"
autofocus
/>
</div>
{#if recentEmojis.length > 0 && !searchTerm}
<div class="picker-section">
<div class="section-title">Frequently Used</div>
<div class="emoji-grid">
{#each recentEmojis as item}
{#if typeof item === 'string'}
<button
class="emoji-btn"
onclick={() => {
onSelect(item);
saveRecent(item);
}}
title={EMOJIS.find(e => e.char === item)?.name}
>
{item}
</button>
{:else}
{@const emoji = chat.customEmojis.find(e => e.id === item)}
{#if emoji}
<button
class="emoji-btn custom"
onclick={() => {
onSelect(undefined, emoji.id);
saveRecent(emoji.id);
}}
title={emoji.name}
>
<img src={getCustomEmojiUrl(emoji.data)} alt={emoji.name} />
</button>
{/if}
{/if}
{/each}
</div>
</div>
{/if}
<div class="picker-content">
{#if searchTerm}
{#if filteredEmojis.custom.length > 0}
<div class="picker-section">
<div class="section-title">Custom Emojis</div>
<div class="emoji-grid">
{#each filteredEmojis.custom as emoji}
<button
class="emoji-btn custom"
onclick={() => {
onSelect(undefined, emoji.id);
saveRecent(emoji.id);
}}
title={emoji.name}
>
<img src={getCustomEmojiUrl(emoji.data)} alt={emoji.name} />
</button>
{/each}
</div>
</div>
{/if}
<div class="picker-section">
<div class="section-title">Standard Emojis</div>
<div class="emoji-grid">
{#each filteredEmojis.standard as emoji}
<button
class="emoji-btn"
onclick={() => {
onSelect(emoji.char);
saveRecent(emoji.char);
}}
title={emoji.name}
>
{emoji.char}
</button>
{/each}
</div>
</div>
{:else}
{#each categories as cat}
{@const catStandard = EMOJIS.filter(e => e.category === cat.id)}
{@const catCustom = cat.id === 'custom' ? chat.customEmojis : []}
{#if catStandard.length > 0 || catCustom.length > 0}
<div class="picker-section" id="cat-{cat.id}">
<div class="section-title">{cat.name}</div>
<div class="emoji-grid">
{#if cat.id === 'custom'}
<label class="emoji-btn upload-btn" title="Upload Custom Emoji">
<i class="fas fa-plus"></i>
<input type="file" accept="image/webp" onchange={handleCustomUpload} style="display: none;" />
</label>
{#each catCustom as emoji}
<button
class="emoji-btn custom"
onclick={() => {
onSelect(undefined, emoji.id);
saveRecent(emoji.id);
}}
title={emoji.name}
>
<img src={getCustomEmojiUrl(emoji.data)} alt={emoji.name} />
</button>
{/each}
{:else}
{#each catStandard as emoji}
<button
class="emoji-btn"
onclick={() => {
onSelect(emoji.char);
saveRecent(emoji.char);
}}
title={emoji.name}
>
{emoji.char}
</button>
{/each}
{/if}
</div>
</div>
{/if}
{/each}
{/if}
</div>
</div>
<style>
.emoji-picker {
width: 320px;
height: 400px;
background-color: var(--background-floating);
border-radius: 8px;
display: flex;
flex-direction: column;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
overflow: hidden;
border: 1px solid var(--background-modifier-accent);
}
.picker-header {
padding: 12px;
border-bottom: 1px solid var(--background-modifier-accent);
}
.search-input {
width: 100%;
padding: 8px 12px;
background-color: var(--background-tertiary);
border: none;
border-radius: 4px;
color: var(--text-normal);
font-size: 0.9rem;
}
.picker-content {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.picker-section {
margin-bottom: 16px;
}
.section-title {
font-size: 0.75rem;
font-weight: bold;
color: var(--text-muted);
text-transform: uppercase;
margin-bottom: 8px;
padding-left: 4px;
}
.emoji-grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 4px;
}
.emoji-btn {
background: none;
border: none;
font-size: 1.5rem;
padding: 4px;
cursor: pointer;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.1s;
}
.emoji-btn:hover {
background-color: var(--background-modifier-hover);
}
.emoji-btn.custom img {
width: 24px;
height: 24px;
object-fit: contain;
}
.upload-btn {
font-size: 1.2rem;
color: var(--text-muted);
border: 1px dashed var(--text-muted);
}
/* Scrollbar */
.picker-content::-webkit-scrollbar {
width: 8px;
}
.picker-content::-webkit-scrollbar-thumb {
background-color: var(--background-tertiary);
border-radius: 4px;
}
</style>
+36 -24
View File
@@ -2,6 +2,9 @@
import { getContext, tick } from "svelte";
import type { ChatService } from "../services/chat.svelte";
import RichText from "./RichText.svelte";
import EmojiPicker from "./EmojiPicker.svelte";
import { getCustomEmojiUrl } from "../utils";
import type * as Types from "../../module_bindings/types";
const chat = getContext<ChatService>("chat");
@@ -103,8 +106,9 @@
{@const existingThread = chat.allThreads.find((t) => t.parentMessageId === msg.id)}
{@const reactions = chat.messageReactions.filter(r => r.messageId === msg.id)}
{@const emojiGroups = reactions.reduce((acc, r) => {
if (!acc[r.emoji]) acc[r.emoji] = [];
acc[r.emoji].push(r);
const key = r.emoji || `custom:${r.customEmojiId}`;
if (!acc[key]) acc[key] = [];
acc[key].push(r);
return acc;
}, {} as Record<string, typeof reactions>)}
{@const isHighlighted = (chat.pendingThreadParentMessageId === msg.id) || (chat.activeThread?.parentMessageId === msg.id)}
@@ -125,24 +129,17 @@
>
<i class="far fa-smile"></i>
</button>
<div
class="simple-emoji-picker"
style="display: {activePickerMessageId === msg.id ? 'flex' : 'none'}; position: absolute; top: 32px; right: 0; background: var(--background-floating); border-radius: 8px; padding: 4px; gap: 4px; box-shadow: 0 4px 8px rgba(0,0,0,0.3); z-index: 100;"
>
{#each ["👍", "❤️", "😂", "😮", "😢", "🔥"] as emoji}
<button
class="picker-emoji-btn"
onclick={(e) => {
chat.handleToggleReaction(msg.id, emoji);
{#if activePickerMessageId === msg.id}
<div class="emoji-picker-popover" style="position: absolute; top: 32px; right: 0; z-index: 100;">
<EmojiPicker
onSelect={(emoji, customId) => {
chat.handleToggleReaction(msg.id, emoji, customId);
activePickerMessageId = null;
}}
style="background: none; border: none; cursor: pointer; padding: 4px; font-size: 1.2rem; border-radius: 4px;"
aria-label="React with {emoji}"
>
{emoji}
</button>
{/each}
</div>
onClose={() => activePickerMessageId = null}
/>
</div>
{/if}
</div>
{/if}
{#if !existingThread && chat.isFullyAuthenticated}
@@ -221,20 +218,28 @@
{/if}
<div class="reactions-container" style="display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px;">
{#each Object.entries(emojiGroups) as [emoji, group]}
{#each Object.entries(emojiGroups) as [key, group]}
{@const hasReacted = group.some(r => r.identity.isEqual(chat.identity))}
<button
{@const emoji = group[0].emoji}
{@const customEmojiId = group[0].customEmojiId}
<button
class="reaction-badge {hasReacted ? 'active' : ''}"
onclick={() => chat.handleToggleReaction(msg.id, emoji)}
onclick={() => chat.handleToggleReaction(msg.id, emoji, customEmojiId)}
title={group.map(r => chat.getUsername(r.identity)).join(", ")}
aria-label="Reacted with {emoji} {group.length} times"
aria-label="Reacted with {emoji || 'custom emoji'} {group.length} times"
>
<span class="emoji">{emoji}</span>
{#if emoji}
<span class="emoji">{emoji}</span>
{:else if customEmojiId}
{@const customEmoji = chat.customEmojis.find(e => e.id === customEmojiId)}
{#if customEmoji}
<img class="custom-emoji-reaction" src={getCustomEmojiUrl(customEmoji.data)} alt={customEmoji.name} />
{/if}
{/if}
<span class="count">{group.length}</span>
</button>
{/each}
</div>
{#if existingThread}
{@const threadMessageCount = chat.allMessages.filter(m => m.threadId === existingThread.id).length}
<button
@@ -313,4 +318,11 @@
from { transform: translate(-50%, -20px); opacity: 0; }
to { transform: translate(-50%, 0); opacity: 1; }
}
.custom-emoji-reaction {
width: 16px;
height: 16px;
object-fit: contain;
vertical-align: middle;
}
</style>
+34 -22
View File
@@ -4,6 +4,8 @@
import type { ChatService } from "../services/chat.svelte";
import type * as Types from "../../module_bindings/types";
import RichText from "./RichText.svelte";
import EmojiPicker from "./EmojiPicker.svelte";
import { getCustomEmojiUrl } from "../utils";
let {
threadMessages,
@@ -105,8 +107,9 @@ $effect(() => {
{@const messageImages = chat.messageImages.filter(mi => mi.messageId === msg.id)}
{@const reactions = chat.messageReactions.filter(r => r.messageId === msg.id)}
{@const emojiGroups = reactions.reduce((acc, r) => {
if (!acc[r.emoji]) acc[r.emoji] = [];
acc[r.emoji].push(r);
const key = r.emoji || `custom:${r.customEmojiId}`;
if (!acc[key]) acc[key] = [];
acc[key].push(r);
return acc;
}, {} as Record<string, typeof reactions>)}
<div
@@ -173,15 +176,24 @@ $effect(() => {
</div>
{/if}
<div class="reactions-container" style="display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px;">
{#each Object.entries(emojiGroups) as [emoji, group]}
{#each Object.entries(emojiGroups) as [key, group]}
{@const hasReacted = group.some(r => r.identity.isEqual(chat.identity))}
{@const emoji = group[0].emoji}
{@const customEmojiId = group[0].customEmojiId}
<button
class="reaction-badge {hasReacted ? 'active' : ''}"
onclick={() => chat.handleToggleReaction(msg.id, emoji)}
onclick={() => chat.handleToggleReaction(msg.id, emoji, customEmojiId)}
title={group.map(r => chat.getUsername(r.identity)).join(", ")}
aria-label="Reacted with {emoji} {group.length} times"
aria-label="Reacted with {emoji || 'custom emoji'} {group.length} times"
>
<span class="emoji">{emoji}</span>
{#if emoji}
<span class="emoji">{emoji}</span>
{:else if customEmojiId}
{@const customEmoji = chat.customEmojis.find(e => e.id === customEmojiId)}
{#if customEmoji}
<img class="custom-emoji-reaction" src={getCustomEmojiUrl(customEmoji.data)} alt={customEmoji.name} />
{/if}
{/if}
<span class="count">{group.length}</span>
</button>
{/each}
@@ -199,24 +211,17 @@ $effect(() => {
>
<i class="far fa-smile"></i>
</button>
<div
class="simple-emoji-picker"
style="display: {activePickerMessageId === msg.id ? 'flex' : 'none'}; position: absolute; top: -40px; left: 0; background: var(--background-floating); border-radius: 8px; padding: 4px; gap: 4px; box-shadow: 0 4px 8px rgba(0,0,0,0.3); z-index: 100;"
>
{#each ["👍", "❤️", "😂", "😮", "😢", "🔥"] as emoji}
<button
class="picker-emoji-btn"
onclick={(e) => {
chat.handleToggleReaction(msg.id, emoji);
{#if activePickerMessageId === msg.id}
<div class="emoji-picker-popover" style="position: absolute; bottom: 32px; left: 0; z-index: 100;">
<EmojiPicker
onSelect={(emoji, customId) => {
chat.handleToggleReaction(msg.id, emoji, customId);
activePickerMessageId = null;
}}
style="background: none; border: none; cursor: pointer; padding: 4px; font-size: 1.2rem; border-radius: 4px;"
aria-label="React with {emoji}"
>
{emoji}
</button>
{/each}
</div>
onClose={() => activePickerMessageId = null}
/>
</div>
{/if}
</div>
{/if}
</div>
@@ -291,4 +296,11 @@ $effect(() => {
from { transform: translate(-50%, -10px); opacity: 0; }
to { transform: translate(-50%, 0); opacity: 1; }
}
.custom-emoji-reaction {
width: 16px;
height: 16px;
object-fit: contain;
vertical-align: middle;
}
</style>
+1128
View File
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -189,6 +189,9 @@ export class ChatService {
get images() {
return this.#db.images;
}
get customEmojis() {
return this.#db.customEmojis;
}
get voiceStates() {
return this.#db.voiceStates;
}
@@ -311,14 +314,18 @@ export class ChatService {
this.#msg.handleLoadMoreMessages();
};
handleToggleReaction = (messageId: bigint, emoji: string) => {
this.#msg.toggleReaction(messageId, emoji);
handleToggleReaction = (messageId: bigint, emoji?: string, customEmojiId?: bigint) => {
this.#msg.toggleReaction(messageId, emoji, customEmojiId);
};
uploadImage = async (data: Uint8Array, mimeType: string, name?: string) => {
this.#msg.uploadImage(data, mimeType, name);
};
uploadCustomEmoji = async (name: string, category: string, data: Uint8Array) => {
this.#msg.uploadCustomEmoji(name, category, data);
};
handleSendThreadMessage = (e: Event) => {
e.preventDefault();
this.#msg.handleSendMessage(
+3
View File
@@ -11,6 +11,7 @@ export class DatabaseService {
messageImages = $state<readonly Types.MessageImage[]>([]);
allThreads = $state<readonly Types.Thread[]>([]);
images = $state<readonly Types.Image[]>([]);
customEmojis = $state<readonly Types.CustomEmoji[]>([]);
messageReactions = $state<readonly Types.MessageReaction[]>([]);
voiceStates = $state<readonly Types.VoiceState[]>([]);
voiceActivity = $state<readonly Types.VoiceActivity[]>([]);
@@ -25,6 +26,7 @@ export class DatabaseService {
const [serverMembersStore] = useTable(tables.server_member);
const [threadsStore] = useTable(tables.thread);
const [imagesStore] = useTable(tables.image);
const [customEmojisStore] = useTable(tables.custom_emoji);
const [voiceActivityStore] = useTable(tables.voice_activity);
const [watchingStore] = useTable(tables.watching);
@@ -36,6 +38,7 @@ export class DatabaseService {
serverMembersStore.subscribe((v) => (this.serverMembers = v));
threadsStore.subscribe((v) => (this.allThreads = v));
imagesStore.subscribe((v) => (this.images = v));
customEmojisStore.subscribe((v) => (this.customEmojis = v));
voiceActivityStore.subscribe((v) => (this.voiceActivity = v));
watchingStore.subscribe((v) => (this.watching = v));
}
+9 -2
View File
@@ -16,6 +16,7 @@ export class MessagingService {
#createThreadWithMessageReducer: any;
#sendMessageReducer: any;
#uploadImageReducer: any;
#uploadCustomEmojiReducer: any;
#toggleReactionReducer: any;
#allMessages = $state<readonly Types.Message[]>([]);
@@ -34,6 +35,7 @@ export class MessagingService {
this.#createThreadWithMessageReducer = useReducer(reducers.createThreadWithMessage);
this.#sendMessageReducer = useReducer(reducers.sendMessage);
this.#uploadImageReducer = useReducer(reducers.uploadImage);
this.#uploadCustomEmojiReducer = useReducer(reducers.uploadCustomEmoji);
this.#toggleReactionReducer = useReducer(reducers.toggleReaction);
const [messagesStore] = useTable(tables.message);
@@ -57,6 +59,7 @@ export class MessagingService {
untrack(() => {
const queries = [
"SELECT * FROM server",
"SELECT * FROM custom_emoji",
];
// 1. Surgical Membership & Identity Pruning
@@ -203,7 +206,11 @@ export class MessagingService {
this.#uploadImageReducer({ data, mimeType, name });
};
toggleReaction = (messageId: bigint, emoji: string) => {
this.#toggleReactionReducer({ messageId, emoji });
uploadCustomEmoji = async (name: string, category: string, data: Uint8Array) => {
this.#uploadCustomEmojiReducer({ name, category, data });
};
toggleReaction = (messageId: bigint, emoji?: string, customEmojiId?: bigint) => {
this.#toggleReactionReducer({ messageId, emoji, customEmojiId });
};
}
+4
View File
@@ -91,3 +91,7 @@ export const optimizeImage = async (
reader.readAsDataURL(file);
});
};
export const getCustomEmojiUrl = (data: Uint8Array) => {
return URL.createObjectURL(new Blob([data], { type: "image/webp" }));
};