forked from abner/for-legacy-web
Use tabWidth 4 without actual tabs.
This commit is contained in:
@@ -16,118 +16,118 @@ import Markdown from "../../markdown/Markdown";
|
||||
import UserIcon from "../user/UserIcon";
|
||||
import { Username } from "../user/UserShort";
|
||||
import MessageBase, {
|
||||
MessageContent,
|
||||
MessageDetail,
|
||||
MessageInfo,
|
||||
MessageContent,
|
||||
MessageDetail,
|
||||
MessageInfo,
|
||||
} from "./MessageBase";
|
||||
import Attachment from "./attachments/Attachment";
|
||||
import { MessageReply } from "./attachments/MessageReply";
|
||||
import Embed from "./embed/Embed";
|
||||
|
||||
interface Props {
|
||||
attachContext?: boolean;
|
||||
queued?: QueuedMessage;
|
||||
message: MessageObject;
|
||||
contrast?: boolean;
|
||||
content?: Children;
|
||||
head?: boolean;
|
||||
attachContext?: boolean;
|
||||
queued?: QueuedMessage;
|
||||
message: MessageObject;
|
||||
contrast?: boolean;
|
||||
content?: Children;
|
||||
head?: boolean;
|
||||
}
|
||||
|
||||
function Message({
|
||||
attachContext,
|
||||
message,
|
||||
contrast,
|
||||
content: replacement,
|
||||
head: preferHead,
|
||||
queued,
|
||||
attachContext,
|
||||
message,
|
||||
contrast,
|
||||
content: replacement,
|
||||
head: preferHead,
|
||||
queued,
|
||||
}: Props) {
|
||||
// TODO: Can improve re-renders here by providing a list
|
||||
// TODO: of dependencies. We only need to update on u/avatar.
|
||||
const user = useUser(message.author);
|
||||
const client = useContext(AppContext);
|
||||
const { openScreen } = useIntermediate();
|
||||
// TODO: Can improve re-renders here by providing a list
|
||||
// TODO: of dependencies. We only need to update on u/avatar.
|
||||
const user = useUser(message.author);
|
||||
const client = useContext(AppContext);
|
||||
const { openScreen } = useIntermediate();
|
||||
|
||||
const content = message.content as string;
|
||||
const head = preferHead || (message.replies && message.replies.length > 0);
|
||||
const content = message.content as string;
|
||||
const head = preferHead || (message.replies && message.replies.length > 0);
|
||||
|
||||
// ! FIXME: tell fatal to make this type generic
|
||||
// bree: Fatal please...
|
||||
const userContext = attachContext
|
||||
? (attachContextMenu("Menu", {
|
||||
user: message.author,
|
||||
contextualChannel: message.channel,
|
||||
}) as any)
|
||||
: undefined;
|
||||
// ! FIXME: tell fatal to make this type generic
|
||||
// bree: Fatal please...
|
||||
const userContext = attachContext
|
||||
? (attachContextMenu("Menu", {
|
||||
user: message.author,
|
||||
contextualChannel: message.channel,
|
||||
}) as any)
|
||||
: undefined;
|
||||
|
||||
const openProfile = () =>
|
||||
openScreen({ id: "profile", user_id: message.author });
|
||||
const openProfile = () =>
|
||||
openScreen({ id: "profile", user_id: message.author });
|
||||
|
||||
return (
|
||||
<div id={message._id}>
|
||||
{message.replies?.map((message_id, index) => (
|
||||
<MessageReply
|
||||
index={index}
|
||||
id={message_id}
|
||||
channel={message.channel}
|
||||
/>
|
||||
))}
|
||||
<MessageBase
|
||||
head={head && !(message.replies && message.replies.length > 0)}
|
||||
contrast={contrast}
|
||||
sending={typeof queued !== "undefined"}
|
||||
mention={message.mentions?.includes(client.user!._id)}
|
||||
failed={typeof queued?.error !== "undefined"}
|
||||
onContextMenu={
|
||||
attachContext
|
||||
? attachContextMenu("Menu", {
|
||||
message,
|
||||
contextualChannel: message.channel,
|
||||
queued,
|
||||
})
|
||||
: undefined
|
||||
}>
|
||||
<MessageInfo>
|
||||
{head ? (
|
||||
<UserIcon
|
||||
target={user}
|
||||
size={36}
|
||||
onContextMenu={userContext}
|
||||
onClick={openProfile}
|
||||
/>
|
||||
) : (
|
||||
<MessageDetail message={message} position="left" />
|
||||
)}
|
||||
</MessageInfo>
|
||||
<MessageContent>
|
||||
{head && (
|
||||
<span className="detail">
|
||||
<Username
|
||||
className="author"
|
||||
user={user}
|
||||
onContextMenu={userContext}
|
||||
onClick={openProfile}
|
||||
/>
|
||||
<MessageDetail message={message} position="top" />
|
||||
</span>
|
||||
)}
|
||||
{replacement ?? <Markdown content={content} />}
|
||||
{queued?.error && (
|
||||
<Overline type="error" error={queued.error} />
|
||||
)}
|
||||
{message.attachments?.map((attachment, index) => (
|
||||
<Attachment
|
||||
key={index}
|
||||
attachment={attachment}
|
||||
hasContent={index > 0 || content.length > 0}
|
||||
/>
|
||||
))}
|
||||
{message.embeds?.map((embed, index) => (
|
||||
<Embed key={index} embed={embed} />
|
||||
))}
|
||||
</MessageContent>
|
||||
</MessageBase>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div id={message._id}>
|
||||
{message.replies?.map((message_id, index) => (
|
||||
<MessageReply
|
||||
index={index}
|
||||
id={message_id}
|
||||
channel={message.channel}
|
||||
/>
|
||||
))}
|
||||
<MessageBase
|
||||
head={head && !(message.replies && message.replies.length > 0)}
|
||||
contrast={contrast}
|
||||
sending={typeof queued !== "undefined"}
|
||||
mention={message.mentions?.includes(client.user!._id)}
|
||||
failed={typeof queued?.error !== "undefined"}
|
||||
onContextMenu={
|
||||
attachContext
|
||||
? attachContextMenu("Menu", {
|
||||
message,
|
||||
contextualChannel: message.channel,
|
||||
queued,
|
||||
})
|
||||
: undefined
|
||||
}>
|
||||
<MessageInfo>
|
||||
{head ? (
|
||||
<UserIcon
|
||||
target={user}
|
||||
size={36}
|
||||
onContextMenu={userContext}
|
||||
onClick={openProfile}
|
||||
/>
|
||||
) : (
|
||||
<MessageDetail message={message} position="left" />
|
||||
)}
|
||||
</MessageInfo>
|
||||
<MessageContent>
|
||||
{head && (
|
||||
<span className="detail">
|
||||
<Username
|
||||
className="author"
|
||||
user={user}
|
||||
onContextMenu={userContext}
|
||||
onClick={openProfile}
|
||||
/>
|
||||
<MessageDetail message={message} position="top" />
|
||||
</span>
|
||||
)}
|
||||
{replacement ?? <Markdown content={content} />}
|
||||
{queued?.error && (
|
||||
<Overline type="error" error={queued.error} />
|
||||
)}
|
||||
{message.attachments?.map((attachment, index) => (
|
||||
<Attachment
|
||||
key={index}
|
||||
attachment={attachment}
|
||||
hasContent={index > 0 || content.length > 0}
|
||||
/>
|
||||
))}
|
||||
{message.embeds?.map((embed, index) => (
|
||||
<Embed key={index} embed={embed} />
|
||||
))}
|
||||
</MessageContent>
|
||||
</MessageBase>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(Message);
|
||||
|
||||
@@ -9,204 +9,204 @@ import { MessageObject } from "../../../context/revoltjs/util";
|
||||
import Tooltip from "../Tooltip";
|
||||
|
||||
export interface BaseMessageProps {
|
||||
head?: boolean;
|
||||
failed?: boolean;
|
||||
mention?: boolean;
|
||||
blocked?: boolean;
|
||||
sending?: boolean;
|
||||
contrast?: boolean;
|
||||
head?: boolean;
|
||||
failed?: boolean;
|
||||
mention?: boolean;
|
||||
blocked?: boolean;
|
||||
sending?: boolean;
|
||||
contrast?: boolean;
|
||||
}
|
||||
|
||||
export default styled.div<BaseMessageProps>`
|
||||
display: flex;
|
||||
overflow-x: none;
|
||||
padding: 0.125rem;
|
||||
flex-direction: row;
|
||||
padding-right: 16px;
|
||||
|
||||
${(props) =>
|
||||
props.contrast &&
|
||||
css`
|
||||
padding: 0.3rem;
|
||||
border-radius: 4px;
|
||||
background: var(--hover);
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.head &&
|
||||
css`
|
||||
margin-top: 12px;
|
||||
`}
|
||||
display: flex;
|
||||
overflow-x: none;
|
||||
padding: 0.125rem;
|
||||
flex-direction: row;
|
||||
padding-right: 16px;
|
||||
|
||||
${(props) =>
|
||||
props.mention &&
|
||||
css`
|
||||
background: var(--mention);
|
||||
`}
|
||||
props.contrast &&
|
||||
css`
|
||||
padding: 0.3rem;
|
||||
border-radius: 4px;
|
||||
background: var(--hover);
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.blocked &&
|
||||
css`
|
||||
filter: blur(4px);
|
||||
transition: 0.2s ease filter;
|
||||
|
||||
&:hover {
|
||||
filter: none;
|
||||
}
|
||||
`}
|
||||
props.head &&
|
||||
css`
|
||||
margin-top: 12px;
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.sending &&
|
||||
css`
|
||||
opacity: 0.8;
|
||||
color: var(--tertiary-foreground);
|
||||
`}
|
||||
props.mention &&
|
||||
css`
|
||||
background: var(--mention);
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.failed &&
|
||||
css`
|
||||
color: var(--error);
|
||||
`}
|
||||
props.blocked &&
|
||||
css`
|
||||
filter: blur(4px);
|
||||
transition: 0.2s ease filter;
|
||||
|
||||
&:hover {
|
||||
filter: none;
|
||||
}
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.sending &&
|
||||
css`
|
||||
opacity: 0.8;
|
||||
color: var(--tertiary-foreground);
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.failed &&
|
||||
css`
|
||||
color: var(--error);
|
||||
`}
|
||||
|
||||
.detail {
|
||||
gap: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
gap: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.author {
|
||||
cursor: pointer;
|
||||
font-weight: 600 !important;
|
||||
.author {
|
||||
cursor: pointer;
|
||||
font-weight: 600 !important;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.copy {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
}
|
||||
.copy {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--hover);
|
||||
&:hover {
|
||||
background: var(--hover);
|
||||
|
||||
time {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
time {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const MessageInfo = styled.div`
|
||||
width: 62px;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
padding-top: 2px;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
width: 62px;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
padding-top: 2px;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
|
||||
.copyBracket {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
}
|
||||
.copyBracket {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.copyTime {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
}
|
||||
.copyTime {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
svg {
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
svg {
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
}
|
||||
&:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
}
|
||||
|
||||
time {
|
||||
opacity: 0;
|
||||
}
|
||||
time {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
time,
|
||||
.edited {
|
||||
margin-top: 1px;
|
||||
cursor: default;
|
||||
display: inline;
|
||||
font-size: 10px;
|
||||
color: var(--tertiary-foreground);
|
||||
}
|
||||
time,
|
||||
.edited {
|
||||
margin-top: 1px;
|
||||
cursor: default;
|
||||
display: inline;
|
||||
font-size: 10px;
|
||||
color: var(--tertiary-foreground);
|
||||
}
|
||||
|
||||
time,
|
||||
.edited > div {
|
||||
&::selection {
|
||||
background-color: transparent;
|
||||
color: var(--tertiary-foreground);
|
||||
}
|
||||
}
|
||||
time,
|
||||
.edited > div {
|
||||
&::selection {
|
||||
background-color: transparent;
|
||||
color: var(--tertiary-foreground);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const MessageContent = styled.div`
|
||||
min-width: 0;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
// overflow: hidden;
|
||||
font-size: 0.875rem;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
// overflow: hidden;
|
||||
font-size: 0.875rem;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
export const DetailBase = styled.div`
|
||||
gap: 4px;
|
||||
font-size: 10px;
|
||||
display: inline-flex;
|
||||
color: var(--tertiary-foreground);
|
||||
gap: 4px;
|
||||
font-size: 10px;
|
||||
display: inline-flex;
|
||||
color: var(--tertiary-foreground);
|
||||
`;
|
||||
|
||||
export function MessageDetail({
|
||||
message,
|
||||
position,
|
||||
message,
|
||||
position,
|
||||
}: {
|
||||
message: MessageObject;
|
||||
position: "left" | "top";
|
||||
message: MessageObject;
|
||||
position: "left" | "top";
|
||||
}) {
|
||||
if (position === "left") {
|
||||
if (message.edited) {
|
||||
return (
|
||||
<>
|
||||
<time className="copyTime">
|
||||
<i className="copyBracket">[</i>
|
||||
{dayjs(decodeTime(message._id)).format("H:mm")}
|
||||
<i className="copyBracket">]</i>
|
||||
</time>
|
||||
<span className="edited">
|
||||
<Tooltip content={dayjs(message.edited).format("LLLL")}>
|
||||
<Text id="app.main.channel.edited" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<time>
|
||||
<i className="copyBracket">[</i>
|
||||
{dayjs(decodeTime(message._id)).format("H:mm")}
|
||||
<i className="copyBracket">]</i>
|
||||
</time>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
if (position === "left") {
|
||||
if (message.edited) {
|
||||
return (
|
||||
<>
|
||||
<time className="copyTime">
|
||||
<i className="copyBracket">[</i>
|
||||
{dayjs(decodeTime(message._id)).format("H:mm")}
|
||||
<i className="copyBracket">]</i>
|
||||
</time>
|
||||
<span className="edited">
|
||||
<Tooltip content={dayjs(message.edited).format("LLLL")}>
|
||||
<Text id="app.main.channel.edited" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<time>
|
||||
<i className="copyBracket">[</i>
|
||||
{dayjs(decodeTime(message._id)).format("H:mm")}
|
||||
<i className="copyBracket">]</i>
|
||||
</time>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DetailBase>
|
||||
<time>{dayjs(decodeTime(message._id)).calendar()}</time>
|
||||
{message.edited && (
|
||||
<Tooltip content={dayjs(message.edited).format("LLLL")}>
|
||||
<Text id="app.main.channel.edited" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</DetailBase>
|
||||
);
|
||||
return (
|
||||
<DetailBase>
|
||||
<time>{dayjs(decodeTime(message._id)).calendar()}</time>
|
||||
{message.edited && (
|
||||
<Tooltip content={dayjs(message.edited).format("LLLL")}>
|
||||
<Text id="app.main.channel.edited" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</DetailBase>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ import { internalEmit, internalSubscribe } from "../../../lib/eventEmitter";
|
||||
import { useTranslation } from "../../../lib/i18n";
|
||||
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
|
||||
import {
|
||||
SingletonMessageRenderer,
|
||||
SMOOTH_SCROLL_ON_RECEIVE,
|
||||
SingletonMessageRenderer,
|
||||
SMOOTH_SCROLL_ON_RECEIVE,
|
||||
} from "../../../lib/renderer/Singleton";
|
||||
|
||||
import { dispatch } from "../../../redux";
|
||||
@@ -27,9 +27,9 @@ import { Reply } from "../../../redux/reducers/queue";
|
||||
import { SoundContext } from "../../../context/Settings";
|
||||
import { useIntermediate } from "../../../context/intermediate/Intermediate";
|
||||
import {
|
||||
FileUploader,
|
||||
grabFiles,
|
||||
uploadFile,
|
||||
FileUploader,
|
||||
grabFiles,
|
||||
uploadFile,
|
||||
} from "../../../context/revoltjs/FileUploads";
|
||||
import { AppContext } from "../../../context/revoltjs/RevoltClient";
|
||||
import { useChannelPermission } from "../../../context/revoltjs/hooks";
|
||||
@@ -43,454 +43,452 @@ import FilePreview from "./bars/FilePreview";
|
||||
import ReplyBar from "./bars/ReplyBar";
|
||||
|
||||
type Props = {
|
||||
channel: Channel;
|
||||
draft?: string;
|
||||
channel: Channel;
|
||||
draft?: string;
|
||||
};
|
||||
|
||||
export type UploadState =
|
||||
| { type: "none" }
|
||||
| { type: "attached"; files: File[] }
|
||||
| {
|
||||
type: "uploading";
|
||||
files: File[];
|
||||
percent: number;
|
||||
cancel: CancelTokenSource;
|
||||
}
|
||||
| { type: "sending"; files: File[] }
|
||||
| { type: "failed"; files: File[]; error: string };
|
||||
| { type: "none" }
|
||||
| { type: "attached"; files: File[] }
|
||||
| {
|
||||
type: "uploading";
|
||||
files: File[];
|
||||
percent: number;
|
||||
cancel: CancelTokenSource;
|
||||
}
|
||||
| { type: "sending"; files: File[] }
|
||||
| { type: "failed"; files: File[]; error: string };
|
||||
|
||||
const Base = styled.div`
|
||||
display: flex;
|
||||
padding: 0 12px;
|
||||
background: var(--message-box);
|
||||
display: flex;
|
||||
padding: 0 12px;
|
||||
background: var(--message-box);
|
||||
|
||||
textarea {
|
||||
font-size: 0.875rem;
|
||||
background: transparent;
|
||||
}
|
||||
textarea {
|
||||
font-size: 0.875rem;
|
||||
background: transparent;
|
||||
}
|
||||
`;
|
||||
|
||||
const Blocked = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 14px 0;
|
||||
user-select: none;
|
||||
font-size: 0.875rem;
|
||||
color: var(--tertiary-foreground);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 14px 0;
|
||||
user-select: none;
|
||||
font-size: 0.875rem;
|
||||
color: var(--tertiary-foreground);
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
margin-inline-end: 10px;
|
||||
}
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
margin-inline-end: 10px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Action = styled.div`
|
||||
display: grid;
|
||||
place-items: center;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
`;
|
||||
|
||||
// ! FIXME: add to app config and load from app config
|
||||
export const CAN_UPLOAD_AT_ONCE = 5;
|
||||
|
||||
function MessageBox({ channel, draft }: Props) {
|
||||
const [uploadState, setUploadState] = useState<UploadState>({
|
||||
type: "none",
|
||||
});
|
||||
const [typing, setTyping] = useState<boolean | number>(false);
|
||||
const [replies, setReplies] = useState<Reply[]>([]);
|
||||
const playSound = useContext(SoundContext);
|
||||
const { openScreen } = useIntermediate();
|
||||
const client = useContext(AppContext);
|
||||
const translate = useTranslation();
|
||||
const [uploadState, setUploadState] = useState<UploadState>({
|
||||
type: "none",
|
||||
});
|
||||
const [typing, setTyping] = useState<boolean | number>(false);
|
||||
const [replies, setReplies] = useState<Reply[]>([]);
|
||||
const playSound = useContext(SoundContext);
|
||||
const { openScreen } = useIntermediate();
|
||||
const client = useContext(AppContext);
|
||||
const translate = useTranslation();
|
||||
|
||||
const permissions = useChannelPermission(channel._id);
|
||||
if (!(permissions & ChannelPermission.SendMessage)) {
|
||||
return (
|
||||
<Base>
|
||||
<Blocked>
|
||||
<PermissionTooltip
|
||||
permission="SendMessages"
|
||||
placement="top">
|
||||
<ShieldX size={22} />
|
||||
</PermissionTooltip>
|
||||
<Text id="app.main.channel.misc.no_sending" />
|
||||
</Blocked>
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
const permissions = useChannelPermission(channel._id);
|
||||
if (!(permissions & ChannelPermission.SendMessage)) {
|
||||
return (
|
||||
<Base>
|
||||
<Blocked>
|
||||
<PermissionTooltip
|
||||
permission="SendMessages"
|
||||
placement="top">
|
||||
<ShieldX size={22} />
|
||||
</PermissionTooltip>
|
||||
<Text id="app.main.channel.misc.no_sending" />
|
||||
</Blocked>
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
|
||||
function setMessage(content?: string) {
|
||||
if (content) {
|
||||
dispatch({
|
||||
type: "SET_DRAFT",
|
||||
channel: channel._id,
|
||||
content,
|
||||
});
|
||||
} else {
|
||||
dispatch({
|
||||
type: "CLEAR_DRAFT",
|
||||
channel: channel._id,
|
||||
});
|
||||
}
|
||||
}
|
||||
function setMessage(content?: string) {
|
||||
if (content) {
|
||||
dispatch({
|
||||
type: "SET_DRAFT",
|
||||
channel: channel._id,
|
||||
content,
|
||||
});
|
||||
} else {
|
||||
dispatch({
|
||||
type: "CLEAR_DRAFT",
|
||||
channel: channel._id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function append(content: string, action: "quote" | "mention") {
|
||||
const text =
|
||||
action === "quote"
|
||||
? `${content
|
||||
.split("\n")
|
||||
.map((x) => `> ${x}`)
|
||||
.join("\n")}\n\n`
|
||||
: `${content} `;
|
||||
useEffect(() => {
|
||||
function append(content: string, action: "quote" | "mention") {
|
||||
const text =
|
||||
action === "quote"
|
||||
? `${content
|
||||
.split("\n")
|
||||
.map((x) => `> ${x}`)
|
||||
.join("\n")}\n\n`
|
||||
: `${content} `;
|
||||
|
||||
if (!draft || draft.length === 0) {
|
||||
setMessage(text);
|
||||
} else {
|
||||
setMessage(`${draft}\n${text}`);
|
||||
}
|
||||
}
|
||||
if (!draft || draft.length === 0) {
|
||||
setMessage(text);
|
||||
} else {
|
||||
setMessage(`${draft}\n${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
return internalSubscribe("MessageBox", "append", append);
|
||||
}, [draft]);
|
||||
return internalSubscribe("MessageBox", "append", append);
|
||||
}, [draft]);
|
||||
|
||||
async function send() {
|
||||
if (uploadState.type === "uploading" || uploadState.type === "sending")
|
||||
return;
|
||||
async function send() {
|
||||
if (uploadState.type === "uploading" || uploadState.type === "sending")
|
||||
return;
|
||||
|
||||
const content = draft?.trim() ?? "";
|
||||
if (uploadState.type === "attached") return sendFile(content);
|
||||
if (content.length === 0) return;
|
||||
const content = draft?.trim() ?? "";
|
||||
if (uploadState.type === "attached") return sendFile(content);
|
||||
if (content.length === 0) return;
|
||||
|
||||
stopTyping();
|
||||
setMessage();
|
||||
setReplies([]);
|
||||
playSound("outbound");
|
||||
stopTyping();
|
||||
setMessage();
|
||||
setReplies([]);
|
||||
playSound("outbound");
|
||||
|
||||
const nonce = ulid();
|
||||
dispatch({
|
||||
type: "QUEUE_ADD",
|
||||
nonce,
|
||||
channel: channel._id,
|
||||
message: {
|
||||
_id: nonce,
|
||||
channel: channel._id,
|
||||
author: client.user!._id,
|
||||
const nonce = ulid();
|
||||
dispatch({
|
||||
type: "QUEUE_ADD",
|
||||
nonce,
|
||||
channel: channel._id,
|
||||
message: {
|
||||
_id: nonce,
|
||||
channel: channel._id,
|
||||
author: client.user!._id,
|
||||
|
||||
content,
|
||||
replies,
|
||||
},
|
||||
});
|
||||
content,
|
||||
replies,
|
||||
},
|
||||
});
|
||||
|
||||
defer(() =>
|
||||
SingletonMessageRenderer.jumpToBottom(
|
||||
channel._id,
|
||||
SMOOTH_SCROLL_ON_RECEIVE,
|
||||
),
|
||||
);
|
||||
defer(() =>
|
||||
SingletonMessageRenderer.jumpToBottom(
|
||||
channel._id,
|
||||
SMOOTH_SCROLL_ON_RECEIVE,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
await client.channels.sendMessage(channel._id, {
|
||||
content,
|
||||
nonce,
|
||||
replies,
|
||||
});
|
||||
} catch (error) {
|
||||
dispatch({
|
||||
type: "QUEUE_FAIL",
|
||||
error: takeError(error),
|
||||
nonce,
|
||||
});
|
||||
}
|
||||
}
|
||||
try {
|
||||
await client.channels.sendMessage(channel._id, {
|
||||
content,
|
||||
nonce,
|
||||
replies,
|
||||
});
|
||||
} catch (error) {
|
||||
dispatch({
|
||||
type: "QUEUE_FAIL",
|
||||
error: takeError(error),
|
||||
nonce,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function sendFile(content: string) {
|
||||
if (uploadState.type !== "attached") return;
|
||||
let attachments: string[] = [];
|
||||
async function sendFile(content: string) {
|
||||
if (uploadState.type !== "attached") return;
|
||||
let attachments: string[] = [];
|
||||
|
||||
const cancel = Axios.CancelToken.source();
|
||||
const files = uploadState.files;
|
||||
stopTyping();
|
||||
setUploadState({ type: "uploading", files, percent: 0, cancel });
|
||||
const cancel = Axios.CancelToken.source();
|
||||
const files = uploadState.files;
|
||||
stopTyping();
|
||||
setUploadState({ type: "uploading", files, percent: 0, cancel });
|
||||
|
||||
try {
|
||||
for (let i = 0; i < files.length && i < CAN_UPLOAD_AT_ONCE; i++) {
|
||||
const file = files[i];
|
||||
attachments.push(
|
||||
await uploadFile(
|
||||
client.configuration!.features.autumn.url,
|
||||
"attachments",
|
||||
file,
|
||||
{
|
||||
onUploadProgress: (e) =>
|
||||
setUploadState({
|
||||
type: "uploading",
|
||||
files,
|
||||
percent: Math.round(
|
||||
(i * 100 + (100 * e.loaded) / e.total) /
|
||||
Math.min(
|
||||
files.length,
|
||||
CAN_UPLOAD_AT_ONCE,
|
||||
),
|
||||
),
|
||||
cancel,
|
||||
}),
|
||||
cancelToken: cancel.token,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err?.message === "cancel") {
|
||||
setUploadState({
|
||||
type: "attached",
|
||||
files,
|
||||
});
|
||||
} else {
|
||||
setUploadState({
|
||||
type: "failed",
|
||||
files,
|
||||
error: takeError(err),
|
||||
});
|
||||
}
|
||||
try {
|
||||
for (let i = 0; i < files.length && i < CAN_UPLOAD_AT_ONCE; i++) {
|
||||
const file = files[i];
|
||||
attachments.push(
|
||||
await uploadFile(
|
||||
client.configuration!.features.autumn.url,
|
||||
"attachments",
|
||||
file,
|
||||
{
|
||||
onUploadProgress: (e) =>
|
||||
setUploadState({
|
||||
type: "uploading",
|
||||
files,
|
||||
percent: Math.round(
|
||||
(i * 100 + (100 * e.loaded) / e.total) /
|
||||
Math.min(
|
||||
files.length,
|
||||
CAN_UPLOAD_AT_ONCE,
|
||||
),
|
||||
),
|
||||
cancel,
|
||||
}),
|
||||
cancelToken: cancel.token,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err?.message === "cancel") {
|
||||
setUploadState({
|
||||
type: "attached",
|
||||
files,
|
||||
});
|
||||
} else {
|
||||
setUploadState({
|
||||
type: "failed",
|
||||
files,
|
||||
error: takeError(err),
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setUploadState({
|
||||
type: "sending",
|
||||
files,
|
||||
});
|
||||
setUploadState({
|
||||
type: "sending",
|
||||
files,
|
||||
});
|
||||
|
||||
const nonce = ulid();
|
||||
try {
|
||||
await client.channels.sendMessage(channel._id, {
|
||||
content,
|
||||
nonce,
|
||||
replies,
|
||||
attachments,
|
||||
});
|
||||
} catch (err) {
|
||||
setUploadState({
|
||||
type: "failed",
|
||||
files,
|
||||
error: takeError(err),
|
||||
});
|
||||
const nonce = ulid();
|
||||
try {
|
||||
await client.channels.sendMessage(channel._id, {
|
||||
content,
|
||||
nonce,
|
||||
replies,
|
||||
attachments,
|
||||
});
|
||||
} catch (err) {
|
||||
setUploadState({
|
||||
type: "failed",
|
||||
files,
|
||||
error: takeError(err),
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setMessage();
|
||||
setReplies([]);
|
||||
playSound("outbound");
|
||||
setMessage();
|
||||
setReplies([]);
|
||||
playSound("outbound");
|
||||
|
||||
if (files.length > CAN_UPLOAD_AT_ONCE) {
|
||||
setUploadState({
|
||||
type: "attached",
|
||||
files: files.slice(CAN_UPLOAD_AT_ONCE),
|
||||
});
|
||||
} else {
|
||||
setUploadState({ type: "none" });
|
||||
}
|
||||
}
|
||||
if (files.length > CAN_UPLOAD_AT_ONCE) {
|
||||
setUploadState({
|
||||
type: "attached",
|
||||
files: files.slice(CAN_UPLOAD_AT_ONCE),
|
||||
});
|
||||
} else {
|
||||
setUploadState({ type: "none" });
|
||||
}
|
||||
}
|
||||
|
||||
function startTyping() {
|
||||
if (typeof typing === "number" && +new Date() < typing) return;
|
||||
function startTyping() {
|
||||
if (typeof typing === "number" && +new Date() < typing) return;
|
||||
|
||||
const ws = client.websocket;
|
||||
if (ws.connected) {
|
||||
setTyping(+new Date() + 4000);
|
||||
ws.send({
|
||||
type: "BeginTyping",
|
||||
channel: channel._id,
|
||||
});
|
||||
}
|
||||
}
|
||||
const ws = client.websocket;
|
||||
if (ws.connected) {
|
||||
setTyping(+new Date() + 4000);
|
||||
ws.send({
|
||||
type: "BeginTyping",
|
||||
channel: channel._id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function stopTyping(force?: boolean) {
|
||||
if (force || typing) {
|
||||
const ws = client.websocket;
|
||||
if (ws.connected) {
|
||||
setTyping(false);
|
||||
ws.send({
|
||||
type: "EndTyping",
|
||||
channel: channel._id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
function stopTyping(force?: boolean) {
|
||||
if (force || typing) {
|
||||
const ws = client.websocket;
|
||||
if (ws.connected) {
|
||||
setTyping(false);
|
||||
ws.send({
|
||||
type: "EndTyping",
|
||||
channel: channel._id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedStopTyping = useCallback(debounce(stopTyping, 1000), [
|
||||
channel._id,
|
||||
]);
|
||||
const {
|
||||
onChange,
|
||||
onKeyUp,
|
||||
onKeyDown,
|
||||
onFocus,
|
||||
onBlur,
|
||||
...autoCompleteProps
|
||||
} = useAutoComplete(setMessage, {
|
||||
users: { type: "channel", id: channel._id },
|
||||
channels:
|
||||
channel.channel_type === "TextChannel"
|
||||
? { server: channel.server }
|
||||
: undefined,
|
||||
});
|
||||
const debouncedStopTyping = useCallback(debounce(stopTyping, 1000), [
|
||||
channel._id,
|
||||
]);
|
||||
const {
|
||||
onChange,
|
||||
onKeyUp,
|
||||
onKeyDown,
|
||||
onFocus,
|
||||
onBlur,
|
||||
...autoCompleteProps
|
||||
} = useAutoComplete(setMessage, {
|
||||
users: { type: "channel", id: channel._id },
|
||||
channels:
|
||||
channel.channel_type === "TextChannel"
|
||||
? { server: channel.server }
|
||||
: undefined,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<AutoComplete {...autoCompleteProps} />
|
||||
<FilePreview
|
||||
state={uploadState}
|
||||
addFile={() =>
|
||||
uploadState.type === "attached" &&
|
||||
grabFiles(
|
||||
20_000_000,
|
||||
(files) =>
|
||||
setUploadState({
|
||||
type: "attached",
|
||||
files: [...uploadState.files, ...files],
|
||||
}),
|
||||
() =>
|
||||
openScreen({ id: "error", error: "FileTooLarge" }),
|
||||
true,
|
||||
)
|
||||
}
|
||||
removeFile={(index) => {
|
||||
if (uploadState.type !== "attached") return;
|
||||
if (uploadState.files.length === 1) {
|
||||
setUploadState({ type: "none" });
|
||||
} else {
|
||||
setUploadState({
|
||||
type: "attached",
|
||||
files: uploadState.files.filter(
|
||||
(_, i) => index !== i,
|
||||
),
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ReplyBar
|
||||
channel={channel._id}
|
||||
replies={replies}
|
||||
setReplies={setReplies}
|
||||
/>
|
||||
<Base>
|
||||
{permissions & ChannelPermission.UploadFiles ? (
|
||||
<Action>
|
||||
<FileUploader
|
||||
size={24}
|
||||
behaviour="multi"
|
||||
style="attachment"
|
||||
fileType="attachments"
|
||||
maxFileSize={20_000_000}
|
||||
attached={uploadState.type !== "none"}
|
||||
uploading={
|
||||
uploadState.type === "uploading" ||
|
||||
uploadState.type === "sending"
|
||||
}
|
||||
remove={async () =>
|
||||
setUploadState({ type: "none" })
|
||||
}
|
||||
onChange={(files) =>
|
||||
setUploadState({ type: "attached", files })
|
||||
}
|
||||
cancel={() =>
|
||||
uploadState.type === "uploading" &&
|
||||
uploadState.cancel.cancel("cancel")
|
||||
}
|
||||
append={(files) => {
|
||||
if (files.length === 0) return;
|
||||
return (
|
||||
<>
|
||||
<AutoComplete {...autoCompleteProps} />
|
||||
<FilePreview
|
||||
state={uploadState}
|
||||
addFile={() =>
|
||||
uploadState.type === "attached" &&
|
||||
grabFiles(
|
||||
20_000_000,
|
||||
(files) =>
|
||||
setUploadState({
|
||||
type: "attached",
|
||||
files: [...uploadState.files, ...files],
|
||||
}),
|
||||
() =>
|
||||
openScreen({ id: "error", error: "FileTooLarge" }),
|
||||
true,
|
||||
)
|
||||
}
|
||||
removeFile={(index) => {
|
||||
if (uploadState.type !== "attached") return;
|
||||
if (uploadState.files.length === 1) {
|
||||
setUploadState({ type: "none" });
|
||||
} else {
|
||||
setUploadState({
|
||||
type: "attached",
|
||||
files: uploadState.files.filter(
|
||||
(_, i) => index !== i,
|
||||
),
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ReplyBar
|
||||
channel={channel._id}
|
||||
replies={replies}
|
||||
setReplies={setReplies}
|
||||
/>
|
||||
<Base>
|
||||
{permissions & ChannelPermission.UploadFiles ? (
|
||||
<Action>
|
||||
<FileUploader
|
||||
size={24}
|
||||
behaviour="multi"
|
||||
style="attachment"
|
||||
fileType="attachments"
|
||||
maxFileSize={20_000_000}
|
||||
attached={uploadState.type !== "none"}
|
||||
uploading={
|
||||
uploadState.type === "uploading" ||
|
||||
uploadState.type === "sending"
|
||||
}
|
||||
remove={async () =>
|
||||
setUploadState({ type: "none" })
|
||||
}
|
||||
onChange={(files) =>
|
||||
setUploadState({ type: "attached", files })
|
||||
}
|
||||
cancel={() =>
|
||||
uploadState.type === "uploading" &&
|
||||
uploadState.cancel.cancel("cancel")
|
||||
}
|
||||
append={(files) => {
|
||||
if (files.length === 0) return;
|
||||
|
||||
if (uploadState.type === "none") {
|
||||
setUploadState({ type: "attached", files });
|
||||
} else if (uploadState.type === "attached") {
|
||||
setUploadState({
|
||||
type: "attached",
|
||||
files: [...uploadState.files, ...files],
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Action>
|
||||
) : undefined}
|
||||
<TextAreaAutoSize
|
||||
autoFocus
|
||||
hideBorder
|
||||
maxRows={5}
|
||||
padding={14}
|
||||
id="message"
|
||||
value={draft ?? ""}
|
||||
onKeyUp={onKeyUp}
|
||||
onKeyDown={(e) => {
|
||||
if (onKeyDown(e)) return;
|
||||
if (uploadState.type === "none") {
|
||||
setUploadState({ type: "attached", files });
|
||||
} else if (uploadState.type === "attached") {
|
||||
setUploadState({
|
||||
type: "attached",
|
||||
files: [...uploadState.files, ...files],
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Action>
|
||||
) : undefined}
|
||||
<TextAreaAutoSize
|
||||
autoFocus
|
||||
hideBorder
|
||||
maxRows={5}
|
||||
padding={14}
|
||||
id="message"
|
||||
value={draft ?? ""}
|
||||
onKeyUp={onKeyUp}
|
||||
onKeyDown={(e) => {
|
||||
if (onKeyDown(e)) return;
|
||||
|
||||
if (
|
||||
e.key === "ArrowUp" &&
|
||||
(!draft || draft.length === 0)
|
||||
) {
|
||||
e.preventDefault();
|
||||
internalEmit("MessageRenderer", "edit_last");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
e.key === "ArrowUp" &&
|
||||
(!draft || draft.length === 0)
|
||||
) {
|
||||
e.preventDefault();
|
||||
internalEmit("MessageRenderer", "edit_last");
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!e.shiftKey &&
|
||||
e.key === "Enter" &&
|
||||
!isTouchscreenDevice
|
||||
) {
|
||||
e.preventDefault();
|
||||
return send();
|
||||
}
|
||||
if (
|
||||
!e.shiftKey &&
|
||||
e.key === "Enter" &&
|
||||
!isTouchscreenDevice
|
||||
) {
|
||||
e.preventDefault();
|
||||
return send();
|
||||
}
|
||||
|
||||
debouncedStopTyping(true);
|
||||
}}
|
||||
placeholder={
|
||||
channel.channel_type === "DirectMessage"
|
||||
? translate("app.main.channel.message_who", {
|
||||
person: client.users.get(
|
||||
client.channels.getRecipient(
|
||||
channel._id,
|
||||
),
|
||||
)?.username,
|
||||
})
|
||||
: channel.channel_type === "SavedMessages"
|
||||
? translate("app.main.channel.message_saved")
|
||||
: translate("app.main.channel.message_where", {
|
||||
channel_name: channel.name,
|
||||
})
|
||||
}
|
||||
disabled={
|
||||
uploadState.type === "uploading" ||
|
||||
uploadState.type === "sending"
|
||||
}
|
||||
onChange={(e) => {
|
||||
setMessage(e.currentTarget.value);
|
||||
startTyping();
|
||||
onChange(e);
|
||||
}}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
{isTouchscreenDevice && (
|
||||
<Action>
|
||||
<IconButton onClick={send}>
|
||||
<Send size={20} />
|
||||
</IconButton>
|
||||
</Action>
|
||||
)}
|
||||
</Base>
|
||||
</>
|
||||
);
|
||||
debouncedStopTyping(true);
|
||||
}}
|
||||
placeholder={
|
||||
channel.channel_type === "DirectMessage"
|
||||
? translate("app.main.channel.message_who", {
|
||||
person: client.users.get(
|
||||
client.channels.getRecipient(channel._id),
|
||||
)?.username,
|
||||
})
|
||||
: channel.channel_type === "SavedMessages"
|
||||
? translate("app.main.channel.message_saved")
|
||||
: translate("app.main.channel.message_where", {
|
||||
channel_name: channel.name,
|
||||
})
|
||||
}
|
||||
disabled={
|
||||
uploadState.type === "uploading" ||
|
||||
uploadState.type === "sending"
|
||||
}
|
||||
onChange={(e) => {
|
||||
setMessage(e.currentTarget.value);
|
||||
startTyping();
|
||||
onChange(e);
|
||||
}}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
{isTouchscreenDevice && (
|
||||
<Action>
|
||||
<IconButton onClick={send}>
|
||||
<Send size={20} />
|
||||
</IconButton>
|
||||
</Action>
|
||||
)}
|
||||
</Base>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default connectState<Omit<Props, "dispatch" | "draft">>(
|
||||
MessageBox,
|
||||
(state, { channel }) => {
|
||||
return {
|
||||
draft: state.drafts[channel._id],
|
||||
};
|
||||
},
|
||||
true,
|
||||
MessageBox,
|
||||
(state, { channel }) => {
|
||||
return {
|
||||
draft: state.drafts[channel._id],
|
||||
};
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
@@ -12,149 +12,149 @@ import UserShort from "../user/UserShort";
|
||||
import MessageBase, { MessageDetail, MessageInfo } from "./MessageBase";
|
||||
|
||||
const SystemContent = styled.div`
|
||||
gap: 4px;
|
||||
display: flex;
|
||||
padding: 2px 0;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
gap: 4px;
|
||||
display: flex;
|
||||
padding: 2px 0;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
`;
|
||||
|
||||
type SystemMessageParsed =
|
||||
| { type: "text"; content: string }
|
||||
| { type: "user_added"; user: User; by: User }
|
||||
| { type: "user_remove"; user: User; by: User }
|
||||
| { type: "user_joined"; user: User }
|
||||
| { type: "user_left"; user: User }
|
||||
| { type: "user_kicked"; user: User }
|
||||
| { type: "user_banned"; user: User }
|
||||
| { type: "channel_renamed"; name: string; by: User }
|
||||
| { type: "channel_description_changed"; by: User }
|
||||
| { type: "channel_icon_changed"; by: User };
|
||||
| { type: "text"; content: string }
|
||||
| { type: "user_added"; user: User; by: User }
|
||||
| { type: "user_remove"; user: User; by: User }
|
||||
| { type: "user_joined"; user: User }
|
||||
| { type: "user_left"; user: User }
|
||||
| { type: "user_kicked"; user: User }
|
||||
| { type: "user_banned"; user: User }
|
||||
| { type: "channel_renamed"; name: string; by: User }
|
||||
| { type: "channel_description_changed"; by: User }
|
||||
| { type: "channel_icon_changed"; by: User };
|
||||
|
||||
interface Props {
|
||||
attachContext?: boolean;
|
||||
message: MessageObject;
|
||||
attachContext?: boolean;
|
||||
message: MessageObject;
|
||||
}
|
||||
|
||||
export function SystemMessage({ attachContext, message }: Props) {
|
||||
const ctx = useForceUpdate();
|
||||
const ctx = useForceUpdate();
|
||||
|
||||
let data: SystemMessageParsed;
|
||||
let content = message.content;
|
||||
if (typeof content === "object") {
|
||||
switch (content.type) {
|
||||
case "text":
|
||||
data = content;
|
||||
break;
|
||||
case "user_added":
|
||||
case "user_remove":
|
||||
data = {
|
||||
type: content.type,
|
||||
user: useUser(content.id, ctx) as User,
|
||||
by: useUser(content.by, ctx) as User,
|
||||
};
|
||||
break;
|
||||
case "user_joined":
|
||||
case "user_left":
|
||||
case "user_kicked":
|
||||
case "user_banned":
|
||||
data = {
|
||||
type: content.type,
|
||||
user: useUser(content.id, ctx) as User,
|
||||
};
|
||||
break;
|
||||
case "channel_renamed":
|
||||
data = {
|
||||
type: "channel_renamed",
|
||||
name: content.name,
|
||||
by: useUser(content.by, ctx) as User,
|
||||
};
|
||||
break;
|
||||
case "channel_description_changed":
|
||||
case "channel_icon_changed":
|
||||
data = {
|
||||
type: content.type,
|
||||
by: useUser(content.by, ctx) as User,
|
||||
};
|
||||
break;
|
||||
default:
|
||||
data = { type: "text", content: JSON.stringify(content) };
|
||||
}
|
||||
} else {
|
||||
data = { type: "text", content };
|
||||
}
|
||||
let data: SystemMessageParsed;
|
||||
let content = message.content;
|
||||
if (typeof content === "object") {
|
||||
switch (content.type) {
|
||||
case "text":
|
||||
data = content;
|
||||
break;
|
||||
case "user_added":
|
||||
case "user_remove":
|
||||
data = {
|
||||
type: content.type,
|
||||
user: useUser(content.id, ctx) as User,
|
||||
by: useUser(content.by, ctx) as User,
|
||||
};
|
||||
break;
|
||||
case "user_joined":
|
||||
case "user_left":
|
||||
case "user_kicked":
|
||||
case "user_banned":
|
||||
data = {
|
||||
type: content.type,
|
||||
user: useUser(content.id, ctx) as User,
|
||||
};
|
||||
break;
|
||||
case "channel_renamed":
|
||||
data = {
|
||||
type: "channel_renamed",
|
||||
name: content.name,
|
||||
by: useUser(content.by, ctx) as User,
|
||||
};
|
||||
break;
|
||||
case "channel_description_changed":
|
||||
case "channel_icon_changed":
|
||||
data = {
|
||||
type: content.type,
|
||||
by: useUser(content.by, ctx) as User,
|
||||
};
|
||||
break;
|
||||
default:
|
||||
data = { type: "text", content: JSON.stringify(content) };
|
||||
}
|
||||
} else {
|
||||
data = { type: "text", content };
|
||||
}
|
||||
|
||||
let children;
|
||||
switch (data.type) {
|
||||
case "text":
|
||||
children = <span>{data.content}</span>;
|
||||
break;
|
||||
case "user_added":
|
||||
case "user_remove":
|
||||
children = (
|
||||
<TextReact
|
||||
id={`app.main.channel.system.${
|
||||
data.type === "user_added" ? "added_by" : "removed_by"
|
||||
}`}
|
||||
fields={{
|
||||
user: <UserShort user={data.user} />,
|
||||
other_user: <UserShort user={data.by} />,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "user_joined":
|
||||
case "user_left":
|
||||
case "user_kicked":
|
||||
case "user_banned":
|
||||
children = (
|
||||
<TextReact
|
||||
id={`app.main.channel.system.${data.type}`}
|
||||
fields={{
|
||||
user: <UserShort user={data.user} />,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "channel_renamed":
|
||||
children = (
|
||||
<TextReact
|
||||
id={`app.main.channel.system.channel_renamed`}
|
||||
fields={{
|
||||
user: <UserShort user={data.by} />,
|
||||
name: <b>{data.name}</b>,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "channel_description_changed":
|
||||
case "channel_icon_changed":
|
||||
children = (
|
||||
<TextReact
|
||||
id={`app.main.channel.system.${data.type}`}
|
||||
fields={{
|
||||
user: <UserShort user={data.by} />,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
let children;
|
||||
switch (data.type) {
|
||||
case "text":
|
||||
children = <span>{data.content}</span>;
|
||||
break;
|
||||
case "user_added":
|
||||
case "user_remove":
|
||||
children = (
|
||||
<TextReact
|
||||
id={`app.main.channel.system.${
|
||||
data.type === "user_added" ? "added_by" : "removed_by"
|
||||
}`}
|
||||
fields={{
|
||||
user: <UserShort user={data.user} />,
|
||||
other_user: <UserShort user={data.by} />,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "user_joined":
|
||||
case "user_left":
|
||||
case "user_kicked":
|
||||
case "user_banned":
|
||||
children = (
|
||||
<TextReact
|
||||
id={`app.main.channel.system.${data.type}`}
|
||||
fields={{
|
||||
user: <UserShort user={data.user} />,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "channel_renamed":
|
||||
children = (
|
||||
<TextReact
|
||||
id={`app.main.channel.system.channel_renamed`}
|
||||
fields={{
|
||||
user: <UserShort user={data.by} />,
|
||||
name: <b>{data.name}</b>,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "channel_description_changed":
|
||||
case "channel_icon_changed":
|
||||
children = (
|
||||
<TextReact
|
||||
id={`app.main.channel.system.${data.type}`}
|
||||
fields={{
|
||||
user: <UserShort user={data.by} />,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<MessageBase
|
||||
onContextMenu={
|
||||
attachContext
|
||||
? attachContextMenu("Menu", {
|
||||
message,
|
||||
contextualChannel: message.channel,
|
||||
})
|
||||
: undefined
|
||||
}>
|
||||
<MessageInfo>
|
||||
<MessageDetail message={message} position="left" />
|
||||
</MessageInfo>
|
||||
<SystemContent>{children}</SystemContent>
|
||||
</MessageBase>
|
||||
);
|
||||
return (
|
||||
<MessageBase
|
||||
onContextMenu={
|
||||
attachContext
|
||||
? attachContextMenu("Menu", {
|
||||
message,
|
||||
contextualChannel: message.channel,
|
||||
})
|
||||
: undefined
|
||||
}>
|
||||
<MessageInfo>
|
||||
<MessageDetail message={message} position="left" />
|
||||
</MessageInfo>
|
||||
<SystemContent>{children}</SystemContent>
|
||||
</MessageBase>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,121 +13,121 @@ import AttachmentActions from "./AttachmentActions";
|
||||
import TextFile from "./TextFile";
|
||||
|
||||
interface Props {
|
||||
attachment: AttachmentRJS;
|
||||
hasContent: boolean;
|
||||
attachment: AttachmentRJS;
|
||||
hasContent: boolean;
|
||||
}
|
||||
|
||||
const MAX_ATTACHMENT_WIDTH = 480;
|
||||
|
||||
export default function Attachment({ attachment, hasContent }: Props) {
|
||||
const client = useContext(AppContext);
|
||||
const { openScreen } = useIntermediate();
|
||||
const { filename, metadata } = attachment;
|
||||
const [spoiler, setSpoiler] = useState(filename.startsWith("SPOILER_"));
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const client = useContext(AppContext);
|
||||
const { openScreen } = useIntermediate();
|
||||
const { filename, metadata } = attachment;
|
||||
const [spoiler, setSpoiler] = useState(filename.startsWith("SPOILER_"));
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
const url = client.generateFileURL(
|
||||
attachment,
|
||||
{ width: MAX_ATTACHMENT_WIDTH * 1.5 },
|
||||
true,
|
||||
);
|
||||
const url = client.generateFileURL(
|
||||
attachment,
|
||||
{ width: MAX_ATTACHMENT_WIDTH * 1.5 },
|
||||
true,
|
||||
);
|
||||
|
||||
switch (metadata.type) {
|
||||
case "Image": {
|
||||
return (
|
||||
<div
|
||||
className={styles.container}
|
||||
onClick={() => spoiler && setSpoiler(false)}>
|
||||
{spoiler && (
|
||||
<div className={styles.overflow}>
|
||||
<span>
|
||||
<Text id="app.main.channel.misc.spoiler_attachment" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
src={url}
|
||||
alt={filename}
|
||||
width={metadata.width}
|
||||
height={metadata.height}
|
||||
data-spoiler={spoiler}
|
||||
data-has-content={hasContent}
|
||||
className={classNames(
|
||||
styles.attachment,
|
||||
styles.image,
|
||||
loaded && styles.loaded,
|
||||
)}
|
||||
onClick={() =>
|
||||
openScreen({ id: "image_viewer", attachment })
|
||||
}
|
||||
onMouseDown={(ev) =>
|
||||
ev.button === 1 && window.open(url, "_blank")
|
||||
}
|
||||
onLoad={() => setLoaded(true)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "Audio": {
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.attachment, styles.audio)}
|
||||
data-has-content={hasContent}>
|
||||
<AttachmentActions attachment={attachment} />
|
||||
<audio src={url} controls />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "Video": {
|
||||
return (
|
||||
<div
|
||||
className={styles.container}
|
||||
onClick={() => spoiler && setSpoiler(false)}>
|
||||
{spoiler && (
|
||||
<div className={styles.overflow}>
|
||||
<span>
|
||||
<Text id="app.main.channel.misc.spoiler_attachment" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
data-spoiler={spoiler}
|
||||
data-has-content={hasContent}
|
||||
className={classNames(styles.attachment, styles.video)}>
|
||||
<AttachmentActions attachment={attachment} />
|
||||
<video
|
||||
src={url}
|
||||
width={metadata.width}
|
||||
height={metadata.height}
|
||||
className={classNames(loaded && styles.loaded)}
|
||||
controls
|
||||
onMouseDown={(ev) =>
|
||||
ev.button === 1 && window.open(url, "_blank")
|
||||
}
|
||||
onLoadedMetadata={() => setLoaded(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "Text": {
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.attachment, styles.text)}
|
||||
data-has-content={hasContent}>
|
||||
<TextFile attachment={attachment} />
|
||||
<AttachmentActions attachment={attachment} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
default: {
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.attachment, styles.file)}
|
||||
data-has-content={hasContent}>
|
||||
<AttachmentActions attachment={attachment} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
switch (metadata.type) {
|
||||
case "Image": {
|
||||
return (
|
||||
<div
|
||||
className={styles.container}
|
||||
onClick={() => spoiler && setSpoiler(false)}>
|
||||
{spoiler && (
|
||||
<div className={styles.overflow}>
|
||||
<span>
|
||||
<Text id="app.main.channel.misc.spoiler_attachment" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
src={url}
|
||||
alt={filename}
|
||||
width={metadata.width}
|
||||
height={metadata.height}
|
||||
data-spoiler={spoiler}
|
||||
data-has-content={hasContent}
|
||||
className={classNames(
|
||||
styles.attachment,
|
||||
styles.image,
|
||||
loaded && styles.loaded,
|
||||
)}
|
||||
onClick={() =>
|
||||
openScreen({ id: "image_viewer", attachment })
|
||||
}
|
||||
onMouseDown={(ev) =>
|
||||
ev.button === 1 && window.open(url, "_blank")
|
||||
}
|
||||
onLoad={() => setLoaded(true)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "Audio": {
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.attachment, styles.audio)}
|
||||
data-has-content={hasContent}>
|
||||
<AttachmentActions attachment={attachment} />
|
||||
<audio src={url} controls />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "Video": {
|
||||
return (
|
||||
<div
|
||||
className={styles.container}
|
||||
onClick={() => spoiler && setSpoiler(false)}>
|
||||
{spoiler && (
|
||||
<div className={styles.overflow}>
|
||||
<span>
|
||||
<Text id="app.main.channel.misc.spoiler_attachment" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
data-spoiler={spoiler}
|
||||
data-has-content={hasContent}
|
||||
className={classNames(styles.attachment, styles.video)}>
|
||||
<AttachmentActions attachment={attachment} />
|
||||
<video
|
||||
src={url}
|
||||
width={metadata.width}
|
||||
height={metadata.height}
|
||||
className={classNames(loaded && styles.loaded)}
|
||||
controls
|
||||
onMouseDown={(ev) =>
|
||||
ev.button === 1 && window.open(url, "_blank")
|
||||
}
|
||||
onLoadedMetadata={() => setLoaded(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "Text": {
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.attachment, styles.text)}
|
||||
data-has-content={hasContent}>
|
||||
<TextFile attachment={attachment} />
|
||||
<AttachmentActions attachment={attachment} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
default: {
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.attachment, styles.file)}
|
||||
data-has-content={hasContent}>
|
||||
<AttachmentActions attachment={attachment} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
Download,
|
||||
LinkExternal,
|
||||
File,
|
||||
Headphone,
|
||||
Video,
|
||||
Download,
|
||||
LinkExternal,
|
||||
File,
|
||||
Headphone,
|
||||
Video,
|
||||
} from "@styled-icons/boxicons-regular";
|
||||
import { Attachment } from "revolt.js/dist/api/objects";
|
||||
|
||||
@@ -18,98 +18,98 @@ import { AppContext } from "../../../../context/revoltjs/RevoltClient";
|
||||
import IconButton from "../../../ui/IconButton";
|
||||
|
||||
interface Props {
|
||||
attachment: Attachment;
|
||||
attachment: Attachment;
|
||||
}
|
||||
|
||||
export default function AttachmentActions({ attachment }: Props) {
|
||||
const client = useContext(AppContext);
|
||||
const { filename, metadata, size } = attachment;
|
||||
const client = useContext(AppContext);
|
||||
const { filename, metadata, size } = attachment;
|
||||
|
||||
const url = client.generateFileURL(attachment)!;
|
||||
const open_url = `${url}/${filename}`;
|
||||
const download_url = url.replace("attachments", "attachments/download");
|
||||
const url = client.generateFileURL(attachment)!;
|
||||
const open_url = `${url}/${filename}`;
|
||||
const download_url = url.replace("attachments", "attachments/download");
|
||||
|
||||
const filesize = determineFileSize(size);
|
||||
const filesize = determineFileSize(size);
|
||||
|
||||
switch (metadata.type) {
|
||||
case "Image":
|
||||
return (
|
||||
<div className={classNames(styles.actions, styles.imageAction)}>
|
||||
<span className={styles.filename}>{filename}</span>
|
||||
<span className={styles.filesize}>
|
||||
{metadata.width + "x" + metadata.height} ({filesize})
|
||||
</span>
|
||||
<a
|
||||
href={open_url}
|
||||
target="_blank"
|
||||
className={styles.iconType}>
|
||||
<IconButton>
|
||||
<LinkExternal size={24} />
|
||||
</IconButton>
|
||||
</a>
|
||||
<a
|
||||
href={download_url}
|
||||
className={styles.downloadIcon}
|
||||
download
|
||||
target="_blank">
|
||||
<IconButton>
|
||||
<Download size={24} />
|
||||
</IconButton>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
case "Audio":
|
||||
return (
|
||||
<div className={classNames(styles.actions, styles.audioAction)}>
|
||||
<Headphone size={24} className={styles.iconType} />
|
||||
<span className={styles.filename}>{filename}</span>
|
||||
<span className={styles.filesize}>{filesize}</span>
|
||||
<a
|
||||
href={download_url}
|
||||
className={styles.downloadIcon}
|
||||
download
|
||||
target="_blank">
|
||||
<IconButton>
|
||||
<Download size={24} />
|
||||
</IconButton>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
case "Video":
|
||||
return (
|
||||
<div className={classNames(styles.actions, styles.videoAction)}>
|
||||
<Video size={24} className={styles.iconType} />
|
||||
<span className={styles.filename}>{filename}</span>
|
||||
<span className={styles.filesize}>
|
||||
{metadata.width + "x" + metadata.height} ({filesize})
|
||||
</span>
|
||||
<a
|
||||
href={download_url}
|
||||
className={styles.downloadIcon}
|
||||
download
|
||||
target="_blank">
|
||||
<IconButton>
|
||||
<Download size={24} />
|
||||
</IconButton>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className={styles.actions}>
|
||||
<File size={24} className={styles.iconType} />
|
||||
<span className={styles.filename}>{filename}</span>
|
||||
<span className={styles.filesize}>{filesize}</span>
|
||||
<a
|
||||
href={download_url}
|
||||
className={styles.downloadIcon}
|
||||
download
|
||||
target="_blank">
|
||||
<IconButton>
|
||||
<Download size={24} />
|
||||
</IconButton>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
switch (metadata.type) {
|
||||
case "Image":
|
||||
return (
|
||||
<div className={classNames(styles.actions, styles.imageAction)}>
|
||||
<span className={styles.filename}>{filename}</span>
|
||||
<span className={styles.filesize}>
|
||||
{metadata.width + "x" + metadata.height} ({filesize})
|
||||
</span>
|
||||
<a
|
||||
href={open_url}
|
||||
target="_blank"
|
||||
className={styles.iconType}>
|
||||
<IconButton>
|
||||
<LinkExternal size={24} />
|
||||
</IconButton>
|
||||
</a>
|
||||
<a
|
||||
href={download_url}
|
||||
className={styles.downloadIcon}
|
||||
download
|
||||
target="_blank">
|
||||
<IconButton>
|
||||
<Download size={24} />
|
||||
</IconButton>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
case "Audio":
|
||||
return (
|
||||
<div className={classNames(styles.actions, styles.audioAction)}>
|
||||
<Headphone size={24} className={styles.iconType} />
|
||||
<span className={styles.filename}>{filename}</span>
|
||||
<span className={styles.filesize}>{filesize}</span>
|
||||
<a
|
||||
href={download_url}
|
||||
className={styles.downloadIcon}
|
||||
download
|
||||
target="_blank">
|
||||
<IconButton>
|
||||
<Download size={24} />
|
||||
</IconButton>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
case "Video":
|
||||
return (
|
||||
<div className={classNames(styles.actions, styles.videoAction)}>
|
||||
<Video size={24} className={styles.iconType} />
|
||||
<span className={styles.filename}>{filename}</span>
|
||||
<span className={styles.filesize}>
|
||||
{metadata.width + "x" + metadata.height} ({filesize})
|
||||
</span>
|
||||
<a
|
||||
href={download_url}
|
||||
className={styles.downloadIcon}
|
||||
download
|
||||
target="_blank">
|
||||
<IconButton>
|
||||
<Download size={24} />
|
||||
</IconButton>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className={styles.actions}>
|
||||
<File size={24} className={styles.iconType} />
|
||||
<span className={styles.filename}>{filename}</span>
|
||||
<span className={styles.filesize}>{filesize}</span>
|
||||
<a
|
||||
href={download_url}
|
||||
className={styles.downloadIcon}
|
||||
download
|
||||
target="_blank">
|
||||
<IconButton>
|
||||
<Download size={24} />
|
||||
</IconButton>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,83 +11,83 @@ import Markdown from "../../../markdown/Markdown";
|
||||
import UserShort from "../../user/UserShort";
|
||||
|
||||
interface Props {
|
||||
channel: string;
|
||||
index: number;
|
||||
id: string;
|
||||
channel: string;
|
||||
index: number;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const ReplyBase = styled.div<{
|
||||
head?: boolean;
|
||||
fail?: boolean;
|
||||
preview?: boolean;
|
||||
head?: boolean;
|
||||
fail?: boolean;
|
||||
preview?: boolean;
|
||||
}>`
|
||||
gap: 4px;
|
||||
display: flex;
|
||||
font-size: 0.8em;
|
||||
margin-left: 30px;
|
||||
user-select: none;
|
||||
margin-bottom: 4px;
|
||||
align-items: center;
|
||||
color: var(--secondary-foreground);
|
||||
gap: 4px;
|
||||
display: flex;
|
||||
font-size: 0.8em;
|
||||
margin-left: 30px;
|
||||
user-select: none;
|
||||
margin-bottom: 4px;
|
||||
align-items: center;
|
||||
color: var(--secondary-foreground);
|
||||
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
svg:first-child {
|
||||
flex-shrink: 0;
|
||||
transform: scaleX(-1);
|
||||
color: var(--tertiary-foreground);
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.fail &&
|
||||
css`
|
||||
color: var(--tertiary-foreground);
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.head &&
|
||||
css`
|
||||
margin-top: 12px;
|
||||
`}
|
||||
svg:first-child {
|
||||
flex-shrink: 0;
|
||||
transform: scaleX(-1);
|
||||
color: var(--tertiary-foreground);
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.preview &&
|
||||
css`
|
||||
margin-left: 0;
|
||||
`}
|
||||
props.fail &&
|
||||
css`
|
||||
color: var(--tertiary-foreground);
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.head &&
|
||||
css`
|
||||
margin-top: 12px;
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.preview &&
|
||||
css`
|
||||
margin-left: 0;
|
||||
`}
|
||||
`;
|
||||
|
||||
export function MessageReply({ index, channel, id }: Props) {
|
||||
const view = useRenderState(channel);
|
||||
if (view?.type !== "RENDER") return null;
|
||||
const view = useRenderState(channel);
|
||||
if (view?.type !== "RENDER") return null;
|
||||
|
||||
const message = view.messages.find((x) => x._id === id);
|
||||
if (!message) {
|
||||
return (
|
||||
<ReplyBase head={index === 0} fail>
|
||||
<Reply size={16} />
|
||||
<span>
|
||||
<Text id="app.main.channel.misc.failed_load" />
|
||||
</span>
|
||||
</ReplyBase>
|
||||
);
|
||||
}
|
||||
const message = view.messages.find((x) => x._id === id);
|
||||
if (!message) {
|
||||
return (
|
||||
<ReplyBase head={index === 0} fail>
|
||||
<Reply size={16} />
|
||||
<span>
|
||||
<Text id="app.main.channel.misc.failed_load" />
|
||||
</span>
|
||||
</ReplyBase>
|
||||
);
|
||||
}
|
||||
|
||||
const user = useUser(message.author);
|
||||
const user = useUser(message.author);
|
||||
|
||||
return (
|
||||
<ReplyBase head={index === 0}>
|
||||
<Reply size={16} />
|
||||
<UserShort user={user} size={16} />
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
<File size={16} />
|
||||
)}
|
||||
<Markdown
|
||||
disallowBigEmoji
|
||||
content={(message.content as string).replace(/\n/g, " ")}
|
||||
/>
|
||||
</ReplyBase>
|
||||
);
|
||||
return (
|
||||
<ReplyBase head={index === 0}>
|
||||
<Reply size={16} />
|
||||
<UserShort user={user} size={16} />
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
<File size={16} />
|
||||
)}
|
||||
<Markdown
|
||||
disallowBigEmoji
|
||||
content={(message.content as string).replace(/\n/g, " ")}
|
||||
/>
|
||||
</ReplyBase>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,67 +6,67 @@ import { useContext, useEffect, useState } from "preact/hooks";
|
||||
|
||||
import RequiresOnline from "../../../../context/revoltjs/RequiresOnline";
|
||||
import {
|
||||
AppContext,
|
||||
StatusContext,
|
||||
AppContext,
|
||||
StatusContext,
|
||||
} from "../../../../context/revoltjs/RevoltClient";
|
||||
|
||||
import Preloader from "../../../ui/Preloader";
|
||||
|
||||
interface Props {
|
||||
attachment: Attachment;
|
||||
attachment: Attachment;
|
||||
}
|
||||
|
||||
const fileCache: { [key: string]: string } = {};
|
||||
|
||||
export default function TextFile({ attachment }: Props) {
|
||||
const [content, setContent] = useState<undefined | string>(undefined);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const status = useContext(StatusContext);
|
||||
const client = useContext(AppContext);
|
||||
const [content, setContent] = useState<undefined | string>(undefined);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const status = useContext(StatusContext);
|
||||
const client = useContext(AppContext);
|
||||
|
||||
const url = client.generateFileURL(attachment)!;
|
||||
const url = client.generateFileURL(attachment)!;
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof content !== "undefined") return;
|
||||
if (loading) return;
|
||||
setLoading(true);
|
||||
useEffect(() => {
|
||||
if (typeof content !== "undefined") return;
|
||||
if (loading) return;
|
||||
setLoading(true);
|
||||
|
||||
let cached = fileCache[attachment._id];
|
||||
if (cached) {
|
||||
setContent(cached);
|
||||
setLoading(false);
|
||||
} else {
|
||||
axios
|
||||
.get(url)
|
||||
.then((res) => {
|
||||
setContent(res.data);
|
||||
fileCache[attachment._id] = res.data;
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
console.error(
|
||||
"Failed to load text file. [",
|
||||
attachment._id,
|
||||
"]",
|
||||
);
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}, [content, loading, status]);
|
||||
let cached = fileCache[attachment._id];
|
||||
if (cached) {
|
||||
setContent(cached);
|
||||
setLoading(false);
|
||||
} else {
|
||||
axios
|
||||
.get(url)
|
||||
.then((res) => {
|
||||
setContent(res.data);
|
||||
fileCache[attachment._id] = res.data;
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
console.error(
|
||||
"Failed to load text file. [",
|
||||
attachment._id,
|
||||
"]",
|
||||
);
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}, [content, loading, status]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.textContent}
|
||||
data-loading={typeof content === "undefined"}>
|
||||
{content ? (
|
||||
<pre>
|
||||
<code>{content}</code>
|
||||
</pre>
|
||||
) : (
|
||||
<RequiresOnline>
|
||||
<Preloader type="ring" />
|
||||
</RequiresOnline>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={styles.textContent}
|
||||
data-loading={typeof content === "undefined"}>
|
||||
{content ? (
|
||||
<pre>
|
||||
<code>{content}</code>
|
||||
</pre>
|
||||
) : (
|
||||
<RequiresOnline>
|
||||
<Preloader type="ring" />
|
||||
</RequiresOnline>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,225 +9,225 @@ import { determineFileSize } from "../../../../lib/fileSize";
|
||||
import { CAN_UPLOAD_AT_ONCE, UploadState } from "../MessageBox";
|
||||
|
||||
interface Props {
|
||||
state: UploadState;
|
||||
addFile: () => void;
|
||||
removeFile: (index: number) => void;
|
||||
state: UploadState;
|
||||
addFile: () => void;
|
||||
removeFile: (index: number) => void;
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
user-select: none;
|
||||
flex-direction: column;
|
||||
background: var(--message-box);
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
user-select: none;
|
||||
flex-direction: column;
|
||||
background: var(--message-box);
|
||||
`;
|
||||
|
||||
const Carousel = styled.div`
|
||||
gap: 8px;
|
||||
display: flex;
|
||||
overflow-x: scroll;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
display: flex;
|
||||
overflow-x: scroll;
|
||||
flex-direction: row;
|
||||
`;
|
||||
|
||||
const Entry = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&.fade {
|
||||
opacity: 0.4;
|
||||
}
|
||||
&.fade {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
span.fn {
|
||||
margin: auto;
|
||||
font-size: 0.8em;
|
||||
overflow: hidden;
|
||||
max-width: 180px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--secondary-foreground);
|
||||
}
|
||||
span.fn {
|
||||
margin: auto;
|
||||
font-size: 0.8em;
|
||||
overflow: hidden;
|
||||
max-width: 180px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--secondary-foreground);
|
||||
}
|
||||
|
||||
span.size {
|
||||
font-size: 0.6em;
|
||||
color: var(--tertiary-foreground);
|
||||
text-align: center;
|
||||
}
|
||||
span.size {
|
||||
font-size: 0.6em;
|
||||
color: var(--tertiary-foreground);
|
||||
text-align: center;
|
||||
}
|
||||
`;
|
||||
|
||||
const Description = styled.div`
|
||||
gap: 4px;
|
||||
display: flex;
|
||||
font-size: 0.9em;
|
||||
align-items: center;
|
||||
color: var(--secondary-foreground);
|
||||
gap: 4px;
|
||||
display: flex;
|
||||
font-size: 0.9em;
|
||||
align-items: center;
|
||||
color: var(--secondary-foreground);
|
||||
`;
|
||||
|
||||
const Divider = styled.div`
|
||||
width: 4px;
|
||||
height: 130px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
background: var(--tertiary-background);
|
||||
width: 4px;
|
||||
height: 130px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
background: var(--tertiary-background);
|
||||
`;
|
||||
|
||||
const EmptyEntry = styled.div`
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
display: grid;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
place-items: center;
|
||||
background: var(--primary-background);
|
||||
transition: 0.1s ease background-color;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
display: grid;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
place-items: center;
|
||||
background: var(--primary-background);
|
||||
transition: 0.1s ease background-color;
|
||||
|
||||
&:hover {
|
||||
background: var(--secondary-background);
|
||||
}
|
||||
&:hover {
|
||||
background: var(--secondary-background);
|
||||
}
|
||||
`;
|
||||
|
||||
const PreviewBox = styled.div`
|
||||
display: grid;
|
||||
grid-template: "main" 100px / minmax(100px, 1fr);
|
||||
justify-items: center;
|
||||
display: grid;
|
||||
grid-template: "main" 100px / minmax(100px, 1fr);
|
||||
justify-items: center;
|
||||
|
||||
background: var(--primary-background);
|
||||
background: var(--primary-background);
|
||||
|
||||
overflow: hidden;
|
||||
overflow: hidden;
|
||||
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
|
||||
.icon,
|
||||
.overlay {
|
||||
grid-area: main;
|
||||
}
|
||||
.icon,
|
||||
.overlay {
|
||||
grid-area: main;
|
||||
}
|
||||
|
||||
.icon {
|
||||
height: 100px;
|
||||
width: 100%;
|
||||
margin-bottom: 4px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.icon {
|
||||
height: 100px;
|
||||
width: 100%;
|
||||
margin-bottom: 4px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
.overlay {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
|
||||
transition: 0.1s ease opacity;
|
||||
}
|
||||
transition: 0.1s ease opacity;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.overlay {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
.overlay {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function FileEntry({
|
||||
file,
|
||||
remove,
|
||||
index,
|
||||
file,
|
||||
remove,
|
||||
index,
|
||||
}: {
|
||||
file: File;
|
||||
remove?: () => void;
|
||||
index: number;
|
||||
file: File;
|
||||
remove?: () => void;
|
||||
index: number;
|
||||
}) {
|
||||
if (!file.type.startsWith("image/"))
|
||||
return (
|
||||
<Entry className={index >= CAN_UPLOAD_AT_ONCE ? "fade" : ""}>
|
||||
<PreviewBox onClick={remove}>
|
||||
<EmptyEntry className="icon">
|
||||
<File size={36} />
|
||||
</EmptyEntry>
|
||||
<div class="overlay">
|
||||
<XCircle size={36} />
|
||||
</div>
|
||||
</PreviewBox>
|
||||
<span class="fn">{file.name}</span>
|
||||
<span class="size">{determineFileSize(file.size)}</span>
|
||||
</Entry>
|
||||
);
|
||||
if (!file.type.startsWith("image/"))
|
||||
return (
|
||||
<Entry className={index >= CAN_UPLOAD_AT_ONCE ? "fade" : ""}>
|
||||
<PreviewBox onClick={remove}>
|
||||
<EmptyEntry className="icon">
|
||||
<File size={36} />
|
||||
</EmptyEntry>
|
||||
<div class="overlay">
|
||||
<XCircle size={36} />
|
||||
</div>
|
||||
</PreviewBox>
|
||||
<span class="fn">{file.name}</span>
|
||||
<span class="size">{determineFileSize(file.size)}</span>
|
||||
</Entry>
|
||||
);
|
||||
|
||||
const [url, setURL] = useState("");
|
||||
const [url, setURL] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let url: string = URL.createObjectURL(file);
|
||||
setURL(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [file]);
|
||||
useEffect(() => {
|
||||
let url: string = URL.createObjectURL(file);
|
||||
setURL(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [file]);
|
||||
|
||||
return (
|
||||
<Entry className={index >= CAN_UPLOAD_AT_ONCE ? "fade" : ""}>
|
||||
<PreviewBox onClick={remove}>
|
||||
<img class="icon" src={url} alt={file.name} />
|
||||
<div class="overlay">
|
||||
<XCircle size={36} />
|
||||
</div>
|
||||
</PreviewBox>
|
||||
<span class="fn">{file.name}</span>
|
||||
<span class="size">{determineFileSize(file.size)}</span>
|
||||
</Entry>
|
||||
);
|
||||
return (
|
||||
<Entry className={index >= CAN_UPLOAD_AT_ONCE ? "fade" : ""}>
|
||||
<PreviewBox onClick={remove}>
|
||||
<img class="icon" src={url} alt={file.name} />
|
||||
<div class="overlay">
|
||||
<XCircle size={36} />
|
||||
</div>
|
||||
</PreviewBox>
|
||||
<span class="fn">{file.name}</span>
|
||||
<span class="size">{determineFileSize(file.size)}</span>
|
||||
</Entry>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FilePreview({ state, addFile, removeFile }: Props) {
|
||||
if (state.type === "none") return null;
|
||||
if (state.type === "none") return null;
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Carousel>
|
||||
{state.files.map((file, index) => (
|
||||
<>
|
||||
{index === CAN_UPLOAD_AT_ONCE && <Divider />}
|
||||
<FileEntry
|
||||
index={index}
|
||||
file={file}
|
||||
key={file.name}
|
||||
remove={
|
||||
state.type === "attached"
|
||||
? () => removeFile(index)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</>
|
||||
))}
|
||||
{state.type === "attached" && (
|
||||
<EmptyEntry onClick={addFile}>
|
||||
<Plus size={48} />
|
||||
</EmptyEntry>
|
||||
)}
|
||||
</Carousel>
|
||||
{state.type === "uploading" && (
|
||||
<Description>
|
||||
<Share size={24} />
|
||||
<Text id="app.main.channel.uploading_file" /> (
|
||||
{state.percent}%)
|
||||
</Description>
|
||||
)}
|
||||
{state.type === "sending" && (
|
||||
<Description>
|
||||
<Share size={24} />
|
||||
Sending...
|
||||
</Description>
|
||||
)}
|
||||
{state.type === "failed" && (
|
||||
<Description>
|
||||
<X size={24} />
|
||||
<Text id={`error.${state.error}`} />
|
||||
</Description>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
return (
|
||||
<Container>
|
||||
<Carousel>
|
||||
{state.files.map((file, index) => (
|
||||
<>
|
||||
{index === CAN_UPLOAD_AT_ONCE && <Divider />}
|
||||
<FileEntry
|
||||
index={index}
|
||||
file={file}
|
||||
key={file.name}
|
||||
remove={
|
||||
state.type === "attached"
|
||||
? () => removeFile(index)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</>
|
||||
))}
|
||||
{state.type === "attached" && (
|
||||
<EmptyEntry onClick={addFile}>
|
||||
<Plus size={48} />
|
||||
</EmptyEntry>
|
||||
)}
|
||||
</Carousel>
|
||||
{state.type === "uploading" && (
|
||||
<Description>
|
||||
<Share size={24} />
|
||||
<Text id="app.main.channel.uploading_file" /> (
|
||||
{state.percent}%)
|
||||
</Description>
|
||||
)}
|
||||
{state.type === "sending" && (
|
||||
<Description>
|
||||
<Share size={24} />
|
||||
Sending...
|
||||
</Description>
|
||||
)}
|
||||
{state.type === "failed" && (
|
||||
<Description>
|
||||
<X size={24} />
|
||||
<Text id={`error.${state.error}`} />
|
||||
</Description>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,61 +4,61 @@ import styled from "styled-components";
|
||||
import { Text } from "preact-i18n";
|
||||
|
||||
import {
|
||||
SingletonMessageRenderer,
|
||||
useRenderState,
|
||||
SingletonMessageRenderer,
|
||||
useRenderState,
|
||||
} from "../../../../lib/renderer/Singleton";
|
||||
|
||||
const Bar = styled.div`
|
||||
z-index: 10;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
position: relative;
|
||||
|
||||
> div {
|
||||
top: -26px;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
border-radius: 4px 4px 0 0;
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
padding: 4px 8px;
|
||||
user-select: none;
|
||||
color: var(--secondary-foreground);
|
||||
background: var(--secondary-background);
|
||||
justify-content: space-between;
|
||||
transition: color ease-in-out 0.08s;
|
||||
> div {
|
||||
top: -26px;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
border-radius: 4px 4px 0 0;
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
padding: 4px 8px;
|
||||
user-select: none;
|
||||
color: var(--secondary-foreground);
|
||||
background: var(--secondary-background);
|
||||
justify-content: space-between;
|
||||
transition: color ease-in-out 0.08s;
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
> div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--primary-text);
|
||||
}
|
||||
&:hover {
|
||||
color: var(--primary-text);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
}
|
||||
&:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default function JumpToBottom({ id }: { id: string }) {
|
||||
const view = useRenderState(id);
|
||||
if (!view || view.type !== "RENDER" || view.atBottom) return null;
|
||||
const view = useRenderState(id);
|
||||
if (!view || view.type !== "RENDER" || view.atBottom) return null;
|
||||
|
||||
return (
|
||||
<Bar>
|
||||
<div
|
||||
onClick={() => SingletonMessageRenderer.jumpToBottom(id, true)}>
|
||||
<div>
|
||||
<Text id="app.main.channel.misc.viewing_old" />
|
||||
</div>
|
||||
<div>
|
||||
<Text id="app.main.channel.misc.jump_present" />{" "}
|
||||
<DownArrow size={18} />
|
||||
</div>
|
||||
</div>
|
||||
</Bar>
|
||||
);
|
||||
return (
|
||||
<Bar>
|
||||
<div
|
||||
onClick={() => SingletonMessageRenderer.jumpToBottom(id, true)}>
|
||||
<div>
|
||||
<Text id="app.main.channel.misc.viewing_old" />
|
||||
</div>
|
||||
<div>
|
||||
<Text id="app.main.channel.misc.jump_present" />{" "}
|
||||
<DownArrow size={18} />
|
||||
</div>
|
||||
</div>
|
||||
</Bar>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
At,
|
||||
Reply as ReplyIcon,
|
||||
File,
|
||||
XCircle,
|
||||
At,
|
||||
Reply as ReplyIcon,
|
||||
File,
|
||||
XCircle,
|
||||
} from "@styled-icons/boxicons-regular";
|
||||
import styled from "styled-components";
|
||||
|
||||
@@ -23,119 +23,119 @@ import UserShort from "../../user/UserShort";
|
||||
import { ReplyBase } from "../attachments/MessageReply";
|
||||
|
||||
interface Props {
|
||||
channel: string;
|
||||
replies: Reply[];
|
||||
setReplies: StateUpdater<Reply[]>;
|
||||
channel: string;
|
||||
replies: Reply[];
|
||||
setReplies: StateUpdater<Reply[]>;
|
||||
}
|
||||
|
||||
const Base = styled.div`
|
||||
display: flex;
|
||||
padding: 0 22px;
|
||||
user-select: none;
|
||||
align-items: center;
|
||||
background: var(--message-box);
|
||||
display: flex;
|
||||
padding: 0 22px;
|
||||
user-select: none;
|
||||
align-items: center;
|
||||
background: var(--message-box);
|
||||
|
||||
div {
|
||||
flex-grow: 1;
|
||||
}
|
||||
div {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.actions {
|
||||
gap: 12px;
|
||||
display: flex;
|
||||
}
|
||||
.actions {
|
||||
gap: 12px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
gap: 4px;
|
||||
display: flex;
|
||||
font-size: 0.7em;
|
||||
align-items: center;
|
||||
}
|
||||
.toggle {
|
||||
gap: 4px;
|
||||
display: flex;
|
||||
font-size: 0.7em;
|
||||
align-items: center;
|
||||
}
|
||||
`;
|
||||
|
||||
// ! FIXME: Move to global config
|
||||
const MAX_REPLIES = 5;
|
||||
export default function ReplyBar({ channel, replies, setReplies }: Props) {
|
||||
useEffect(() => {
|
||||
return internalSubscribe(
|
||||
"ReplyBar",
|
||||
"add",
|
||||
(id) =>
|
||||
replies.length < MAX_REPLIES &&
|
||||
!replies.find((x) => x.id === id) &&
|
||||
setReplies([...replies, { id, mention: false }]),
|
||||
);
|
||||
}, [replies]);
|
||||
useEffect(() => {
|
||||
return internalSubscribe(
|
||||
"ReplyBar",
|
||||
"add",
|
||||
(id) =>
|
||||
replies.length < MAX_REPLIES &&
|
||||
!replies.find((x) => x.id === id) &&
|
||||
setReplies([...replies, { id, mention: false }]),
|
||||
);
|
||||
}, [replies]);
|
||||
|
||||
const view = useRenderState(channel);
|
||||
if (view?.type !== "RENDER") return null;
|
||||
const view = useRenderState(channel);
|
||||
if (view?.type !== "RENDER") return null;
|
||||
|
||||
const ids = replies.map((x) => x.id);
|
||||
const messages = view.messages.filter((x) => ids.includes(x._id));
|
||||
const users = useUsers(messages.map((x) => x.author));
|
||||
const ids = replies.map((x) => x.id);
|
||||
const messages = view.messages.filter((x) => ids.includes(x._id));
|
||||
const users = useUsers(messages.map((x) => x.author));
|
||||
|
||||
return (
|
||||
<div>
|
||||
{replies.map((reply, index) => {
|
||||
let message = messages.find((x) => reply.id === x._id);
|
||||
// ! FIXME: better solution would be to
|
||||
// ! have a hook for resolving messages from
|
||||
// ! render state along with relevant users
|
||||
// -> which then fetches any unknown messages
|
||||
if (!message)
|
||||
return (
|
||||
<span>
|
||||
<Text id="app.main.channel.misc.failed_load" />
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
{replies.map((reply, index) => {
|
||||
let message = messages.find((x) => reply.id === x._id);
|
||||
// ! FIXME: better solution would be to
|
||||
// ! have a hook for resolving messages from
|
||||
// ! render state along with relevant users
|
||||
// -> which then fetches any unknown messages
|
||||
if (!message)
|
||||
return (
|
||||
<span>
|
||||
<Text id="app.main.channel.misc.failed_load" />
|
||||
</span>
|
||||
);
|
||||
|
||||
let user = users.find((x) => message!.author === x?._id);
|
||||
if (!user) return;
|
||||
let user = users.find((x) => message!.author === x?._id);
|
||||
if (!user) return;
|
||||
|
||||
return (
|
||||
<Base key={reply.id}>
|
||||
<ReplyBase preview>
|
||||
<ReplyIcon size={22} />
|
||||
<UserShort user={user} size={16} />
|
||||
{message.attachments &&
|
||||
message.attachments.length > 0 && (
|
||||
<File size={16} />
|
||||
)}
|
||||
<Markdown
|
||||
disallowBigEmoji
|
||||
content={(message.content as string).replace(
|
||||
/\n/g,
|
||||
" ",
|
||||
)}
|
||||
/>
|
||||
</ReplyBase>
|
||||
<span class="actions">
|
||||
<IconButton
|
||||
onClick={() =>
|
||||
setReplies(
|
||||
replies.map((_, i) =>
|
||||
i === index
|
||||
? { ..._, mention: !_.mention }
|
||||
: _,
|
||||
),
|
||||
)
|
||||
}>
|
||||
<span class="toggle">
|
||||
<At size={16} />{" "}
|
||||
{reply.mention ? "ON" : "OFF"}
|
||||
</span>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={() =>
|
||||
setReplies(
|
||||
replies.filter((_, i) => i !== index),
|
||||
)
|
||||
}>
|
||||
<XCircle size={16} />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Base>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Base key={reply.id}>
|
||||
<ReplyBase preview>
|
||||
<ReplyIcon size={22} />
|
||||
<UserShort user={user} size={16} />
|
||||
{message.attachments &&
|
||||
message.attachments.length > 0 && (
|
||||
<File size={16} />
|
||||
)}
|
||||
<Markdown
|
||||
disallowBigEmoji
|
||||
content={(message.content as string).replace(
|
||||
/\n/g,
|
||||
" ",
|
||||
)}
|
||||
/>
|
||||
</ReplyBase>
|
||||
<span class="actions">
|
||||
<IconButton
|
||||
onClick={() =>
|
||||
setReplies(
|
||||
replies.map((_, i) =>
|
||||
i === index
|
||||
? { ..._, mention: !_.mention }
|
||||
: _,
|
||||
),
|
||||
)
|
||||
}>
|
||||
<span class="toggle">
|
||||
<At size={16} />{" "}
|
||||
{reply.mention ? "ON" : "OFF"}
|
||||
</span>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={() =>
|
||||
setReplies(
|
||||
replies.filter((_, i) => i !== index),
|
||||
)
|
||||
}>
|
||||
<XCircle size={16} />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Base>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,111 +11,111 @@ import { AppContext } from "../../../../context/revoltjs/RevoltClient";
|
||||
import { useUsers } from "../../../../context/revoltjs/hooks";
|
||||
|
||||
interface Props {
|
||||
typing?: TypingUser[];
|
||||
typing?: TypingUser[];
|
||||
}
|
||||
|
||||
const Base = styled.div`
|
||||
position: relative;
|
||||
position: relative;
|
||||
|
||||
> div {
|
||||
height: 24px;
|
||||
margin-top: -24px;
|
||||
position: absolute;
|
||||
> div {
|
||||
height: 24px;
|
||||
margin-top: -24px;
|
||||
position: absolute;
|
||||
|
||||
gap: 8px;
|
||||
display: flex;
|
||||
padding: 0 10px;
|
||||
user-select: none;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
width: calc(100% - 3px);
|
||||
color: var(--secondary-foreground);
|
||||
background: var(--secondary-background);
|
||||
}
|
||||
gap: 8px;
|
||||
display: flex;
|
||||
padding: 0 10px;
|
||||
user-select: none;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
width: calc(100% - 3px);
|
||||
color: var(--secondary-foreground);
|
||||
background: var(--secondary-background);
|
||||
}
|
||||
|
||||
.avatars {
|
||||
display: flex;
|
||||
.avatars {
|
||||
display: flex;
|
||||
|
||||
img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-left: -4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
&:not(:first-child) {
|
||||
margin-left: -4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.usernames {
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.usernames {
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
`;
|
||||
|
||||
export function TypingIndicator({ typing }: Props) {
|
||||
if (typing && typing.length > 0) {
|
||||
const client = useContext(AppContext);
|
||||
const users = useUsers(typing.map((x) => x.id)).filter(
|
||||
(x) => typeof x !== "undefined",
|
||||
) as User[];
|
||||
if (typing && typing.length > 0) {
|
||||
const client = useContext(AppContext);
|
||||
const users = useUsers(typing.map((x) => x.id)).filter(
|
||||
(x) => typeof x !== "undefined",
|
||||
) as User[];
|
||||
|
||||
users.sort((a, b) =>
|
||||
a._id.toUpperCase().localeCompare(b._id.toUpperCase()),
|
||||
);
|
||||
users.sort((a, b) =>
|
||||
a._id.toUpperCase().localeCompare(b._id.toUpperCase()),
|
||||
);
|
||||
|
||||
let text;
|
||||
if (users.length >= 5) {
|
||||
text = <Text id="app.main.channel.typing.several" />;
|
||||
} else if (users.length > 1) {
|
||||
const usersCopy = [...users];
|
||||
text = (
|
||||
<Text
|
||||
id="app.main.channel.typing.multiple"
|
||||
fields={{
|
||||
user: usersCopy.pop()?.username,
|
||||
userlist: usersCopy.map((x) => x.username).join(", "),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
text = (
|
||||
<Text
|
||||
id="app.main.channel.typing.single"
|
||||
fields={{ user: users[0].username }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
let text;
|
||||
if (users.length >= 5) {
|
||||
text = <Text id="app.main.channel.typing.several" />;
|
||||
} else if (users.length > 1) {
|
||||
const usersCopy = [...users];
|
||||
text = (
|
||||
<Text
|
||||
id="app.main.channel.typing.multiple"
|
||||
fields={{
|
||||
user: usersCopy.pop()?.username,
|
||||
userlist: usersCopy.map((x) => x.username).join(", "),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
text = (
|
||||
<Text
|
||||
id="app.main.channel.typing.single"
|
||||
fields={{ user: users[0].username }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Base>
|
||||
<div>
|
||||
<div className="avatars">
|
||||
{users.map((user) => (
|
||||
<img
|
||||
src={client.users.getAvatarURL(
|
||||
user._id,
|
||||
{ max_side: 256 },
|
||||
true,
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="usernames">{text}</div>
|
||||
</div>
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Base>
|
||||
<div>
|
||||
<div className="avatars">
|
||||
{users.map((user) => (
|
||||
<img
|
||||
src={client.users.getAvatarURL(
|
||||
user._id,
|
||||
{ max_side: 256 },
|
||||
true,
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="usernames">{text}</div>
|
||||
</div>
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
return null;
|
||||
}
|
||||
|
||||
export default connectState<{ id: string }>(TypingIndicator, (state, props) => {
|
||||
return {
|
||||
typing: state.typing && state.typing[props.id],
|
||||
};
|
||||
return {
|
||||
typing: state.typing && state.typing[props.id],
|
||||
};
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ import { MessageAreaWidthContext } from "../../../../pages/channels/messaging/Me
|
||||
import EmbedMedia from "./EmbedMedia";
|
||||
|
||||
interface Props {
|
||||
embed: EmbedRJS;
|
||||
embed: EmbedRJS;
|
||||
}
|
||||
|
||||
const MAX_EMBED_WIDTH = 480;
|
||||
@@ -19,149 +19,149 @@ const CONTAINER_PADDING = 24;
|
||||
const MAX_PREVIEW_SIZE = 150;
|
||||
|
||||
export default function Embed({ embed }: Props) {
|
||||
// ! FIXME: temp code
|
||||
// ! add proxy function to client
|
||||
function proxyImage(url: string) {
|
||||
return "https://jan.revolt.chat/proxy?url=" + encodeURIComponent(url);
|
||||
}
|
||||
// ! FIXME: temp code
|
||||
// ! add proxy function to client
|
||||
function proxyImage(url: string) {
|
||||
return "https://jan.revolt.chat/proxy?url=" + encodeURIComponent(url);
|
||||
}
|
||||
|
||||
const { openScreen } = useIntermediate();
|
||||
const maxWidth = Math.min(
|
||||
useContext(MessageAreaWidthContext) - CONTAINER_PADDING,
|
||||
MAX_EMBED_WIDTH,
|
||||
);
|
||||
const { openScreen } = useIntermediate();
|
||||
const maxWidth = Math.min(
|
||||
useContext(MessageAreaWidthContext) - CONTAINER_PADDING,
|
||||
MAX_EMBED_WIDTH,
|
||||
);
|
||||
|
||||
function calculateSize(
|
||||
w: number,
|
||||
h: number,
|
||||
): { width: number; height: number } {
|
||||
let limitingWidth = Math.min(maxWidth, w);
|
||||
function calculateSize(
|
||||
w: number,
|
||||
h: number,
|
||||
): { width: number; height: number } {
|
||||
let limitingWidth = Math.min(maxWidth, w);
|
||||
|
||||
let limitingHeight = Math.min(MAX_EMBED_HEIGHT, h);
|
||||
let limitingHeight = Math.min(MAX_EMBED_HEIGHT, h);
|
||||
|
||||
// Calculate smallest possible WxH.
|
||||
let width = Math.min(limitingWidth, limitingHeight * (w / h));
|
||||
// Calculate smallest possible WxH.
|
||||
let width = Math.min(limitingWidth, limitingHeight * (w / h));
|
||||
|
||||
let height = Math.min(limitingHeight, limitingWidth * (h / w));
|
||||
let height = Math.min(limitingHeight, limitingWidth * (h / w));
|
||||
|
||||
return { width, height };
|
||||
}
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
switch (embed.type) {
|
||||
case "Website": {
|
||||
// Determine special embed size.
|
||||
let mw, mh;
|
||||
let largeMedia =
|
||||
(embed.special && embed.special.type !== "None") ||
|
||||
embed.image?.size === "Large";
|
||||
switch (embed.special?.type) {
|
||||
case "YouTube":
|
||||
case "Bandcamp": {
|
||||
mw = embed.video?.width ?? 1280;
|
||||
mh = embed.video?.height ?? 720;
|
||||
break;
|
||||
}
|
||||
case "Twitch": {
|
||||
mw = 1280;
|
||||
mh = 720;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
if (embed.image?.size === "Preview") {
|
||||
mw = MAX_EMBED_WIDTH;
|
||||
mh = Math.min(
|
||||
embed.image.height ?? 0,
|
||||
MAX_PREVIEW_SIZE,
|
||||
);
|
||||
} else {
|
||||
mw = embed.image?.width ?? MAX_EMBED_WIDTH;
|
||||
mh = embed.image?.height ?? 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
switch (embed.type) {
|
||||
case "Website": {
|
||||
// Determine special embed size.
|
||||
let mw, mh;
|
||||
let largeMedia =
|
||||
(embed.special && embed.special.type !== "None") ||
|
||||
embed.image?.size === "Large";
|
||||
switch (embed.special?.type) {
|
||||
case "YouTube":
|
||||
case "Bandcamp": {
|
||||
mw = embed.video?.width ?? 1280;
|
||||
mh = embed.video?.height ?? 720;
|
||||
break;
|
||||
}
|
||||
case "Twitch": {
|
||||
mw = 1280;
|
||||
mh = 720;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
if (embed.image?.size === "Preview") {
|
||||
mw = MAX_EMBED_WIDTH;
|
||||
mh = Math.min(
|
||||
embed.image.height ?? 0,
|
||||
MAX_PREVIEW_SIZE,
|
||||
);
|
||||
} else {
|
||||
mw = embed.image?.width ?? MAX_EMBED_WIDTH;
|
||||
mh = embed.image?.height ?? 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let { width, height } = calculateSize(mw, mh);
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.embed, styles.website)}
|
||||
style={{
|
||||
borderInlineStartColor:
|
||||
embed.color ?? "var(--tertiary-background)",
|
||||
width: width + CONTAINER_PADDING,
|
||||
}}>
|
||||
<div>
|
||||
{embed.site_name && (
|
||||
<div className={styles.siteinfo}>
|
||||
{embed.icon_url && (
|
||||
<img
|
||||
className={styles.favicon}
|
||||
src={proxyImage(embed.icon_url)}
|
||||
draggable={false}
|
||||
onError={(e) =>
|
||||
(e.currentTarget.style.display =
|
||||
"none")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.site}>
|
||||
{embed.site_name}{" "}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
let { width, height } = calculateSize(mw, mh);
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.embed, styles.website)}
|
||||
style={{
|
||||
borderInlineStartColor:
|
||||
embed.color ?? "var(--tertiary-background)",
|
||||
width: width + CONTAINER_PADDING,
|
||||
}}>
|
||||
<div>
|
||||
{embed.site_name && (
|
||||
<div className={styles.siteinfo}>
|
||||
{embed.icon_url && (
|
||||
<img
|
||||
className={styles.favicon}
|
||||
src={proxyImage(embed.icon_url)}
|
||||
draggable={false}
|
||||
onError={(e) =>
|
||||
(e.currentTarget.style.display =
|
||||
"none")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.site}>
|
||||
{embed.site_name}{" "}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/*<span><a href={embed.url} target={"_blank"} className={styles.author}>Author</a></span>*/}
|
||||
{embed.title && (
|
||||
<span>
|
||||
<a
|
||||
href={embed.url}
|
||||
target={"_blank"}
|
||||
className={styles.title}>
|
||||
{embed.title}
|
||||
</a>
|
||||
</span>
|
||||
)}
|
||||
{embed.description && (
|
||||
<div className={styles.description}>
|
||||
{embed.description}
|
||||
</div>
|
||||
)}
|
||||
{/*<span><a href={embed.url} target={"_blank"} className={styles.author}>Author</a></span>*/}
|
||||
{embed.title && (
|
||||
<span>
|
||||
<a
|
||||
href={embed.url}
|
||||
target={"_blank"}
|
||||
className={styles.title}>
|
||||
{embed.title}
|
||||
</a>
|
||||
</span>
|
||||
)}
|
||||
{embed.description && (
|
||||
<div className={styles.description}>
|
||||
{embed.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{largeMedia && (
|
||||
<EmbedMedia embed={embed} height={height} />
|
||||
)}
|
||||
</div>
|
||||
{!largeMedia && (
|
||||
<div>
|
||||
<EmbedMedia
|
||||
embed={embed}
|
||||
width={
|
||||
height *
|
||||
((embed.image?.width ?? 0) /
|
||||
(embed.image?.height ?? 0))
|
||||
}
|
||||
height={height}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "Image": {
|
||||
return (
|
||||
<img
|
||||
className={classNames(styles.embed, styles.image)}
|
||||
style={calculateSize(embed.width, embed.height)}
|
||||
src={proxyImage(embed.url)}
|
||||
type="text/html"
|
||||
frameBorder="0"
|
||||
onClick={() => openScreen({ id: "image_viewer", embed })}
|
||||
onMouseDown={(ev) =>
|
||||
ev.button === 1 && window.open(embed.url, "_blank")
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
{largeMedia && (
|
||||
<EmbedMedia embed={embed} height={height} />
|
||||
)}
|
||||
</div>
|
||||
{!largeMedia && (
|
||||
<div>
|
||||
<EmbedMedia
|
||||
embed={embed}
|
||||
width={
|
||||
height *
|
||||
((embed.image?.width ?? 0) /
|
||||
(embed.image?.height ?? 0))
|
||||
}
|
||||
height={height}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "Image": {
|
||||
return (
|
||||
<img
|
||||
className={classNames(styles.embed, styles.image)}
|
||||
style={calculateSize(embed.width, embed.height)}
|
||||
src={proxyImage(embed.url)}
|
||||
type="text/html"
|
||||
frameBorder="0"
|
||||
onClick={() => openScreen({ id: "image_viewer", embed })}
|
||||
onMouseDown={(ev) =>
|
||||
ev.button === 1 && window.open(embed.url, "_blank")
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,96 +5,96 @@ import styles from "./Embed.module.scss";
|
||||
import { useIntermediate } from "../../../../context/intermediate/Intermediate";
|
||||
|
||||
interface Props {
|
||||
embed: Embed;
|
||||
width?: number;
|
||||
height: number;
|
||||
embed: Embed;
|
||||
width?: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export default function EmbedMedia({ embed, width, height }: Props) {
|
||||
// ! FIXME: temp code
|
||||
// ! add proxy function to client
|
||||
function proxyImage(url: string) {
|
||||
return "https://jan.revolt.chat/proxy?url=" + encodeURIComponent(url);
|
||||
}
|
||||
// ! FIXME: temp code
|
||||
// ! add proxy function to client
|
||||
function proxyImage(url: string) {
|
||||
return "https://jan.revolt.chat/proxy?url=" + encodeURIComponent(url);
|
||||
}
|
||||
|
||||
if (embed.type !== "Website") return null;
|
||||
const { openScreen } = useIntermediate();
|
||||
if (embed.type !== "Website") return null;
|
||||
const { openScreen } = useIntermediate();
|
||||
|
||||
switch (embed.special?.type) {
|
||||
case "YouTube":
|
||||
return (
|
||||
<iframe
|
||||
src={`https://www.youtube-nocookie.com/embed/${embed.special.id}?modestbranding=1`}
|
||||
allowFullScreen
|
||||
style={{ height }}
|
||||
/>
|
||||
);
|
||||
case "Twitch":
|
||||
return (
|
||||
<iframe
|
||||
src={`https://player.twitch.tv/?${embed.special.content_type.toLowerCase()}=${
|
||||
embed.special.id
|
||||
}&parent=${window.location.hostname}&autoplay=false`}
|
||||
frameBorder="0"
|
||||
allowFullScreen
|
||||
scrolling="no"
|
||||
style={{ height }}
|
||||
/>
|
||||
);
|
||||
case "Spotify":
|
||||
return (
|
||||
<iframe
|
||||
src={`https://open.spotify.com/embed/${embed.special.content_type}/${embed.special.id}`}
|
||||
frameBorder="0"
|
||||
allowFullScreen
|
||||
allowTransparency
|
||||
style={{ height }}
|
||||
/>
|
||||
);
|
||||
case "Soundcloud":
|
||||
return (
|
||||
<iframe
|
||||
src={`https://w.soundcloud.com/player/?url=${encodeURIComponent(
|
||||
embed.url!,
|
||||
)}&color=%23FF7F50&auto_play=false&hide_related=false&show_comments=true&show_user=true&show_reposts=false&show_teaser=true&visual=true`}
|
||||
frameBorder="0"
|
||||
scrolling="no"
|
||||
style={{ height }}
|
||||
/>
|
||||
);
|
||||
case "Bandcamp": {
|
||||
return (
|
||||
<iframe
|
||||
src={`https://bandcamp.com/EmbeddedPlayer/${embed.special.content_type.toLowerCase()}=${
|
||||
embed.special.id
|
||||
}/size=large/bgcol=181a1b/linkcol=056cc4/tracklist=false/transparent=true/`}
|
||||
seamless
|
||||
style={{ height }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default: {
|
||||
if (embed.image) {
|
||||
let url = embed.image.url;
|
||||
return (
|
||||
<img
|
||||
className={styles.image}
|
||||
src={proxyImage(url)}
|
||||
style={{ width, height }}
|
||||
onClick={() =>
|
||||
openScreen({
|
||||
id: "image_viewer",
|
||||
embed: embed.image,
|
||||
})
|
||||
}
|
||||
onMouseDown={(ev) =>
|
||||
ev.button === 1 && window.open(url, "_blank")
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
switch (embed.special?.type) {
|
||||
case "YouTube":
|
||||
return (
|
||||
<iframe
|
||||
src={`https://www.youtube-nocookie.com/embed/${embed.special.id}?modestbranding=1`}
|
||||
allowFullScreen
|
||||
style={{ height }}
|
||||
/>
|
||||
);
|
||||
case "Twitch":
|
||||
return (
|
||||
<iframe
|
||||
src={`https://player.twitch.tv/?${embed.special.content_type.toLowerCase()}=${
|
||||
embed.special.id
|
||||
}&parent=${window.location.hostname}&autoplay=false`}
|
||||
frameBorder="0"
|
||||
allowFullScreen
|
||||
scrolling="no"
|
||||
style={{ height }}
|
||||
/>
|
||||
);
|
||||
case "Spotify":
|
||||
return (
|
||||
<iframe
|
||||
src={`https://open.spotify.com/embed/${embed.special.content_type}/${embed.special.id}`}
|
||||
frameBorder="0"
|
||||
allowFullScreen
|
||||
allowTransparency
|
||||
style={{ height }}
|
||||
/>
|
||||
);
|
||||
case "Soundcloud":
|
||||
return (
|
||||
<iframe
|
||||
src={`https://w.soundcloud.com/player/?url=${encodeURIComponent(
|
||||
embed.url!,
|
||||
)}&color=%23FF7F50&auto_play=false&hide_related=false&show_comments=true&show_user=true&show_reposts=false&show_teaser=true&visual=true`}
|
||||
frameBorder="0"
|
||||
scrolling="no"
|
||||
style={{ height }}
|
||||
/>
|
||||
);
|
||||
case "Bandcamp": {
|
||||
return (
|
||||
<iframe
|
||||
src={`https://bandcamp.com/EmbeddedPlayer/${embed.special.content_type.toLowerCase()}=${
|
||||
embed.special.id
|
||||
}/size=large/bgcol=181a1b/linkcol=056cc4/tracklist=false/transparent=true/`}
|
||||
seamless
|
||||
style={{ height }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default: {
|
||||
if (embed.image) {
|
||||
let url = embed.image.url;
|
||||
return (
|
||||
<img
|
||||
className={styles.image}
|
||||
src={proxyImage(url)}
|
||||
style={{ width, height }}
|
||||
onClick={() =>
|
||||
openScreen({
|
||||
id: "image_viewer",
|
||||
embed: embed.image,
|
||||
})
|
||||
}
|
||||
onMouseDown={(ev) =>
|
||||
ev.button === 1 && window.open(url, "_blank")
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -6,25 +6,25 @@ import styles from "./Embed.module.scss";
|
||||
import IconButton from "../../../ui/IconButton";
|
||||
|
||||
interface Props {
|
||||
embed: EmbedImage;
|
||||
embed: EmbedImage;
|
||||
}
|
||||
|
||||
export default function EmbedMediaActions({ embed }: Props) {
|
||||
const filename = embed.url.split("/").pop();
|
||||
const filename = embed.url.split("/").pop();
|
||||
|
||||
return (
|
||||
<div className={styles.actions}>
|
||||
<div className={styles.info}>
|
||||
<span className={styles.filename}>{filename}</span>
|
||||
<span className={styles.filesize}>
|
||||
{embed.width + "x" + embed.height}
|
||||
</span>
|
||||
</div>
|
||||
<a href={embed.url} target="_blank">
|
||||
<IconButton>
|
||||
<LinkExternal size={24} />
|
||||
</IconButton>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className={styles.actions}>
|
||||
<div className={styles.info}>
|
||||
<span className={styles.filename}>{filename}</span>
|
||||
<span className={styles.filesize}>
|
||||
{embed.width + "x" + embed.height}
|
||||
</span>
|
||||
</div>
|
||||
<a href={embed.url} target="_blank">
|
||||
<IconButton>
|
||||
<LinkExternal size={24} />
|
||||
</IconButton>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user