refactor: project configuration
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user