reactions

This commit is contained in:
2026-04-02 02:20:25 -04:00
parent cdece0001a
commit cdfbc72c57
9 changed files with 222 additions and 2 deletions
+46
View File
@@ -304,6 +304,26 @@ const message = table(
},
);
const message_reaction = table(
{
name: "message_reaction",
public: true,
indexes: [
{
accessor: "by_message_id",
algorithm: "btree",
columns: ["message_id"],
},
],
},
{
id: t.u64().primaryKey().autoInc(),
message_id: t.u64(),
identity: t.identity(),
emoji: t.string(),
},
);
const image = table(
{
name: "image",
@@ -332,6 +352,7 @@ const spacetimedb = schema({
screen_ice_candidate,
thread,
message,
message_reaction,
image,
});
export default spacetimedb;
@@ -348,6 +369,31 @@ export const upload_image = spacetimedb.reducer(
},
);
export const toggle_reaction = spacetimedb.reducer(
{ messageId: t.u64(), emoji: t.string() },
(ctx, { messageId, emoji }) => {
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");
}
const existing = [
...ctx.db.message_reaction.by_message_id.filter(messageId),
].find((r) => r.identity.isEqual(ctx.sender) && r.emoji === emoji);
if (existing) {
ctx.db.message_reaction.id.delete(existing.id);
} else {
ctx.db.message_reaction.insert({
id: 0n,
message_id: messageId,
identity: ctx.sender,
emoji,
});
}
},
);
export const set_name = spacetimedb.reducer(
{ name: t.string() },
(ctx, { name }) => {
+62
View File
@@ -523,6 +523,68 @@ body {
text-decoration: underline;
}
/* Reactions */
.reaction-badge {
background-color: var(--background-accent);
border: 1px solid transparent;
border-radius: 8px;
padding: 2px 6px;
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
transition: all 0.1s;
color: var(--text-normal);
}
.reaction-badge:hover {
background-color: var(--background-modifier-hover);
border-color: var(--interactive-normal);
}
.reaction-badge.active {
background-color: rgba(88, 101, 242, 0.15);
border-color: var(--brand);
}
.reaction-badge.active .count {
color: var(--brand);
}
.reaction-badge .emoji {
font-size: 1rem;
}
.reaction-badge .count {
font-size: 0.8rem;
font-weight: 600;
color: var(--interactive-normal);
}
.add-reaction-btn {
background: none;
border: none;
color: var(--interactive-normal);
cursor: pointer;
padding: 4px;
border-radius: 4px;
opacity: 0;
transition: opacity 0.2s;
}
.message-item:hover .add-reaction-btn {
opacity: 1;
}
.add-reaction-btn:hover {
color: var(--interactive-hover);
background-color: var(--background-modifier-hover);
}
.picker-emoji-btn:hover {
background-color: var(--background-modifier-hover) !important;
}
/* Chat Input */
.chat-input-container {
padding: 0 16px 24px 16px;
-1
View File
@@ -1,4 +1,3 @@
// src/auth/index.ts
export { auth } from "./auth.svelte";
export { default as AuthGate } from "./AuthGate.svelte";
export { default as UsernamePasswordAuth } from "./UsernamePasswordAuth.svelte";
+49
View File
@@ -20,6 +20,12 @@
{#each chat.channelMessages as msg (msg.id.toString())}
{@const msgUsername = chat.getUsername(msg.sender)}
{@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);
return acc;
}, {} as Record<string, typeof reactions>)}
<div class="message-item">
<div class="avatar message-avatar">
@@ -60,6 +66,49 @@
{/if}
{/if}
<div class="reactions-container" style="display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px;">
{#each Object.entries(emojiGroups) as [emoji, group]}
{@const hasReacted = group.some(r => r.identity.isEqual(chat.identity))}
<button
class="reaction-badge {hasReacted ? 'active' : ''}"
onclick={() => chat.handleToggleReaction(msg.id, emoji)}
title={group.map(r => chat.getUsername(r.identity)).join(", ")}
>
<span class="emoji">{emoji}</span>
<span class="count">{group.length}</span>
</button>
{/each}
{#if chat.isFullyAuthenticated}
<div class="add-reaction-wrapper" style="position: relative;">
<button
class="add-reaction-btn"
onclick={(e) => {
const picker = (e.currentTarget as HTMLElement).nextElementSibling as HTMLElement;
picker.style.display = picker.style.display === 'flex' ? 'none' : 'flex';
}}
title="Add Reaction"
>
<i class="far fa-smile"></i>
</button>
<div class="simple-emoji-picker" style="display: 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);
((e.currentTarget as HTMLElement).parentElement as HTMLElement).style.display = 'none';
}}
style="background: none; border: none; cursor: pointer; padding: 4px; font-size: 1.2rem; border-radius: 4px;"
>
{emoji}
</button>
{/each}
</div>
</div>
{/if}
</div>
{#if existingThread}
<button
class="thread-link"
@@ -44,6 +44,12 @@
>
{#each threadMessages as msg (msg.id.toString())}
{@const msgUsername = getUsername(msg.sender, users)}
{@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);
return acc;
}, {} as Record<string, typeof reactions>)}
<div
class="message-item thread-message-item"
>
@@ -73,6 +79,49 @@
</div>
{/if}
{/if}
<div class="reactions-container" style="display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px;">
{#each Object.entries(emojiGroups) as [emoji, group]}
{@const hasReacted = group.some(r => r.identity.isEqual(chat.identity))}
<button
class="reaction-badge {hasReacted ? 'active' : ''}"
onclick={() => chat.handleToggleReaction(msg.id, emoji)}
title={group.map(r => chat.getUsername(r.identity)).join(", ")}
>
<span class="emoji">{emoji}</span>
<span class="count">{group.length}</span>
</button>
{/each}
{#if chat.isFullyAuthenticated}
<div class="add-reaction-wrapper" style="position: relative;">
<button
class="add-reaction-btn"
onclick={(e) => {
const picker = (e.currentTarget as HTMLElement).nextElementSibling as HTMLElement;
picker.style.display = picker.style.display === 'flex' ? 'none' : 'flex';
}}
title="Add Reaction"
>
<i class="far fa-smile"></i>
</button>
<div class="simple-emoji-picker" style="display: 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);
((e.currentTarget as HTMLElement).parentElement as HTMLElement).style.display = 'none';
}}
style="background: none; border: none; cursor: pointer; padding: 4px; font-size: 1.2rem; border-radius: 4px;"
>
{emoji}
</button>
{/each}
</div>
</div>
{/if}
</div>
</div>
</div>
{/each}
+7
View File
@@ -183,6 +183,9 @@ export class ChatService {
get images() {
return this.#db.images;
}
get messageReactions() {
return this.#db.messageReactions;
}
get voiceStates() {
return this.#db.voiceStates;
}
@@ -291,6 +294,10 @@ export class ChatService {
this.#msg.handleSendMessage(text, threadId, imageId);
};
handleToggleReaction = (messageId: bigint, emoji: string) => {
this.#msg.toggleReaction(messageId, emoji);
};
uploadImage = async (data: Uint8Array, mimeType: string) => {
this.#msg.uploadImage(data, mimeType);
};
+3
View File
@@ -10,6 +10,7 @@ export class DatabaseService {
allMessages = $state<readonly Types.Message[]>([]);
allThreads = $state<readonly Types.Thread[]>([]);
images = $state<readonly Types.Image[]>([]);
messageReactions = $state<readonly Types.MessageReaction[]>([]);
voiceStates = $state<readonly Types.VoiceState[]>([]);
voiceActivity = $state<readonly Types.VoiceActivity[]>([]);
isUsersReady = $state(false);
@@ -23,6 +24,7 @@ export class DatabaseService {
const [messagesStore] = useTable(tables.message);
const [threadsStore] = useTable(tables.thread);
const [imagesStore] = useTable(tables.image);
const [messageReactionsStore] = useTable(tables.message_reaction);
const [voiceActivityStore] = useTable(tables.voice_activity);
serversStore.subscribe((v) => (this.servers = v));
@@ -34,6 +36,7 @@ export class DatabaseService {
messagesStore.subscribe((v) => (this.allMessages = v));
threadsStore.subscribe((v) => (this.allThreads = v));
imagesStore.subscribe((v) => (this.images = v));
messageReactionsStore.subscribe((v) => (this.messageReactions = v));
voiceActivityStore.subscribe((v) => (this.voiceActivity = v));
}
}
+5
View File
@@ -14,6 +14,7 @@ export class MessagingService {
);
#sendMessageReducer = useReducer(reducers.sendMessage);
#uploadImageReducer = useReducer(reducers.uploadImage);
#toggleReactionReducer = useReducer(reducers.toggleReaction);
constructor(db: DatabaseService, nav: NavigationService) {
this.#db = db;
@@ -81,4 +82,8 @@ export class MessagingService {
uploadImage = async (data: Uint8Array, mimeType: string) => {
this.#uploadImageReducer({ data, mimeType });
};
toggleReaction = (messageId: bigint, emoji: string) => {
this.#toggleReactionReducer({ messageId, emoji });
};
}
+1 -1
View File
@@ -8,7 +8,7 @@ export const getUsername = (
if (!userIdentity) return "Unknown";
const user = users.find((u) => u.identity?.isEqual(userIdentity));
return (
user?.name || user?.username || userIdentity.toHexString().substring(0, 8)
user?.name || userIdentity.toHexString().substring(0, 8)
);
};