Use tabWidth 4 without actual tabs.

This commit is contained in:
Paul
2021-07-05 11:25:20 +01:00
parent 7bd33d8d34
commit b5a11d5c8f
180 changed files with 16619 additions and 16622 deletions

View File

@@ -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);

View File

@@ -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>
);
}

View File

@@ -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,
);

View File

@@ -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>
);
}

View File

@@ -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>
);
}
}
}

View File

@@ -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>
);
}
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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],
};
});

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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>
);
}