Files
MatrixGW/matrixgw_frontend/src/widgets/messages/RoomMessagesList.tsx

466 lines
14 KiB
TypeScript

import AddReactionIcon from "@mui/icons-material/AddReaction";
import DeleteIcon from "@mui/icons-material/Delete";
import EditIcon from "@mui/icons-material/Edit";
import DownloadIcon from "@mui/icons-material/Download";
import {
Box,
Button,
ButtonGroup,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
TextField,
Tooltip,
Typography,
useTheme,
} from "@mui/material";
import EmojiPicker, { EmojiStyle, Theme } from "emoji-picker-react";
import React from "react";
import { MatrixApiEvent } from "../../api/matrix/MatrixApiEvent";
import type { UsersMap } from "../../api/matrix/MatrixApiProfile";
import type { Room } from "../../api/matrix/MatrixApiRoom";
import { useAlert } from "../../hooks/contexts_provider/AlertDialogProvider";
import { useConfirm } from "../../hooks/contexts_provider/ConfirmDialogProvider";
import { useLoadingMessage } from "../../hooks/contexts_provider/LoadingMessageProvider";
import { useSnackbar } from "../../hooks/contexts_provider/SnackbarProvider";
import type {
Message,
MessageReaction,
RoomEventsManager,
} from "../../utils/RoomEventsManager";
import { useUserInfo } from "../dashboard/BaseAuthenticatedPage";
import { EmojiIcon } from "../EmojiIcon";
import { AccountIcon } from "./AccountIcon";
export function RoomMessagesList(p: {
room: Room;
users: UsersMap;
mgr: RoomEventsManager;
}): React.ReactElement {
const messagesEndRef = React.createRef<HTMLDivElement>();
// Automatically scroll to bottom when number of messages change
React.useEffect(() => {
if (messagesEndRef)
messagesEndRef.current?.scrollIntoView({ behavior: "instant" });
}, [p.mgr.messages.length]);
return (
<div
style={{
flex: 1,
width: "100%",
paddingRight: "50px",
overflow: "scroll",
paddingLeft: "20px",
}}
>
{p.mgr.messages.map((m, idx) => (
<RoomMessage
key={m.event_id}
{...p}
message={m}
previousFromSamePerson={
idx > 0 &&
p.mgr.messages[idx - 1].account === m.account &&
m.time_sent - p.mgr.messages[idx - 1].time_sent < 60 * 3 * 1000
}
firstMessageOfDay={
idx === 0 ||
m.time_sent_dayjs.startOf("day").unix() !=
p.mgr.messages[idx - 1].time_sent_dayjs.startOf("day").unix()
}
/>
))}
<div ref={messagesEndRef} style={{ height: "10px" }} />
</div>
);
}
function RoomMessage(p: {
room: Room;
users: UsersMap;
message: Message;
previousFromSamePerson: boolean;
firstMessageOfDay: boolean;
}): React.ReactElement {
const theme = useTheme();
const user = useUserInfo();
const alert = useAlert();
const confirm = useConfirm();
const snackbar = useSnackbar();
const loadingMessage = useLoadingMessage();
const [showImageFullScreen, setShowImageFullScreen] = React.useState(false);
const [editMessage, setEditMessage] = React.useState<string | undefined>();
const [pickReaction, setPickReaction] = React.useState(false);
const closeImageFullScreen = () => setShowImageFullScreen(false);
const sender = p.users.get(p.message.account);
const handleDeleteMessage = async () => {
if (!(await confirm(`Do you really want to delete this message?`))) return;
try {
await MatrixApiEvent.DeleteEvent(p.room, p.message.event_id);
} catch (e) {
console.error(`Failed to delete message!`, e),
alert(`Failed to delete message!${e}`);
}
};
const handleEditMessage = () => setEditMessage(p.message.content);
const handleCancelEditMessage = () => setEditMessage(undefined);
const handleSubmitEditMessage = async (event: React.FormEvent) => {
event.preventDefault();
try {
loadingMessage.show(`Updating message content...`);
await MatrixApiEvent.SetTextMessageContent(
p.room,
p.message.event_id,
editMessage!
);
setEditMessage(undefined);
} catch (e) {
console.error(`Failed to edit message!`, e);
alert(`Failed to edit message content! ${e}`);
} finally {
loadingMessage.hide();
}
};
const handleAddReaction = () => setPickReaction(true);
const handleCancelAddReaction = () => setPickReaction(false);
const handleSelectEmoji = async (key: string) => {
loadingMessage.show("Setting reaction...");
try {
await MatrixApiEvent.ReactToEvent(p.room, p.message.event_id, key);
setPickReaction(false);
} catch (e) {
console.error("Failed to select emoji!", e);
alert(`Failed to select emoji! ${e}`);
} finally {
loadingMessage.hide();
}
};
const handleToggleReaction = async (
key: string,
reaction: MessageReaction | undefined
) => {
try {
if (!reaction)
await MatrixApiEvent.ReactToEvent(p.room, p.message.event_id, key);
else await MatrixApiEvent.DeleteEvent(p.room, reaction.event_id);
} catch (e) {
console.error(`Failed to toggle reaction!`, e);
snackbar(`Failed to toggle reaction! ${e}`);
}
};
return (
<>
{/* Print date if required */}
{p.firstMessageOfDay && (
<Typography
variant="caption"
component={"div"}
style={{ textAlign: "center", marginTop: "50px" }}
>
{p.message.time_sent_dayjs.format("DD/MM/YYYY")}
</Typography>
)}
{/* Give person name if required */}
{(!p.previousFromSamePerson || p.firstMessageOfDay) && sender && (
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
marginTop: "20px",
}}
>
<AccountIcon user={sender} />
&nbsp;&nbsp;&nbsp;
{sender.display_name}
</div>
)}
{/* Message content */}
<Box
style={{
wordBreak: "break-all",
wordWrap: "break-word",
maxWidth: "100%",
transition: "all 0.01s ease-in",
position: "relative",
display: "flex",
flexDirection: "row",
}}
component="div"
sx={{
[theme.getColorSchemeSelector("dark") + "&:hover"]: {
backgroundColor: "#ffffff2b",
},
[theme.getColorSchemeSelector("light") + "&:hover"]: {
backgroundColor: "#00000039",
},
"&:hover *": { visibility: "visible" },
}}
>
<Typography variant="caption">
&nbsp; {p.message.time_sent_dayjs.format("HH:mm")}
</Typography>
{/** Message itself */}
<div style={{ marginLeft: "15px", whiteSpace: "pre-wrap" }}>
{/* Image */}
{p.message.type === "m.image" && (
<img
onClick={() => setShowImageFullScreen(true)}
src={MatrixApiEvent.GetEventFileURL(
p.room,
p.message.event_id,
true
)}
style={{
maxWidth: "200px",
}}
/>
)}
{/* Audio */}
{p.message.type === "m.audio" && (
<audio controls>
<source
src={MatrixApiEvent.GetEventFileURL(
p.room,
p.message.event_id,
false
)}
/>
</audio>
)}
{/* Video */}
{p.message.type === "m.video" && (
<video controls style={{ maxHeight: "300px", maxWidth: "300px" }}>
<source
src={MatrixApiEvent.GetEventFileURL(
p.room,
p.message.event_id,
false
)}
/>
</video>
)}
{/* File */}
{p.message.type === "m.file" && (
<a
href={MatrixApiEvent.GetEventFileURL(
p.room,
p.message.event_id,
false
)}
target="_blank"
rel="noopener"
>
<Button variant="outlined" startIcon={<DownloadIcon />}>
{p.message.content}
</Button>
</a>
)}
{/* Text message */}
{p.message.type === "m.text" && p.message.content}
</div>
<ButtonGroup
className="buttons"
size="small"
style={{
position: "absolute",
visibility: "hidden",
display: "block",
top: "-34px",
right: "0px",
}}
>
{/* Common reactions */}
<ReactionButton {...p} emojiKey="👍" /> {/* 👍 */}
<ReactionButton {...p} emojiKey="♥️" /> {/* ♥️ */}
<ReactionButton {...p} emojiKey="😂" /> {/* 😂 */}
{/* Add reaction */}
<Button onClick={handleAddReaction}>
<AddReactionIcon />
</Button>
{/* Edit text message */}
{p.message.account === user.info.matrix_user_id &&
!p.message.file && (
<Button onClick={handleEditMessage}>
<EditIcon />
</Button>
)}
{/* Delete message */}
{p.message.account === user.info.matrix_user_id && (
<Button onClick={handleDeleteMessage}>
<DeleteIcon color="error" />
</Button>
)}
</ButtonGroup>
</Box>
{/* Reaction */}
<Box sx={{ marginLeft: "50px" }}>
{[...p.message.reactions.keys()].map((r) => {
const reactions = p.message.reactions.get(r)!;
const userReaction = reactions.find(
(r) => r.account === user.info.matrix_user_id
);
return (
<Tooltip
enterDelay={50}
placement="top"
arrow
title={
<span style={{ whiteSpace: "pre-wrap" }}>
{reactions
.map((r) => p.users.get(r.account)?.display_name)
.join("\n")}
</span>
}
>
<Chip
size="small"
style={{
height: "2em",
marginRight: "5px",
maxHeight: "unset",
cursor: "pointer",
}}
slotProps={{
root: {
onClick: () => handleToggleReaction(r, userReaction),
},
label: { style: { height: "2em" } },
}}
color={userReaction !== undefined ? "success" : undefined}
variant="filled"
label={
<span
style={{
display: "inline-flex",
justifyContent: "center",
alignItems: "center",
flexDirection: "row",
}}
>
<div style={{ margin: "3px 3px" }}>
<EmojiIcon emojiKey={r} />
</div>
<div style={{ height: "2em", marginLeft: "2px" }}>
{reactions.length}
</div>
</span>
}
/>
</Tooltip>
);
})}
</Box>
{/* Full screen image dialog */}
<Dialog open={showImageFullScreen} onClose={closeImageFullScreen}>
<img
src={MatrixApiEvent.GetEventFileURL(
p.room,
p.message.event_id,
false
)}
/>
</Dialog>
{/* Pick reaction dialog */}
<Dialog open={pickReaction} onClose={handleCancelAddReaction}>
<EmojiPicker
emojiStyle={EmojiStyle.GOOGLE}
theme={Theme.AUTO}
onEmojiClick={(emoji) => handleSelectEmoji(emoji.emoji)}
/>
</Dialog>
{/* Edit message dialog */}
<Dialog open={!!editMessage} onClose={handleCancelEditMessage} fullWidth>
<DialogTitle>Edit message content</DialogTitle>
<DialogContent>
<DialogContentText>Enter new message content:</DialogContentText>
<form
onSubmit={handleSubmitEditMessage}
id={`edit-message-${p.message.event_id}`}
>
<TextField
autoFocus
required
margin="dense"
label="New content"
type="text"
fullWidth
variant="standard"
multiline
value={editMessage}
onChange={(e) => setEditMessage(e.target.value)}
/>
</form>
</DialogContent>
<DialogActions>
<Button onClick={handleCancelEditMessage}>Cancel</Button>
<Button type="submit" form={`edit-message-${p.message.event_id}`}>
Edit
</Button>
</DialogActions>
</Dialog>
</>
);
}
function ReactionButton(p: {
room: Room;
message: Message;
emojiKey: string;
}): React.ReactElement {
const alert = useAlert();
const user = useUserInfo();
const sendEmoji = async () => {
try {
await MatrixApiEvent.ReactToEvent(p.room, p.message.event_id, p.emojiKey);
} catch (e) {
console.error("Failed to send reaction!", e);
alert(`Failed to send reaction! ${e}`);
}
};
// Do not offer to react to existing reactions
if (
p.message.reactions
.get(p.emojiKey)
?.find(
(r) => r.key === p.emojiKey && r.account === user.info.matrix_user_id
) !== undefined
)
return <></>;
return (
<Button onClick={sendEmoji}>
<EmojiIcon {...p} />
</Button>
);
}