emoji picker
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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(
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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" }));
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user