diff --git a/bun.lock b/bun.lock
index 365738b..3050f1a 100644
--- a/bun.lock
+++ b/bun.lock
@@ -30,6 +30,7 @@
"@tauri-apps/plugin-opener": "^2.4.0",
"convex": "^1.25.4",
"convex-svelte": "^0.0.11",
+ "emoji-picker-element": "^1.27.0",
"robot3": "^1.1.1",
"runed": "^0.31.0",
"unplugin-icons": "^22.2.0",
@@ -676,6 +677,8 @@
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
+ "emoji-picker-element": ["emoji-picker-element@1.27.0", "", {}, "sha512-CeN9g5/kq41+BfYPDpAbE2ejZRHbs1faFDmU9+E9wGA4JWLkok9zo1hwcAFnUhV4lPR3ZuLHiJxNG1mpjoF4TQ=="],
+
"emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"enhanced-resolve": ["enhanced-resolve@5.18.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ=="],
diff --git a/packages/client/package.json b/packages/client/package.json
index 4e8ebf4..4d72a9f 100644
--- a/packages/client/package.json
+++ b/packages/client/package.json
@@ -51,6 +51,7 @@
"@tauri-apps/plugin-opener": "^2.4.0",
"convex": "^1.25.4",
"convex-svelte": "^0.0.11",
+ "emoji-picker-element": "^1.27.0",
"robot3": "^1.1.1",
"runed": "^0.31.0",
"unplugin-icons": "^22.2.0"
diff --git a/packages/client/src/components/chat/EmojiPalette.svelte b/packages/client/src/components/chat/EmojiPalette.svelte
new file mode 100644
index 0000000..2ae4d26
--- /dev/null
+++ b/packages/client/src/components/chat/EmojiPalette.svelte
@@ -0,0 +1,71 @@
+
+
+
diff --git a/packages/client/src/components/chat/MessageInput.svelte b/packages/client/src/components/chat/MessageInput.svelte
index eaf8b2f..46a0041 100644
--- a/packages/client/src/components/chat/MessageInput.svelte
+++ b/packages/client/src/components/chat/MessageInput.svelte
@@ -5,7 +5,9 @@
import FilePreview from "~/features/files/upload/FilePreview.svelte";
import FileSelector from "~/features/files/upload/Selector.svelte";
import { FileUploader } from "~/features/files/upload/uploader.svelte";
+ import MdiClose from "~/icons/mdi-close.svelte";
import { useMutation } from "~/lib/useMutation.svelte.ts";
+ import EmojiPalette from "./EmojiPalette.svelte";
interface Props {
organizationId: Id<"organizations">;
@@ -20,6 +22,7 @@
let messageContent = $state("");
let authorName = $state("");
+ let showEmojiPalette = $state(false);
let showFileSelector = $state(false);
let attachedFiles = $state([]);
@@ -68,10 +71,20 @@
{#if replyingTo}
-
-
返信先:
-
{replyingTo.author}
-
{replyingTo.content}
+
+
+ 返信先:
+ {replyingTo.author}
+ : {replyingTo.content}
+
+
{/if}
@@ -160,7 +173,24 @@
送信
{/if}
+
+ {#if showEmojiPalette}
+
(showEmojiPalette = false)}
+ onEmojiSelected={(emoji) => {
+ messageContent += emoji;
+ }}
+ />
+ {/if}
{#if sendMessageMutation.error}
diff --git a/packages/client/src/components/chat/MessageList.svelte b/packages/client/src/components/chat/MessageList.svelte
index 264d426..0f966a7 100644
--- a/packages/client/src/components/chat/MessageList.svelte
+++ b/packages/client/src/components/chat/MessageList.svelte
@@ -3,8 +3,14 @@
import type { Doc } from "@packages/convex/src/convex/_generated/dataModel";
import { useQuery } from "convex-svelte";
import { onMount } from "svelte";
+ import Modal, { ModalManager } from "$lib/modal/modal.svelte";
+ import MdiDotsVertical from "~/icons/mdi-dots-vertical.svelte";
+ import { useMutation } from "~/lib/useMutation.svelte";
import FileAttachment from "../../features/files/view/FileAttachment.svelte";
+ import EmojiPalette from "./EmojiPalette.svelte";
import MessageDropdown from "./MessageDropdown.svelte";
+ import ReactionButtons from "./ReactionButtons.svelte";
+ import ReactionList from "./ReactionList.svelte";
interface Props {
channelId: Id<"channels">;
@@ -17,6 +23,8 @@
channelId,
}));
+ const addReaction = useMutation(api.messages.addReaction);
+
const messagesById = $derived(
new Map(messages.data?.map((message) => [message._id, message])),
);
@@ -49,14 +57,23 @@
let clientX = $state(0);
let clientY = $state(0);
let visibleDropdown = $state
| null>(null);
+ let reactionPaletteVisibleFor = $state | null>(null);
+ const modalManager = new ModalManager();
+
document.addEventListener("click", () => {
visibleDropdown = null;
});
+
+
{#if messages.data}
{#each messages.data as message (message._id)}
+ {#snippet reactionListSnippet()}
+
+ {/snippet}
+
{#snippet dropdownContent()}
+ -
+
+
+ -
+
+
{/snippet}
+ {#if reactionPaletteVisibleFor && reactionPaletteVisibleFor === message._id}
+ {
+ reactionPaletteVisibleFor = null;
+ }}
+ onEmojiSelected={async (emoji) => {
+ if (!reactionPaletteVisibleFor) return;
+ await addReaction.run({
+ messageId: reactionPaletteVisibleFor,
+ emoji,
+ });
+ reactionPaletteVisibleFor = null;
+ }}
+ />
+ {/if}
+
{
e.preventDefault();
- clientX = e.clientX;
+ const menuWidth = 160; // w-40
+ clientX =
+ e.clientX + menuWidth > window.innerWidth
+ ? e.clientX - menuWidth
+ : e.clientX;
clientY = e.clientY;
visibleDropdown = message._id;
}}
@@ -118,19 +171,23 @@
-
-
-
- -
-
-
-
-
+
+
{:else}
diff --git a/packages/client/src/components/chat/ReactionButtons.svelte b/packages/client/src/components/chat/ReactionButtons.svelte
new file mode 100644
index 0000000..2eee92d
--- /dev/null
+++ b/packages/client/src/components/chat/ReactionButtons.svelte
@@ -0,0 +1,62 @@
+
+
+
+ {#each [...reactionsByEmoji.entries()] as [emoji, detail]}
+ {@const amIin = detail.me}
+ {@const count = detail.count}
+
+
+
+ {/each}
+
diff --git a/packages/client/src/components/chat/ReactionList.svelte b/packages/client/src/components/chat/ReactionList.svelte
new file mode 100644
index 0000000..01db719
--- /dev/null
+++ b/packages/client/src/components/chat/ReactionList.svelte
@@ -0,0 +1,99 @@
+
+
+
+ {#if reactionDetailsByEmoji.size > 0}
+
+
+ {#each [...reactionDetailsByEmoji.entries()] as [emoji, detail]}
+
+ {/each}
+
+
+ {#if selectedEmoji}
+
+
+ {#if userNamesById.data && reactionDetailsByEmoji.get(selectedEmoji)}
+ {@const userIdsForSelectedEmoji =
+ reactionDetailsByEmoji.get(selectedEmoji)?.users ?? []}
+ {#if userIdsForSelectedEmoji.length === 0}
+
+ | No one has reacted with this emoji. |
+
+ {/if}
+ {#each userIdsForSelectedEmoji as userId}
+
+ | {userNamesById.data[userId]} |
+
+ {/each}
+ {:else if userNamesById.isLoading || reactions.isLoading}
+
+ | Loading... |
+
+ {:else}
+
+ | Error |
+
+ {/if}
+
+
+ {/if}
+
+
+ {:else}
+
+
There are no reactions yet.
+
+ {/if}
+
diff --git a/packages/client/src/icons/mdi-dots-vertical.svelte b/packages/client/src/icons/mdi-dots-vertical.svelte
new file mode 100644
index 0000000..9855bb1
--- /dev/null
+++ b/packages/client/src/icons/mdi-dots-vertical.svelte
@@ -0,0 +1,5 @@
+
+
+
diff --git a/packages/client/src/lib/modal/modal.svelte b/packages/client/src/lib/modal/modal.svelte
index fc30535..090372f 100644
--- a/packages/client/src/lib/modal/modal.svelte
+++ b/packages/client/src/lib/modal/modal.svelte
@@ -11,7 +11,6 @@
close() {
this.dialog?.close();
- this.snippet = null;
}
}
diff --git a/packages/convex/src/convex/messages.ts b/packages/convex/src/convex/messages.ts
index 69aef3b..e8311f6 100644
--- a/packages/convex/src/convex/messages.ts
+++ b/packages/convex/src/convex/messages.ts
@@ -1,3 +1,4 @@
+import { getAuthUserId } from "@convex-dev/auth/server";
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
import { getMessagePerms, validateFileAttachments } from "./perms";
@@ -28,6 +29,11 @@ export const send = mutation({
attachments: v.optional(v.array(v.id("files"))),
},
handler: async (ctx, args) => {
+ const userId = await getAuthUserId(ctx);
+ if (!userId) {
+ throw new Error("User not authenticated");
+ }
+
const perms = await getMessagePerms(ctx, {
channelId: args.channelId,
});
@@ -44,9 +50,75 @@ export const send = mutation({
channelId: args.channelId,
content: args.content,
author: args.author,
+ userId: userId,
createdAt: Date.now(),
parentId: args.parentId,
attachments: args.attachments,
});
},
});
+
+export const getReactions = query({
+ args: { messageId: v.id("messages") },
+ handler: async (ctx, args) => {
+ return await ctx.db
+ .query("reactions")
+ .withIndex("by_message", (q) => q.eq("messageId", args.messageId))
+ .collect();
+ },
+});
+
+export const addReaction = mutation({
+ args: {
+ messageId: v.id("messages"),
+ emoji: v.string(),
+ },
+ handler: async (ctx, args) => {
+ const userId = await getAuthUserId(ctx);
+ if (!userId) {
+ throw new Error("User not authenticated");
+ }
+
+ const existingReaction = await ctx.db
+ .query("reactions")
+ .withIndex("by_message", (q) => q.eq("messageId", args.messageId))
+ .filter((q) => q.eq(q.field("userId"), userId))
+ .filter((q) => q.eq(q.field("emoji"), args.emoji))
+ .first();
+
+ if (existingReaction) {
+ return;
+ }
+
+ await ctx.db.insert("reactions", {
+ messageId: args.messageId,
+ userId: userId,
+ emoji: args.emoji,
+ createdAt: Date.now(),
+ });
+ },
+});
+
+export const removeReaction = mutation({
+ args: {
+ messageId: v.id("messages"),
+ emoji: v.string(),
+ },
+ handler: async (ctx, args) => {
+ const userId = await getAuthUserId(ctx);
+ if (!userId) {
+ throw new Error("User not authenticated");
+ }
+
+ const reaction = await ctx.db
+ .query("reactions")
+ .withIndex("by_message", (q) => q.eq("messageId", args.messageId))
+ .filter((q) => q.eq(q.field("userId"), userId))
+ .filter((q) => q.eq(q.field("emoji"), args.emoji))
+ .first();
+
+ if (reaction) {
+ await ctx.db.delete(reaction._id);
+ }
+ },
+});
diff --git a/packages/convex/src/convex/schema.ts b/packages/convex/src/convex/schema.ts
index 11e4212..044203e 100644
--- a/packages/convex/src/convex/schema.ts
+++ b/packages/convex/src/convex/schema.ts
@@ -37,11 +37,20 @@ export default defineSchema({
channelId: v.id("channels"),
content: v.string(),
author: v.string(),
+ userId: v.id("users"),
createdAt: v.number(),
parentId: v.optional(v.id("messages")),
// 添付ファイル
attachments: v.optional(v.array(v.id("files"))),
}).index("by_channel", ["channelId"]),
+ reactions: defineTable({
+ messageId: v.id("messages"),
+ userId: v.id("users"),
+ emoji: v.string(),
+ createdAt: v.number(),
+ })
+ .index("by_message", ["messageId"])
+ .index("by_user", ["userId"]),
personalization: defineTable({
userId: v.id("users"),
organizationId: v.id("organizations"),
diff --git a/packages/convex/src/convex/users.ts b/packages/convex/src/convex/users.ts
index b273178..2d795cb 100644
--- a/packages/convex/src/convex/users.ts
+++ b/packages/convex/src/convex/users.ts
@@ -1,4 +1,6 @@
import { getAuthUserId } from "@convex-dev/auth/server";
+import { v } from "convex/values";
+import type { Id } from "./_generated/dataModel";
import { query } from "./_generated/server";
export const me = query({
@@ -11,3 +13,21 @@ export const me = query({
return await ctx.db.get(userId);
},
});
+
+export const getUserNames = query({
+ args: {
+ userIds: v.array(v.id("users")),
+ },
+ handler: async (ctx, { userIds }) => {
+ const users = await Promise.all(
+ userIds.map((userId) => ctx.db.get(userId)),
+ );
+ const userNames: Record, string> = Object.fromEntries(
+ users
+ .filter((user) => user !== null)
+ .map((user) => [user._id, user.name ?? ""]),
+ );
+
+ return userNames;
+ },
+});