Implement new auto-size text area.

Add bars + header + sidebar to channels.
This commit is contained in:
Paul 2021-06-21 21:11:53 +01:00
parent d965b20ee2
commit 602cca1047
27 changed files with 1140 additions and 242 deletions

View file

@ -24,15 +24,19 @@ export default function Message({ attachContext, message, contrast, content: rep
const content = message.content as string; const content = message.content as string;
return ( return (
<MessageBase contrast={contrast} <MessageBase id={message._id}
contrast={contrast}
onContextMenu={attachContext ? attachContextMenu('Menu', { message, contextualChannel: message.channel }) : undefined}> onContextMenu={attachContext ? attachContextMenu('Menu', { message, contextualChannel: message.channel }) : undefined}>
<MessageInfo> <MessageInfo>
{ head ? { head ?
<UserIcon target={user} size={36} /> : <UserIcon target={user} size={36} /> :
<MessageDetail message={message} /> } <MessageDetail message={message} position="left" /> }
</MessageInfo> </MessageInfo>
<MessageContent> <MessageContent>
{ head && <Username user={user} /> } { head && <span className="author">
<Username user={user} />
<MessageDetail message={message} position="top" />
</span> }
{ replacement ?? <Markdown content={content} /> } { replacement ?? <Markdown content={content} /> }
{ message.attachments?.map((attachment, index) => { message.attachments?.map((attachment, index) =>
<Attachment key={index} attachment={attachment} hasContent={ index > 0 || content.length > 0 } />) } <Attachment key={index} attachment={attachment} hasContent={ index > 0 || content.length > 0 } />) }

View file

@ -1,6 +1,8 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import styled, { css } from "styled-components"; import Tooltip from "../Tooltip";
import { decodeTime } from "ulid"; import { decodeTime } from "ulid";
import { Text } from "preact-i18n";
import styled, { css } from "styled-components";
import { MessageObject } from "../../../context/revoltjs/util"; import { MessageObject } from "../../../context/revoltjs/util";
export interface BaseMessageProps { export interface BaseMessageProps {
@ -51,6 +53,12 @@ export default styled.div<BaseMessageProps>`
color: var(--error); color: var(--error);
` } ` }
.author {
gap: 8px;
display: flex;
align-items: center;
}
.copy { .copy {
width: 0; width: 0;
opacity: 0; opacity: 0;
@ -98,14 +106,47 @@ export const MessageContent = styled.div`
justify-content: center; justify-content: center;
`; `;
export function MessageDetail({ message }: { message: MessageObject }) { export const DetailBase = styled.div`
gap: 4px;
font-size: 10px;
display: inline-flex;
color: var(--tertiary-foreground);
`;
export function MessageDetail({ message, position }: { message: MessageObject, position: 'left' | 'top' }) {
if (position === 'left') {
if (message.edited) {
return (
<span>
<span className="copy">
[<time>{dayjs(decodeTime(message._id)).format("H:mm")}</time>]
</span>
<Tooltip content={dayjs(message.edited).format("LLLL")}>
<Text id="app.main.channel.edited" />
</Tooltip>
</span>
)
} else {
return ( return (
<> <>
<time> <time>
<i className="copy">[</i> <i className="copy">[</i>
{dayjs(decodeTime(message._id)).format("H:mm")} { dayjs(decodeTime(message._id)).format("H:mm") }
<i className="copy">]</i> <i className="copy">]</i>
</time> </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>
)
} }

View file

@ -1,24 +1,59 @@
import { ulid } from "ulid"; import { ulid } from "ulid";
import { Channel } from "revolt.js"; import { Channel } from "revolt.js";
import TextArea from "../../ui/TextArea"; import styled from "styled-components";
import { useContext } from "preact/hooks";
import { defer } from "../../../lib/defer"; import { defer } from "../../../lib/defer";
import IconButton from "../../ui/IconButton"; import IconButton from "../../ui/IconButton";
import { Send } from '@styled-icons/feather'; import { Send } from '@styled-icons/feather';
import Axios, { CancelTokenSource } from "axios";
import { useTranslation } from "../../../lib/i18n";
import { useCallback, useContext, useState } from "preact/hooks";
import { connectState } from "../../../redux/connector"; import { connectState } from "../../../redux/connector";
import { WithDispatcher } from "../../../redux/reducers"; import { WithDispatcher } from "../../../redux/reducers";
import { takeError } from "../../../context/revoltjs/util"; import { takeError } from "../../../context/revoltjs/util";
import TextAreaAutoSize from "../../../lib/TextAreaAutoSize";
import { AppContext } from "../../../context/revoltjs/RevoltClient"; import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
import { useIntermediate } from "../../../context/intermediate/Intermediate";
import { FileUploader, grabFiles, uploadFile } from "../../../context/revoltjs/FileUploads";
import { SingletonMessageRenderer, SMOOTH_SCROLL_ON_RECEIVE } from "../../../lib/renderer/Singleton"; import { SingletonMessageRenderer, SMOOTH_SCROLL_ON_RECEIVE } from "../../../lib/renderer/Singleton";
import FilePreview from './bars/FilePreview';
import { debounce } from "../../../lib/debounce";
type Props = WithDispatcher & { type Props = WithDispatcher & {
channel: Channel; channel: Channel;
draft?: string; 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 };
const Base = styled.div`
display: flex;
padding: 0 12px;
background: var(--message-box);
textarea {
font-size: .875rem;
background: transparent;
}
`;
const Action = styled.div`
display: grid;
place-items: center;
`;
function MessageBox({ channel, draft, dispatcher }: Props) { function MessageBox({ channel, draft, dispatcher }: Props) {
const [ uploadState, setUploadState ] = useState<UploadState>({ type: 'none' });
const [typing, setTyping] = useState<boolean | number>(false);
const { openScreen } = useIntermediate();
const client = useContext(AppContext); const client = useContext(AppContext);
const translate = useTranslation();
function setMessage(content?: string) { function setMessage(content?: string) {
if (content) { if (content) {
@ -36,12 +71,16 @@ function MessageBox({ channel, draft, dispatcher }: Props) {
} }
async function send() { async function send() {
const nonce = ulid(); if (uploadState.type === 'uploading' || uploadState.type === 'sending') return;
const content = draft?.trim() ?? ''; const content = draft?.trim() ?? '';
if (uploadState.type === 'attached') return sendFile(content);
if (content.length === 0) return; if (content.length === 0) return;
stopTyping();
setMessage(); setMessage();
const nonce = ulid();
dispatcher({ dispatcher({
type: "QUEUE_ADD", type: "QUEUE_ADD",
nonce, nonce,
@ -55,7 +94,6 @@ function MessageBox({ channel, draft, dispatcher }: Props) {
}); });
defer(() => SingletonMessageRenderer.jumpToBottom(channel._id, SMOOTH_SCROLL_ON_RECEIVE)); defer(() => SingletonMessageRenderer.jumpToBottom(channel._id, SMOOTH_SCROLL_ON_RECEIVE));
// Sounds.playOutbound();
try { try {
await client.channels.sendMessage(channel._id, { await client.channels.sendMessage(channel._id, {
@ -71,21 +109,164 @@ function MessageBox({ channel, draft, dispatcher }: Props) {
} }
} }
async function sendFile(content: string) {
if (uploadState.type !== 'attached') return;
let attachments = [];
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++) {
if (i>0)continue; // ! FIXME: temp, allow multiple uploads on server
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) / (files.length)),
cancel
}),
cancelToken: cancel.token
})
);
}
} catch (err) {
if (err?.message === "cancel") {
setUploadState({
type: "attached",
files
});
} else {
setUploadState({
type: "failed",
files,
error: takeError(err)
});
}
return;
}
setUploadState({
type: "sending",
files
});
const nonce = ulid();
try {
await client.channels.sendMessage(channel._id, {
content,
nonce,
attachment: attachments[0] // ! FIXME: temp, allow multiple uploads on server
});
} catch (err) {
setUploadState({
type: "failed",
files,
error: takeError(err)
});
return;
}
setMessage();
setUploadState({ type: "none" });
}
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
});
}
}
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 ]);
return ( return (
<div style={{ display: 'flex' }}> <>
<TextArea <FilePreview state={uploadState} addFile={() => uploadState.type === 'attached' &&
value={draft} 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) });
}
}} />
<Base>
<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")}
/>
</Action>
<TextAreaAutoSize
hideBorder
maxRows={5}
padding={15}
value={draft ?? ''}
onKeyDown={e => { onKeyDown={e => {
if (!e.shiftKey && e.key === "Enter" && !isTouchscreenDevice) { if (!e.shiftKey && e.key === "Enter" && !isTouchscreenDevice) {
e.preventDefault(); e.preventDefault();
return send(); return send();
} }
debouncedStopTyping(true);
}} }}
onChange={e => setMessage(e.currentTarget.value)} /> 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();
}} />
<Action>
<IconButton onClick={send}> <IconButton onClick={send}>
<Send size={20} /> <Send size={20} />
</IconButton> </IconButton>
</div> </Action>
</Base>
</>
) )
} }

