Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions bun.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -676,6 +677,8 @@

"eastasianwidth": ["[email protected]", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],

"emoji-picker-element": ["[email protected]", "", {}, "sha512-CeN9g5/kq41+BfYPDpAbE2ejZRHbs1faFDmU9+E9wGA4JWLkok9zo1hwcAFnUhV4lPR3ZuLHiJxNG1mpjoF4TQ=="],

"emoji-regex": ["[email protected]", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],

"enhanced-resolve": ["[email protected]", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ=="],
Expand Down
1 change: 1 addition & 0 deletions packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
71 changes: 71 additions & 0 deletions packages/client/src/components/chat/EmojiPalette.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<script lang="ts">
import { Picker } from "emoji-picker-element";

interface Props {
onClose: () => void;
onEmojiSelected: (emoji: string) => void;
x?: number;
y?: number;
}
let { onClose, onEmojiSelected, x, y }: Props = $props();
let paletteRef: HTMLElement;

const paletteWidth = 350; // emoji-picker-element default width
const paletteHeight = 450; // emoji-picker-element default height

let finalX = $state(x);
let finalY = $state(y);

$effect(() => {
if (x !== undefined && y !== undefined) {
finalX = x + paletteWidth > window.innerWidth ? x - paletteWidth : x;
finalY = y + paletteHeight > window.innerHeight ? y - paletteHeight : y;
}
});

$effect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
paletteRef &&
!paletteRef.contains(event.target as Node)
// !toggleButtonRef?.contains(event.target as Node)
) {
onClose();
}
};

const picker = new Picker();
paletteRef.appendChild(picker);

paletteRef.addEventListener("click", (event: MouseEvent) => {
event.stopPropagation();
});

document.addEventListener("click", handleClickOutside);

const emojiPicker = document.querySelector("emoji-picker");

emojiPicker?.addEventListener("emoji-click", (event) => {
const emoji = event.detail.unicode;
if (!emoji) return;
onEmojiSelected(emoji);
});

return () => {
document.removeEventListener("click", handleClickOutside);
if (emojiPicker) {
emojiPicker.removeEventListener("emoji-click", () => {});
}
};
});
</script>

<div
bind:this={paletteRef}
class={`z-10 ${
x === undefined || y === undefined
? "absolute right-4 bottom-24"
: "absolute"
}`}
style={`top: ${finalY}px; left: ${finalX}px;`}
></div>
38 changes: 34 additions & 4 deletions packages/client/src/components/chat/MessageInput.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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">;
Expand All @@ -20,6 +22,7 @@

let messageContent = $state("");
let authorName = $state("");
let showEmojiPalette = $state(false);
let showFileSelector = $state(false);
let attachedFiles = $state<File[]>([]);

Expand Down Expand Up @@ -68,10 +71,20 @@

<div class="border-base-300 bg-base-100 space-y-4 border-t p-4">
{#if replyingTo}
<div class="text-base-content/70 text-sm">
<span class="font-semibold">返信先:</span>
<span class="text-primary font-semibold">{replyingTo.author}</span>
<span>{replyingTo.content}</span>
<div
class="bg-base-200 mb-2 flex items-center justify-between rounded-md p-2 text-sm"
>
<div class="text-base-content/70 truncate">
<span class="font-semibold">返信先:</span>
<span class="text-primary font-semibold">{replyingTo.author}</span>
<span class="truncate">: {replyingTo.content}</span>
</div>
<button
class="btn btn-ghost btn-circle btn-sm"
onclick={() => (replyingTo = null)}
>
<MdiClose />
</button>
</div>
{/if}

Expand Down Expand Up @@ -160,7 +173,24 @@
送信
{/if}
</button>
<button
class="btn btn-secondary self-end"
onclick={(e) => {
e.stopPropagation();
showEmojiPalette = !showEmojiPalette;
}}
>
😀
</button>
</div>
{#if showEmojiPalette}
<EmojiPalette
onClose={() => (showEmojiPalette = false)}
onEmojiSelected={(emoji) => {
messageContent += emoji;
}}
/>
{/if}

{#if sendMessageMutation.error}
<div class="alert alert-error text-sm">
Expand Down
83 changes: 70 additions & 13 deletions packages/client/src/components/chat/MessageList.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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">;
Expand All @@ -17,6 +23,8 @@
channelId,
}));

const addReaction = useMutation(api.messages.addReaction);

const messagesById = $derived(
new Map(messages.data?.map((message) => [message._id, message])),
);
Expand Down Expand Up @@ -49,21 +57,44 @@
let clientX = $state(0);
let clientY = $state(0);
let visibleDropdown = $state<Id<"messages"> | null>(null);
let reactionPaletteVisibleFor = $state<Id<"messages"> | null>(null);
const modalManager = new ModalManager();

document.addEventListener("click", () => {
visibleDropdown = null;
});
</script>

<Modal manager={modalManager} />

<div bind:this={messagesContainer} class="flex-1 space-y-2 overflow-y-auto p-4">
{#if messages.data}
{#each messages.data as message (message._id)}
{#snippet reactionListSnippet()}
<ReactionList messageId={message._id} />
{/snippet}

{#snippet dropdownContent()}
<ul
class="menu dropdown-content bg-base-100 absolute z-[1] w-40 rounded-md border p-2 shadow"
>
<li>
<button onclick={() => (replyingTo = message)}>返信</button>
</li>
<li>
<button
onclick={(e) => {
e.stopPropagation();
reactionPaletteVisibleFor = message._id;
visibleDropdown = null;
}}>リアクションを付ける</button
>
</li>
<li>
<button onclick={() => modalManager.dispatch(reactionListSnippet)}
>リアクションを表示</button
>
</li>
</ul>
{/snippet}
<MessageDropdown
Expand All @@ -74,13 +105,35 @@
{@render dropdownContent()}
</MessageDropdown>

{#if reactionPaletteVisibleFor && reactionPaletteVisibleFor === message._id}
<EmojiPalette
x={clientX}
y={clientY}
onClose={() => {
reactionPaletteVisibleFor = null;
}}
onEmojiSelected={async (emoji) => {
if (!reactionPaletteVisibleFor) return;
await addReaction.run({
messageId: reactionPaletteVisibleFor,
emoji,
});
reactionPaletteVisibleFor = null;
}}
/>
{/if}

<div
role="button"
tabindex="0"
class="p-1 hover:bg-sky-900"
oncontextmenu={(e) => {
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;
}}
Expand Down Expand Up @@ -118,19 +171,23 @@
<div
class="bg-base-100 absolute top-4 right-4 -translate-y-1/2 rounded-md border opacity-0 group-hover:opacity-100"
>
<div class="dropdown dropdown-end">
<button class="btn btn-ghost btn-sm p-2" tabindex="0"> ⋮ </button>
<ul
tabindex="0"
role="menu"
class="menu dropdown-content bg-base-100 z-[1] w-40 rounded-md border p-2 shadow"
>
<li>
<button onclick={() => (replyingTo = message)}>返信</button>
</li>
</ul>
</div>
<button
class="btn btn-ghost btn-sm p-2"
onclick={(e) => {
e.stopPropagation();
const menuWidth = 160; // w-40
clientX =
e.clientX + menuWidth > window.innerWidth
? e.clientX - menuWidth
: e.clientX;
clientY = e.clientY;
visibleDropdown = message._id;
}}
>
<MdiDotsVertical />
</button>
</div>
<ReactionButtons messageId={message._id} />
</div>
</div>
{:else}
Expand Down
62 changes: 62 additions & 0 deletions packages/client/src/components/chat/ReactionButtons.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<script lang="ts">
import { api, type Id } from "@packages/convex";
import { useQuery } from "convex-svelte";
import { fly } from "svelte/transition";
import { useMutation } from "~/lib/useMutation.svelte.ts";

interface Props {
messageId: Id<"messages">;
}

let { messageId }: Props = $props();

const reactions = useQuery(api.messages.getReactions, () => ({
messageId,
}));

const me = useQuery(api.users.me, {});

const addReaction = useMutation(api.messages.addReaction);
const removeReaction = useMutation(api.messages.removeReaction);

const reactionsByEmoji = $derived.by(() => {
const counts = new Map<string, { count: number; me: boolean }>();
if (!reactions.data) {
return counts;
}
for (const r of reactions.data) {
counts.set(r.emoji, {
count: (counts.get(r.emoji)?.count ?? 0) + 1,
me: counts.get(r.emoji)?.me || r.userId === me.data?._id,
});
}
return counts;
});

function handleReactionClick(emoji: string, amIin: boolean) {
if (!me.data) return;

if (amIin) {
removeReaction.run({ messageId, emoji });
} else {
addReaction.run({ messageId, emoji });
}
}
</script>

<div class="flex gap-1">
{#each [...reactionsByEmoji.entries()] as [emoji, detail]}
{@const amIin = detail.me}
{@const count = detail.count}
<div in:fly={{ y: -5, duration: 150 }}>
<button
class="btn btn-xs flex w-12 justify-between"
class:btn-primary={amIin}
onclick={() => handleReactionClick(emoji, amIin)}
>
{emoji}
<span class="text-xs">{count}</span>
</button>
</div>
{/each}
</div>
Loading