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} + + + + {/if} + {#each userIdsForSelectedEmoji as userId} + + + + {/each} + {:else if userNamesById.isLoading || reactions.isLoading} + + + + {:else} + + + + {/if} + +
No one has reacted with this emoji.
{userNamesById.data[userId]}
Loading...
Error
+ {/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; + }, +});