(wip): add reactions
(qol): update todo
This commit is contained in:
parent
d57940d1b3
commit
7ba08090fe
20 changed files with 4533 additions and 5894 deletions
29
packages/chat/src/components/emojiPicker.tsx
Normal file
29
packages/chat/src/components/emojiPicker.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { Button } from "@tensamin/ui";
|
||||
import Emoji from "@tensamin/markdown/emoji";
|
||||
import { getRecentEmojis, useEmojiRanks } from "./emojiRanks";
|
||||
|
||||
export default function EmojiPicker({
|
||||
onSelect,
|
||||
}: {
|
||||
onSelect: (emoji: string) => void;
|
||||
}) {
|
||||
const { ranks } = useEmojiRanks();
|
||||
const emojis = getRecentEmojis(ranks, 3);
|
||||
|
||||
return (
|
||||
<div className="flex flex-row gap-1 p-1">
|
||||
{emojis.map((emoji) => (
|
||||
<Button
|
||||
key={emoji}
|
||||
aria-label={`Select ${emoji}`}
|
||||
className="h-10 w-10 p-0"
|
||||
onClick={() => onSelect(emoji)}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Emoji shortcode={emoji} />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
packages/chat/src/components/emojiRanks.ts
Normal file
95
packages/chat/src/components/emojiRanks.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { useStorage } from "@tensamin/storage/context";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { normalizeShortcode } from "@tensamin/markdown/emoji";
|
||||
|
||||
const RANKS_CHANGED_EVENT = "tensamin-reaction-ranks-changed";
|
||||
let recordQueue = Promise.resolve();
|
||||
|
||||
function normalizeRanks(ranks: Record<string, number>) {
|
||||
return Object.entries(ranks).reduce<Record<string, number>>(
|
||||
(normalized, [value, frequency]) => {
|
||||
const shortcode = normalizeShortcode(value);
|
||||
if (!shortcode || !Number.isFinite(frequency) || frequency <= 0) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
normalized[shortcode] = (normalized[shortcode] ?? 0) + frequency;
|
||||
return normalized;
|
||||
},
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
function rankedEmojis(ranks: Record<string, number>) {
|
||||
return Object.entries(ranks)
|
||||
.filter(
|
||||
([emoji, frequency]) =>
|
||||
normalizeShortcode(emoji) !== undefined &&
|
||||
Number.isFinite(frequency) &&
|
||||
frequency > 0,
|
||||
)
|
||||
.sort(([emojiA, frequencyA], [emojiB, frequencyB]) =>
|
||||
frequencyB === frequencyA
|
||||
? emojiA.localeCompare(emojiB)
|
||||
: frequencyB - frequencyA,
|
||||
)
|
||||
.flatMap(([emoji]) => {
|
||||
const shortcode = normalizeShortcode(emoji);
|
||||
return shortcode ? [shortcode] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export function getRecentEmojis(ranks: Record<string, number>, amount: number) {
|
||||
return rankedEmojis(ranks).slice(0, amount);
|
||||
}
|
||||
|
||||
export function useEmojiRanks() {
|
||||
const { load, save } = useStorage();
|
||||
const [ranks, setRanks] = useState<Record<string, number>>({});
|
||||
|
||||
useEffect(() => {
|
||||
void load("reactions").then((storedRanks) => {
|
||||
const normalized = normalizeRanks(storedRanks);
|
||||
setRanks(normalized);
|
||||
if (JSON.stringify(normalized) !== JSON.stringify(storedRanks)) {
|
||||
void save("reactions", normalized);
|
||||
}
|
||||
});
|
||||
|
||||
function handleRanksChanged(event: Event) {
|
||||
setRanks((event as CustomEvent<Record<string, number>>).detail);
|
||||
}
|
||||
|
||||
window.addEventListener(RANKS_CHANGED_EVENT, handleRanksChanged);
|
||||
return () =>
|
||||
window.removeEventListener(RANKS_CHANGED_EVENT, handleRanksChanged);
|
||||
}, [load, save]);
|
||||
|
||||
return { ranks };
|
||||
}
|
||||
|
||||
export function useRecordEmojiUse() {
|
||||
const { load, save } = useStorage();
|
||||
|
||||
return useCallback(
|
||||
(emoji: string) => {
|
||||
const shortcode = normalizeShortcode(emoji);
|
||||
if (!shortcode) return;
|
||||
|
||||
recordQueue = recordQueue
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
const normalized = normalizeRanks(await load("reactions"));
|
||||
const next = {
|
||||
...normalized,
|
||||
[shortcode]: (normalized[shortcode] ?? 0) + 1,
|
||||
};
|
||||
await save("reactions", next);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(RANKS_CHANGED_EVENT, { detail: next }),
|
||||
);
|
||||
});
|
||||
},
|
||||
[load, save],
|
||||
);
|
||||
}
|
||||
|
|
@ -23,6 +23,8 @@ import { encryptChatText } from "@tensamin/crypto/chatSecret";
|
|||
|
||||
import { useSession } from "@tensamin/storage/session";
|
||||
import GifPicker from "./gifPicker";
|
||||
import EmojiPicker from "./emojiPicker";
|
||||
import { useEmojiRanks, useRecordEmojiUse } from "./emojiRanks";
|
||||
import Wrapper from "@tensamin/user/wrapper";
|
||||
import Text from "@tensamin/markdown/text";
|
||||
|
||||
|
|
@ -48,6 +50,9 @@ export default function InputComponent({
|
|||
const { moveUserIdToTop } = useSession();
|
||||
const gifPopoverRef = useRef<HTMLDivElement>(null);
|
||||
const [gifPopoverOpen, setGifPopoverOpen] = useState(false);
|
||||
const [emojiPopoverOpen, setEmojiPopoverOpen] = useState(false);
|
||||
const recordUse = useRecordEmojiUse();
|
||||
const { ranks: emojiFrequencies } = useEmojiRanks();
|
||||
const [gifPopoverSize, setGifPopoverSize] = useState<{
|
||||
width: number;
|
||||
height: number;
|
||||
|
|
@ -257,6 +262,8 @@ export default function InputComponent({
|
|||
setValue={setValue}
|
||||
onSubmit={handleSubmit}
|
||||
invertEnterBehavior={invertEnterBehavior}
|
||||
emojiFrequencies={emojiFrequencies}
|
||||
onEmojiSelect={recordUse}
|
||||
/>
|
||||
<div className="w-full flex justify-between gap-1 p-1 pt-0">
|
||||
<div className="flex gap-1">
|
||||
|
|
@ -268,9 +275,31 @@ export default function InputComponent({
|
|||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button className="w-9 h-9 p-0" variant="ghost">
|
||||
<Laugh size={20} />
|
||||
</Button>
|
||||
<Popover
|
||||
open={emojiPopoverOpen}
|
||||
onOpenChange={setEmojiPopoverOpen}
|
||||
>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label="Open emoji picker"
|
||||
className="w-9 h-9 p-0"
|
||||
variant="ghost"
|
||||
>
|
||||
<Laugh size={20} />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent className="w-auto p-0">
|
||||
<EmojiPicker
|
||||
onSelect={(shortcode) => {
|
||||
setValue(`${value}${shortcode} `);
|
||||
recordUse(shortcode);
|
||||
setEmojiPopoverOpen(false);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Popover open={gifPopoverOpen} onOpenChange={setGifPopoverOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ import Input from "@tensamin/markdown/input";
|
|||
import { useChat } from "../context";
|
||||
import { encryptChatText } from "@tensamin/crypto/chatSecret";
|
||||
import { log, toast } from "@tensamin/shared/log";
|
||||
import Emoji, { normalizeShortcode } from "@tensamin/markdown/emoji";
|
||||
import { useRecordEmojiUse } from "./emojiRanks";
|
||||
|
||||
function MessageComponent({
|
||||
grouped,
|
||||
|
|
@ -133,7 +135,15 @@ function MessageComponent({
|
|||
}, [message.Content]);
|
||||
|
||||
// Message editing
|
||||
const { chatSecret, editMessage, userId, deleteMessage } = useChat();
|
||||
const {
|
||||
addReaction,
|
||||
chatSecret,
|
||||
editMessage,
|
||||
userId,
|
||||
deleteMessage,
|
||||
removeReaction,
|
||||
} = useChat();
|
||||
const recordUse = useRecordEmojiUse();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editDraft, setEditDraft] = useState(message.Content);
|
||||
useEffect(() => {
|
||||
|
|
@ -187,6 +197,39 @@ function MessageComponent({
|
|||
],
|
||||
);
|
||||
|
||||
const groupedReactions = Object.entries(
|
||||
(message.Reactions ?? []).reduce<
|
||||
Record<string, { count: number; reactedByMe: boolean }>
|
||||
>((groups, item) => {
|
||||
const reaction = normalizeShortcode(item.Reaction);
|
||||
if (!reaction) return groups;
|
||||
|
||||
const group = groups[reaction] ?? {
|
||||
count: 0,
|
||||
reactedByMe: false,
|
||||
};
|
||||
group.count += 1;
|
||||
group.reactedByMe ||= item.SenderId === ownId;
|
||||
groups[reaction] = group;
|
||||
return groups;
|
||||
}, {}),
|
||||
);
|
||||
|
||||
function toggleReaction(reaction: string) {
|
||||
const reactedByMe = message.Reactions?.some(
|
||||
(item) =>
|
||||
normalizeShortcode(item.Reaction) === reaction &&
|
||||
item.SenderId === ownId,
|
||||
);
|
||||
|
||||
if (reactedByMe) {
|
||||
return removeReaction(message.SendTime, reaction);
|
||||
}
|
||||
|
||||
recordUse(reaction);
|
||||
return addReaction(message.SendTime, reaction);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
// pt-3 is to get a gap between messages
|
||||
|
|
@ -198,6 +241,7 @@ function MessageComponent({
|
|||
hideMiniMenu={editing}
|
||||
isOwnMessage={message.SenderId === ownId}
|
||||
messageId={message.SendTime}
|
||||
onReact={toggleReaction}
|
||||
onSetEditing={setEditing}
|
||||
>
|
||||
<div
|
||||
|
|
@ -305,6 +349,30 @@ function MessageComponent({
|
|||
) : (
|
||||
<Text value={message.Content} />
|
||||
)}
|
||||
{groupedReactions.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1 pb-1">
|
||||
{groupedReactions.map(
|
||||
([reaction, { count, reactedByMe }]) => (
|
||||
<Button
|
||||
key={reaction}
|
||||
aria-label={`${reactedByMe ? "Remove" : "Add"} ${reaction} reaction`}
|
||||
className={cn(
|
||||
"h-7 gap-2 rounded-lg py-3.5 px-1.5! border",
|
||||
reactedByMe
|
||||
? "border-(--primary-foreground-alt)/40!"
|
||||
: "",
|
||||
)}
|
||||
onClick={() => void toggleReaction(reaction)}
|
||||
size="xs"
|
||||
variant={reactedByMe ? "subtleDefault" : "outline"}
|
||||
>
|
||||
<Emoji className="h-5 w-5" shortcode={reaction} />
|
||||
<span className="text-sm">{count}</span>
|
||||
</Button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
|
|
@ -324,6 +392,8 @@ export default React.memo(MessageComponent, (prev, next) => {
|
|||
prev.message.MessageState === next.message.MessageState &&
|
||||
prev.message.failed === next.message.failed &&
|
||||
prev.message.decryptionFailed === next.message.decryptionFailed &&
|
||||
JSON.stringify(prev.message.Reactions) ===
|
||||
JSON.stringify(next.message.Reactions) &&
|
||||
prev.grouped === next.grouped &&
|
||||
prev.user === next.user
|
||||
);
|
||||
|
|
|
|||
|
|
@ -15,7 +15,13 @@ import {
|
|||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerTitle,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Separator,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
useIsMobile,
|
||||
} from "@tensamin/ui";
|
||||
import {
|
||||
|
|
@ -23,18 +29,23 @@ import {
|
|||
Ellipsis,
|
||||
Forward,
|
||||
Laugh,
|
||||
Plus,
|
||||
Pen,
|
||||
Pin,
|
||||
Reply,
|
||||
Trash,
|
||||
} from "lucide-react";
|
||||
import { cloneElement, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import type {
|
||||
MouseEvent as ReactMouseEvent,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
import { useChat } from "../context";
|
||||
import Emoji from "@tensamin/markdown/emoji";
|
||||
import EmojiPicker from "./emojiPicker";
|
||||
import { getRecentEmojis, useEmojiRanks } from "./emojiRanks";
|
||||
|
||||
async function copyText(text: string) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
|
|
@ -140,8 +151,31 @@ function getMobileMenuComponents({
|
|||
};
|
||||
}
|
||||
|
||||
function ReactionItems({ Item }: { Item: MenuComponents["Item"] }) {
|
||||
return <Item>Cool item</Item>;
|
||||
function ReactionItems({
|
||||
Item,
|
||||
emojis,
|
||||
onMore,
|
||||
onSelect,
|
||||
}: {
|
||||
Item: MenuComponents["Item"];
|
||||
emojis: string[];
|
||||
onMore: () => void;
|
||||
onSelect: (emoji: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{emojis.map((emoji) => (
|
||||
<Item key={emoji} onClick={() => onSelect(emoji)}>
|
||||
<Emoji className="w-4.5 h-4.5" shortcode={emoji} />
|
||||
<span>{emoji}</span>
|
||||
</Item>
|
||||
))}
|
||||
<Item onClick={onMore}>
|
||||
<Plus className="size-4.5" />
|
||||
<span>More reactions</span>
|
||||
</Item>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
let shiftPressed = false;
|
||||
|
|
@ -190,11 +224,63 @@ function useShiftPressed() {
|
|||
);
|
||||
}
|
||||
|
||||
let activeMiniMenuId: number | null = null;
|
||||
let clearMiniMenuTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const miniMenuListeners = new Set<() => void>();
|
||||
|
||||
function setActiveMiniMenu(messageId: number | null) {
|
||||
if (clearMiniMenuTimeout !== undefined) {
|
||||
clearTimeout(clearMiniMenuTimeout);
|
||||
clearMiniMenuTimeout = undefined;
|
||||
}
|
||||
if (activeMiniMenuId === messageId) return;
|
||||
|
||||
activeMiniMenuId = messageId;
|
||||
miniMenuListeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
function scheduleMiniMenuClose() {
|
||||
clearMiniMenuTimeout = setTimeout(() => {
|
||||
clearMiniMenuTimeout = undefined;
|
||||
setActiveMiniMenu(null);
|
||||
}, 80);
|
||||
}
|
||||
|
||||
function useActiveMiniMenuId() {
|
||||
return useSyncExternalStore(
|
||||
(listener) => {
|
||||
miniMenuListeners.add(listener);
|
||||
return () => miniMenuListeners.delete(listener);
|
||||
},
|
||||
() => activeMiniMenuId,
|
||||
() => null,
|
||||
);
|
||||
}
|
||||
|
||||
function MiniMenuTooltip({
|
||||
children,
|
||||
label,
|
||||
}: {
|
||||
children: ReactElement;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={children} />
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniMessageMenu({
|
||||
isOwnMessage,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onOpenMenu,
|
||||
onOpenPicker,
|
||||
usePickerTrigger,
|
||||
onReact,
|
||||
quickReactions,
|
||||
onReply,
|
||||
shiftIsPressed,
|
||||
}: {
|
||||
|
|
@ -202,54 +288,137 @@ function MiniMessageMenu({
|
|||
onDelete: () => void;
|
||||
onEdit: () => void;
|
||||
onOpenMenu: (event: ReactMouseEvent<HTMLElement>) => void;
|
||||
onOpenPicker: () => void;
|
||||
usePickerTrigger: boolean;
|
||||
onReact: (emoji: string) => void;
|
||||
quickReactions: string[];
|
||||
onReply: () => void;
|
||||
shiftIsPressed: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Card className="absolute right-4 -top-3 opacity-0 group-hover/message-menu:opacity-100 flex flex-row justify-end gap-0! z-10 p-0! shadow-lg rounded-lg!">
|
||||
{[0, 1, 2].map((item) => (
|
||||
<Button key={item} variant="ghost" className="h-8 w-8">
|
||||
{item === 0 && "👍"}
|
||||
{item === 1 && "🔥"}
|
||||
{item === 2 && "✅"}
|
||||
</Button>
|
||||
))}
|
||||
<Separator orientation="vertical" className="my-1" />
|
||||
<Button variant="ghost" className="h-8 w-8">
|
||||
<Laugh />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
aria-label={isOwnMessage ? "Edit message" : "Reply to message"}
|
||||
onClick={isOwnMessage ? onEdit : onReply}
|
||||
>
|
||||
{isOwnMessage ? <Pen /> : <Reply />}
|
||||
</Button>
|
||||
<Button variant="ghost" className="h-8 w-8">
|
||||
<Forward />
|
||||
</Button>
|
||||
<Separator orientation="vertical" className="my-1" />
|
||||
{isOwnMessage && shiftIsPressed ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 text-destructive"
|
||||
aria-label="Delete message"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
aria-label="Open message menu"
|
||||
onClick={onOpenMenu}
|
||||
>
|
||||
<Ellipsis />
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
<motion.div
|
||||
layoutId="chat-mini-message-menu"
|
||||
className="absolute right-4 -top-3 z-10"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
transition: {
|
||||
opacity: { duration: 0.12, delay: 0.08 },
|
||||
},
|
||||
}}
|
||||
transition={{
|
||||
layout: {
|
||||
type: "spring",
|
||||
stiffness: 650,
|
||||
damping: 34,
|
||||
mass: 0.65,
|
||||
},
|
||||
opacity: { duration: 0.12 },
|
||||
}}
|
||||
>
|
||||
<Card className="flex flex-row justify-end gap-0! rounded-lg! p-0! shadow-lg">
|
||||
{quickReactions.map((emoji) => (
|
||||
<MiniMenuTooltip
|
||||
key={emoji}
|
||||
label={emoji}
|
||||
children={
|
||||
<Button
|
||||
aria-label={`React with ${emoji}`}
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0!"
|
||||
onClick={() => onReact(emoji)}
|
||||
>
|
||||
<Emoji className="h-5 w-5" shortcode={emoji} tooltip={false} />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<Separator orientation="vertical" className="my-1" />
|
||||
<MiniMenuTooltip
|
||||
label="Add reaction"
|
||||
children={
|
||||
usePickerTrigger ? (
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label="Add reaction"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<Laugh />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
aria-label="Add reaction"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
onClick={onOpenPicker}
|
||||
>
|
||||
<Laugh />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<MiniMenuTooltip
|
||||
label={isOwnMessage ? "Edit message" : "Reply to message"}
|
||||
children={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
aria-label={isOwnMessage ? "Edit message" : "Reply to message"}
|
||||
onClick={isOwnMessage ? onEdit : onReply}
|
||||
>
|
||||
{isOwnMessage ? <Pen /> : <Reply />}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<MiniMenuTooltip
|
||||
label="Forward message"
|
||||
children={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
aria-label="Forward message"
|
||||
>
|
||||
<Forward />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Separator orientation="vertical" className="my-1" />
|
||||
{isOwnMessage && shiftIsPressed ? (
|
||||
<MiniMenuTooltip
|
||||
label="Delete message"
|
||||
children={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 text-destructive"
|
||||
aria-label="Delete message"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<MiniMenuTooltip
|
||||
label="Open message menu"
|
||||
children={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
aria-label="Open message menu"
|
||||
onClick={onOpenMenu}
|
||||
>
|
||||
<Ellipsis />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -260,6 +429,8 @@ function MessageMenuContent({
|
|||
isOwnMessage,
|
||||
messageId,
|
||||
onAddReaction,
|
||||
reactionEmojis,
|
||||
onReact,
|
||||
showReactionItems = true,
|
||||
onSetEditing,
|
||||
}: {
|
||||
|
|
@ -269,6 +440,8 @@ function MessageMenuContent({
|
|||
isOwnMessage: boolean;
|
||||
messageId: number;
|
||||
onAddReaction?: () => void | Promise<void>;
|
||||
reactionEmojis: string[];
|
||||
onReact: (emoji: string) => void;
|
||||
showReactionItems?: boolean;
|
||||
onSetEditing: (value: boolean) => void;
|
||||
}) {
|
||||
|
|
@ -281,10 +454,17 @@ function MessageMenuContent({
|
|||
<Content>
|
||||
<Group>
|
||||
<Sub>
|
||||
<SubTrigger onClick={onAddReaction}>Add Reaction</SubTrigger>
|
||||
<SubTrigger onClick={showReactionItems ? undefined : onAddReaction}>
|
||||
Add Reaction
|
||||
</SubTrigger>
|
||||
{showReactionItems && (
|
||||
<SubContent>
|
||||
<ReactionItems Item={Item} />
|
||||
<ReactionItems
|
||||
Item={Item}
|
||||
emojis={reactionEmojis}
|
||||
onMore={() => void onAddReaction?.()}
|
||||
onSelect={onReact}
|
||||
/>
|
||||
</SubContent>
|
||||
)}
|
||||
</Sub>
|
||||
|
|
@ -359,6 +539,7 @@ export default function MessageContextMenu({
|
|||
hideMiniMenu = false,
|
||||
isOwnMessage,
|
||||
messageId,
|
||||
onReact,
|
||||
onSetEditing,
|
||||
}: {
|
||||
children: ReactElement;
|
||||
|
|
@ -366,10 +547,12 @@ export default function MessageContextMenu({
|
|||
hideMiniMenu?: boolean;
|
||||
isOwnMessage: boolean;
|
||||
messageId: number;
|
||||
onReact: (emoji: string) => void | Promise<void>;
|
||||
onSetEditing: (value: boolean) => void;
|
||||
}) {
|
||||
const isMobile = useIsMobile();
|
||||
const shiftIsPressed = useShiftPressed();
|
||||
const activeMenuId = useActiveMiniMenuId();
|
||||
const { deleteMessage, setReplyTo } = useChat();
|
||||
const devEnabled = useMemo(
|
||||
() => Number(localStorage.getItem("log_level")) >= 3,
|
||||
|
|
@ -377,6 +560,16 @@ export default function MessageContextMenu({
|
|||
);
|
||||
const [mainDrawerOpen, setMainDrawerOpen] = useState(false);
|
||||
const [reactionDrawerOpen, setReactionDrawerOpen] = useState(false);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const { ranks } = useEmojiRanks();
|
||||
const quickReactions = getRecentEmojis(ranks, 3);
|
||||
const menuReactions = getRecentEmojis(ranks, 5);
|
||||
|
||||
function selectReaction(emoji: string) {
|
||||
void onReact(emoji);
|
||||
setReactionDrawerOpen(false);
|
||||
setPickerOpen(false);
|
||||
}
|
||||
const mainDrawerComponents = useMemo(
|
||||
() =>
|
||||
getMobileMenuComponents({
|
||||
|
|
@ -400,18 +593,28 @@ export default function MessageContextMenu({
|
|||
openMenu: (event: ReactMouseEvent<HTMLElement>) => void,
|
||||
) {
|
||||
return (
|
||||
<div className="group/message-menu relative w-full">
|
||||
<div
|
||||
className="group/message-menu relative w-full"
|
||||
onPointerEnter={() => setActiveMiniMenu(messageId)}
|
||||
onPointerLeave={scheduleMiniMenuClose}
|
||||
>
|
||||
{children}
|
||||
{!hideMiniMenu && (
|
||||
<MiniMessageMenu
|
||||
isOwnMessage={isOwnMessage}
|
||||
onDelete={() => deleteMessage(messageId)}
|
||||
onEdit={() => onSetEditing(true)}
|
||||
onOpenMenu={openMenu}
|
||||
onReply={() => setReplyTo(messageId)}
|
||||
shiftIsPressed={shiftIsPressed}
|
||||
/>
|
||||
)}
|
||||
<AnimatePresence>
|
||||
{!hideMiniMenu && activeMenuId === messageId && (
|
||||
<MiniMessageMenu
|
||||
isOwnMessage={isOwnMessage}
|
||||
onDelete={() => deleteMessage(messageId)}
|
||||
onEdit={() => onSetEditing(true)}
|
||||
onOpenMenu={openMenu}
|
||||
onOpenPicker={() => setPickerOpen(true)}
|
||||
usePickerTrigger={!isMobile}
|
||||
onReact={selectReaction}
|
||||
quickReactions={quickReactions}
|
||||
onReply={() => setReplyTo(messageId)}
|
||||
shiftIsPressed={shiftIsPressed}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -438,6 +641,8 @@ export default function MessageContextMenu({
|
|||
isOwnMessage={isOwnMessage}
|
||||
messageId={messageId}
|
||||
onAddReaction={() => setReactionDrawerOpen(true)}
|
||||
onReact={selectReaction}
|
||||
reactionEmojis={menuReactions}
|
||||
showReactionItems={false}
|
||||
onSetEditing={onSetEditing}
|
||||
/>
|
||||
|
|
@ -449,7 +654,26 @@ export default function MessageContextMenu({
|
|||
Choose a reaction to add to this message.
|
||||
</DrawerDescription>
|
||||
<div className="p-3!">
|
||||
<ReactionItems Item={reactionDrawerComponents.Item} />
|
||||
<ReactionItems
|
||||
Item={reactionDrawerComponents.Item}
|
||||
emojis={menuReactions}
|
||||
onMore={() => {
|
||||
setReactionDrawerOpen(false);
|
||||
setPickerOpen(true);
|
||||
}}
|
||||
onSelect={selectReaction}
|
||||
/>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
<Drawer open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
<DrawerContent>
|
||||
<DrawerTitle className="sr-only">Choose an emoji</DrawerTitle>
|
||||
<DrawerDescription className="sr-only">
|
||||
Choose an emoji to react with.
|
||||
</DrawerDescription>
|
||||
<div className="p-3">
|
||||
<EmojiPicker onSelect={selectReaction} />
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
|
@ -471,16 +695,24 @@ export default function MessageContextMenu({
|
|||
});
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger render={child} />
|
||||
<MessageMenuContent
|
||||
components={desktopMenuComponents}
|
||||
content={content}
|
||||
devEnabled={devEnabled}
|
||||
isOwnMessage={isOwnMessage}
|
||||
messageId={messageId}
|
||||
onSetEditing={onSetEditing}
|
||||
/>
|
||||
</ContextMenu>
|
||||
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger render={child} />
|
||||
<MessageMenuContent
|
||||
components={desktopMenuComponents}
|
||||
content={content}
|
||||
devEnabled={devEnabled}
|
||||
isOwnMessage={isOwnMessage}
|
||||
messageId={messageId}
|
||||
onAddReaction={() => setPickerOpen(true)}
|
||||
onReact={selectReaction}
|
||||
reactionEmojis={menuReactions}
|
||||
onSetEditing={onSetEditing}
|
||||
/>
|
||||
</ContextMenu>
|
||||
<PopoverContent className="w-auto p-0">
|
||||
<EmojiPicker onSelect={selectReaction} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,7 +61,10 @@ function bytesFromProtocol(value: unknown): Uint8Array {
|
|||
|
||||
type EditableMessage = RawMessage & { failed?: boolean };
|
||||
type MessageEdit = Partial<
|
||||
Pick<EditableMessage, "Content" | "Edited" | "MessageState" | "failed">
|
||||
Pick<
|
||||
EditableMessage,
|
||||
"Content" | "Edited" | "MessageState" | "Reactions" | "failed"
|
||||
>
|
||||
>;
|
||||
|
||||
function updateMessagesBySendTime<T extends EditableMessage>(
|
||||
|
|
@ -495,6 +498,91 @@ export default function Provider({ children }: { children: ReactNode }) {
|
|||
[currentChatSecret, send, userIdValue],
|
||||
);
|
||||
|
||||
const setReaction = useCallback(
|
||||
async (sendTime: number, reaction: string, add: boolean) => {
|
||||
let previousReactions: RawMessage["Reactions"];
|
||||
let foundMessage = false;
|
||||
|
||||
const applyOptimisticUpdate = (message: EditableMessage) => {
|
||||
const current = message.Reactions ?? [];
|
||||
previousReactions = current;
|
||||
foundMessage = true;
|
||||
|
||||
return add
|
||||
? [...current, { Reaction: reaction, SenderId: ownId }]
|
||||
: current.filter(
|
||||
(item) => item.Reaction !== reaction || item.SenderId !== ownId,
|
||||
);
|
||||
};
|
||||
|
||||
setLiveMessagesState((current) =>
|
||||
current.map((message) =>
|
||||
message.SendTime === sendTime
|
||||
? { ...message, Reactions: applyOptimisticUpdate(message) }
|
||||
: message,
|
||||
),
|
||||
);
|
||||
|
||||
const queryKey = [
|
||||
"chat-messages",
|
||||
String(userIdValue),
|
||||
currentChatSecret !== null,
|
||||
] as const;
|
||||
queryClient.setQueryData<InfiniteData<RawMessages>>(
|
||||
queryKey,
|
||||
(current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
pages: current.pages.map((page) =>
|
||||
page.map((message) =>
|
||||
message.SendTime === sendTime
|
||||
? {
|
||||
...message,
|
||||
Reactions: applyOptimisticUpdate(message),
|
||||
}
|
||||
: message,
|
||||
),
|
||||
),
|
||||
}
|
||||
: current,
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await send(
|
||||
add ? "MessageReactionAdd" : "MessageReactionRemove",
|
||||
{
|
||||
ChatPartnerId: userIdValue,
|
||||
Reaction: reaction,
|
||||
SendTime: sendTime,
|
||||
},
|
||||
);
|
||||
assertProtocolSuccess(
|
||||
add ? "MessageReactionAdd" : "MessageReactionRemove",
|
||||
response,
|
||||
);
|
||||
} catch (err) {
|
||||
if (foundMessage) {
|
||||
editMessage(sendTime, { Reactions: previousReactions });
|
||||
}
|
||||
log(1, "chat", "red", "Failed to update reaction", err);
|
||||
toast("error", "Failed to update reaction", String(err));
|
||||
}
|
||||
},
|
||||
[currentChatSecret, editMessage, ownId, send, userIdValue],
|
||||
);
|
||||
|
||||
const addReaction = useCallback(
|
||||
(sendTime: number, reaction: string) =>
|
||||
setReaction(sendTime, reaction, true),
|
||||
[setReaction],
|
||||
);
|
||||
const removeReaction = useCallback(
|
||||
(sendTime: number, reaction: string) =>
|
||||
setReaction(sendTime, reaction, false),
|
||||
[setReaction],
|
||||
);
|
||||
|
||||
const addLiveMessage = useCallback(
|
||||
(message: RawMessage) => {
|
||||
const localId =
|
||||
|
|
@ -578,6 +666,27 @@ export default function Provider({ children }: { children: ReactNode }) {
|
|||
return;
|
||||
}
|
||||
|
||||
if (message.type === "MessageReactionLive") {
|
||||
const rawData = message.data as {
|
||||
ChatPartnerId: unknown;
|
||||
SendTime: unknown;
|
||||
};
|
||||
const chatPartnerId = Number(rawData.ChatPartnerId);
|
||||
const sendTime = Number(rawData.SendTime);
|
||||
|
||||
if (chatPartnerId !== userIdValue || !Number.isFinite(sendTime)) return;
|
||||
|
||||
void send("MessageGet", { SendTime: sendTime })
|
||||
.then((response) => {
|
||||
assertProtocolSuccess("MessageGet", response);
|
||||
editMessage(sendTime, { Reactions: response.data.Reactions ?? [] });
|
||||
})
|
||||
.catch((err) => {
|
||||
log(1, "chat", "red", "Failed to refresh message reactions", err);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type !== "MessageState") return;
|
||||
|
||||
const rawData = message.data as {
|
||||
|
|
@ -623,7 +732,7 @@ export default function Provider({ children }: { children: ReactNode }) {
|
|||
MessageState: nextState.MessageState,
|
||||
});
|
||||
});
|
||||
}, [currentChatSecret, editMessage, subscribePush, userIdValue]);
|
||||
}, [currentChatSecret, editMessage, send, subscribePush, userIdValue]);
|
||||
|
||||
// Replys
|
||||
const [replyTo, setReplyTo] = useState<number | undefined>(undefined);
|
||||
|
|
@ -638,6 +747,8 @@ export default function Provider({ children }: { children: ReactNode }) {
|
|||
addLiveMessage,
|
||||
editMessage,
|
||||
deleteMessage,
|
||||
addReaction,
|
||||
removeReaction,
|
||||
clearLiveMessages,
|
||||
chatSecret: currentChatSecret,
|
||||
userId: userIdValue,
|
||||
|
|
@ -663,6 +774,8 @@ type contextType = {
|
|||
};
|
||||
editMessage: (sendTime: number, edit: MessageEdit) => void;
|
||||
deleteMessage: (sendTime: number) => void;
|
||||
addReaction: (sendTime: number, reaction: string) => Promise<void>;
|
||||
removeReaction: (sendTime: number, reaction: string) => Promise<void>;
|
||||
clearLiveMessages: () => void;
|
||||
chatSecret: Uint8Array | null;
|
||||
userId: number;
|
||||
|
|
|
|||
|
|
@ -186,34 +186,14 @@ export default function Screen() {
|
|||
return [...liveMessageChunks, ...historicalMessageChunks];
|
||||
}, [historicalMessageChunks, liveMessageChunks]);
|
||||
|
||||
const shouldShowConversationStart =
|
||||
!!messagesQuery.data && !messagesQuery.hasNextPage;
|
||||
const virtualRowCount =
|
||||
messageChunks.length + (shouldShowConversationStart ? 1 : 0);
|
||||
const virtualRowCount = messageChunks.length;
|
||||
|
||||
const getItemKey = React.useCallback(
|
||||
(index: number) => {
|
||||
if (shouldShowConversationStart && index === messageChunks.length) {
|
||||
return "conversation-start";
|
||||
}
|
||||
|
||||
return messageChunks[index]?.key ?? index;
|
||||
},
|
||||
[messageChunks, shouldShowConversationStart],
|
||||
(index: number) => messageChunks[index]?.key ?? index,
|
||||
[messageChunks],
|
||||
);
|
||||
|
||||
const estimateSize = React.useCallback(
|
||||
(index: number) => {
|
||||
const isConversationStart =
|
||||
shouldShowConversationStart && index === messageChunks.length;
|
||||
if (isConversationStart) {
|
||||
return FALLBACK_MESSAGE_HEIGHT;
|
||||
}
|
||||
|
||||
return FALLBACK_MESSAGE_HEIGHT;
|
||||
},
|
||||
[messageChunks, shouldShowConversationStart],
|
||||
);
|
||||
const estimateSize = React.useCallback(() => FALLBACK_MESSAGE_HEIGHT, []);
|
||||
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const virtualizer = useVirtualizer({
|
||||
|
|
@ -464,32 +444,6 @@ export default function Screen() {
|
|||
className="absolute bottom-0 left-0 h-px w-full"
|
||||
/>
|
||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||
if (
|
||||
shouldShowConversationStart &&
|
||||
virtualRow.index === messageChunks.length
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
key="conversation-start"
|
||||
data-index={virtualRow.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
transform: `translateY(${verticalOffset + virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
<div className="w-full flex justify-start scale-y-[-1]">
|
||||
<div className="text-sm text-foreground/55 px-2.5">
|
||||
Conversation start
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const chunkIndex = virtualRow.index;
|
||||
const chunk = messageChunks[chunkIndex];
|
||||
if (!chunk) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue