refactor: project configuration

This commit is contained in:
Tristan Yang
2023-05-15 16:46:50 +08:00
parent 187ffd24d1
commit 20b907aae4
293 changed files with 991 additions and 964 deletions
+178
View File
@@ -0,0 +1,178 @@
import { useState, useRef, FC } from "react";
import { useDispatch } from "react-redux";
import Tippy from "@tippyjs/react";
import { hideAll } from "tippy.js";
import toast from "react-hot-toast";
import { updateSelectMessages } from "@/app/slices/ui";
import ContextMenu, { Item } from "../ContextMenu";
import Tooltip from "../Tooltip";
import useFavMessage from "@/hooks/useFavMessage";
import useSendMessage from "@/hooks/useSendMessage";
import ReactionPicker from "./ReactionPicker";
import replyIcon from "@/assets/icons/reply.svg?url";
import reactIcon from "@/assets/icons/reaction.svg?url";
import editIcon from "@/assets/icons/edit.svg?url";
import IconBookmark from "@/assets/icons/bookmark.add.svg";
import IconPin from "@/assets/icons/pin.svg";
import IconForward from "@/assets/icons/forward.svg";
import IconSelect from "@/assets/icons/select.svg";
import IconDelete from "@/assets/icons/delete.svg";
import moreIcon from "@/assets/icons/more.svg?url";
import useMessageOperation from "./useMessageOperation";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import { ChatContext } from "@/types/common";
type Props = {
isSelf: boolean;
context: ChatContext;
contextId: number;
mid: number;
toggleEditMessage: () => void;
};
const Commands: FC<Props> = ({ isSelf, context = "dm", contextId = 0, mid = 0, toggleEditMessage }) => {
const { t } = useTranslation();
const {
canDelete,
canReply,
canEdit,
canPin,
unPin,
pinned,
toggleDeleteModal,
toggleForwardModal,
togglePinModal,
PinModal,
DeleteModal,
ForwardModal
} = useMessageOperation({ mid, context, contextId });
const { setReplying } = useSendMessage({ context, to: contextId });
const { addFavorite, isFavorited } = useFavMessage({
cid: context == "channel" ? contextId : null
});
const dispatch = useDispatch();
const [tippyVisible, setTippyVisible] = useState(false);
const cmdsRef = useRef(null);
const handleReply = () => {
if (contextId) {
setReplying(mid);
}
hideAll();
};
const handleTippyVisible = (visible = true) => {
setTippyVisible(visible);
};
const handleSelect = (mid: number) => {
dispatch(updateSelectMessages({ context, id: contextId, data: mid }));
hideAll();
};
const handleUnpin = () => {
hideAll();
unPin(mid);
};
const handleAddFav = async () => {
hideAll();
const faved = isFavorited(mid);
if (faved) {
toast.success("Favorited!");
return;
}
const added = await addFavorite(mid);
if (added) {
toast.success("Added Favorites!");
} else {
toast.error("Added Favorites Failed!");
}
};
const cmdClass = `flex cursor-pointer p-1 md:hover:bg-gray-100 md:dark:hover:bg-gray-800`;
return (
<>
<ul
ref={cmdsRef}
className={clsx(`bg-white dark:bg-gray-900 rounded-md z-[999] absolute top-0 -translate-y-1/2 flex items-center border border-solid border-black/10 invisible group-hover:visible`,
tippyVisible && '!visible',
isSelf ? "left-2.5" : "right-2.5"
)}>
<Tippy
onShow={handleTippyVisible.bind(null, true)}
onHide={handleTippyVisible.bind(null, false)}
interactive
placement="left-start"
trigger="click"
content={<ReactionPicker mid={mid} hidePicker={hideAll} />}
>
<li className={cmdClass}>
<Tooltip placement="top" tip={t("action.add_reaction")}>
<img src={reactIcon} className="toggler w-6 h-6" alt="icon emoji" />
</Tooltip>
</li>
</Tippy>
{canEdit && (
<li className={cmdClass} onClick={toggleEditMessage}>
<Tooltip placement="top" tip={t("action.edit")}>
<img src={editIcon} className="w-6 h-6" alt="icon edit" />
</Tooltip>
</li>
)}
{canReply && (
<li className={cmdClass} onClick={handleReply}>
<Tooltip placement="top" tip={t("action.reply")}>
<img src={replyIcon} className="w-6 h-6" alt="icon reply" />
</Tooltip>
</li>
)}
<li className={cmdClass} onClick={handleAddFav}>
<Tooltip placement="top" tip={t("action.add_to_fav")}>
<IconBookmark className="fill-slate-500 w-6 h-6" />
</Tooltip>
</li>
<Tippy
onShow={handleTippyVisible.bind(null, true)}
onHide={handleTippyVisible.bind(null, false)}
interactive
placement="left-start"
trigger="click"
content={
<ContextMenu
items={
[
canPin && {
title: pinned ? t("action.unpin") : t("action.pin"),
icon: <IconPin className="icon" />,
handler: pinned ? handleUnpin : togglePinModal
},
{
title: t("action.forward"),
icon: <IconForward className="icon" />,
handler: toggleForwardModal
},
{
title: t("action.select"),
icon: <IconSelect className="icon" />,
handler: handleSelect.bind(null, mid)
},
canDelete && {
title: t("action.remove"),
danger: true,
icon: <IconDelete className="icon" />,
handler: toggleDeleteModal
}
].filter(Boolean) as Item[]
}
/>
}
>
<li className="flex cursor-pointer p-1 md:hover:bg-gray-100 md:dark:hover:bg-gray-800">
<Tooltip placement="top" tip={t("more")}>
<img src={moreIcon} alt="icon more" />
</Tooltip>
</li>
</Tippy>
</ul>
{PinModal}
{ForwardModal}
{DeleteModal}
</>
);
};
export default Commands;
+120
View File
@@ -0,0 +1,120 @@
import { FC, ReactElement } from "react";
import Tippy from "@tippyjs/react";
import { useDispatch } from "react-redux";
import ContextMenu, { Item } from "../ContextMenu";
import IconDelete from "@/assets/icons/delete.svg";
import IconEdit from "@/assets/icons/edit.svg";
import IconReply from "@/assets/icons/reply.svg";
import IconForward from "@/assets/icons/forward.svg";
import IconPin from "@/assets/icons/pin.svg";
import IconCopy from "@/assets/icons/copy.svg";
import IconSelect from "@/assets/icons/select.svg";
import { updateSelectMessages } from "@/app/slices/ui";
import useSendMessage from "@/hooks/useSendMessage";
import useMessageOperation from "./useMessageOperation";
import { useTranslation } from "react-i18next";
import { ChatContext } from "@/types/common";
type Props = {
context: ChatContext;
contextId: number;
mid: number;
visible: boolean;
hide: () => void;
editMessage: () => void;
children: ReactElement;
};
const MessageContextMenu: FC<Props> = ({
context,
contextId,
mid,
visible,
hide,
editMessage,
children
}) => {
const { t } = useTranslation();
const {
copyContent,
canEdit,
canPin,
canDelete,
canCopy,
canReply,
pinned,
unPin,
toggleDeleteModal,
toggleForwardModal,
togglePinModal,
PinModal,
ForwardModal,
DeleteModal
} = useMessageOperation({ mid, contextId, context });
const dispatch = useDispatch();
const { setReplying } = useSendMessage({ context, to: contextId });
const handleSelect = () => {
dispatch(updateSelectMessages({ context, id: contextId, data: mid }));
};
const handleReply = () => {
if (contextId) {
setReplying(mid);
}
};
const items = [
canEdit && {
title: t("action.edit_msg"),
icon: <IconEdit className="icon" />,
handler: editMessage
},
canReply && {
title: t("action.reply"),
icon: <IconReply className="icon" />,
handler: handleReply
},
canCopy && {
title: t("action.copy"),
icon: <IconCopy className="icon" />,
handler: copyContent
},
canPin && {
title: pinned ? t("action.unpin") : t("action.pin"),
icon: <IconPin className="icon" />,
handler: pinned ? unPin.bind(null, mid) : togglePinModal
},
{
title: t("action.forward"),
icon: <IconForward className="icon" />,
handler: toggleForwardModal
},
{
title: t("action.select"),
icon: <IconSelect className="icon" />,
handler: handleSelect
},
canDelete && {
title: t("action.remove"),
danger: true,
icon: <IconDelete className="icon" />,
handler: toggleDeleteModal
}
].filter((v) => typeof v !== "boolean" && "title" in (v ?? {})) as Item[];
return (
<>
{ForwardModal}
{PinModal}
{DeleteModal}
<Tippy
visible={visible}
followCursor={"initial"}
interactive
placement="right-start"
popperOptions={{ strategy: "fixed" }}
onClickOutside={hide}
key={mid}
content={<ContextMenu hideMenu={hide} items={items} />}
>
{children}
</Tippy>
</>
);
};
export default MessageContextMenu;
+92
View File
@@ -0,0 +1,92 @@
import { useState, useRef, useEffect, ChangeEvent, KeyboardEvent, FC } from "react";
import TextareaAutosize from "react-textarea-autosize";
import { useKey } from "rooks";
import { useEditMessageMutation } from "@/app/services/message";
import { ContentTypes } from "@/app/config";
import { useAppSelector } from "@/app/store";
type Props = {
mid: number;
cancelEdit: () => void;
};
const EditMessage: FC<Props> = ({ mid, cancelEdit }) => {
const inputRef = useRef<HTMLTextAreaElement>(null);
const msg = useAppSelector((store) => store.message[mid]);
const [shift, setShift] = useState(false);
const [enter, setEnter] = useState(false);
const [currMsg, setCurrMsg] = useState(msg?.content);
const [edit, { isLoading: isEditing, isSuccess }] = useEditMessageMutation();
useEffect(() => {
if (isSuccess) {
cancelEdit();
}
}, [isSuccess]);
useKey(
"Shift",
(e) => {
setShift(e.type == "keydown");
},
{ eventTypes: ["keydown", "keyup"], target: inputRef }
);
// cancel by esc
useKey(
"Escape",
() => {
cancelEdit();
},
{ eventTypes: ["keydown", "keyup"], target: inputRef }
);
const handleMsgChange = (evt: ChangeEvent<HTMLTextAreaElement>) => {
if (enter && !shift) {
handleSave();
} else {
setCurrMsg(evt.target.value);
}
};
const handleInputKeydown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
setEnter(e.key === "Enter");
};
const handleSave = () => {
edit({
mid,
content: currMsg,
type: msg.content_type == ContentTypes.markdown ? "markdown" : "text"
});
};
if (!msg) return null;
return (
<div className="w-full">
<div className="bg-gray-200 rounded-lg p-4">
<TextareaAutosize
autoFocus
onFocus={(e) =>
e.currentTarget.setSelectionRange(
e.currentTarget.value.length,
e.currentTarget.value.length
)
}
ref={inputRef}
className="content w-full resize-none bg-transparent text-gray-800 text-sm break-all"
maxRows={8}
minRows={1}
onKeyDown={handleInputKeydown}
onChange={handleMsgChange}
value={currMsg}
placeholder={`Edit Message`}
/>
</div>
<div className="flex items-center p-1 gap-4 text-xs">
<span>
esc to <button className="text-primary-500 cursor-pointer px-1" onClick={cancelEdit}>cancel</button>
</span>
<span>
enter to <button className="text-primary-500 cursor-pointer px-1" onClick={handleSave}>{isEditing ? "saving" : `save`}</button>
</span>
</div>
</div>
);
};
export default EditMessage;
+83
View File
@@ -0,0 +1,83 @@
import dayjs from 'dayjs';
import { FC, useEffect, useState, useCallback } from 'react';
import { useDispatch } from 'react-redux';
import { removeMessage } from '../../app/slices/message';
import { removeChannelMsg } from '../../app/slices/message.channel';
import { removeUserMsg } from '../../app/slices/message.user';
import IconTimer from '@/assets/icons/timer.svg';
import { ChatContext } from '../../types/common';
type Props = {
context: ChatContext,
contextId: number,
mid: number;
expiresIn: number;
createAt: number;
};
const ExpireTimer: FC<Props> = ({ mid, createAt, expiresIn, context, contextId }) => {
const [countdown, setCountdown] = useState<number | undefined>();
const dispatch = useDispatch();
const clearMsgFromClient = useCallback(
() => {
if (context == "dm") {
dispatch(removeUserMsg({ mid, id: contextId }));
} else {
dispatch(removeChannelMsg({ mid, id: contextId }));
}
dispatch(removeMessage(mid));
},
[context, contextId, mid],
);
useEffect(() => {
if (expiresIn > 0 && createAt > 0) {
const expire_time = createAt + expiresIn * 1000;
if (dayjs().isAfter(new Date(expire_time))) {
// 已过期,立即删除
clearMsgFromClient();
} else {
// 倒计时
setCountdown(Math.floor((expire_time - new Date().getTime()) / 1000));
}
}
}, [expiresIn, createAt]);
useEffect(() => {
let timer = 0;
if (typeof countdown !== "undefined") {
if (countdown > 0) {
timer = window.setTimeout(() => {
setCountdown(prev => {
const _prev = prev ?? 0;
return _prev - 1;
});
}, 1000);
} else {
// 倒计时结束
console.log("countdown over", mid, countdown);
clearMsgFromClient();
}
}
return () => {
if (typeof countdown !== "undefined") {
clearTimeout(timer);
}
};
}, [countdown, mid]);
if (!countdown) return null;
const duration = dayjs.duration(countdown * 1000);
const day = duration.days() !== 0 ? `${duration.days()} day` : "";
const hours = duration.hours() !== 0 ? `${duration.hours().toString().padStart(2, '0')}:` : "";
const minutes = duration.minutes() !== 0 ? `${duration.minutes().toString().padStart(2, '0')}:` : "";
const formatted_countdown = `${day} ${hours}${minutes}${duration.seconds().toString().padStart(2, '0')}`;
return (
<div className='absolute bottom-1 right-2 text-xs text-gray-400 flex items-center gap-1 font-mono'>
<IconTimer className="w-4 h-4 stroke-slate-400" />
{formatted_countdown}
</div>
);
};
export default ExpireTimer;
+58
View File
@@ -0,0 +1,58 @@
import { FC, ReactElement, useEffect, useState } from "react";
import renderContent from "./renderContent";
import Avatar from "../Avatar";
import useFavMessage from "@/hooks/useFavMessage";
type Props = {
id?: string;
};
const FavoredMessage: FC<Props> = ({ id = "" }) => {
const { favorites } = useFavMessage({});
const [msgs, setMsgs] = useState<ReactElement | null>(null);
useEffect(() => {
const current = favorites.find((f) => f.id == id);
const { messages } = current || {};
if (!messages) return;
const favorite_mids = messages.map(({ from_mid }) => +from_mid) || [];
setMsgs(
<div data-favorite-mids={favorite_mids.join(",")} className="favorite flex flex-col rounded-md bg-slate-50 dark:bg-slate-800">
<div className="list">
{messages.map((msg, idx) => {
const { user = {}, download, content, content_type, properties, thumbnail } = msg;
return (
<div className="w-full relative flex items-start gap-3 px-2 py-1 my-2 rounded-lg md:dark:hover:bg-gray-800" key={idx}>
{user && (
<div className="shrink-0 w-10 h-10 flex">
<Avatar width={40} height={40} className="rounded-full object-cover" src={user.avatar} name={user.name} />
</div>
)}
<div className="w-full flex flex-col gap-2 text-sm">
<div className="flex items-center gap-2 font-semibold">
<span className="text-gray-600 dark:text-gray-400">{user?.name || "Deleted User"}</span>
</div>
<div className="select-text text-gray-800 break-all whitespace-pre-wrap dark:text-white">
{renderContent({
download,
content,
content_type,
properties,
thumbnail
})}
</div>
</div>
</div>
);
})}
</div>
</div>
);
}, [favorites, id]);
if (!id) return null;
return msgs;
};
export default FavoredMessage;
@@ -0,0 +1,71 @@
import { useEffect, useState, FC, ReactElement } from "react";
import renderContent from "./renderContent";
import Avatar from "../Avatar";
import IconForward from "@/assets/icons/forward.svg";
import useNormalizeMessage from "@/hooks/useNormalizeMessage";
import { useTranslation } from "react-i18next";
import { ChatContext } from "@/types/common";
type Props = {
context: ChatContext;
to: number;
from_uid: number;
id: string;
};
const ForwardedMessage: FC<Props> = ({ context, to, from_uid, id }) => {
const { t } = useTranslation();
const { messages, isLoading } = useNormalizeMessage(id);
const [forwards, setForwards] = useState<ReactElement | null>(null);
useEffect(() => {
if (messages) {
const forward_mids = messages.map(({ from_mid }) => from_mid) || [];
// console.log("fff", messages);
setForwards(
<div data-forwarded-mids={forward_mids.join(",")} className="flex flex-col text-left rounded-lg bg-gray-200 dark:bg-gray-900">
<h4 className="p-2 pb-0 flex items-center gap-1 text-gray-500 text-xs">
<IconForward className="w-4 h-4 fill-gray-500" />
{t("action.forward")}
</h4>
<div className="list">
{messages.map((msg, idx) => {
const { user = {}, download, content, content_type, properties, thumbnail } = msg;
return (
<div className="w-full relative flex items-start gap-4 px-2 py-1 my-2 rounded-lg" key={idx}>
{user && (
<div className="w-6 h-6 rounded-full flex shrink-0 overflow-hidden">
<Avatar width={24} height={24} src={user.avatar} name={user.name} />
</div>
)}
<div className="w-full flex flex-col">
<div className="flex items-center gap-2 font-semibold">
<span className="text-gray-500 text-sm">{user?.name || "Deleted User"}</span>
</div>
<div className="select-text text-gray-500 text-sm break-all whitespace-pre-wrap dark:text-white">
{renderContent({
download,
context,
to,
from_uid,
content,
content_type,
properties,
thumbnail
})}
</div>
</div>
</div>
);
})}
</div>
</div>
);
}
}, [messages, context, to, from_uid]);
if (!id) return null;
if (isLoading) return <span className="text-sm dark:text-white">Loading</span>;
return forwards;
};
export default ForwardedMessage;
+31
View File
@@ -0,0 +1,31 @@
// import { FC, ReactNode } from "react";
import Tippy from "@tippyjs/react";
import Profile from "../Profile";
import { useAppSelector } from "@/app/store";
interface Props {
uid: number;
popover?: boolean;
cid?: number;
textOnly?: boolean;
}
const Mention = ({ uid, popover = true, cid, textOnly = false }: Props) => {
const usersData = useAppSelector((store) => store.users.byId);
const user = usersData[uid];
if (!user) return null;
if (textOnly) return <>{`@${user.name}`}</>;
if (!popover) return <span className="px-0.5 text-primary-400">{`@${user.name}`}</span>;
return (
<Tippy
interactive
placement="top"
trigger="click"
content={<Profile uid={uid} type="card" cid={cid} />}
>
<span className="px-0.5 text-primary-400 cursor-pointer">{`@${user.name}`}</span>
</Tippy>
);
};
export default Mention;
@@ -0,0 +1,57 @@
import { FC } from "react";
import { useEffect } from "react";
import toast from "react-hot-toast";
import usePinMessage from "@/hooks/usePinMessage";
import StyledModal from "../styled/Modal";
import Button from "../styled/Button";
import Modal from "../Modal";
import PreviewMessage from "./PreviewMessage";
import { useTranslation } from "react-i18next";
interface Props {
closeModal: () => void;
mid: number;
gid: number;
}
const PinMessageModal: FC<Props> = ({ closeModal, mid = 0, gid = 0 }) => {
const { t } = useTranslation();
const { channel, pinMessage, isPining, isSuccess } = usePinMessage(gid);
const handlePin = () => {
pinMessage(mid);
};
useEffect(() => {
if (isSuccess) {
closeModal();
toast.success(t("tip.pin"));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSuccess]);
if (!mid) return null;
return (
<Modal>
<StyledModal
className="min-w-[320px] md:min-w-[406px]"
buttons={
<>
<Button onClick={closeModal} className="cancel">
{t("action.cancel")}
</Button>
<Button disabled={isPining} onClick={handlePin} className="main">
{isPining ? "Pining" : t("action.pin")}
</Button>
</>
}
title={t("action.pin")}
description={`Do you want to pin this message to #${channel?.name}`}
>
<PreviewMessage mid={mid} context="pin" />
</StyledModal>
</Modal>
);
};
export default PinMessageModal;
+44
View File
@@ -0,0 +1,44 @@
import { FC } from "react";
import clsx from "clsx";
import renderContent from "./renderContent";
import Avatar from "../Avatar";
import { useAppSelector } from "@/app/store";
interface Props {
mid?: number;
context?: "forward" | "pin"
}
const PreviewMessage: FC<Props> = ({ mid = 0, context = "forward" }) => {
const { msg, usersData } = useAppSelector((store) => {
return { msg: store.message[mid], usersData: store.users.byId };
});
if (!msg) return null;
const { from_uid = 0, content_type, content, thumbnail = "", properties } = msg;
const { name, avatar } = usersData[from_uid] ?? {};
const pinMsg = context == "pin";
const forwardMsg = context == "forward";
return (
<div className={clsx(`w-full relative flex items-start gap-3 p-2 my-2 rounded-lg`, pinMsg && "max-h-64 overflow-auto overflow-x-hidden border border-solid border-gray-200 dark:border-gray-400")}>
<div className="w-10 h-10 flex shrink-0">
<Avatar width={40} height={40} className="rounded-full object-cover" src={avatar} name={name} />
</div>
<div className="w-full flex flex-col items-start">
<div className="flex items-center gap-2 font-semibold">
<span className="text-gray-500 text-sm">{name}</span>
</div>
<div className={clsx(`select-text text-gray-600 text-sm break-all whitespace-pre-wrap dark:text-white`, forwardMsg && "max-h-72 overflow-y-scroll")}>
{renderContent({
content_type,
content,
thumbnail,
from_uid,
properties
})}
</div>
</div>
</div>
);
};
export default PreviewMessage;
+105
View File
@@ -0,0 +1,105 @@
import { FC } from "react";
import Tippy from "@tippyjs/react";
import { hideAll } from "tippy.js";
import ReactionItem, { Emojis, ReactionMap } from "../ReactionItem";
import ReactionPicker from "./ReactionPicker";
import Tooltip from "../Tooltip";
import { useReactMessageMutation } from "@/app/services/message";
import IconAddEmoji from "@/assets/icons/add.emoji.svg";
import { useAppSelector } from "@/app/store";
const ReactionDetails = ({
uids = [],
emoji,
index
}: {
uids: number[];
emoji: keyof Emojis;
index: number;
}) => {
const usersData = useAppSelector((store) => store.users.byId);
const names = uids.map((id) => {
return usersData[id]?.name ?? "Deleted User";
});
// const emojiData = getEmojiDataFromNative(emoji || "", "apple", AppleEmojiData);
const prefixDesc =
names.length > 3
? `${names.join(", ")} and ${names.length - 3} others reacted with`
: `${names.join(", ")} reacted with`;
return (
<div className={`relative bg-white rounded-lg shadow flex items-start gap-2 p-2 ${index == 0 ? "first" : ""}`}>
<div className="w-8 h-8">
<ReactionItem native={emoji} />
</div>
<div className="flex flex-col w-[140px] text-xs text-gray-800">
<span>{prefixDesc}</span>
<span>{ReactionMap[emoji]}</span>
</div>
</div>
);
};
type Props = {
readOnly?: boolean;
mid: number;
reactions?: {
[key in keyof Emojis]: number[];
};
};
const Reaction: FC<Props> = ({ mid, reactions = null, readOnly = false }) => {
const [reactWithEmoji] = useReactMessageMutation();
const { currUid } = useAppSelector((store) => {
return {
currUid: store.authData.user?.uid
};
});
const handleReact = (emoji: string) => {
reactWithEmoji({ mid, action: emoji });
};
if (!reactions || Object.entries(reactions).length == 0) return null;
return (
<span className="group relative my-1 flex items-center gap-1 w-fit">
{Object.entries(reactions).map(([reaction, uids], idx) => {
const reacted = uids.findIndex((id: number) => id == currUid) > -1;
return uids.length > 0 ? (
<span
onClick={readOnly ? undefined : handleReact.bind(null, reaction)}
className={`cursor-pointer rounded-md relative flex items-center gap-1 p-1 md:hover:bg-cyan-100 ${reacted ? "shadow-[inset_0_0_0_1px_#06aed4] bg-cyan-200" : ""}`}
key={reaction}
>
<Tippy
disabled={readOnly}
offset={[0, 20]}
// visible={true}
interactive
placement="top"
content={<ReactionDetails uids={uids} emoji={reaction as keyof Emojis} index={idx} />}
>
<i className="emoji w-4 h-4">
<ReactionItem native={reaction as keyof Emojis} />
</i>
</Tippy>
{uids.length > 1 ? <i className="text-primary-600 text-xs not-italic">{`${uids.length}`} </i> : null}
</span>
) : null;
})}
{!readOnly && (
<Tooltip placement="top" tip="Add Reaction">
<Tippy
interactive
placement="right-start"
trigger="click"
content={<ReactionPicker mid={mid} hidePicker={hideAll} />}
>
<button className="invisible group-hover:visible w-6 h-6 bg-cyan-100 md:hover:bg-cyan-200 rounded-md flex-center">
<IconAddEmoji className={'w-4 h-4'} />
</button>
</Tippy>
</Tooltip>
)}
</span>
);
};
export default Reaction;
+50
View File
@@ -0,0 +1,50 @@
import { FC } from "react";
import { useReactMessageMutation } from "@/app/services/message";
import { Emojis } from "@/app/config";
import Emoji from "../ReactionItem";
import { useAppSelector } from "@/app/store";
type Props = {
mid: number;
hidePicker: () => void;
};
const ReactionPicker: FC<Props> = ({ mid, hidePicker }) => {
const [reactMessage, { isLoading }] = useReactMessageMutation();
const { reactionData, currUid } = useAppSelector((store) => {
return {
reactionData: store.reactionMessage[mid] || {},
currUid: store.authData.user?.uid
};
});
const handleReact = (emoji: string) => {
reactMessage({ mid, action: emoji });
hidePicker();
// scroll in to view
const el = document.querySelector(`[data-msg-mid='${mid}']`);
// console.log("eee", el);
if (el) {
el.scrollIntoViewIfNeeded(false);
}
};
return (
<div className="z-[999]">
<ul className={`p-1 grid grid-cols-[repeat(4,_1fr)] gap-2 bg-white dark:bg-gray-900 drop-shadow-md rounded-xl ${isLoading ? "opacity-60" : ""}`}>
{Emojis.map((emoji) => {
let reacted =
reactionData[emoji] && reactionData[emoji].findIndex((id) => id == currUid) > -1;
return (
<li
className={`flex-center cursor-pointer rounded-lg p-4 md:hover:bg-gray-50 w-4 h-4 ${reacted ? "bg-gray-50" : ""}`}
key={emoji}
onClick={handleReact.bind(null, emoji)}
>
<Emoji native={emoji} />
</li>
);
})}
</ul>
</div>
);
};
export default ReactionPicker;
+123
View File
@@ -0,0 +1,123 @@
import React, { MouseEvent, FC } from "react";
import clsx from "clsx";
import MarkdownRender from "../MarkdownRender";
import { ContentTypes } from "@/app/config";
import { getFileIcon, isImage } from "@/utils";
import LinkifyText from '../LinkifyText';
import Avatar from "../Avatar";
import { useAppSelector } from "@/app/store";
import { MessagePayload } from "@/app/slices/message";
import { useTranslation } from "react-i18next";
const renderContent = (data: MessagePayload) => {
const { content_type, content, thumbnail, properties } = data;
let res = null;
switch (content_type) {
case ContentTypes.text:
res = (
<span className="max-w-lg md:truncate md:break-words md:break-all text-gray-800 dark:text-gray-100">
<LinkifyText text={content as string} url={false} mentionTextOnly={true} mentionPopOver={false} />
</span>
);
break;
case ContentTypes.audio:
res = (
<span className=" text-primary-400 text-sm">[Voice Message]</span>
);
break;
case ContentTypes.markdown:
res = (
<div className="max-h-[152px] overflow-hidden dark:text-gray-100">
<MarkdownRender content={content as string} />
</div>
);
break;
case ContentTypes.file:
{
const { content_type = "", name, size } = properties || {};
const icon = getFileIcon(content_type, name, "w-4 h-5");
if (isImage(content_type, size)) {
res = <img className="w-10 h-10 object-cover" src={thumbnail} />;
} else {
res = (
<div className="flex gap-1">
{icon}
<span className="text-[10px] text-gray-500 dark:text-gray-100">{name}</span>
</div>
);
}
}
break;
default:
break;
}
return res;
};
interface ReplyProps {
mid: number;
interactive?: boolean;
}
const Reply: FC<ReplyProps> = ({ mid, interactive = true }) => {
const { t } = useTranslation("chat");
const { data, users } = useAppSelector((store) => {
return { data: store.message[mid], users: store.users.byId };
});
const handleClick = (evt: MouseEvent<HTMLDivElement>) => {
const { mid } = evt.currentTarget.dataset;
const msgEle = document.querySelector<HTMLDivElement>(`[data-msg-mid='${mid}']`);
if (msgEle) {
const _class1 = `md:dark:bg-gray-800`;
const _class2 = `md:bg-gray-100`;
msgEle.classList.add(_class1);
msgEle.classList.add(_class2);
msgEle.scrollIntoView({ behavior: "smooth", block: "center" });
setTimeout(() => {
msgEle.classList.remove(_class1);
msgEle.classList.remove(_class2);
}, 3000);
}
};
const defaultClass = `w-fit flex items-start flex-col md:flex-row p-2 bg-gray-200 dark:bg-gray-900 rounded-lg gap-2 mb-1`;
if (!data) return <div
key={mid}
data-mid={mid}
className={clsx(defaultClass, 'italic')}
>{t("reply_msg_del")}</div>;
const currUser = users[data.from_uid || 0];
if (!currUser) return null;
return (
<div
key={mid}
data-mid={mid}
className={clsx(defaultClass,
interactive ? "cursor-pointer" : "!bg-transparent"
)}
onClick={interactive ? handleClick : undefined}
>
<div className="md:flex shrink-0 w-6 h-6 hidden ">
<Avatar
width={24}
height={24}
className="rounded-full object-cover"
src={currUser.avatar}
name={currUser.name}
/>
</div>
<div className={clsx("text-sm flex flex-col", interactive && "relative")}>
<span className="text-sm text-primary-500">{currUser.name}</span>
{renderContent(data)}
{interactive && <div className="absolute top-0 left-0 w-full h-full"></div>}
</div>
</div>
);
};
export default React.memo(Reply, (prevs, nexts) => {
return prevs.mid == nexts.mid;
});
+73
View File
@@ -0,0 +1,73 @@
import { useState, useEffect } from "react";
import { useDispatch } from "react-redux";
import { useLazyGetOGInfoQuery } from "@/app/services/message";
import { upsertOG } from "@/app/slices/footprint";
import { useAppSelector } from "@/app/store";
export default function URLPreview({ url = "" }) {
const dispatch = useDispatch();
const [favicon, setFavicon] = useState("");
const [getInfo, { isLoading }] = useLazyGetOGInfoQuery();
const ogData = useAppSelector(store => store.footprint.og[url]);
const [data, setData] = useState<{ title: string; description: string; ogImage: string } | null>(
null
);
useEffect(() => {
if (ogData) {
let defaultFavIcon = "";
try {
defaultFavIcon = `${new URL(url).origin}/favicon.ico`;
} catch {
defaultFavIcon = `${location.origin}/favicon.ico`;
}
const title = ogData?.title || ogData?.site_name || "";
const description = ogData?.description || "";
const ogImage = ogData?.images.find((i) => !!i.url)?.url || "";
const favicon = ogData?.favicon_url || defaultFavIcon;
setFavicon(favicon);
setData({ title, description, ogImage });
} else if (url) {
// fetch first
getInfo(url);
}
}, [url, ogData]);
// const handleFavError = () => {
// setFavicon("");
// };
const handleOGImageError = () => {
dispatch(upsertOG({ key: url, value: { ...ogData, images: [] } }));
};
if (isLoading) return <div className="h-28"></div>;
if (!url || !data || !data.title) return null;
const { title, description, ogImage } = data;
const containerClass = `flex items-center border border-solid border-gray-300 dark:border-gray-600 box-border rounded-md w-[80%] md:w-[380px]`;
return ogImage ? (
// 简版
<a className={`${containerClass} flex-col !items-start p-3`} href={url} target="_blank" rel="noreferrer">
<h3 className={`text-primary-500 w-full truncate`}>{title}</h3>
<p className={`text-xs text-gray-400 mb-2 w-full truncate`}>{description}</p>
<div className="w-full h-[180px]">
<img className="w-full h-full object-cover" onError={handleOGImageError} src={ogImage} alt="og image" />
</div>
</a>
) : (
// 带图详情
<a
className={`${containerClass} gap-2 px-2 py-3`}
href={url} target="_blank" rel="noreferrer">
{favicon && (
<div className="flex rounded">
<img className="object-contain w-12 h-12" src={favicon} alt="favicon" />
</div>
)}
<div className="flex flex-col">
<h3 className="text-sm text-gray-900 dark:text-gray-100">{title}</h3>
<p className={`hidden md:block text-xs text-gray-500 dark:text-gray-400 w-[288px] truncate`}>{description}</p>
<span className={`text-[10px] text-gray-500 w-[288px] truncate`}>{url}</span>
</div>
</a>
);
}
+209
View File
@@ -0,0 +1,209 @@
import React, { useRef, useState, useEffect, FC } from "react";
import dayjs from "dayjs";
import Tippy from "@tippyjs/react";
import clsx from "clsx";
import useInView from "./useInView";
import Reaction from "./Reaction";
import Reply from "./Reply";
import Profile from "../Profile";
import Avatar from "../Avatar";
import Commands from "./Commands";
import EditMessage from "./EditMessage";
import renderContent from "./renderContent";
import Tooltip from "../Tooltip";
import ContextMenu from "./ContextMenu";
import useContextMenu from "@/hooks/useContextMenu";
import usePinMessage from "@/hooks/usePinMessage";
import { useAppSelector } from "@/app/store";
import ExpireTimer from "./ExpireTimer";
import IconInfo from '@/assets/icons/info.svg';
import { ChatContext } from "@/types/common";
interface IProps {
readOnly?: boolean;
contextId: number;
context?: ChatContext;
read?: boolean;
mid: number;
updateReadIndex?: (param: any) => void;
}
const Message: FC<IProps> = ({
readOnly = false,
contextId,
mid,
context = "dm",
updateReadIndex,
read = true
}) => {
const { visible: contextMenuVisible, handleContextMenuEvent, hideContextMenu } = useContextMenu();
const inViewRef = useInView<HTMLDivElement>();
const [edit, setEdit] = useState(false);
const avatarRef = useRef(null);
const { getPinInfo } = usePinMessage(context == "channel" ? contextId : 0);
const { message, reactionMessageData, usersData, loginUid, enableRightLayout } = useAppSelector((store) => {
return {
enableRightLayout: store.server.chat_layout_mode == "SelfRight",
reactionMessageData: store.reactionMessage,
message: store.message[mid],
usersData: store.users.byId,
loginUid: store.authData.user?.uid
};
});
const toggleEditMessage = () => {
setEdit((prev) => !prev);
};
useEffect(() => {
if (!read) {
// 标记已读
const data =
context == "dm"
? { users: [{ uid: +contextId, mid }] }
: { groups: [{ gid: +contextId, mid }] };
if (updateReadIndex) {
updateReadIndex(data);
}
}
}, [mid, read]);
if (!message) return <div className="w-full h-[1px] invisible"></div>;
const {
reply_mid,
from_uid: fromUid,
created_at: time,
sending = false,
content,
thumbnail,
download,
content_type = "text/plain",
edited,
properties,
expires_in = 0,
failed = false
} = message;
const reactions = reactionMessageData[mid];
const currUser = usersData[fromUid || 0];
// if (!message) return null;
let timePrefix = null;
const dayjsTime = dayjs(time);
timePrefix = dayjsTime.isToday() ? "Today" : dayjsTime.isYesterday() ? "Yesterday" : null;
const pinInfo = getPinInfo(mid);
// return null;
const _key = properties?.local_id || mid;
const showExpire = (expires_in ?? 0) > 0;
const isSelf = fromUid == loginUid && enableRightLayout;
return (
<div
key={_key}
onContextMenu={readOnly ? undefined : handleContextMenuEvent}
data-msg-mid={mid}
ref={inViewRef}
className={clsx(`group w-full relative flex items-start gap-2 md:gap-4 p-1 md:p-2 my-2 rounded-lg md:dark:hover:bg-gray-800 md:hover:bg-gray-100`,
readOnly && "hover:bg-transparent",
showExpire && "bg-red-200 dark:bg-red-200/40",
pinInfo && "bg-cyan-50 dark:bg-cyan-800 pt-7",
isSelf && "flex-row-reverse",
)}
>
<Tippy
key={_key}
popperOptions={{ strategy: "fixed" }}
disabled={readOnly}
interactive
placement="right"
trigger="click"
content={<Profile uid={fromUid || 0} type="card" cid={context == "dm" ? 0 : contextId} />}
>
<div className="cursor-pointer w-10 h-10 shrink-0" data-uid={fromUid} ref={avatarRef}>
<Avatar className="w-10 h-10 rounded-full object-cover" width={40} height={40} src={currUser?.avatar} name={currUser?.name} />
</div>
</Tippy>
<ContextMenu
editMessage={toggleEditMessage}
context={context}
contextId={contextId}
mid={mid}
visible={contextMenuVisible && !failed}
hide={hideContextMenu}
>
<div
className={clsx("w-full flex flex-col gap-2", pinInfo && "relative", isSelf && "items-end")}
data-pin-tip={`pinned by ${pinInfo?.created_by ? usersData[pinInfo.created_by]?.name : ""
}`}
>
{pinInfo && <span className="absolute left-0 -top-1 -translate-y-full text-xs text-gray-400">
{`pinned by ${pinInfo.created_by ? usersData[pinInfo.created_by]?.name : ""}`}
</span>}
<div className={clsx(`flex items-center gap-2 font-semibold`, isSelf && "flex-row-reverse")}>
<span className="text-primary-500 text-sm">{currUser?.name || "Deleted User"}</span>
<Tooltip
delay={200}
disabled={!timePrefix || readOnly}
placement="top"
tip={dayjsTime.format("YYYY-MM-DD h:mm:ss A")}
>
<time className="text-gray-400 text-xs">
{timePrefix
? `${timePrefix} ${dayjsTime.format("h:mm A")}`
: dayjsTime.format("YYYY-MM-DD h:mm:ss A")}
</time>
</Tooltip>
{failed && <span className="text-red-500 text-xs flex items-center gap-1">
<IconInfo className="stroke-red-600 w-4 h-4" /> Send Failed
</span>}
</div>
<div className={clsx(`select-text text-gray-800 text-sm break-all whitespace-pre-wrap dark:!text-white`,
sending && "opacity-90",
)}>
{reply_mid && <Reply key={reply_mid} mid={reply_mid} />}
{edit ? (
<EditMessage mid={mid} cancelEdit={toggleEditMessage} />
) : (
renderContent({
context,
to: contextId,
from_uid: fromUid,
created_at: time,
content_type,
properties,
content,
thumbnail,
download,
edited
})
)}
</div>
{reactions && <Reaction mid={mid} reactions={reactions} readOnly={readOnly} />}
</div>
</ContextMenu>
{showExpire && (
<ExpireTimer
mid={message.mid}
context={context}
contextId={contextId}
expiresIn={expires_in ?? 0}
createAt={time ?? 0}
/>
)}
{!edit && !failed && !readOnly && (
<Commands
isSelf={isSelf}
context={context}
contextId={contextId}
mid={mid}
toggleEditMessage={toggleEditMessage}
/>
)}
</div>
);
};
export default React.memo(Message, (prevs, nexts) => {
return prevs.mid == nexts.mid;
});
+102
View File
@@ -0,0 +1,102 @@
// import React from "react";
import dayjs from "dayjs";
import i18n from "@/i18n";
import { ContentTypes } from "@/app/config";
import ForwardedMessage from "./ForwardedMessage";
import MarkdownRender from "../MarkdownRender";
import FileMessage from "../FileMessage";
import { ContentType } from "@/types/message";
import LinkifyText from '../LinkifyText';
import VoiceMessage from "../VoiceMessage";
import { ChatContext } from "@/types/common";
type Props = {
context: ChatContext;
to?: number;
from_uid?: number;
created_at?: number;
properties?: object;
content_type: ContentType;
content: string;
download?: string;
thumbnail?: string;
edited?: boolean | number;
};
const renderContent = ({
context,
to = 0,
from_uid = 0,
created_at,
properties,
content_type,
content,
download,
thumbnail,
edited = false
}: Props) => {
let ctn = null;
switch (content_type) {
case ContentTypes.text:
ctn = (
<>
<LinkifyText text={content} cid={to} />
{edited && (
<span className="ml-1 text-gray-500 text-[10px]" title={dayjs(+edited).format("YYYY-MM-DD h:mm:ss A")}>
({i18n.t("edited", { ns: "chat" })})
</span>
)}
</>
);
break;
case ContentTypes.markdown:
{
ctn = <MarkdownRender content={content} />;
}
break;
case ContentTypes.audio:
{
// const { url, secure_url } = properties; todo
ctn = <VoiceMessage file_path={content} />;
}
break;
case ContentTypes.file:
{
// const { size, name, file_type } = properties;
ctn = (
<FileMessage
properties={properties}
context={context}
to={to}
download={download}
thumbnail={thumbnail}
from_uid={from_uid}
created_at={created_at}
content={content}
/>
);
}
break;
case ContentTypes.archive:
{
// const { size, name, file_type } = properties;
ctn = (
<ForwardedMessage
properties={properties}
context={context}
to={to}
from_uid={from_uid}
created_at={created_at}
id={content}
thumbnail={thumbnail}
/>
);
}
break;
default:
break;
}
return ctn;
};
export default renderContent;
+32
View File
@@ -0,0 +1,32 @@
import { useRef, useEffect } from "react";
export default function useInView<T extends HTMLElement>() {
const ref = useRef<T>(null);
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const intersecting = entry.isIntersecting;
const currEle = entry.target;
if (intersecting) {
currEle.classList.add("in_view");
} else {
currEle.classList.remove("in_view");
}
});
},
{ threshold: 0 }
);
useEffect(() => {
const currEle = ref.current;
if (currEle) {
observer.observe(ref.current);
}
return () => {
if (currEle) {
observer.unobserve(currEle);
}
};
}, [ref]);
return ref;
}
@@ -0,0 +1,103 @@
import { useState, useEffect } from "react";
import toast from "react-hot-toast";
import DeleteMessageConfirm from "../DeleteMessageConfirm";
import ForwardModal from "../ForwardModal";
import PinMessageModal from "./PinMessageModal";
import { ContentTypes } from "@/app/config";
import useCopy from "@/hooks/useCopy";
import usePinMessage from "@/hooks/usePinMessage";
import { useAppSelector } from "@/app/store";
import { ChatContext } from "@/types/common";
interface Params {
mid: number;
context: ChatContext;
contextId: number;
}
export default function useMessageOperation({ mid, context, contextId }: Params) {
const { copy } = useCopy();
const { loginUser, message, channel } = useAppSelector((store) => {
return {
channel: context == "channel" ? store.channels.byId[contextId] : undefined,
message: store.message[mid],
loginUser: store.authData.user
};
});
const { canPin, pins, unpinMessage, isUnpinSuccess } = usePinMessage(
context == "channel" ? contextId : 0
);
const [mids, setMids] = useState<number[]>([]);
const [pinModalVisible, setPinModalVisible] = useState(false);
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const [forwardModalVisible, setForwardModalVisible] = useState(false);
const { content_type, properties, from_uid, content } = message ?? {};
const toggleForwardModal = () => {
// hideAll();
setForwardModalVisible((prev) => !prev);
};
const toggleDeleteModal = () => {
// hideAll();
setDeleteModalVisible((prev) => !prev);
};
const togglePinModal = () => {
// hideAll();
setPinModalVisible((prev) => !prev);
};
const copyContent = (image = false) => {
copy(content, image);
};
useEffect(() => {
if (forwardModalVisible && content_type == ContentTypes.archive) {
// forward message
const forwardEle = document.querySelector(
`[data-msg-mid='${mid}'] .down [data-forwarded-mids]`
) as HTMLDivElement;
if (forwardEle) {
const mids = forwardEle.dataset.forwardedMids?.split(",").map((m) => +m) || [];
setMids(mids);
}
} else {
setMids([mid]);
}
}, [mid, forwardModalVisible, content_type]);
useEffect(() => {
if (isUnpinSuccess) {
toast.success("Unpin Message Successfully!");
}
}, [isUnpinSuccess]);
const enablePin = context == "channel" && canPin;
// const enableReply = currUid != from_uid;
const isImage =
content_type == ContentTypes.file &&
!!properties?.content_type &&
properties?.content_type.startsWith("image");
const enableEdit =
loginUser?.uid == from_uid && [ContentTypes.text, ContentTypes.markdown].includes(content_type);
const canDelete = loginUser?.uid == from_uid || loginUser?.is_admin || (channel && channel.owner == loginUser?.uid);
const canCopy = [ContentTypes.text, ContentTypes.markdown].includes(content_type) || isImage;
return {
copyContent: isImage ? copyContent.bind(null, true) : copyContent.bind(null, false),
canCopy,
isImage,
isMarkdown: content_type == ContentTypes.markdown,
canDelete,
canPin: context == "channel" && canPin,
pinned: enablePin ? pins.findIndex((p) => p.mid == mid) > -1 : false,
unPin: unpinMessage,
canReply: true,
canEdit: enableEdit,
toggleDeleteModal,
toggleForwardModal,
togglePinModal,
DeleteModal: deleteModalVisible ? (
<DeleteMessageConfirm closeModal={toggleDeleteModal} mids={mid} />
) : null,
ForwardModal: forwardModalVisible ? (
<ForwardModal mids={mids} closeModal={toggleForwardModal} />
) : null,
PinModal: pinModalVisible ? (
<PinMessageModal mid={mid} gid={contextId} closeModal={togglePinModal} />
) : null
};
}