Skip to content

Commit 42b7467

Browse files
authored
絵文字とリアクション機能 (#6)
1 parent dc2fe4d commit 42b7467

File tree

12 files changed

+446
-18
lines changed

12 files changed

+446
-18
lines changed

bun.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/client/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
"@tauri-apps/plugin-opener": "^2.4.0",
5252
"convex": "^1.25.4",
5353
"convex-svelte": "^0.0.11",
54+
"emoji-picker-element": "^1.27.0",
5455
"robot3": "^1.1.1",
5556
"runed": "^0.31.0",
5657
"unplugin-icons": "^22.2.0"
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<script lang="ts">
2+
import { Picker } from "emoji-picker-element";
3+
4+
interface Props {
5+
onClose: () => void;
6+
onEmojiSelected: (emoji: string) => void;
7+
x?: number;
8+
y?: number;
9+
}
10+
let { onClose, onEmojiSelected, x, y }: Props = $props();
11+
let paletteRef: HTMLElement;
12+
13+
const paletteWidth = 350; // emoji-picker-element default width
14+
const paletteHeight = 450; // emoji-picker-element default height
15+
16+
let finalX = $state(x);
17+
let finalY = $state(y);
18+
19+
$effect(() => {
20+
if (x !== undefined && y !== undefined) {
21+
finalX = x + paletteWidth > window.innerWidth ? x - paletteWidth : x;
22+
finalY = y + paletteHeight > window.innerHeight ? y - paletteHeight : y;
23+
}
24+
});
25+
26+
$effect(() => {
27+
const handleClickOutside = (event: MouseEvent) => {
28+
if (
29+
paletteRef &&
30+
!paletteRef.contains(event.target as Node)
31+
// !toggleButtonRef?.contains(event.target as Node)
32+
) {
33+
onClose();
34+
}
35+
};
36+
37+
const picker = new Picker();
38+
paletteRef.appendChild(picker);
39+
40+
paletteRef.addEventListener("click", (event: MouseEvent) => {
41+
event.stopPropagation();
42+
});
43+
44+
document.addEventListener("click", handleClickOutside);
45+
46+
const emojiPicker = document.querySelector("emoji-picker");
47+
48+
emojiPicker?.addEventListener("emoji-click", (event) => {
49+
const emoji = event.detail.unicode;
50+
if (!emoji) return;
51+
onEmojiSelected(emoji);
52+
});
53+
54+
return () => {
55+
document.removeEventListener("click", handleClickOutside);
56+
if (emojiPicker) {
57+
emojiPicker.removeEventListener("emoji-click", () => {});
58+
}
59+
};
60+
});
61+
</script>
62+
63+
<div
64+
bind:this={paletteRef}
65+
class={`z-10 ${
66+
x === undefined || y === undefined
67+
? "absolute right-4 bottom-24"
68+
: "absolute"
69+
}`}
70+
style={`top: ${finalY}px; left: ${finalX}px;`}
71+
></div>

packages/client/src/components/chat/MessageInput.svelte

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
import FilePreview from "~/features/files/upload/FilePreview.svelte";
66
import FileSelector from "~/features/files/upload/Selector.svelte";
77
import { FileUploader } from "~/features/files/upload/uploader.svelte";
8+
import MdiClose from "~/icons/mdi-close.svelte";
89
import { useMutation } from "~/lib/useMutation.svelte.ts";
10+
import EmojiPalette from "./EmojiPalette.svelte";
911
1012
interface Props {
1113
organizationId: Id<"organizations">;
@@ -20,6 +22,7 @@
2022
2123
let messageContent = $state("");
2224
let authorName = $state("");
25+
let showEmojiPalette = $state(false);
2326
let showFileSelector = $state(false);
2427
let attachedFiles = $state<File[]>([]);
2528
@@ -68,10 +71,20 @@
6871

6972
<div class="border-base-300 bg-base-100 space-y-4 border-t p-4">
7073
{#if replyingTo}
71-
<div class="text-base-content/70 text-sm">
72-
<span class="font-semibold">返信先:</span>
73-
<span class="text-primary font-semibold">{replyingTo.author}</span>
74-
<span>{replyingTo.content}</span>
74+
<div
75+
class="bg-base-200 mb-2 flex items-center justify-between rounded-md p-2 text-sm"
76+
>
77+
<div class="text-base-content/70 truncate">
78+
<span class="font-semibold">返信先:</span>
79+
<span class="text-primary font-semibold">{replyingTo.author}</span>
80+
<span class="truncate">: {replyingTo.content}</span>
81+
</div>
82+
<button
83+
class="btn btn-ghost btn-circle btn-sm"
84+
onclick={() => (replyingTo = null)}
85+
>
86+
<MdiClose />
87+
</button>
7588
</div>
7689
{/if}
7790

@@ -160,7 +173,24 @@
160173
送信
161174
{/if}
162175
</button>
176+
<button
177+
class="btn btn-secondary self-end"
178+
onclick={(e) => {
179+
e.stopPropagation();
180+
showEmojiPalette = !showEmojiPalette;
181+
}}
182+
>
183+
😀
184+
</button>
163185
</div>
186+
{#if showEmojiPalette}
187+
<EmojiPalette
188+
onClose={() => (showEmojiPalette = false)}
189+
onEmojiSelected={(emoji) => {
190+
messageContent += emoji;
191+
}}
192+
/>
193+
{/if}
164194

165195
{#if sendMessageMutation.error}
166196
<div class="alert alert-error text-sm">

packages/client/src/components/chat/MessageList.svelte

Lines changed: 70 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,14 @@
33
import type { Doc } from "@packages/convex/src/convex/_generated/dataModel";
44
import { useQuery } from "convex-svelte";
55
import { onMount } from "svelte";
6+
import Modal, { ModalManager } from "$lib/modal/modal.svelte";
7+
import MdiDotsVertical from "~/icons/mdi-dots-vertical.svelte";
8+
import { useMutation } from "~/lib/useMutation.svelte";
69
import FileAttachment from "../../features/files/view/FileAttachment.svelte";
10+
import EmojiPalette from "./EmojiPalette.svelte";
711
import MessageDropdown from "./MessageDropdown.svelte";
12+
import ReactionButtons from "./ReactionButtons.svelte";
13+
import ReactionList from "./ReactionList.svelte";
814
915
interface Props {
1016
channelId: Id<"channels">;
@@ -17,6 +23,8 @@
1723
channelId,
1824
}));
1925
26+
const addReaction = useMutation(api.messages.addReaction);
27+
2028
const messagesById = $derived(
2129
new Map(messages.data?.map((message) => [message._id, message])),
2230
);
@@ -49,21 +57,44 @@
4957
let clientX = $state(0);
5058
let clientY = $state(0);
5159
let visibleDropdown = $state<Id<"messages"> | null>(null);
60+
let reactionPaletteVisibleFor = $state<Id<"messages"> | null>(null);
61+
const modalManager = new ModalManager();
62+
5263
document.addEventListener("click", () => {
5364
visibleDropdown = null;
5465
});
5566
</script>
5667

68+
<Modal manager={modalManager} />
69+
5770
<div bind:this={messagesContainer} class="flex-1 space-y-2 overflow-y-auto p-4">
5871
{#if messages.data}
5972
{#each messages.data as message (message._id)}
73+
{#snippet reactionListSnippet()}
74+
<ReactionList messageId={message._id} />
75+
{/snippet}
76+
6077
{#snippet dropdownContent()}
6178
<ul
6279
class="menu dropdown-content bg-base-100 absolute z-[1] w-40 rounded-md border p-2 shadow"
6380
>
6481
<li>
6582
<button onclick={() => (replyingTo = message)}>返信</button>
6683
</li>
84+
<li>
85+
<button
86+
onclick={(e) => {
87+
e.stopPropagation();
88+
reactionPaletteVisibleFor = message._id;
89+
visibleDropdown = null;
90+
}}>リアクションを付ける</button
91+
>
92+
</li>
93+
<li>
94+
<button onclick={() => modalManager.dispatch(reactionListSnippet)}
95+
>リアクションを表示</button
96+
>
97+
</li>
6798
</ul>
6899
{/snippet}
69100
<MessageDropdown
@@ -74,13 +105,35 @@
74105
{@render dropdownContent()}
75106
</MessageDropdown>
76107

108+
{#if reactionPaletteVisibleFor && reactionPaletteVisibleFor === message._id}
109+
<EmojiPalette
110+
x={clientX}
111+
y={clientY}
112+
onClose={() => {
113+
reactionPaletteVisibleFor = null;
114+
}}
115+
onEmojiSelected={async (emoji) => {
116+
if (!reactionPaletteVisibleFor) return;
117+
await addReaction.run({
118+
messageId: reactionPaletteVisibleFor,
119+
emoji,
120+
});
121+
reactionPaletteVisibleFor = null;
122+
}}
123+
/>
124+
{/if}
125+
77126
<div
78127
role="button"
79128
tabindex="0"
80129
class="p-1 hover:bg-sky-900"
81130
oncontextmenu={(e) => {
82131
e.preventDefault();
83-
clientX = e.clientX;
132+
const menuWidth = 160; // w-40
133+
clientX =
134+
e.clientX + menuWidth > window.innerWidth
135+
? e.clientX - menuWidth
136+
: e.clientX;
84137
clientY = e.clientY;
85138
visibleDropdown = message._id;
86139
}}
@@ -118,19 +171,23 @@
118171
<div
119172
class="bg-base-100 absolute top-4 right-4 -translate-y-1/2 rounded-md border opacity-0 group-hover:opacity-100"
120173
>
121-
<div class="dropdown dropdown-end">
122-
<button class="btn btn-ghost btn-sm p-2" tabindex="0"> ⋮ </button>
123-
<ul
124-
tabindex="0"
125-
role="menu"
126-
class="menu dropdown-content bg-base-100 z-[1] w-40 rounded-md border p-2 shadow"
127-
>
128-
<li>
129-
<button onclick={() => (replyingTo = message)}>返信</button>
130-
</li>
131-
</ul>
132-
</div>
174+
<button
175+
class="btn btn-ghost btn-sm p-2"
176+
onclick={(e) => {
177+
e.stopPropagation();
178+
const menuWidth = 160; // w-40
179+
clientX =
180+
e.clientX + menuWidth > window.innerWidth
181+
? e.clientX - menuWidth
182+
: e.clientX;
183+
clientY = e.clientY;
184+
visibleDropdown = message._id;
185+
}}
186+
>
187+
<MdiDotsVertical />
188+
</button>
133189
</div>
190+
<ReactionButtons messageId={message._id} />
134191
</div>
135192
</div>
136193
{:else}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<script lang="ts">
2+
import { api, type Id } from "@packages/convex";
3+
import { useQuery } from "convex-svelte";
4+
import { fly } from "svelte/transition";
5+
import { useMutation } from "~/lib/useMutation.svelte.ts";
6+
7+
interface Props {
8+
messageId: Id<"messages">;
9+
}
10+
11+
let { messageId }: Props = $props();
12+
13+
const reactions = useQuery(api.messages.getReactions, () => ({
14+
messageId,
15+
}));
16+
17+
const me = useQuery(api.users.me, {});
18+
19+
const addReaction = useMutation(api.messages.addReaction);
20+
const removeReaction = useMutation(api.messages.removeReaction);
21+
22+
const reactionsByEmoji = $derived.by(() => {
23+
const counts = new Map<string, { count: number; me: boolean }>();
24+
if (!reactions.data) {
25+
return counts;
26+
}
27+
for (const r of reactions.data) {
28+
counts.set(r.emoji, {
29+
count: (counts.get(r.emoji)?.count ?? 0) + 1,
30+
me: counts.get(r.emoji)?.me || r.userId === me.data?._id,
31+
});
32+
}
33+
return counts;
34+
});
35+
36+
function handleReactionClick(emoji: string, amIin: boolean) {
37+
if (!me.data) return;
38+
39+
if (amIin) {
40+
removeReaction.run({ messageId, emoji });
41+
} else {
42+
addReaction.run({ messageId, emoji });
43+
}
44+
}
45+
</script>
46+
47+
<div class="flex gap-1">
48+
{#each [...reactionsByEmoji.entries()] as [emoji, detail]}
49+
{@const amIin = detail.me}
50+
{@const count = detail.count}
51+
<div in:fly={{ y: -5, duration: 150 }}>
52+
<button
53+
class="btn btn-xs flex w-12 justify-between"
54+
class:btn-primary={amIin}
55+
onclick={() => handleReactionClick(emoji, amIin)}
56+
>
57+
{emoji}
58+
<span class="text-xs">{count}</span>
59+
</button>
60+
</div>
61+
{/each}
62+
</div>

0 commit comments

Comments
 (0)