(feat): add reply box to messages
(feat): update methanium ui (fix): user data caching (fix): other random stuff (qol): update todo
This commit is contained in:
parent
53728b7436
commit
6a5236bafe
32 changed files with 722 additions and 394 deletions
|
|
@ -1,20 +1,16 @@
|
|||
import Input from "@tensamin/markdown/input";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
Card,
|
||||
CardHeader,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Skeleton,
|
||||
} from "@methanium/ui";
|
||||
import { useStorage } from "@tensamin/storage/context";
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import { Button } from "@methanium/ui";
|
||||
|
||||
import { Plus, Laugh, FileVideo, Forward, X } from "lucide-react";
|
||||
import { Plus, Laugh, FileVideo } from "lucide-react";
|
||||
import { useChat, useReplyMessage } from "../context";
|
||||
import { useMTP } from "@tensamin/mtp";
|
||||
import { log, toast } from "@tensamin/shared/log";
|
||||
|
|
@ -25,8 +21,7 @@ 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";
|
||||
import ReplyBox from "./replyBox";
|
||||
|
||||
export default function InputComponent({
|
||||
value,
|
||||
|
|
@ -196,54 +191,15 @@ export default function InputComponent({
|
|||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{/* reply */}
|
||||
{replyTo !== undefined && (
|
||||
<div className="self-center rounded-t-lg bg-background border-t border-x flex items-center justify-start w-[90%] p-1">
|
||||
<div className="flex gap-1 w-full items-center">
|
||||
<Forward
|
||||
color="var(--muted-foreground)"
|
||||
className="opacity-60"
|
||||
size={18}
|
||||
/>
|
||||
{replyMessage ? (
|
||||
<>
|
||||
<Wrapper
|
||||
userId={replyUserId ?? ownId}
|
||||
loading={<Skeleton className="rounded-full" />}
|
||||
component={(user) => (
|
||||
<Avatar>
|
||||
<AvatarImage src={user.Avatar} />
|
||||
<AvatarFallback>
|
||||
{user.Display.slice(2, 0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
/>
|
||||
<div className="max-w-full max-h-5 h-5 text-xs truncate">
|
||||
<Text value={replyMessage.Content} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Skeleton className="w-5 h-5 aspect-square rounded-full" />
|
||||
<Skeleton className="w-full h-5" />
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
setReplyTo(undefined);
|
||||
}}
|
||||
className="w-5 h-5 rounded-md! p-0!"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<X
|
||||
color="var(--muted-foreground)"
|
||||
className="opacity-60"
|
||||
size={18}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ReplyBox
|
||||
content={replyMessage?.Content}
|
||||
loading={!replyMessage}
|
||||
onDismiss={() => setReplyTo(undefined)}
|
||||
userId={replyUserId ?? ownId}
|
||||
variant="composer"
|
||||
/>
|
||||
)}
|
||||
<Card
|
||||
ref={inputBoxRef}
|
||||
|
|
@ -267,7 +223,7 @@ export default function InputComponent({
|
|||
/>
|
||||
<div className="w-full flex justify-between gap-1 p-1 pt-0">
|
||||
<div className="flex gap-1">
|
||||
<Button className="w-9 h-9 p-0" variant="ghost">
|
||||
<Button className="w-9 h-9! p-0" variant="ghost">
|
||||
<Plus size={20} />
|
||||
</Button>
|
||||
<div className="w-auto flex text-red-500">
|
||||
|
|
@ -280,15 +236,16 @@ export default function InputComponent({
|
|||
onOpenChange={setEmojiPopoverOpen}
|
||||
>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
render={({ onClick }) => (
|
||||
<Button
|
||||
onClick={onClick}
|
||||
aria-label="Open emoji picker"
|
||||
className="w-9 h-9 p-0"
|
||||
className="w-9 h-9! p-0"
|
||||
variant="ghost"
|
||||
>
|
||||
<Laugh size={20} />
|
||||
</Button>
|
||||
}
|
||||
)}
|
||||
/>
|
||||
<PopoverContent className="w-auto p-0">
|
||||
<EmojiPicker
|
||||
|
|
@ -302,11 +259,15 @@ export default function InputComponent({
|
|||
</Popover>
|
||||
<Popover open={gifPopoverOpen} onOpenChange={setGifPopoverOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button className="w-9 h-9 p-0" variant="ghost">
|
||||
render={({ onClick }) => (
|
||||
<Button
|
||||
onClick={onClick}
|
||||
className="w-9 h-9! p-0"
|
||||
variant="ghost"
|
||||
>
|
||||
<FileVideo size={20} />
|
||||
</Button>
|
||||
}
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
ref={gifPopoverRef}
|
||||
|
|
|
|||
|
|
@ -17,11 +17,13 @@ import Media from "./media";
|
|||
import { useStorage } from "@tensamin/storage/context";
|
||||
import { useMTP } from "@tensamin/mtp";
|
||||
import Input from "@tensamin/markdown/input";
|
||||
import { useChat } from "../context";
|
||||
import { encryptChatText } from "@tensamin/crypto/chatSecret";
|
||||
import { getMessage, useChat } from "../context";
|
||||
import { decryptChatText, encryptChatText } from "@tensamin/crypto/chatSecret";
|
||||
import { log, toast } from "@tensamin/shared/log";
|
||||
import Emoji, { normalizeShortcode } from "@tensamin/markdown/emoji";
|
||||
import { useRecordEmojiUse } from "./emojiRanks";
|
||||
import { useUser } from "@tensamin/user/context";
|
||||
import ReplyBox from "./replyBox";
|
||||
|
||||
function MessageComponent({
|
||||
grouped,
|
||||
|
|
@ -141,7 +143,45 @@ function MessageComponent({
|
|||
userId,
|
||||
deleteMessage,
|
||||
removeReaction,
|
||||
replyTo,
|
||||
} = useChat();
|
||||
const { get: getUser } = useUser();
|
||||
const [replyMessage, setReplyMessage] = useState<RawMessage | null>(null);
|
||||
const [replyUser, setReplyUser] = useState<User | null>(null);
|
||||
useEffect(() => {
|
||||
if (!message.ReplyId || !ownId || !chatSecret) {
|
||||
setReplyMessage(null);
|
||||
setReplyUser(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
void getMessage({
|
||||
sendTime: message.ReplyId,
|
||||
ownId,
|
||||
chatPartnerId: userId,
|
||||
send,
|
||||
})
|
||||
.then(async (reply) => {
|
||||
const [Content, author] = await Promise.all([
|
||||
decryptChatText(chatSecret, reply.Content),
|
||||
getUser(reply.SenderId),
|
||||
]);
|
||||
if (!active) return;
|
||||
setReplyMessage({ ...reply, Content });
|
||||
setReplyUser(author);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!active) return;
|
||||
setReplyMessage(null);
|
||||
setReplyUser(null);
|
||||
log(1, "chat", "red", "Failed to get replied-to message", err);
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [chatSecret, getUser, message.ReplyId, ownId, send, userId]);
|
||||
const recordUse = useRecordEmojiUse();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editDraft, setEditDraft] = useState(message.Content);
|
||||
|
|
@ -232,8 +272,16 @@ function MessageComponent({
|
|||
return (
|
||||
<div
|
||||
// pt-3 is to get a gap between messages
|
||||
className={`${grouped ? "" : "pt-3"} w-full flex justify-start transition-opacity duration-150 ${opacityClass}`}
|
||||
className={`${grouped ? "" : "pt-3"} w-full flex flex-col gap-1 justify-start items-start transition-opacity duration-150 ${opacityClass}`}
|
||||
>
|
||||
{/* reply */}
|
||||
{replyMessage && replyUser && (
|
||||
<ReplyBox
|
||||
content={replyMessage.Content}
|
||||
user={replyUser}
|
||||
variant="message"
|
||||
/>
|
||||
)}
|
||||
{user && message.Content ? (
|
||||
<MessageContextMenu
|
||||
content={message.Content}
|
||||
|
|
@ -243,138 +291,148 @@ function MessageComponent({
|
|||
onReact={toggleReaction}
|
||||
onSetEditing={setEditing}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"group hover:bg-muted/50 select-text! relative justify-start flex gap-1 items-center w-full px-2 whitespace-pre-wrap break-all",
|
||||
{
|
||||
"bg-(--destructive)/10 text-destructive-foreground hover:bg-(--destructive)/15":
|
||||
actuallyFailed,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<>
|
||||
{grouped ? (
|
||||
<p className="w-9 text-xs group-hover:visible invisible text-muted-foreground">
|
||||
{new Date(message.SendTime).toLocaleString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<Avatar className="mr-1 mb-auto mt-1 w-10">
|
||||
<AvatarImage src={user.Avatar} />
|
||||
<AvatarFallback>
|
||||
{user.Display.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"group hover:bg-muted/50 select-text! relative justify-start flex gap-1 items-center w-full px-2 whitespace-pre-wrap break-all transition-colors duration-200 ease-in-out",
|
||||
{
|
||||
"bg-(--destructive)/10 text-destructive-foreground hover:bg-(--destructive)/15":
|
||||
actuallyFailed,
|
||||
"bg-(--primary-foreground-alt)/15 hover:bg-(--primary-foreground-alt)/20":
|
||||
replyTo === message.SendTime && !actuallyFailed,
|
||||
},
|
||||
)}
|
||||
<div
|
||||
className={cn("flex flex-col group", {
|
||||
"min-w-0 flex-1": editing,
|
||||
})}
|
||||
>
|
||||
{!grouped && (
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="font-medium">{user.Display}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(message.SendTime).toLocaleString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</p>
|
||||
<div className="flex items-center justify-start gap-1">
|
||||
{message.MessageState === "read" ? (
|
||||
<Check
|
||||
size={12}
|
||||
color="var(--primary-foreground-alt)"
|
||||
/>
|
||||
) : message.MessageState === "received" ? (
|
||||
<CheckLine size={12} color="var(--muted-foreground)" />
|
||||
) : message.MessageState === "sent" ? (
|
||||
<Check size={12} color="var(--muted-foreground)" />
|
||||
) : message.MessageState === "sending" ? (
|
||||
<RefreshCw size={12} color="var(--muted-foreground)" />
|
||||
) : null}
|
||||
{actuallyFailed && (
|
||||
<AlertTriangle color="var(--destructive)" size={12} />
|
||||
>
|
||||
<>
|
||||
{grouped ? (
|
||||
<p className="w-9 text-xs group-hover:visible invisible text-muted-foreground">
|
||||
{new Date(message.SendTime).toLocaleString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<Avatar className="mr-1 mb-auto mt-1 w-10">
|
||||
<AvatarImage src={user.Avatar} />
|
||||
<AvatarFallback>
|
||||
{user.Display.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
<div
|
||||
className={cn("flex flex-col group", {
|
||||
"min-w-0 flex-1": editing,
|
||||
})}
|
||||
>
|
||||
{!grouped && (
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="font-semibold">{user.Display}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(message.SendTime).toLocaleString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</p>
|
||||
<div className="flex items-center justify-start gap-1">
|
||||
{message.MessageState === "read" ? (
|
||||
<Check
|
||||
size={12}
|
||||
color="var(--primary-foreground-alt)"
|
||||
/>
|
||||
) : message.MessageState === "received" ? (
|
||||
<CheckLine
|
||||
size={12}
|
||||
color="var(--muted-foreground)"
|
||||
/>
|
||||
) : message.MessageState === "sent" ? (
|
||||
<Check size={12} color="var(--muted-foreground)" />
|
||||
) : message.MessageState === "sending" ? (
|
||||
<RefreshCw
|
||||
size={12}
|
||||
color="var(--muted-foreground)"
|
||||
/>
|
||||
) : null}
|
||||
{actuallyFailed && (
|
||||
<AlertTriangle color="var(--destructive)" size={12} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{editing ? (
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<Input
|
||||
className="w-full"
|
||||
styled
|
||||
setValue={setEditDraft}
|
||||
value={editDraft}
|
||||
onSubmit={() => {
|
||||
submitEditMessage(editDraft);
|
||||
setEditing(false);
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="link"
|
||||
className="text-primary-foreground-alt"
|
||||
onClick={() => {
|
||||
if (editDraft === message.Content) {
|
||||
deleteMessage(message.SendTime);
|
||||
} else {
|
||||
submitEditMessage(editDraft);
|
||||
}
|
||||
setEditDraft(message.Content);
|
||||
setEditing(false);
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="link"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => {
|
||||
setEditDraft(message.Content);
|
||||
setEditing(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : isValidURL ? (
|
||||
<Media link={message.Content} />
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
{editing ? (
|
||||
<div className="flex w-full flex-col">
|
||||
<Input
|
||||
className="w-full"
|
||||
styled
|
||||
setValue={setEditDraft}
|
||||
value={editDraft}
|
||||
onSubmit={() => {
|
||||
submitEditMessage(editDraft);
|
||||
setEditing(false);
|
||||
}}
|
||||
/>
|
||||
<div className="flex">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="link"
|
||||
className="text-primary-foreground-alt"
|
||||
onClick={() => {
|
||||
if (editDraft === message.Content) {
|
||||
deleteMessage(message.SendTime);
|
||||
} else {
|
||||
submitEditMessage(editDraft);
|
||||
}
|
||||
setEditDraft(message.Content);
|
||||
setEditing(false);
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="link"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => {
|
||||
setEditDraft(message.Content);
|
||||
setEditing(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : isValidURL ? (
|
||||
<Media link={message.Content} />
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
</>
|
||||
</MessageContextMenu>
|
||||
) : (
|
||||
<Skeleton className="h-10 w-30" />
|
||||
|
|
@ -388,6 +446,7 @@ export default memo(MessageComponent, (prev, next) => {
|
|||
prev.message.SendTime === next.message.SendTime &&
|
||||
prev.message.Content === next.message.Content &&
|
||||
prev.message.SenderId === next.message.SenderId &&
|
||||
prev.message.ReplyId === next.message.ReplyId &&
|
||||
prev.message.MessageState === next.message.MessageState &&
|
||||
prev.message.failed === next.message.failed &&
|
||||
prev.message.decryptionFailed === next.message.decryptionFailed &&
|
||||
|
|
|
|||
|
|
@ -35,11 +35,13 @@ import {
|
|||
Reply,
|
||||
Trash,
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { cloneElement, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import type {
|
||||
MouseEvent as ReactMouseEvent,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
Ref,
|
||||
} from "react";
|
||||
import { useChat } from "../context";
|
||||
import Emoji from "@tensamin/markdown/emoji";
|
||||
|
|
@ -260,18 +262,32 @@ function MiniMenuTooltip({
|
|||
children,
|
||||
label,
|
||||
}: {
|
||||
children: ReactElement;
|
||||
children: ReactElement<{
|
||||
onClick?: (event: ReactMouseEvent<HTMLElement>) => void;
|
||||
ref?: Ref<HTMLElement>;
|
||||
}>;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={children} />
|
||||
<TooltipTrigger
|
||||
render={({ ref, onClick }) =>
|
||||
cloneElement(children, {
|
||||
ref,
|
||||
onClick: (event) => {
|
||||
onClick?.(event);
|
||||
children.props.onClick?.(event);
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniMessageMenu({
|
||||
fadeOut,
|
||||
isOwnMessage,
|
||||
onDelete,
|
||||
onEdit,
|
||||
|
|
@ -283,6 +299,7 @@ function MiniMessageMenu({
|
|||
onReply,
|
||||
shiftIsPressed,
|
||||
}: {
|
||||
fadeOut: boolean;
|
||||
isOwnMessage: boolean;
|
||||
onDelete: () => void;
|
||||
onEdit: () => void;
|
||||
|
|
@ -295,8 +312,12 @@ function MiniMessageMenu({
|
|||
shiftIsPressed: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="absolute right-4 -top-3 z-10">
|
||||
<Card className="flex flex-row justify-end gap-0! rounded-lg! p-0! shadow-lg">
|
||||
<motion.div
|
||||
className="absolute right-4 -top-3 z-10"
|
||||
exit={fadeOut ? { opacity: 0 } : undefined}
|
||||
transition={fadeOut ? { duration: 0.048 } : undefined}
|
||||
>
|
||||
<Card className="flex flex-row justify-end gap-0! rounded-lg! p-px! shadow-lg">
|
||||
{quickReactions.map((emoji) => (
|
||||
<MiniMenuTooltip
|
||||
key={emoji}
|
||||
|
|
@ -319,15 +340,16 @@ function MiniMessageMenu({
|
|||
children={
|
||||
usePickerTrigger ? (
|
||||
<PopoverTrigger
|
||||
render={
|
||||
render={({ onClick }) => (
|
||||
<Button
|
||||
onClick={onClick}
|
||||
aria-label="Add reaction"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<Laugh />
|
||||
</Button>
|
||||
}
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
|
|
@ -397,7 +419,7 @@ function MiniMessageMenu({
|
|||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -538,8 +560,11 @@ export default function MessageContextMenu({
|
|||
[],
|
||||
);
|
||||
const [mainDrawerOpen, setMainDrawerOpen] = useState(false);
|
||||
const [contextMenuOpen, setContextMenuOpen] = useState(false);
|
||||
const [reactionDrawerOpen, setReactionDrawerOpen] = useState(false);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [pickerClosing, setPickerClosing] = useState(false);
|
||||
const [fadeMiniMenu, setFadeMiniMenu] = useState(false);
|
||||
const { ranks } = useEmojiRanks();
|
||||
const quickReactions = getRecentEmojis(ranks, 3);
|
||||
const menuReactions = getRecentEmojis(ranks, 5);
|
||||
|
|
@ -547,7 +572,11 @@ export default function MessageContextMenu({
|
|||
function selectReaction(emoji: string) {
|
||||
void onReact(emoji);
|
||||
setReactionDrawerOpen(false);
|
||||
setPickerOpen(false);
|
||||
setPickerVisibility(false);
|
||||
}
|
||||
function setPickerVisibility(open: boolean) {
|
||||
setPickerOpen(open);
|
||||
setPickerClosing(!isMobile && !open);
|
||||
}
|
||||
const mainDrawerComponents = useMemo(
|
||||
() =>
|
||||
|
|
@ -578,20 +607,28 @@ export default function MessageContextMenu({
|
|||
onPointerLeave={scheduleMiniMenuClose}
|
||||
>
|
||||
{children}
|
||||
{!isMobile && !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 onExitComplete={() => setFadeMiniMenu(false)}>
|
||||
{!isMobile &&
|
||||
!hideMiniMenu &&
|
||||
(activeMenuId === messageId ||
|
||||
contextMenuOpen ||
|
||||
pickerOpen ||
|
||||
pickerClosing) && (
|
||||
<MiniMessageMenu
|
||||
fadeOut={fadeMiniMenu}
|
||||
isOwnMessage={isOwnMessage}
|
||||
onDelete={() => deleteMessage(messageId)}
|
||||
onEdit={() => onSetEditing(true)}
|
||||
onOpenMenu={openMenu}
|
||||
onOpenPicker={() => setPickerVisibility(true)}
|
||||
usePickerTrigger={!isMobile}
|
||||
onReact={selectReaction}
|
||||
quickReactions={quickReactions}
|
||||
onReply={() => setReplyTo(messageId)}
|
||||
shiftIsPressed={shiftIsPressed}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -636,14 +673,14 @@ export default function MessageContextMenu({
|
|||
emojis={menuReactions}
|
||||
onMore={() => {
|
||||
setReactionDrawerOpen(false);
|
||||
setPickerOpen(true);
|
||||
setPickerVisibility(true);
|
||||
}}
|
||||
onSelect={selectReaction}
|
||||
/>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
<Drawer open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
<Drawer open={pickerOpen} onOpenChange={setPickerVisibility}>
|
||||
<DrawerContent>
|
||||
<DrawerTitle className="sr-only">Choose an emoji</DrawerTitle>
|
||||
<DrawerDescription className="sr-only">
|
||||
|
|
@ -672,8 +709,17 @@ export default function MessageContextMenu({
|
|||
});
|
||||
|
||||
return (
|
||||
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
<ContextMenu>
|
||||
<Popover
|
||||
open={pickerOpen}
|
||||
onOpenChange={(open, eventDetails) => {
|
||||
setPickerVisibility(open);
|
||||
setFadeMiniMenu(!open && eventDetails.reason === "outside-press");
|
||||
}}
|
||||
onOpenChangeComplete={(open) => {
|
||||
if (!open) setPickerClosing(false);
|
||||
}}
|
||||
>
|
||||
<ContextMenu onOpenChange={setContextMenuOpen}>
|
||||
<ContextMenuTrigger render={child} />
|
||||
<MessageMenuContent
|
||||
components={desktopMenuComponents}
|
||||
|
|
@ -681,7 +727,7 @@ export default function MessageContextMenu({
|
|||
devEnabled={devEnabled}
|
||||
isOwnMessage={isOwnMessage}
|
||||
messageId={messageId}
|
||||
onAddReaction={() => setPickerOpen(true)}
|
||||
onAddReaction={() => setPickerVisibility(true)}
|
||||
onReact={selectReaction}
|
||||
reactionEmojis={menuReactions}
|
||||
onSetEditing={onSetEditing}
|
||||
|
|
|
|||
97
packages/chat/src/components/replyBox.tsx
Normal file
97
packages/chat/src/components/replyBox.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
Button,
|
||||
cn,
|
||||
Skeleton,
|
||||
} from "@methanium/ui";
|
||||
import Text from "@tensamin/markdown/text";
|
||||
import type { User } from "@tensamin/user/context";
|
||||
import Wrapper from "@tensamin/user/wrapper";
|
||||
import { Forward, X } from "lucide-react";
|
||||
|
||||
function ReplyUser({ user }: { user: User }) {
|
||||
return (
|
||||
<div className="flex gap-1 items-center">
|
||||
<Avatar className="h-5 w-5 shrink-0">
|
||||
<AvatarImage src={user.Avatar} />
|
||||
<AvatarFallback className="text-[0.6rem]">
|
||||
{user.Display.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<p className="text-sm font-semibold text-muted-foreground">
|
||||
{user.Display}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ReplyBox({
|
||||
content,
|
||||
loading = false,
|
||||
onDismiss,
|
||||
user,
|
||||
userId,
|
||||
variant,
|
||||
}: {
|
||||
content?: string;
|
||||
loading?: boolean;
|
||||
onDismiss?: () => void;
|
||||
user?: User;
|
||||
userId?: number;
|
||||
variant: "composer" | "message";
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
variant === "composer" ? "flex justify-center" : "pl-5 w-full",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-[90%] items-center gap-1 text-xs",
|
||||
variant === "composer"
|
||||
? "justify-start rounded-t-lg border-x border-t border-(--border)/65 bg-background p-1"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Forward className="shrink-0 opacity-60" size={18} />
|
||||
{loading ? (
|
||||
<>
|
||||
<Skeleton className="h-5 w-5 shrink-0 rounded-full" />
|
||||
<Skeleton className="h-5 w-full" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{user ? (
|
||||
<ReplyUser user={user} />
|
||||
) : userId ? (
|
||||
<Wrapper
|
||||
userId={userId}
|
||||
loading={<Skeleton className="h-5 w-5 rounded-full" />}
|
||||
component={(resolvedUser) => <ReplyUser user={resolvedUser} />}
|
||||
/>
|
||||
) : null}
|
||||
{content !== undefined && (
|
||||
<div className="h-5.5 min-w-0 flex-1 truncate">
|
||||
<Text fontSize="0.88rem" value={content} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{onDismiss && (
|
||||
<Button
|
||||
aria-label="Cancel reply"
|
||||
className="ml-auto h-5 w-5 rounded-md! p-0!"
|
||||
onClick={onDismiss}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="opacity-60" size={18} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue