reactions
This commit is contained in:
@@ -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
@@ -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,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";
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user