mirror of
https://github.com/Qortal/Qortal-Hub.git
synced 2025-04-24 20:07:51 +00:00
591 lines
17 KiB
TypeScript
591 lines
17 KiB
TypeScript
import { Message } from "@chatscope/chat-ui-kit-react";
|
|
import React, { useCallback, useContext, useEffect, useMemo, useState } from "react";
|
|
import { useInView } from "react-intersection-observer";
|
|
import { MessageDisplay } from "./MessageDisplay";
|
|
import { Avatar, Box, Button, ButtonBase, List, ListItem, ListItemText, Popover, Tooltip, Typography } from "@mui/material";
|
|
import { formatTimestamp } from "../../utils/time";
|
|
import { getBaseApi } from "../../background";
|
|
import { MyContext, getBaseApiReact } from "../../App";
|
|
import { generateHTML } from "@tiptap/react";
|
|
import Highlight from "@tiptap/extension-highlight";
|
|
import Mention from "@tiptap/extension-mention";
|
|
import StarterKit from "@tiptap/starter-kit";
|
|
import Underline from "@tiptap/extension-underline";
|
|
import { executeEvent } from "../../utils/events";
|
|
import { WrapperUserAction } from "../WrapperUserAction";
|
|
import ReplyIcon from "@mui/icons-material/Reply";
|
|
import { Spacer } from "../../common/Spacer";
|
|
import { ReactionPicker } from "../ReactionPicker";
|
|
import KeyOffIcon from '@mui/icons-material/KeyOff';
|
|
import EditIcon from '@mui/icons-material/Edit';
|
|
import TextStyle from '@tiptap/extension-text-style';
|
|
import { addressInfoKeySelector } from "../../atoms/global";
|
|
import { useRecoilValue } from "recoil";
|
|
import level0Img from "../../assets/badges/level-0.png"
|
|
import level1Img from "../../assets/badges/level-1.png"
|
|
import level2Img from "../../assets/badges/level-2.png"
|
|
import level3Img from "../../assets/badges/level-3.png"
|
|
import level4Img from "../../assets/badges/level-4.png"
|
|
import level5Img from "../../assets/badges/level-5.png"
|
|
import level6Img from "../../assets/badges/level-6.png"
|
|
import level7Img from "../../assets/badges/level-7.png"
|
|
import level8Img from "../../assets/badges/level-8.png"
|
|
import level9Img from "../../assets/badges/level-9.png"
|
|
import level10Img from "../../assets/badges/level-10.png"
|
|
|
|
const getBadgeImg = (level)=> {
|
|
switch(level?.toString()){
|
|
|
|
case '0': return level0Img
|
|
case '1': return level1Img
|
|
case '2': return level2Img
|
|
case '3': return level3Img
|
|
case '4': return level4Img
|
|
case '5': return level5Img
|
|
case '6': return level6Img
|
|
case '7': return level7Img
|
|
case '8': return level8Img
|
|
case '9': return level9Img
|
|
case '10': return level10Img
|
|
default: return level0Img
|
|
}
|
|
}
|
|
export const MessageItem = React.memo(({
|
|
message,
|
|
onSeen,
|
|
isLast,
|
|
isTemp,
|
|
myAddress,
|
|
onReply,
|
|
isShowingAsReply,
|
|
reply,
|
|
replyIndex,
|
|
scrollToItem,
|
|
handleReaction,
|
|
reactions,
|
|
isUpdating,
|
|
lastSignature,
|
|
onEdit,
|
|
isPrivate
|
|
}) => {
|
|
|
|
const {getIndividualUserInfo} = useContext(MyContext)
|
|
const [anchorEl, setAnchorEl] = useState(null);
|
|
const [selectedReaction, setSelectedReaction] = useState(null);
|
|
const [userInfo, setUserInfo] = useState(null)
|
|
|
|
|
|
useEffect(()=> {
|
|
const getInfo = async ()=> {
|
|
if(!message?.sender) return
|
|
try {
|
|
const res = await getIndividualUserInfo(message?.sender)
|
|
if(!res) return null
|
|
setUserInfo(res)
|
|
} catch (error) {
|
|
//
|
|
}
|
|
}
|
|
|
|
getInfo()
|
|
}, [message?.sender, getIndividualUserInfo])
|
|
|
|
const htmlText = useMemo(()=> {
|
|
|
|
if(message?.messageText){
|
|
return generateHTML(message?.messageText, [
|
|
StarterKit,
|
|
Underline,
|
|
Highlight,
|
|
Mention,
|
|
TextStyle
|
|
])
|
|
}
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
const htmlReply = useMemo(()=> {
|
|
|
|
if(reply?.messageText){
|
|
return generateHTML(reply?.messageText, [
|
|
StarterKit,
|
|
Underline,
|
|
Highlight,
|
|
Mention,
|
|
TextStyle
|
|
])
|
|
}
|
|
|
|
}, [])
|
|
|
|
const userAvatarUrl = useMemo(()=> {
|
|
return message?.senderName ? `${getBaseApiReact()}/arbitrary/THUMBNAIL/${
|
|
message?.senderName
|
|
}/qortal_avatar?async=true` : ''
|
|
}, [])
|
|
|
|
const onSeenFunc = useCallback(()=> {
|
|
onSeen(message.id);
|
|
}, [message?.id])
|
|
|
|
|
|
return (
|
|
<>
|
|
{message?.divide && (
|
|
<div className="unread-divider" id="unread-divider-id">
|
|
Unread messages below
|
|
</div>
|
|
)}
|
|
|
|
<MessageWragger lastMessage={lastSignature === message?.signature} isLast={isLast} onSeen={onSeenFunc}>
|
|
|
|
|
|
<div
|
|
style={{
|
|
padding: "10px",
|
|
backgroundColor: "#232428",
|
|
borderRadius: "7px",
|
|
width: "95%",
|
|
display: "flex",
|
|
gap: "7px",
|
|
opacity: (isTemp || isUpdating) ? 0.5 : 1,
|
|
}}
|
|
id={message?.signature}
|
|
>
|
|
{isShowingAsReply ? (
|
|
<ReplyIcon
|
|
sx={{
|
|
fontSize: "30px",
|
|
}}
|
|
/>
|
|
) : (
|
|
<Box sx={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: '20px',
|
|
alignItems: 'center'
|
|
}}>
|
|
<WrapperUserAction
|
|
disabled={myAddress === message?.sender}
|
|
address={message?.sender}
|
|
name={message?.senderName}
|
|
>
|
|
|
|
<Avatar
|
|
sx={{
|
|
backgroundColor: "#27282c",
|
|
color: "white",
|
|
height: '40px',
|
|
width: '40px'
|
|
}}
|
|
alt={message?.senderName}
|
|
src={userAvatarUrl}
|
|
>
|
|
{message?.senderName?.charAt(0)}
|
|
</Avatar>
|
|
|
|
|
|
</WrapperUserAction>
|
|
<Tooltip disableFocusListener title={`level ${userInfo}`}>
|
|
|
|
|
|
<img style={{
|
|
visibility: userInfo !== undefined ? 'visible' : 'hidden',
|
|
width: '30px',
|
|
height: 'auto'
|
|
}} src={getBadgeImg(userInfo)} />
|
|
</Tooltip>
|
|
</Box>
|
|
)}
|
|
|
|
<Box
|
|
sx={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: "7px",
|
|
width: "100%",
|
|
height: isShowingAsReply && "40px",
|
|
}}
|
|
>
|
|
<Box
|
|
sx={{
|
|
display: "flex",
|
|
width: "100%",
|
|
justifyContent: "space-between",
|
|
}}
|
|
>
|
|
<WrapperUserAction
|
|
disabled={myAddress === message?.sender}
|
|
address={message?.sender}
|
|
name={message?.senderName}
|
|
>
|
|
<Typography
|
|
sx={{
|
|
fontWight: 600,
|
|
fontFamily: "Inter",
|
|
color: "cadetBlue",
|
|
}}
|
|
>
|
|
{message?.senderName || message?.sender}
|
|
</Typography>
|
|
|
|
</WrapperUserAction>
|
|
<Box sx={{
|
|
display: 'flex',
|
|
gap: '10px',
|
|
alignItems: 'center'
|
|
}}>
|
|
{message?.sender === myAddress && (!message?.isNotEncrypted || isPrivate === false) && (
|
|
<ButtonBase
|
|
onClick={() => {
|
|
onEdit(message);
|
|
}}
|
|
>
|
|
<EditIcon />
|
|
</ButtonBase>
|
|
)}
|
|
{!isShowingAsReply && (
|
|
<ButtonBase
|
|
onClick={() => {
|
|
onReply(message);
|
|
}}
|
|
>
|
|
<ReplyIcon />
|
|
</ButtonBase>
|
|
)}
|
|
{!isShowingAsReply && handleReaction && (
|
|
<ReactionPicker onReaction={(val)=> {
|
|
|
|
if(reactions && reactions[val] && reactions[val]?.find((item)=> item?.sender === myAddress)){
|
|
handleReaction(val, message, false)
|
|
} else {
|
|
handleReaction(val, message, true)
|
|
}
|
|
|
|
}} />
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
{reply && (
|
|
<>
|
|
<Spacer height="20px" />
|
|
<Box
|
|
sx={{
|
|
width: "100%",
|
|
borderRadius: "5px",
|
|
backgroundColor: "var(--bg-primary)",
|
|
overflow: 'hidden',
|
|
display: 'flex',
|
|
gap: '20px',
|
|
maxHeight: '90px',
|
|
cursor: 'pointer'
|
|
}}
|
|
onClick={()=> {
|
|
scrollToItem(replyIndex)
|
|
|
|
|
|
}}
|
|
>
|
|
<Box sx={{
|
|
height: '100%',
|
|
width: '5px',
|
|
background: 'white'
|
|
}} />
|
|
<Box sx={{
|
|
padding: '5px'
|
|
}}>
|
|
<Typography sx={{
|
|
fontSize: '12px',
|
|
fontWeight: 600
|
|
}}>Replied to {reply?.senderName || reply?.senderAddress}</Typography>
|
|
{reply?.messageText && (
|
|
<MessageDisplay
|
|
htmlContent={htmlReply}
|
|
/>
|
|
)}
|
|
{reply?.decryptedData?.type === "notification" ? (
|
|
<MessageDisplay htmlContent={reply.decryptedData?.data?.message} />
|
|
) : (
|
|
<MessageDisplay isReply htmlContent={reply.text} />
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
</>
|
|
)}
|
|
{htmlText && (
|
|
<MessageDisplay
|
|
htmlContent={htmlText}
|
|
/>
|
|
)}
|
|
|
|
|
|
{message?.decryptedData?.type === "notification" ? (
|
|
<MessageDisplay htmlContent={message.decryptedData?.data?.message} />
|
|
) : (
|
|
<MessageDisplay htmlContent={message.text} />
|
|
)}
|
|
<Box
|
|
sx={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
width: "100%",
|
|
}}
|
|
>
|
|
<Box sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '5px'
|
|
}}>
|
|
{reactions && Object.keys(reactions).map((reaction)=> {
|
|
const numberOfReactions = reactions[reaction]?.length
|
|
// const myReaction = reactions
|
|
if(numberOfReactions === 0) return null
|
|
return (
|
|
<ButtonBase key={reaction} sx={{
|
|
height: '30px',
|
|
minWidth: '45px',
|
|
background: 'var(--bg-2)',
|
|
borderRadius: '7px'
|
|
}} onClick={(event) => {
|
|
event.stopPropagation(); // Prevent event bubbling
|
|
setAnchorEl(event.currentTarget);
|
|
setSelectedReaction(reaction);
|
|
}}>
|
|
<div style={{
|
|
fontSize: '16px'
|
|
}}>{reaction}</div> {numberOfReactions > 1 && (
|
|
<Typography sx={{
|
|
marginLeft: '4px'
|
|
}}>{' '} {numberOfReactions}</Typography>
|
|
)}
|
|
</ButtonBase>
|
|
)
|
|
})}
|
|
</Box>
|
|
{selectedReaction && (
|
|
<Popover
|
|
open={Boolean(anchorEl)}
|
|
anchorEl={anchorEl}
|
|
onClose={() => {
|
|
setAnchorEl(null);
|
|
setSelectedReaction(null);
|
|
}}
|
|
anchorOrigin={{
|
|
vertical: "top",
|
|
horizontal: "center",
|
|
}}
|
|
transformOrigin={{
|
|
vertical: "bottom",
|
|
horizontal: "center",
|
|
}}
|
|
PaperProps={{
|
|
style: {
|
|
backgroundColor: "#232428",
|
|
color: "white",
|
|
},
|
|
}}
|
|
>
|
|
<Box sx={{ p: 2 }}>
|
|
<Typography variant="subtitle1" sx={{ marginBottom: 1 }}>
|
|
People who reacted with {selectedReaction}
|
|
</Typography>
|
|
<List sx={{
|
|
overflow: 'auto',
|
|
maxWidth: '300px',
|
|
maxHeight: '300px'
|
|
}}>
|
|
{reactions[selectedReaction]?.map((reactionItem) => (
|
|
<ListItem key={reactionItem.sender}>
|
|
<ListItemText
|
|
primary={reactionItem.senderName || reactionItem.sender}
|
|
/>
|
|
</ListItem>
|
|
))}
|
|
</List>
|
|
<Button
|
|
variant="contained"
|
|
color="primary"
|
|
onClick={() => {
|
|
if (
|
|
reactions[selectedReaction]?.find(
|
|
(item) => item?.sender === myAddress
|
|
)
|
|
) {
|
|
handleReaction(selectedReaction, message, false); // Remove reaction
|
|
} else {
|
|
handleReaction(selectedReaction, message, true); // Add reaction
|
|
}
|
|
setAnchorEl(null);
|
|
setSelectedReaction(null);
|
|
}}
|
|
sx={{ marginTop: 2 }}
|
|
>
|
|
{reactions[selectedReaction]?.find(
|
|
(item) => item?.sender === myAddress
|
|
)
|
|
? "Remove Reaction"
|
|
: "Add Reaction"}
|
|
</Button>
|
|
</Box>
|
|
</Popover>
|
|
)}
|
|
<Box sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '15px'
|
|
}}>
|
|
{message?.isNotEncrypted && isPrivate && (
|
|
<KeyOffIcon sx={{
|
|
color: 'white',
|
|
marginLeft: '10px'
|
|
}} />
|
|
)}
|
|
|
|
{isUpdating ? (
|
|
<Typography
|
|
sx={{
|
|
fontSize: "14px",
|
|
color: "gray",
|
|
fontFamily: "Inter",
|
|
}}
|
|
>
|
|
{message?.status === 'failed-permanent' ? 'Failed to update' : 'Updating...'}
|
|
</Typography>
|
|
) : isTemp ? (
|
|
<Typography
|
|
sx={{
|
|
fontSize: "14px",
|
|
color: "gray",
|
|
fontFamily: "Inter",
|
|
}}
|
|
>
|
|
{message?.status === 'failed-permanent' ? 'Failed to send' : 'Sending...'}
|
|
</Typography>
|
|
) : (
|
|
<>
|
|
{message?.isEdit && (
|
|
<Typography
|
|
sx={{
|
|
fontSize: "14px",
|
|
color: "gray",
|
|
fontFamily: "Inter",
|
|
fontStyle: 'italic'
|
|
}}
|
|
>
|
|
Edited
|
|
</Typography>
|
|
)}
|
|
<Typography
|
|
sx={{
|
|
fontSize: "14px",
|
|
color: "gray",
|
|
fontFamily: "Inter",
|
|
}}
|
|
>
|
|
{formatTimestamp(message.timestamp)}
|
|
</Typography>
|
|
</>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
|
|
|
|
</div>
|
|
</MessageWragger>
|
|
</>
|
|
);
|
|
});
|
|
|
|
|
|
export const ReplyPreview = ({message, isEdit})=> {
|
|
|
|
return (
|
|
<Box
|
|
sx={{
|
|
marginTop: '20px',
|
|
width: "100%",
|
|
borderRadius: "5px",
|
|
backgroundColor: "var(--bg-primary)",
|
|
overflow: 'hidden',
|
|
display: 'flex',
|
|
gap: '20px',
|
|
maxHeight: '90px',
|
|
cursor: 'pointer'
|
|
}}
|
|
>
|
|
<Box sx={{
|
|
height: '100%',
|
|
width: '5px',
|
|
background: 'white'
|
|
}} />
|
|
<Box sx={{
|
|
padding: '5px'
|
|
}}>
|
|
{isEdit ? (
|
|
<Typography sx={{
|
|
fontSize: '12px',
|
|
fontWeight: 600
|
|
}}>Editing Message</Typography>
|
|
) : (
|
|
<Typography sx={{
|
|
fontSize: '12px',
|
|
fontWeight: 600
|
|
}}>Replied to {message?.senderName || message?.senderAddress}</Typography>
|
|
)}
|
|
|
|
{message?.messageText && (
|
|
<MessageDisplay
|
|
htmlContent={generateHTML(message?.messageText, [
|
|
StarterKit,
|
|
Underline,
|
|
Highlight,
|
|
Mention,
|
|
TextStyle
|
|
])}
|
|
/>
|
|
)}
|
|
{message?.decryptedData?.type === "notification" ? (
|
|
<MessageDisplay htmlContent={message.decryptedData?.data?.message} />
|
|
) : (
|
|
<MessageDisplay isReply htmlContent={message.text} />
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
|
|
)
|
|
}
|
|
|
|
const MessageWragger = ({lastMessage, onSeen, isLast, children})=> {
|
|
|
|
if(lastMessage){
|
|
return (
|
|
<WatchComponent onSeen={onSeen} isLast={isLast}>{children}</WatchComponent>
|
|
)
|
|
}
|
|
return children
|
|
}
|
|
|
|
const WatchComponent = ({onSeen, isLast, children})=> {
|
|
const { ref, inView } = useInView({
|
|
threshold: 0.7, // Fully visible
|
|
triggerOnce: true, // Only trigger once when it becomes visible
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (inView && isLast && onSeen) {
|
|
onSeen();
|
|
}
|
|
}, [inView, isLast, onSeen]);
|
|
|
|
return <div ref={ref} style={{
|
|
width: '100%',
|
|
display: 'flex',
|
|
justifyContent: 'center'
|
|
}}>
|
|
{children}
|
|
</div>
|
|
|
|
} |