View file

@ -1,14 +1,11 @@
import { User } from "revolt.js"; import { User } from "revolt.js";
import classNames from "classnames"; import styled from "styled-components";
import UserShort from "../user/UserShort";
import { TextReact } from "../../../lib/i18n";
import { attachContextMenu } from "preact-context-menu"; import { attachContextMenu } from "preact-context-menu";
import { MessageObject } from "../../../context/revoltjs/util"; import { MessageObject } from "../../../context/revoltjs/util";
import { useForceUpdate, useUser } from "../../../context/revoltjs/hooks";
import { TextReact } from "../../../lib/i18n";
import UserIcon from "../user/UserIcon";
import Username from "../user/UserShort";
import UserShort from "../user/UserShort";
import MessageBase, { MessageDetail, MessageInfo } from "./MessageBase"; import MessageBase, { MessageDetail, MessageInfo } from "./MessageBase";
import styled from "styled-components"; import { useForceUpdate, useUser } from "../../../context/revoltjs/hooks";
const SystemContent = styled.div` const SystemContent = styled.div`
gap: 4px; gap: 4px;
@ -144,7 +141,7 @@ export function SystemMessage({ attachContext, message }: Props) {
{ message, contextualChannel: message.channel } { message, contextualChannel: message.channel }
) : undefined}> ) : undefined}>
<MessageInfo> <MessageInfo>
<MessageDetail message={message} /> <MessageDetail message={message} position="left" />
</MessageInfo> </MessageInfo>
<SystemContent>{children}</SystemContent> <SystemContent>{children}</SystemContent>
</MessageBase> </MessageBase>

View file

@ -2,6 +2,7 @@ import { useContext } from 'preact/hooks';
import styles from './Attachment.module.scss'; import styles from './Attachment.module.scss';
import IconButton from '../../../ui/IconButton'; import IconButton from '../../../ui/IconButton';
import { Attachment } from "revolt.js/dist/api/objects"; import { Attachment } from "revolt.js/dist/api/objects";
import { determineFileSize } from '../../../../lib/fileSize';
import { AppContext } from '../../../../context/revoltjs/RevoltClient'; import { AppContext } from '../../../../context/revoltjs/RevoltClient';
import { Download, ExternalLink, File, Headphones, Video } from '@styled-icons/feather'; import { Download, ExternalLink, File, Headphones, Video } from '@styled-icons/feather';
@ -9,16 +10,6 @@ interface Props {
attachment: Attachment; attachment: Attachment;
} }
export function determineFileSize(size: number) {
if (size > 1e6) {
return `${(size / 1e6).toFixed(2)} MB`;
} else if (size > 1e3) {
return `${(size / 1e3).toFixed(2)} KB`;
}
return `${size} B`;
}
export default function AttachmentActions({ attachment }: Props) { export default function AttachmentActions({ attachment }: Props) {
const client = useContext(AppContext); const client = useContext(AppContext);
const { filename, metadata, size } = attachment; const { filename, metadata, size } = attachment;

View file

@ -0,0 +1,158 @@
import { Text } from "preact-i18n";
import styled from "styled-components";
import { UploadState } from "../MessageBox";
import { useEffect, useState } from 'preact/hooks';
import { XCircle, Plus, Share, X } from "@styled-icons/feather";
import { determineFileSize } from '../../../../lib/fileSize';
interface Props {
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);
`;
const Carousel = styled.div`
gap: 8px;
display: flex;
overflow-x: scroll;
flex-direction: row;
`;
const Entry = styled.div`
display: flex;
flex-direction: column;
img {
height: 100px;
margin-bottom: 4px;
border-radius: 4px;
object-fit: contain;
background: var(--secondary-background);
}
span.fn {
margin: auto;
font-size: .8em;
overflow: hidden;
max-width: 180px;
text-align: center;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--secondary-foreground);
}
span.size {
font-size: .6em;
color: var(--tertiary-foreground);
text-align: center;
}
div {
position: relative;
height: 0;
div {
display: grid;
height: 100px;
cursor: pointer;
border-radius: 4px;
place-items: center;
opacity: 0;
transition: 0.1s ease opacity;
background: rgba(0, 0, 0, 0.5);
&:hover {
opacity: 1;
}
}
}
`;
const Description = styled.div`
gap: 4px;
display: flex;
font-size: 0.9em;
align-items: center;
color: var(--secondary-foreground);
`;
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;
&:hover {
background: var(--secondary-background);
}
`;
function FileEntry({ file, remove }: { file: File, remove?: () => void }) {
if (!file.type.startsWith('image/')) return (
<Entry>
<div><div onClick={remove}><XCircle size={36} /></div></div>
<span class="fn">{file.name}</span>
<span class="size">{determineFileSize(file.size)}</span>
</Entry>
);
const [ url, setURL ] = useState('');
useEffect(() => {
let url: string = URL.createObjectURL(file);
setURL(url);
return () => URL.revokeObjectURL(url);
}, [ file ]);
return (
<Entry>
{ remove && <div><div onClick={remove}><XCircle size={36} /></div></div> }
<img src={url}
alt={file.name} />
<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;
return (
<Container>
<Carousel>
{ state.files.map((file, index) => <FileEntry file={file} key={file.name} remove={state.type === 'attached' ? () => removeFile(index) : undefined} />) }
{ state.type === 'attached' && <EmptyEntry onClick={addFile}><Plus size={48} /></EmptyEntry> }
</Carousel>
{ state.files.length > 1 && state.type === 'attached' && <Description>Warning: Only first file will be uploaded, this will be changed in a future update.</Description> }
{ 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

@ -0,0 +1,53 @@
import { Text } from "preact-i18n";
import styled from "styled-components";
import { ArrowDown } from "@styled-icons/feather";
import { SingletonMessageRenderer, useRenderState } from "../../../../lib/renderer/Singleton";
const Bar = styled.div`
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 .08s;
> div {
display: flex;
align-items: center;
gap: 6px;
}
&:hover {
color: var(--primary-text);
}
&:active {
transform: translateY(1px);
}
}
`;
export default function JumpToBottom({ id }: { id: string }) {
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" /> <ArrowDown size={18} strokeWidth={2}/></div>
</div>
</Bar>
)
}

View file

@ -0,0 +1,111 @@
import { User } from 'revolt.js';
import { Text } from "preact-i18n";
import styled from 'styled-components';
import { useContext } from 'preact/hooks';
import { connectState } from '../../../../redux/connector';
import { useUsers } from '../../../../context/revoltjs/hooks';
import { TypingUser } from '../../../../redux/reducers/typing';
import { AppContext } from '../../../../context/revoltjs/RevoltClient';
interface Props {
typing?: TypingUser[]
}
const Base = styled.div`
position: relative;
> 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);
}
.avatars {
display: flex;
img {
width: 16px;
height: 16px;
object-fit: cover;
border-radius: 50%;
&:not(:first-child) {
margin-left: -4px;
}
}
}
.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[];
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 }}
/>
);
}
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;
}
export default connectState<{ id: string }>(TypingIndicator, (state, props) => {
return {
typing: state.typing && state.typing[props.id]
};
});

View file

@ -1,19 +1,18 @@
import { Route, Switch } from "react-router"; import { Route, Switch } from "react-router";
import SidebarBase from "./SidebarBase"; import SidebarBase from "./SidebarBase";
// import { MemberSidebar } from "./right/MemberSidebar"; import MemberSidebar from "./right/MemberSidebar";
export default function RightSidebar() { export default function RightSidebar() {
return ( return (
<SidebarBase> <SidebarBase>
<Switch> <Switch>
{/*
<Route path="/server/:server/channel/:channel"> <Route path="/server/:server/channel/:channel">
<MemberSidebar /> <MemberSidebar />
</Route> </Route>
<Route path="/channel/:channel"> <Route path="/channel/:channel">
<MemberSidebar /> <MemberSidebar />
</Route> */ } </Route>
</Switch> </Switch>
</SidebarBase> </SidebarBase>
); );

View file

@ -12,3 +12,22 @@ export default styled.div`
padding-bottom: 50px; padding-bottom: 50px;
` } ` }
`; `;
export const GenericSidebarBase = styled.div`
height: 100%;
width: 240px;
display: flex;
flex-shrink: 0;
flex-direction: column;
background: var(--secondary-background);
`;
export const GenericSidebarList = styled.div`
padding: 6px;
flex-grow: 1;
overflow-y: scroll;
> svg {
width: 100%;
}
`;

View file

@ -2,7 +2,6 @@ import { Localizer, Text } from "preact-i18n";
import { useContext } from "preact/hooks"; import { useContext } from "preact/hooks";
import { Home, Users, Tool, Save } from "@styled-icons/feather"; import { Home, Users, Tool, Save } from "@styled-icons/feather";
import styled from "styled-components";
import Category from '../../ui/Category'; import Category from '../../ui/Category';
import PaintCounter from "../../../lib/PaintCounter"; import PaintCounter from "../../../lib/PaintCounter";
import UserHeader from "../../common/user/UserHeader"; import UserHeader from "../../common/user/UserHeader";
@ -16,6 +15,7 @@ import { Users as UsersNS } from 'revolt.js/dist/api/objects';
import ButtonItem, { ChannelButton } from '../items/ButtonItem'; import ButtonItem, { ChannelButton } from '../items/ButtonItem';
import { AppContext } from "../../../context/revoltjs/RevoltClient"; import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
import { GenericSidebarBase, GenericSidebarList } from "../SidebarBase";
import { Link, Redirect, useLocation, useParams } from "react-router-dom"; import { Link, Redirect, useLocation, useParams } from "react-router-dom";
import { useIntermediate } from "../../../context/intermediate/Intermediate"; import { useIntermediate } from "../../../context/intermediate/Intermediate";
import { useDMs, useForceUpdate, useUsers } from "../../../context/revoltjs/hooks"; import { useDMs, useForceUpdate, useUsers } from "../../../context/revoltjs/hooks";
@ -24,25 +24,6 @@ type Props = WithDispatcher & {
unreads: Unreads; unreads: Unreads;
} }
const HomeBase = styled.div`
height: 100%;
width: 240px;
display: flex;
flex-shrink: 0;
flex-direction: column;
background: var(--secondary-background);
`;
const HomeList = styled.div`
padding: 6px;
flex-grow: 1;
overflow-y: scroll;
> svg {
width: 100%;
}
`;
function HomeSidebar(props: Props) { function HomeSidebar(props: Props) {
const { pathname } = useLocation(); const { pathname } = useLocation();
const client = useContext(AppContext); const client = useContext(AppContext);
@ -68,10 +49,10 @@ function HomeSidebar(props: Props) {
channelsArr.sort((b, a) => a.timestamp.localeCompare(b.timestamp)); channelsArr.sort((b, a) => a.timestamp.localeCompare(b.timestamp));
return ( return (
<HomeBase> <GenericSidebarBase>
<UserHeader user={client.user!} /> <UserHeader user={client.user!} />
<ConnectionStatus /> <ConnectionStatus />
<HomeList> <GenericSidebarList>
{!isTouchscreenDevice && ( {!isTouchscreenDevice && (
<> <>
<Link to="/"> <Link to="/">
@ -146,8 +127,8 @@ function HomeSidebar(props: Props) {
); );
})} })}
<PaintCounter /> <PaintCounter />
</HomeList> </GenericSidebarList>
</HomeBase> </GenericSidebarBase>
); );
}; };

View file

@ -0,0 +1,37 @@
import { useRenderState } from "../../../lib/renderer/Singleton";
interface Props {
id: string;
}
export function ChannelDebugInfo({ id }: Props) {
if (process.env.NODE_ENV !== "development") return null;
let view = useRenderState(id);
if (!view) return null;
return (
<span style={{ display: "block", padding: "12px 10px 0 10px" }}>
<span
style={{
display: "block",
fontSize: "12px",
textTransform: "uppercase",
fontWeight: "600"
}}
>
Channel Info
</span>
<p style={{ fontSize: "10px", userSelect: "text" }}>
State: <b>{ view.type }</b> <br />
{ view.type === 'RENDER' && view.messages.length > 0 &&
<>
Start: <b>{view.messages[0]._id}</b> <br />
End: <b>{view.messages[view.messages.length - 1]._id}</b> <br />
At Top: <b>{view.atTop ? "Yes" : "No"}</b> <br />
At Bottom: <b>{view.atBottom ? "Yes" : "No"}</b>
</>
}
</p>
</span>
);
}

View file

@ -0,0 +1,206 @@
import { Text } from "preact-i18n";
import { useContext, useEffect, useState } from "preact/hooks";
import { User } from "revolt.js";
import Category from "../../ui/Category";
import { useParams } from "react-router";
import { UserButton } from "../items/ButtonItem";
import { ChannelDebugInfo } from "./ChannelDebugInfo";
import { Channels, Servers, Users } from "revolt.js/dist/api/objects";
import { GenericSidebarBase, GenericSidebarList } from "../SidebarBase";
import { ClientboundNotification } from "revolt.js/dist/websocket/notifications";
import { HookContext, useChannel, useForceUpdate, useUsers } from "../../../context/revoltjs/hooks";
import placeholderSVG from "../items/placeholder.svg";
import { AppContext, ClientStatus, StatusContext } from "../../../context/revoltjs/RevoltClient";
interface Props {
ctx: HookContext
}
export default function MemberSidebar(props: { channel?: Channels.Channel }) {
const ctx = useForceUpdate();
const { channel: cid } = useParams<{ channel: string }>();
const channel = props.channel ?? useChannel(cid, ctx);
switch (channel?.channel_type) {
case 'Group': return <GroupMemberSidebar channel={channel} ctx={ctx} />;
case 'TextChannel': return <ServerMemberSidebar channel={channel} ctx={ctx} />;
default: return null;
}
}
export function GroupMemberSidebar({ channel, ctx }: Props & { channel: Channels.GroupChannel }) {
const users = useUsers(undefined, ctx);
let members = channel.recipients
.map(x => users.find(y => y?._id === x))
.filter(x => typeof x !== "undefined") as User[];
/*const voice = useContext(VoiceContext);
const voiceActive = voice.roomId === channel._id;
let voiceParticipants: User[] = [];
if (voiceActive) {
const idArray = Array.from(voice.participants.keys());
voiceParticipants = idArray
.map(x => users.find(y => y?._id === x))
.filter(x => typeof x !== "undefined") as User[];
members = members.filter(member => idArray.indexOf(member._id) === -1);
voiceParticipants.sort((a, b) => a.username.localeCompare(b.username));
}*/
members.sort((a, b) => {
// ! FIXME: should probably rewrite all this code
let l = ((a.online &&
a.status?.presence !== Users.Presence.Invisible) ??
false) as any | 0;
let r = ((b.online &&
b.status?.presence !== Users.Presence.Invisible) ??
false) as any | 0;
let n = r - l;
if (n !== 0) {
return n;
}
return a.username.localeCompare(b.username);
});
return (
<GenericSidebarBase>
<GenericSidebarList>
<ChannelDebugInfo id={channel._id} />
{/*voiceActive && voiceParticipants.length !== 0 && (
<Fragment>
<Category
type="members"
text={
<span>
<Text id="app.main.categories.participants" />{" "}
{voiceParticipants.length}
</span>
}
/>
{voiceParticipants.map(
user =>
user && (
<LinkProfile user_id={user._id}>
<UserButton
key={user._id}
user={user}
context={channel}
/>
</LinkProfile>
)
)}
</Fragment>
)*/}
{!(members.length === 0 /*&& voiceActive*/) && (
<Category
variant="uniform"
text={
<span>
<Text id="app.main.categories.members" /> {" "}
{channel.recipients.length}
</span>
}
/>
)}
{members.length === 0 && /*!voiceActive &&*/ <img src={placeholderSVG} />}
{members.map(
user =>
user && (
// <LinkProfile user_id={user._id}>
<UserButton
key={user._id}
user={user}
context={channel}
/>
// </LinkProfile>
)
)}
</GenericSidebarList>
</GenericSidebarBase>
);
}
export function ServerMemberSidebar({ channel, ctx }: Props & { channel: Channels.TextChannel }) {
const [members, setMembers] = useState<Servers.Member[] | undefined>(undefined);
const users = useUsers(members?.map(x => x._id.user) ?? []).filter(x => typeof x !== 'undefined', ctx) as Users.User[];
const status = useContext(StatusContext);
const client = useContext(AppContext);
useEffect(() => {
if (status === ClientStatus.ONLINE && typeof members === 'undefined') {
client.servers.members.fetchMembers(channel.server)
.then(members => setMembers(members))
}
}, [ status ]);
// ! FIXME: temporary code
useEffect(() => {
function onPacket(packet: ClientboundNotification) {
if (!members) return;
if (packet.type === 'ServerMemberJoin') {
if (packet.id !== channel.server) return;
setMembers([ ...members, { _id: { server: packet.id, user: packet.user } } ]);
} else if (packet.type === 'ServerMemberLeave') {
if (packet.id !== channel.server) return;
setMembers(members.filter(x => !(x._id.user === packet.user && x._id.server === packet.id)));
}
}
client.addListener('packet', onPacket);
return () => client.removeListener('packet', onPacket);
}, [ members ]);
// copy paste from above
users.sort((a, b) => {
// ! FIXME: should probably rewrite all this code
let l = ((a.online &&
a.status?.presence !== Users.Presence.Invisible) ??
false) as any | 0;
let r = ((b.online &&
b.status?.presence !== Users.Presence.Invisible) ??
false) as any | 0;
let n = r - l;
if (n !== 0) {
return n;
}
return a.username.localeCompare(b.username);
});
return (
<GenericSidebarBase>
<GenericSidebarList>
<ChannelDebugInfo id={channel._id} />
<Category
variant="uniform"
text={
<span>
<Text id="app.main.categories.members" /> {" "}
{users.length}
</span>
}
/>
{users.length === 0 && <img src={placeholderSVG} />}
{users.map(
user =>
user && (
// <LinkProfile user_id={user._id}>
<UserButton
key={user._id}
user={user}
context={channel}
/>
// </LinkProfile>
)
)}
</GenericSidebarList>
</GenericSidebarBase>
);
}

View file

@ -5,4 +5,12 @@ export default styled.select`
border-radius: 2px; border-radius: 2px;
color: var(--secondary-foreground); color: var(--secondary-foreground);
background: var(--secondary-background); background: var(--secondary-background);
border: none;
outline: 2px solid transparent;
transition: outline-color 0.2s ease-in-out;
&:focus {
outline-color: var(--accent);
}
`; `;

View file

@ -8,18 +8,21 @@ export default styled.input<Props>`
z-index: 1; z-index: 1;
padding: 8px 16px; padding: 8px 16px;
border-radius: 6px; border-radius: 6px;
color: var(--foreground); color: var(--foreground);
border: 2px solid transparent;
background: var(--primary-background); background: var(--primary-background);
transition: 0.2s ease background-color; transition: 0.2s ease background-color;
transition: border-color 0.2s ease-in-out;
border: none;
outline: 2px solid transparent;
transition: outline-color 0.2s ease-in-out;
&:hover { &:hover {
background: var(--secondary-background); background: var(--secondary-background);
} }
&:focus { &:focus {
border: 2px solid var(--accent); outline: 2px solid var(--accent);
} }
${(props) => ${(props) =>

View file

@ -7,23 +7,39 @@ import styled, { css } from "styled-components";
export interface TextAreaProps { export interface TextAreaProps {
code?: boolean; code?: boolean;
padding?: number; padding?: number;
lineHeight?: number;
hideBorder?: boolean;
} }
export const TEXT_AREA_BORDER_WIDTH = 2;
export const DEFAULT_TEXT_AREA_PADDING = 16;
export const DEFAULT_LINE_HEIGHT = 20;
export default styled.textarea<TextAreaProps>` export default styled.textarea<TextAreaProps>`
width: 100%; width: 100%;
resize: none; resize: none;
display: block; display: block;
border-radius: 4px;
padding: ${ props => props.padding ?? 16 }px;
color: var(--foreground); color: var(--foreground);
border: 2px solid transparent;
background: var(--secondary-background); background: var(--secondary-background);
padding: ${ props => props.padding ?? DEFAULT_TEXT_AREA_PADDING }px;
line-height: ${ props => props.lineHeight ?? DEFAULT_LINE_HEIGHT }px;
${ props => props.hideBorder && css`
border: none;
` }
${ props => !props.hideBorder && css`
border-radius: 4px;
transition: border-color .2s ease-in-out; transition: border-color .2s ease-in-out;
border: ${TEXT_AREA_BORDER_WIDTH}px solid transparent;
` }
&:focus { &:focus {
outline: none; outline: none;
border: 2px solid var(--accent);
${ props => !props.hideBorder && css`
border: ${TEXT_AREA_BORDER_WIDTH}px solid var(--accent);
` }
} }
${ props => props.code ? css` ${ props => props.code ? css`

View file

@ -1,18 +1,15 @@
// ! FIXME: also TEMP CODE
// ! RE-WRITE WITH STYLED-COMPONENTS
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { takeError } from "./util"; import { takeError } from "./util";
import classNames from "classnames"; import classNames from "classnames";
import { AppContext } from "./RevoltClient";
import styles from './FileUploads.module.scss'; import styles from './FileUploads.module.scss';
import Axios, { AxiosRequestConfig } from "axios"; import Axios, { AxiosRequestConfig } from "axios";
import { useContext, useState } from "preact/hooks"; import { useContext, useState } from "preact/hooks";
import { Edit, Plus, X } from "@styled-icons/feather";
import Preloader from "../../components/ui/Preloader"; import Preloader from "../../components/ui/Preloader";
import { determineFileSize } from "../../lib/fileSize"; import { determineFileSize } from "../../lib/fileSize";
import IconButton from '../../components/ui/IconButton'; import IconButton from '../../components/ui/IconButton';
import { Edit, Plus, X, XCircle } from "@styled-icons/feather";
import { useIntermediate } from "../intermediate/Intermediate"; import { useIntermediate } from "../intermediate/Intermediate";
import { AppContext } from "./RevoltClient";
type Props = { type Props = {
maxFileSize: number maxFileSize: number
@ -20,6 +17,7 @@ type Props = {
fileType: 'backgrounds' | 'icons' | 'avatars' | 'attachments' | 'banners' fileType: 'backgrounds' | 'icons' | 'avatars' | 'attachments' | 'banners'
} & ( } & (
{ behaviour: 'ask', onChange: (file: File) => void } | { behaviour: 'ask', onChange: (file: File) => void } |
{ behaviour: 'multi', onChange: (files: File[]) => void } |
{ behaviour: 'upload', onUpload: (id: string) => Promise<void> } { behaviour: 'upload', onUpload: (id: string) => Promise<void> }
) & ( ) & (
{ style: 'icon' | 'banner', defaultPreview?: string, previewURL?: string, width?: number, height?: number } | { style: 'icon' | 'banner', defaultPreview?: string, previewURL?: string, width?: number, height?: number } |
@ -40,6 +38,26 @@ export async function uploadFile(autumnURL: string, tag: string, file: File, con
return res.data.id; return res.data.id;
} }
export function grabFiles(maxFileSize: number, cb: (files: File[]) => void, tooLarge: () => void, multiple?: boolean) {
const input = document.createElement("input");
input.type = "file";
input.multiple = multiple ?? false;
input.onchange = async e => {
const files = (e.target as any)?.files;
if (!files) return;
for (let file of files) {
if (file.size > maxFileSize) {
return tooLarge();
}
}
cb(Array.from(files));
};
input.click();
}
export function FileUploader(props: Props) { export function FileUploader(props: Props) {
const { fileType, maxFileSize, remove } = props; const { fileType, maxFileSize, remove } = props;
const { openScreen } = useIntermediate(); const { openScreen } = useIntermediate();
@ -50,35 +68,25 @@ export function FileUploader(props: Props) {
function onClick() { function onClick() {
if (uploading) return; if (uploading) return;
const input = document.createElement("input"); grabFiles(maxFileSize, async files => {
input.type = "file";
input.onchange = async e => {
setUploading(true); setUploading(true);
try { try {
const files = (e.target as any)?.files; if (props.behaviour === 'multi') {
if (files && files[0]) { props.onChange(files);
let file = files[0]; } else if (props.behaviour === 'ask') {
props.onChange(files[0]);
if (file.size > maxFileSize) {
return openScreen({ id: "error", error: "FileTooLarge" });
}
if (props.behaviour === 'ask') {
await props.onChange(file);
} else { } else {
await props.onUpload(await uploadFile(client.configuration!.features.autumn.url, fileType, file)); await props.onUpload(await uploadFile(client.configuration!.features.autumn.url, fileType, files[0]));
}
} }
} catch (err) { } catch (err) {
return openScreen({ id: "error", error: takeError(err) }); return openScreen({ id: "error", error: takeError(err) });
} finally { } finally {
setUploading(false); setUploading(false);
} }
}; }, () =>
openScreen({ id: "error", error: "FileTooLarge" }),
input.click(); props.behaviour === 'multi');
} }
function removeOrUpload() { function removeOrUpload() {
@ -139,7 +147,7 @@ export function FileUploader(props: Props) {
if (attached) return remove(); if (attached) return remove();
onClick(); onClick();
}}> }}>
{ attached ? <X size={size} /> : <Plus size={size} />} { uploading ? <XCircle size={size} /> : attached ? <X size={size} /> : <Plus size={size} />}
</IconButton> </IconButton>
) )
} }

View file

@ -1,8 +1,8 @@
import { decodeTime } from "ulid"; import { decodeTime } from "ulid";
import { AppContext } from "./RevoltClient"; import { AppContext } from "./RevoltClient";
import { useTranslation } from "../../lib/i18n";
import { Users } from "revolt.js/dist/api/objects"; import { Users } from "revolt.js/dist/api/objects";
import { useContext, useEffect } from "preact/hooks"; import { useContext, useEffect } from "preact/hooks";
import { IntlContext, translate } from "preact-i18n";
import { connectState } from "../../redux/connector"; import { connectState } from "../../redux/connector";
import { playSound } from "../../assets/sounds/Audio"; import { playSound } from "../../assets/sounds/Audio";
import { Message, SYSTEM_USER_ID, User } from "revolt.js"; import { Message, SYSTEM_USER_ID, User } from "revolt.js";
@ -25,7 +25,7 @@ async function createNotification(title: string, options: globalThis.Notificatio
} }
function Notifier(props: Props) { function Notifier(props: Props) {
const { intl } = useContext(IntlContext) as any; const translate = useTranslation();
const showNotification = props.options?.desktopEnabled ?? false; const showNotification = props.options?.desktopEnabled ?? false;
// const playIncoming = props.options?.soundEnabled ?? true; // const playIncoming = props.options?.soundEnabled ?? true;
// const playOutgoing = props.options?.outgoingSoundEnabled ?? true; // const playOutgoing = props.options?.outgoingSoundEnabled ?? true;
@ -88,45 +88,37 @@ function Notifier(props: Props) {
} else { } else {
let users = client.users; let users = client.users;
switch (msg.content.type) { switch (msg.content.type) {
// ! FIXME: update to support new replacements
case "user_added": case "user_added":
body = `${users.get(msg.content.id)?.username} ${translate(
"app.main.channel.system.user_joined",
"",
intl.dictionary
)} (${translate(
"app.main.channel.system.added_by",
"",
intl.dictionary
)} ${users.get(msg.content.by)?.username})`;
icon = client.users.getAvatarURL(msg.content.id, { max_side: 256 });
break;
case "user_remove": case "user_remove":
body = `${users.get(msg.content.id)?.username} ${translate( body = translate(
"app.main.channel.system.user_left", `app.main.channel.system.${msg.content.type === 'user_added' ? 'added_by' : 'removed_by'}`,
"", { user: users.get(msg.content.id)?.username, other_user: users.get(msg.content.by)?.username }
intl.dictionary );
)} (${translate(
"app.main.channel.system.added_by",
"",
intl.dictionary
)} ${users.get(msg.content.by)?.username})`;
icon = client.users.getAvatarURL(msg.content.id, { max_side: 256 }); icon = client.users.getAvatarURL(msg.content.id, { max_side: 256 });
break; break;
case "user_joined":
case "user_left": case "user_left":
body = `${users.get(msg.content.id)?.username} ${translate( case "user_kicked":
"app.main.channel.system.user_left", case "user_banned":
"", body = translate(
intl.dictionary `app.main.channel.system.${msg.content.type}`,
)}`; { user: users.get(msg.content.id)?.username }
);
icon = client.users.getAvatarURL(msg.content.id, { max_side: 256 }); icon = client.users.getAvatarURL(msg.content.id, { max_side: 256 });
break; break;
case "channel_renamed": case "channel_renamed":
body = `${users.get(msg.content.by)?.username} ${translate( body = translate(
"app.main.channel.system.channel_renamed", `app.main.channel.system.channel_renamed`,
"", { user: users.get(msg.content.by)?.username, name: msg.content.name }
intl.dictionary );
)} ${msg.content.name}`; icon = client.users.getAvatarURL(msg.content.by, { max_side: 256 });
break;
case "channel_description_changed":
case "channel_icon_changed":
body = translate(
`app.main.channel.system.${msg.content.type}`,
{ user: users.get(msg.content.by)?.username }
);
icon = client.users.getAvatarURL(msg.content.by, { max_side: 256 }); icon = client.users.getAvatarURL(msg.content.by, { max_side: 256 });
break; break;
} }
@ -173,20 +165,10 @@ function Notifier(props: Props) {
let event; let event;
switch (user.relationship) { switch (user.relationship) {
case Users.Relationship.Incoming: case Users.Relationship.Incoming:
event = translate( event = translate("notifications.sent_request", { person: user.username });
"notifications.sent_request",
"",
intl.dictionary,
{ person: user.username }
);
break; break;
case Users.Relationship.Friend: case Users.Relationship.Friend:
event = translate( event = translate("notifications.now_friends", { person: user.username });
"notifications.now_friends",
"",
intl.dictionary,
{ person: user.username }
);
break; break;
default: default:
return; return;

View file

@ -1,6 +1,5 @@
import styled from "styled-components"; import TextArea, { DEFAULT_LINE_HEIGHT, DEFAULT_TEXT_AREA_PADDING, TextAreaProps, TEXT_AREA_BORDER_WIDTH } from "../components/ui/TextArea";
import TextArea, { TextAreaProps } from "../components/ui/TextArea"; import { useEffect, useRef } from "preact/hooks";
import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks";
type TextAreaAutoSizeProps = Omit<JSX.HTMLAttributes<HTMLTextAreaElement>, 'style' | 'value'> & TextAreaProps & { type TextAreaAutoSizeProps = Omit<JSX.HTMLAttributes<HTMLTextAreaElement>, 'style' | 'value'> & TextAreaProps & {
autoFocus?: boolean, autoFocus?: boolean,
@ -9,51 +8,15 @@ type TextAreaAutoSizeProps = Omit<JSX.HTMLAttributes<HTMLTextAreaElement>, 'styl
value: string value: string
}; };
const lineHeight = 20;
const Ghost = styled.div`
width: 100%;
overflow: hidden;
position: relative;
> div {
width: 100%;
white-space: pre-wrap;
top: 0;
position: absolute;
visibility: hidden;
}
`;
export default function TextAreaAutoSize(props: TextAreaAutoSizeProps) { export default function TextAreaAutoSize(props: TextAreaAutoSizeProps) {
const { autoFocus, minHeight, maxRows, value, padding, children, as, ...textAreaProps } = props; const { autoFocus, minHeight, maxRows, value, padding, lineHeight, hideBorder, children, as, ...textAreaProps } = props;
const line = lineHeight ?? DEFAULT_LINE_HEIGHT;
const heightPadding = (padding ?? 0) * 2; const heightPadding = ((padding ?? DEFAULT_TEXT_AREA_PADDING) + (hideBorder ? 0 : TEXT_AREA_BORDER_WIDTH)) * 2;
const minimumHeight = (minHeight ?? lineHeight) + heightPadding; const height = Math.max(Math.min(value.split('\n').length, maxRows ?? Infinity) * line + heightPadding, minHeight ?? 0);
console.log(value.split('\n').length, line, heightPadding, height);
var height = Math.max(Math.min(value.split('\n').length, maxRows ?? Infinity) * lineHeight + heightPadding, minimumHeight);
const ref = useRef<HTMLTextAreaElement>(); const ref = useRef<HTMLTextAreaElement>();
/*function setHeight(h: number = lineHeight) {
let newHeight = Math.min(
Math.max(
lineHeight,
maxRows ? Math.min(h, maxRows * lineHeight) : h
),
minHeight ?? Infinity
);
if (heightPadding) newHeight += heightPadding;
if (height !== newHeight) {
setHeightState(newHeight);
}
}*/
{/*useLayoutEffect(() => {
setHeight(ghost.current.clientHeight);
}, [ghost, value]);*/}
useEffect(() => { useEffect(() => {
autoFocus && ref.current.focus(); autoFocus && ref.current.focus();
}, [value]); }, [value]);
@ -89,17 +52,12 @@ export default function TextAreaAutoSize(props: TextAreaAutoSizeProps) {
return () => document.body.removeEventListener("keydown", keyDown); return () => document.body.removeEventListener("keydown", keyDown);
}, [ref]); }, [ref]);
return <> return <TextArea
<TextArea
ref={ref} ref={ref}
value={value} value={value}
padding={padding} padding={padding}
style={{ height }} style={{ height }}
{...textAreaProps} /> hideBorder={hideBorder}
{/*<Ghost><div ref={ghost}> lineHeight={lineHeight}
{ props.value.split('\n') {...textAreaProps} />;
.map(x => `\u0020${x}`)
.join('\n') }
</div></Ghost>*/}
</>;
} }

View file

@ -1,4 +1,4 @@
import { IntlContext } from "preact-i18n"; import { IntlContext, translate } from "preact-i18n";
import { useContext } from "preact/hooks"; import { useContext } from "preact/hooks";
import { Children } from "../types/Preact"; import { Children } from "../types/Preact";
@ -52,3 +52,8 @@ export function TextReact({ id, fields }: Props) {
return <>{ recursiveReplaceFields(entry as string, fields) }</>; return <>{ recursiveReplaceFields(entry as string, fields) }</>;
} }
export function useTranslation() {
const { intl } = useContext(IntlContext) as unknown as IntlType;
return (id: string, fields?: Object, plural?: number, fallback?: string) => translate(id, "", intl.dictionary, fields, plural, fallback);
}

View file

@ -1,10 +1,15 @@
import styled from "styled-components"; import styled from "styled-components";
import { useState } from "preact/hooks";
import ChannelHeader from "./ChannelHeader";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import Header from "../../components/ui/Header";
import { useRenderState } from "../../lib/renderer/Singleton";
import { useChannel, useForceUpdate, useUsers } from "../../context/revoltjs/hooks";
import { MessageArea } from "./messaging/MessageArea"; import { MessageArea } from "./messaging/MessageArea";
// import { useRenderState } from "../../lib/renderer/Singleton";
import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice";
import MessageBox from "../../components/common/messaging/MessageBox"; import MessageBox from "../../components/common/messaging/MessageBox";
import { useChannel, useForceUpdate } from "../../context/revoltjs/hooks";
import MemberSidebar from "../../components/navigation/right/MemberSidebar";
import JumpToBottom from "../../components/common/messaging/bars/JumpToBottom";
import TypingIndicator from "../../components/common/messaging/bars/TypingIndicator";
const ChannelMain = styled.div` const ChannelMain = styled.div`
flex-grow: 1; flex-grow: 1;
@ -21,26 +26,30 @@ const ChannelContent = styled.div`
flex-direction: column; flex-direction: column;
`; `;
export default function Channel() { export function Channel({ id }: { id: string }) {
const { channel: id } = useParams<{ channel: string }>();
const ctx = useForceUpdate(); const ctx = useForceUpdate();
const channel = useChannel(id, ctx); const channel = useChannel(id, ctx);
if (!channel) return null; if (!channel) return null;
// const view = useRenderState(id); const [ showMembers, setMembers ] = useState(true);
return ( return (
<> <>
<Header placement="primary"> <ChannelHeader channel={channel} toggleSidebar={() => setMembers(!showMembers)} />
Channel
</Header>
<ChannelMain> <ChannelMain>
<ChannelContent> <ChannelContent>
<MessageArea id={id} /> <MessageArea id={id} />
<TypingIndicator id={channel._id} />
<JumpToBottom id={id} />
<MessageBox channel={channel} /> <MessageBox channel={channel} />
</ChannelContent> </ChannelContent>
{ !isTouchscreenDevice && showMembers && <MemberSidebar channel={channel} /> }
</ChannelMain> </ChannelMain>
</> </>
) )
} }
export default function() {
const { channel } = useParams<{ channel: string }>();
return <Channel id={channel} key={channel} />;
}

View file

@ -0,0 +1,136 @@
import styled from "styled-components";
import { Channel, User } from "revolt.js";
import { useContext } from "preact/hooks";
import { useHistory } from "react-router-dom";
import Header from "../../components/ui/Header";
import IconButton from "../../components/ui/IconButton";
import Markdown from "../../components/markdown/Markdown";
import { getChannelName } from "../../context/revoltjs/util";
import UserStatus from "../../components/common/user/UserStatus";
import { AppContext } from "../../context/revoltjs/RevoltClient";
import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice";
import { useStatusColour } from "../../components/common/user/UserIcon";
import { useIntermediate } from "../../context/intermediate/Intermediate";
import { Save, AtSign, Users, Hash, UserPlus, Settings, Sidebar as SidebarIcon } from "@styled-icons/feather";
interface Props {
channel: Channel,
toggleSidebar?: () => void
}
const Info = styled.div`
flex-grow: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
* {
display: inline-block;
}
.divider {
height: 14px;
margin: 0 5px;
padding-left: 1px;
background-color: var(--tertiary-background);
}
.status {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
margin-inline-end: 6px;
}
.desc {
cursor: pointer;
font-size: 0.8em;
font-weight: 400;
color: var(--secondary-foreground);
}
`;
export default function ChannelHeader({ channel, toggleSidebar }: Props) {
const { openScreen } = useIntermediate();
const client = useContext(AppContext);
const history = useHistory();
const name = getChannelName(client, channel);
let icon, recipient;
switch (channel.channel_type) {
case "SavedMessages":
icon = <Save size={20} strokeWidth={1.5} />;
break;
case "DirectMessage":
icon = <AtSign size={20} strokeWidth={1.5} />;
const uid = client.channels.getRecipient(channel._id);
recipient = client.users.get(uid);
break;
case "Group":
icon = <Users size={20} strokeWidth={1.5} />;
break;
case "TextChannel":
icon = <Hash size={20} strokeWidth={1.5} />;
break;
}
return (
<Header placement="primary">
{ icon }
<Info>
<span className="name">{ name }</span>
{channel.channel_type === "DirectMessage" && (
<>
<div className="divider" />
<span className="desc">
<div className="status" style={{ backgroundColor: useStatusColour(recipient as User) }} />
<UserStatus user={recipient as User} />
</span>
</>
)}
{(channel.channel_type === "Group" || channel.channel_type === "TextChannel") && channel.description && (
<>
<div className="divider" />
<span
className="desc"
onClick={() =>
openScreen({
id: "channel_info",
channel_id: channel._id
})
}>
<Markdown content={channel.description.split("\n")[0] ?? ""} disallowBigEmoji />
</span>
</>
)}
</Info>
<>
{ channel.channel_type === "Group" && (
<>
<IconButton onClick={() =>
openScreen({
id: "user_picker",
omit: channel.recipients,
callback: async users => {
for (const user of users) {
await client.channels.addMember(channel._id, user);
}
}
})}>
<UserPlus size={22} />
</IconButton>
<IconButton onClick={() => history.push(`/channel/${channel._id}/settings`)}>
<Settings size={22} />
</IconButton>
</>
) }
{ channel.channel_type === "Group" && !isTouchscreenDevice && (
<IconButton onClick={toggleSidebar}>
<SidebarIcon size={22} />
</IconButton>
) }
</>
</Header>
)
}

View file

@ -23,6 +23,7 @@ const Area = styled.div`
> div { > div {
display: flex; display: flex;
min-height: 100%; min-height: 100%;
padding-bottom: 20px;
flex-direction: column; flex-direction: column;
justify-content: flex-end; justify-content: flex-end;
} }

View file

@ -160,7 +160,7 @@ function MessageRenderer({ id, state, queue }: Props) {
); );
} }
render.push(<div>end</div>); // render.push(<div>end</div>);
} else { } else {
render.push( render.push(
<RequiresOnline> <RequiresOnline>

View file

@ -3,7 +3,7 @@ import styles from "./Panes.module.scss";
import Button from "../../../components/ui/Button"; import Button from "../../../components/ui/Button";
import { Channels } from "revolt.js/dist/api/objects"; import { Channels } from "revolt.js/dist/api/objects";
import InputBox from "../../../components/ui/InputBox"; import InputBox from "../../../components/ui/InputBox";
import TextArea from "../../../components/ui/TextArea"; import TextAreaAutoSize from "../../../lib/TextAreaAutoSize";
import { useContext, useEffect, useState } from "preact/hooks"; import { useContext, useEffect, useState } from "preact/hooks";
import { AppContext } from "../../../context/revoltjs/RevoltClient"; import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { FileUploader } from "../../../context/revoltjs/FileUploads"; import { FileUploader } from "../../../context/revoltjs/FileUploads";
@ -70,9 +70,9 @@ export function Overview({ channel }: Props) {
<Text id="app.main.groups.description" /> : <Text id="app.main.groups.description" /> :
<Text id="app.main.servers.channel_description" /> } <Text id="app.main.servers.channel_description" /> }
</h3> </h3>
<TextArea <TextAreaAutoSize
// maxRows={10} maxRows={10}
// minHeight={60} minHeight={60}
maxLength={1024} maxLength={1024}
value={description} value={description}
placeholder={"Add a description..."} placeholder={"Add a description..."}

View file

@ -2,10 +2,10 @@ import { Text } from "preact-i18n";
import styles from "./Panes.module.scss"; import styles from "./Panes.module.scss";
import { debounce } from "../../../lib/debounce"; import { debounce } from "../../../lib/debounce";
import Button from "../../../components/ui/Button"; import Button from "../../../components/ui/Button";
import TextArea from "../../../components/ui/TextArea";
import InputBox from "../../../components/ui/InputBox"; import InputBox from "../../../components/ui/InputBox";
import { connectState } from "../../../redux/connector"; import { connectState } from "../../../redux/connector";
import { WithDispatcher } from "../../../redux/reducers"; import { WithDispatcher } from "../../../redux/reducers";
import TextAreaAutoSize from "../../../lib/TextAreaAutoSize";
import ColourSwatches from "../../../components/ui/ColourSwatches"; import ColourSwatches from "../../../components/ui/ColourSwatches";
import { EmojiPacks, Settings } from "../../../redux/reducers/settings"; import { EmojiPacks, Settings } from "../../../redux/reducers/settings";
import { Theme, ThemeContext, ThemeOptions } from "../../../context/Theme"; import { Theme, ThemeContext, ThemeOptions } from "../../../context/Theme";
@ -267,19 +267,13 @@ export function Component(props: Props & WithDispatcher) {
<h3> <h3>
<Text id="app.settings.pages.appearance.custom_css" /> <Text id="app.settings.pages.appearance.custom_css" />
</h3> </h3>
<TextArea <TextAreaAutoSize
// maxRows={20} maxRows={20}
// minHeight={480} minHeight={480}
code code
value={css} value={css}
onChange={ev => setCSS(ev.currentTarget.value)} onChange={ev => setCSS(ev.currentTarget.value)} />
/>
</details> </details>
{/*<h3>
<Text id="app.settings.pages.appearance.sync" />
</h3>
<p>Coming soon!</p>*/}
</div> </div>
); );
} }

View file

@ -2,8 +2,8 @@ import { Text } from "preact-i18n";
import styles from './Panes.module.scss'; import styles from './Panes.module.scss';
import Button from "../../../components/ui/Button"; import Button from "../../../components/ui/Button";
import { Servers } from "revolt.js/dist/api/objects"; import { Servers } from "revolt.js/dist/api/objects";
import TextArea from "../../../components/ui/TextArea";
import InputBox from "../../../components/ui/InputBox"; import InputBox from "../../../components/ui/InputBox";
import TextAreaAutoSize from "../../../lib/TextAreaAutoSize";
import { useContext, useEffect, useState } from "preact/hooks"; import { useContext, useEffect, useState } from "preact/hooks";
import { AppContext } from "../../../context/revoltjs/RevoltClient"; import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { FileUploader } from "../../../context/revoltjs/FileUploads"; import { FileUploader } from "../../../context/revoltjs/FileUploads";
@ -65,9 +65,9 @@ export function Overview({ server }: Props) {
<h3> <h3>
<Text id="app.main.servers.description" /> <Text id="app.main.servers.description" />
</h3> </h3>
<TextArea <TextAreaAutoSize
// maxRows={10} maxRows={10}
// minHeight={60} minHeight={60}
maxLength={1024} maxLength={1024}
value={description} value={description}
placeholder={"Add a topic..."} placeholder={"Add a topic..."}