466 lines
14 KiB
TypeScript
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} />
|
|
|
|
{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">
|
|
{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>
|
|
);
|
|
}
|