338 lines
8.4 KiB
TypeScript
338 lines
8.4 KiB
TypeScript
import React, {
|
|
useCallback,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import cx from "classnames";
|
|
import key from "weak-key";
|
|
import humanizeDuration from "humanize-duration";
|
|
import cloneDeep from "lodash.clonedeep";
|
|
import { Username } from "./Username";
|
|
import {
|
|
DIRECT_MESSAGE_ID,
|
|
OPTIMISTIC_MESSAGE_ID,
|
|
useChat,
|
|
useRootContext,
|
|
} from "../../hooks";
|
|
import { QuotedMessageLink } from "./QuotedMessageLink";
|
|
import "./ChatMessage.css";
|
|
|
|
interface ChatMessageProps {
|
|
message: IChatMessage;
|
|
timestampUpdates: number;
|
|
showUser?: boolean;
|
|
actionsOpen: boolean;
|
|
onToggleActions(messageId: string): void;
|
|
}
|
|
|
|
const TIMESTAMP_UPDATE_INTERVAL = 20000;
|
|
|
|
export function ChatMessage({
|
|
message,
|
|
showUser = true,
|
|
timestampUpdates,
|
|
actionsOpen,
|
|
onToggleActions,
|
|
}: ChatMessageProps) {
|
|
const {
|
|
id,
|
|
user_id,
|
|
avatar,
|
|
namecolor,
|
|
username,
|
|
hat,
|
|
text,
|
|
text_html,
|
|
text_censored,
|
|
time,
|
|
quotes,
|
|
dm,
|
|
} = message;
|
|
const {
|
|
id: userId,
|
|
username: userUsername,
|
|
admin,
|
|
censored,
|
|
themeColor,
|
|
} = useRootContext();
|
|
const {
|
|
messageLookup,
|
|
userToDm,
|
|
quote,
|
|
deleteMessage,
|
|
quoteMessage,
|
|
updateUserToDm,
|
|
} = useChat();
|
|
const [confirmedDelete, setConfirmedDelete] = useState(false);
|
|
const quotedMessage = messageLookup[quotes];
|
|
const content = censored ? text_censored : text_html;
|
|
const isMention =
|
|
quotedMessage?.username === userUsername ||
|
|
(text_html.includes(`/id/${userId}`) &&
|
|
userUsername &&
|
|
username !== userUsername);
|
|
const isDirect = id === DIRECT_MESSAGE_ID;
|
|
const isOptimistic = id === OPTIMISTIC_MESSAGE_ID;
|
|
const timestamp = useMemo(
|
|
() => formatTimeAgo(time),
|
|
[time, timestampUpdates]
|
|
);
|
|
const handleDeleteMessage = useCallback(() => {
|
|
if (confirmedDelete) {
|
|
deleteMessage(text);
|
|
} else {
|
|
setConfirmedDelete(true);
|
|
}
|
|
}, [text, confirmedDelete]);
|
|
const handleQuoteMessageAction = useCallback(() => {
|
|
updateUserToDm(null);
|
|
quoteMessage(message);
|
|
onToggleActions(message.id);
|
|
}, [message, onToggleActions]);
|
|
const handleDirectMessage = useCallback(
|
|
(toggle?: boolean) => {
|
|
const userId = message.user_id ?? "";
|
|
|
|
if (userToDm && userToDm.id === userId) {
|
|
updateUserToDm(null);
|
|
} else if (userId) {
|
|
updateUserToDm({
|
|
id: userId,
|
|
username: message.username,
|
|
});
|
|
|
|
quoteMessage(null);
|
|
|
|
if (toggle) {
|
|
onToggleActions(message.id);
|
|
}
|
|
}
|
|
},
|
|
[userToDm, message.id, message.user_id, message.username]
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!actionsOpen) {
|
|
setConfirmedDelete(false);
|
|
}
|
|
}, [actionsOpen]);
|
|
|
|
return (
|
|
<div
|
|
className={cx("ChatMessage", {
|
|
ChatMessage__isDm: dm,
|
|
ChatMessage__isOptimistic: isOptimistic,
|
|
})}
|
|
id={id}
|
|
style={
|
|
isMention && !dm
|
|
? {
|
|
background: `#${themeColor}25`,
|
|
borderLeft: `1px solid #${themeColor}`,
|
|
}
|
|
: {}
|
|
}
|
|
>
|
|
{!isDirect && !isOptimistic && !actionsOpen && (
|
|
<div className="ChatMessage-actions-button">
|
|
<button
|
|
className="btn btn-secondary"
|
|
onClick={() => {
|
|
updateUserToDm(null);
|
|
quoteMessage(quote ? null : message);
|
|
}}
|
|
>
|
|
<i className="fas fa-reply" />
|
|
</button>
|
|
<button
|
|
className="btn btn-secondary"
|
|
onClick={() => onToggleActions(id)}
|
|
>
|
|
...
|
|
</button>
|
|
</div>
|
|
)}
|
|
{!isDirect && !isOptimistic && actionsOpen && (
|
|
<div className="ChatMessage-actions">
|
|
{userId && parseInt(userId) !== parseInt(user_id) && (
|
|
<button
|
|
className="btn btn-secondary ChatMessage-button"
|
|
onClick={() => handleDirectMessage(true)}
|
|
>
|
|
📨 DM @{message.username}
|
|
</button>
|
|
)}
|
|
<button
|
|
className="btn btn-secondary ChatMessage-button"
|
|
onClick={handleQuoteMessageAction}
|
|
>
|
|
<i className="fas fa-reply" /> Reply
|
|
</button>
|
|
{admin && (
|
|
<button
|
|
className={cx("btn btn-secondary ChatMessage-button", {
|
|
"ChatMessage-button__confirmed": confirmedDelete,
|
|
})}
|
|
onClick={handleDeleteMessage}
|
|
>
|
|
<i className="fas fa-trash-alt" />{" "}
|
|
{confirmedDelete ? "Are you sure?" : "Delete"}
|
|
</button>
|
|
)}
|
|
<button
|
|
className="btn btn-secondary ChatMessage-button"
|
|
onClick={() => onToggleActions(id)}
|
|
>
|
|
<i>X</i> Close
|
|
</button>
|
|
</div>
|
|
)}
|
|
{showUser && (
|
|
<div className="ChatMessage-top">
|
|
<Username
|
|
avatar={avatar}
|
|
name={username}
|
|
color={namecolor}
|
|
hat={hat}
|
|
/>
|
|
<div className="ChatMessage-timestamp">{timestamp}</div>
|
|
</div>
|
|
)}
|
|
{quotes && quotedMessage && (
|
|
<div className="ChatMessage-quoted-link">
|
|
<QuotedMessageLink message={quotedMessage} />
|
|
</div>
|
|
)}
|
|
{!isDirect && dm && (
|
|
<small className="ChatMessage-quoted-link text-primary">
|
|
<em>(Sent only to you)</em>
|
|
</small>
|
|
)}
|
|
<div className="ChatMessage-bottom">
|
|
<div>
|
|
<span
|
|
className="ChatMessage-content"
|
|
title={content}
|
|
dangerouslySetInnerHTML={{
|
|
__html: content,
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ChatMessageList() {
|
|
const listRef = useRef<HTMLDivElement>(null);
|
|
const { messages } = useChat();
|
|
const [timestampUpdates, setTimestampUpdates] = useState(0);
|
|
const groupedMessages = useMemo(() => groupMessages(messages), [messages]);
|
|
const [actionsOpenForMessage, setActionsOpenForMessage] = useState<
|
|
string | null
|
|
>(null);
|
|
const handleToggleActionsForMessage = useCallback(
|
|
(messageId: string) =>
|
|
setActionsOpenForMessage(
|
|
messageId === actionsOpenForMessage ? null : messageId
|
|
),
|
|
[actionsOpenForMessage]
|
|
);
|
|
|
|
useEffect(() => {
|
|
const updatingTimestamps = setInterval(
|
|
() => setTimestampUpdates((prev) => prev + 1),
|
|
TIMESTAMP_UPDATE_INTERVAL
|
|
);
|
|
|
|
return () => {
|
|
clearInterval(updatingTimestamps);
|
|
};
|
|
}, []);
|
|
|
|
useLayoutEffect(() => {
|
|
const images = Array.from(
|
|
listRef.current.getElementsByTagName("img")
|
|
).filter((image) => image.dataset.src);
|
|
|
|
for (const image of images) {
|
|
image.src = image.dataset.src;
|
|
}
|
|
}, [messages]);
|
|
|
|
return (
|
|
<div className="ChatMessageList" ref={listRef}>
|
|
{groupedMessages.map((group) => (
|
|
<div key={key(group)} className="ChatMessageList-group">
|
|
{group.map((message, index) => (
|
|
<ChatMessage
|
|
key={key(message)}
|
|
message={message}
|
|
timestampUpdates={timestampUpdates}
|
|
showUser={index === 0}
|
|
actionsOpen={actionsOpenForMessage === message.id}
|
|
onToggleActions={handleToggleActionsForMessage}
|
|
/>
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function formatTimeAgo(time: number) {
|
|
const shortEnglishHumanizer = humanizeDuration.humanizer({
|
|
language: "shortEn",
|
|
languages: {
|
|
shortEn: {
|
|
y: () => "y",
|
|
mo: () => "mo",
|
|
w: () => "w",
|
|
d: () => "d",
|
|
h: () => "h",
|
|
m: () => "m",
|
|
s: () => "s",
|
|
ms: () => "ms",
|
|
},
|
|
},
|
|
round: true,
|
|
units: ["h", "m", "s"],
|
|
largest: 2,
|
|
spacer: "",
|
|
delimiter: ", ",
|
|
});
|
|
const now = new Date().getTime();
|
|
const humanized = `${shortEnglishHumanizer(time * 1000 - now)} ago`;
|
|
|
|
return humanized === "0s ago" ? "just now" : humanized;
|
|
}
|
|
|
|
function groupMessages(messages: IChatMessage[]) {
|
|
const grouped: IChatMessage[][] = [];
|
|
let lastUsername = "";
|
|
let temp: IChatMessage[] = [];
|
|
|
|
for (const message of messages) {
|
|
if (!lastUsername) {
|
|
lastUsername = message.username;
|
|
}
|
|
|
|
if (message.username === lastUsername) {
|
|
temp.push(message);
|
|
} else {
|
|
grouped.push(cloneDeep(temp));
|
|
lastUsername = message.username;
|
|
temp = [message];
|
|
}
|
|
}
|
|
|
|
if (temp.length > 0) {
|
|
grouped.push(cloneDeep(temp));
|
|
}
|
|
|
|
return grouped;
|
|
}
|