(wip): add reactions
Some checks failed
/ release (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
/ build-web (push) Has been cancelled
/ build-mobile (push) Has been cancelled

(qol): update todo
This commit is contained in:
Alois 2026-07-11 14:53:12 +02:00
commit 7ba08090fe
20 changed files with 4533 additions and 5894 deletions

View file

@ -22,12 +22,13 @@
"@tanstack/react-virtual": "^3.0.0",
"@tensamin/crypto": "workspace:*",
"@tensamin/markdown": "workspace:*",
"@tensamin/mtp": "workspace:*",
"@tensamin/shared": "workspace:*",
"@tensamin/storage": "workspace:*",
"@tensamin/mtp": "workspace:*",
"@tensamin/ui": "*",
"@tensamin/user": "workspace:*",
"lucide-react": "^1.14.0",
"motion": "^12.42.2",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"zod": "^4.3.6"

View 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>
);
}

View 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],
);
}

View file

@ -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={

View file

@ -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
);

View file

@ -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>
);
}

View file

@ -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;

View file

@ -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) {

View file

@ -1,6 +1,10 @@
- Implement context menu features
- Forward
- Pin Message
- Reply
- Add default-emoji-hotkey
- Placeholder image if media fails to load
- Signature verifications via ed25519 key
- Confirmation when exiting with text in the input box.
- Add arrow up hotkey to edit last message
- Drop any unique reactions above 10