From 602cca10474f4366d78fbd9b8c3dbabb46ddd676 Mon Sep 17 00:00:00 2001 From: Paul <paulmakles@gmail.com> Date: Mon, 21 Jun 2021 21:11:53 +0100 Subject: [PATCH] Implement new auto-size text area. Add bars + header + sidebar to channels. --- src/components/common/messaging/Message.tsx | 10 +- .../common/messaging/MessageBase.tsx | 55 ++++- .../common/messaging/MessageBox.tsx | 219 ++++++++++++++++-- .../common/messaging/SystemMessage.tsx | 13 +- .../attachments/AttachmentActions.tsx | 11 +- .../common/messaging/bars/FilePreview.tsx | 158 +++++++++++++ .../common/messaging/bars/JumpToBottom.tsx | 53 +++++ .../common/messaging/bars/TypingIndicator.tsx | 111 +++++++++ src/components/navigation/RightSidebar.tsx | 5 +- src/components/navigation/SidebarBase.tsx | 19 ++ .../navigation/left/HomeSidebar.tsx | 29 +-- .../navigation/right/ChannelDebugInfo.tsx | 37 +++ .../navigation/right/MemberSidebar.tsx | 206 ++++++++++++++++ src/components/ui/ComboBox.tsx | 8 + src/components/ui/InputBox.tsx | 9 +- src/components/ui/TextArea.tsx | 28 ++- src/context/revoltjs/FileUploads.tsx | 60 ++--- src/context/revoltjs/Notifications.tsx | 72 +++--- src/lib/TextAreaAutoSize.tsx | 72 ++---- src/lib/i18n.tsx | 7 +- src/pages/channels/Channel.tsx | 29 ++- src/pages/channels/ChannelHeader.tsx | 136 +++++++++++ src/pages/channels/messaging/MessageArea.tsx | 1 + .../channels/messaging/MessageRenderer.tsx | 2 +- src/pages/settings/channel/Overview.tsx | 8 +- src/pages/settings/panes/Appearance.tsx | 16 +- src/pages/settings/server/Overview.tsx | 8 +- 27 files changed, 1140 insertions(+), 242 deletions(-) create mode 100644 src/components/common/messaging/bars/FilePreview.tsx create mode 100644 src/components/common/messaging/bars/JumpToBottom.tsx create mode 100644 src/components/common/messaging/bars/TypingIndicator.tsx create mode 100644 src/components/navigation/right/ChannelDebugInfo.tsx create mode 100644 src/components/navigation/right/MemberSidebar.tsx create mode 100644 src/pages/channels/ChannelHeader.tsx diff --git a/src/components/common/messaging/Message.tsx b/src/components/common/messaging/Message.tsx index 4de938e..03cd3d1 100644 --- a/src/components/common/messaging/Message.tsx +++ b/src/components/common/messaging/Message.tsx @@ -24,15 +24,19 @@ export default function Message({ attachContext, message, contrast, content: rep const content = message.content as string; return ( - <MessageBase contrast={contrast} + <MessageBase id={message._id} + contrast={contrast} onContextMenu={attachContext ? attachContextMenu('Menu', { message, contextualChannel: message.channel }) : undefined}> <MessageInfo> { head ? <UserIcon target={user} size={36} /> : - <MessageDetail message={message} /> } + <MessageDetail message={message} position="left" /> } </MessageInfo> <MessageContent> - { head && <Username user={user} /> } + { head && <span className="author"> + <Username user={user} /> + <MessageDetail message={message} position="top" /> + </span> } { replacement ?? <Markdown content={content} /> } { message.attachments?.map((attachment, index) => <Attachment key={index} attachment={attachment} hasContent={ index > 0 || content.length > 0 } />) } diff --git a/src/components/common/messaging/MessageBase.tsx b/src/components/common/messaging/MessageBase.tsx index 209e640..7284dba 100644 --- a/src/components/common/messaging/MessageBase.tsx +++ b/src/components/common/messaging/MessageBase.tsx @@ -1,6 +1,8 @@ import dayjs from "dayjs"; -import styled, { css } from "styled-components"; +import Tooltip from "../Tooltip"; import { decodeTime } from "ulid"; +import { Text } from "preact-i18n"; +import styled, { css } from "styled-components"; import { MessageObject } from "../../../context/revoltjs/util"; export interface BaseMessageProps { @@ -50,6 +52,12 @@ export default styled.div<BaseMessageProps>` ${ props => props.status && css` color: var(--error); ` } + + .author { + gap: 8px; + display: flex; + align-items: center; + } .copy { width: 0; @@ -98,14 +106,47 @@ export const MessageContent = styled.div` 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 ( + <> + <time> + <i className="copy">[</i> + { dayjs(decodeTime(message._id)).format("H:mm") } + <i className="copy">]</i> + </time> + </> + ) + } + } + return ( - <> + <DetailBase> <time> - <i className="copy">[</i> - {dayjs(decodeTime(message._id)).format("H:mm")} - <i className="copy">]</i> + {dayjs(decodeTime(message._id)).calendar()} </time> - </> + { message.edited && <Tooltip content={dayjs(message.edited).format("LLLL")}> + <Text id="app.main.channel.edited" /> + </Tooltip> } + </DetailBase> ) } diff --git a/src/components/common/messaging/MessageBox.tsx b/src/components/common/messaging/MessageBox.tsx index 32b4e34..f0e6a8a 100644 --- a/src/components/common/messaging/MessageBox.tsx +++ b/src/components/common/messaging/MessageBox.tsx @@ -1,24 +1,59 @@ import { ulid } from "ulid"; import { Channel } from "revolt.js"; -import TextArea from "../../ui/TextArea"; -import { useContext } from "preact/hooks"; +import styled from "styled-components"; import { defer } from "../../../lib/defer"; import IconButton from "../../ui/IconButton"; 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 { WithDispatcher } from "../../../redux/reducers"; import { takeError } from "../../../context/revoltjs/util"; +import TextAreaAutoSize from "../../../lib/TextAreaAutoSize"; import { AppContext } from "../../../context/revoltjs/RevoltClient"; 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 FilePreview from './bars/FilePreview'; +import { debounce } from "../../../lib/debounce"; + type Props = WithDispatcher & { 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 }; + +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) { + const [ uploadState, setUploadState ] = useState<UploadState>({ type: 'none' }); + const [typing, setTyping] = useState<boolean | number>(false); + const { openScreen } = useIntermediate(); const client = useContext(AppContext); + const translate = useTranslation(); function setMessage(content?: string) { if (content) { @@ -36,12 +71,16 @@ function MessageBox({ channel, draft, dispatcher }: Props) { } async function send() { - const nonce = ulid(); - + if (uploadState.type === 'uploading' || uploadState.type === 'sending') return; + const content = draft?.trim() ?? ''; + if (uploadState.type === 'attached') return sendFile(content); if (content.length === 0) return; - + + stopTyping(); setMessage(); + + const nonce = ulid(); dispatcher({ type: "QUEUE_ADD", nonce, @@ -55,7 +94,6 @@ function MessageBox({ channel, draft, dispatcher }: Props) { }); defer(() => SingletonMessageRenderer.jumpToBottom(channel._id, SMOOTH_SCROLL_ON_RECEIVE)); - // Sounds.playOutbound(); try { 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 ( - <div style={{ display: 'flex' }}> - <TextArea - value={draft} - onKeyDown={e => { - if (!e.shiftKey && e.key === "Enter" && !isTouchscreenDevice) { - e.preventDefault(); - return send(); + <> + <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) }); + } + }} /> + <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 => { + 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 }) } - }} - onChange={e => setMessage(e.currentTarget.value)} /> - <IconButton onClick={send}> - <Send size={20} /> - </IconButton> - </div> + disabled={uploadState.type === 'uploading' || uploadState.type === 'sending'} + onChange={e => { + setMessage(e.currentTarget.value); + startTyping(); + }} /> + <Action> + <IconButton onClick={send}> + <Send size={20} /> + </IconButton> + </Action> + </Base> + </> ) } diff --git a/src/components/common/messaging/SystemMessage.tsx b/src/components/common/messaging/SystemMessage.tsx index 26de5f5..e239076 100644 --- a/src/components/common/messaging/SystemMessage.tsx +++ b/src/components/common/messaging/SystemMessage.tsx @@ -1,14 +1,11 @@ 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 { 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 styled from "styled-components"; +import { useForceUpdate, useUser } from "../../../context/revoltjs/hooks"; const SystemContent = styled.div` gap: 4px; @@ -144,7 +141,7 @@ export function SystemMessage({ attachContext, message }: Props) { { message, contextualChannel: message.channel } ) : undefined}> <MessageInfo> - <MessageDetail message={message} /> + <MessageDetail message={message} position="left" /> </MessageInfo> <SystemContent>{children}</SystemContent> </MessageBase> diff --git a/src/components/common/messaging/attachments/AttachmentActions.tsx b/src/components/common/messaging/attachments/AttachmentActions.tsx index f33eebf..cf701ad 100644 --- a/src/components/common/messaging/attachments/AttachmentActions.tsx +++ b/src/components/common/messaging/attachments/AttachmentActions.tsx @@ -2,6 +2,7 @@ import { useContext } from 'preact/hooks'; import styles from './Attachment.module.scss'; import IconButton from '../../../ui/IconButton'; import { Attachment } from "revolt.js/dist/api/objects"; +import { determineFileSize } from '../../../../lib/fileSize'; import { AppContext } from '../../../../context/revoltjs/RevoltClient'; import { Download, ExternalLink, File, Headphones, Video } from '@styled-icons/feather'; @@ -9,16 +10,6 @@ interface Props { 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) { const client = useContext(AppContext); const { filename, metadata, size } = attachment; diff --git a/src/components/common/messaging/bars/FilePreview.tsx b/src/components/common/messaging/bars/FilePreview.tsx new file mode 100644 index 0000000..605efbb --- /dev/null +++ b/src/components/common/messaging/bars/FilePreview.tsx @@ -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> + ); +} diff --git a/src/components/common/messaging/bars/JumpToBottom.tsx b/src/components/common/messaging/bars/JumpToBottom.tsx new file mode 100644 index 0000000..14b9a97 --- /dev/null +++ b/src/components/common/messaging/bars/JumpToBottom.tsx @@ -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> + ) +} diff --git a/src/components/common/messaging/bars/TypingIndicator.tsx b/src/components/common/messaging/bars/TypingIndicator.tsx new file mode 100644 index 0000000..d886015 --- /dev/null +++ b/src/components/common/messaging/bars/TypingIndicator.tsx @@ -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] + }; +}); diff --git a/src/components/navigation/RightSidebar.tsx b/src/components/navigation/RightSidebar.tsx index 775f583..45371a8 100644 --- a/src/components/navigation/RightSidebar.tsx +++ b/src/components/navigation/RightSidebar.tsx @@ -1,19 +1,18 @@ import { Route, Switch } from "react-router"; import SidebarBase from "./SidebarBase"; -// import { MemberSidebar } from "./right/MemberSidebar"; +import MemberSidebar from "./right/MemberSidebar"; export default function RightSidebar() { return ( <SidebarBase> <Switch> - {/* <Route path="/server/:server/channel/:channel"> <MemberSidebar /> </Route> <Route path="/channel/:channel"> <MemberSidebar /> - </Route> */ } + </Route> </Switch> </SidebarBase> ); diff --git a/src/components/navigation/SidebarBase.tsx b/src/components/navigation/SidebarBase.tsx index cefe6b7..b9060c0 100644 --- a/src/components/navigation/SidebarBase.tsx +++ b/src/components/navigation/SidebarBase.tsx @@ -12,3 +12,22 @@ export default styled.div` 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%; + } +`; diff --git a/src/components/navigation/left/HomeSidebar.tsx b/src/components/navigation/left/HomeSidebar.tsx index 9c6cf7a..4437ccc 100644 --- a/src/components/navigation/left/HomeSidebar.tsx +++ b/src/components/navigation/left/HomeSidebar.tsx @@ -2,7 +2,6 @@ import { Localizer, Text } from "preact-i18n"; import { useContext } from "preact/hooks"; import { Home, Users, Tool, Save } from "@styled-icons/feather"; -import styled from "styled-components"; import Category from '../../ui/Category'; import PaintCounter from "../../../lib/PaintCounter"; 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 { AppContext } from "../../../context/revoltjs/RevoltClient"; import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; +import { GenericSidebarBase, GenericSidebarList } from "../SidebarBase"; import { Link, Redirect, useLocation, useParams } from "react-router-dom"; import { useIntermediate } from "../../../context/intermediate/Intermediate"; import { useDMs, useForceUpdate, useUsers } from "../../../context/revoltjs/hooks"; @@ -24,25 +24,6 @@ type Props = WithDispatcher & { 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) { const { pathname } = useLocation(); const client = useContext(AppContext); @@ -68,10 +49,10 @@ function HomeSidebar(props: Props) { channelsArr.sort((b, a) => a.timestamp.localeCompare(b.timestamp)); return ( - <HomeBase> + <GenericSidebarBase> <UserHeader user={client.user!} /> <ConnectionStatus /> - <HomeList> + <GenericSidebarList> {!isTouchscreenDevice && ( <> <Link to="/"> @@ -146,8 +127,8 @@ function HomeSidebar(props: Props) { ); })} <PaintCounter /> - </HomeList> - </HomeBase> + </GenericSidebarList> + </GenericSidebarBase> ); }; diff --git a/src/components/navigation/right/ChannelDebugInfo.tsx b/src/components/navigation/right/ChannelDebugInfo.tsx new file mode 100644 index 0000000..8584288 --- /dev/null +++ b/src/components/navigation/right/ChannelDebugInfo.tsx @@ -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> + ); +} diff --git a/src/components/navigation/right/MemberSidebar.tsx b/src/components/navigation/right/MemberSidebar.tsx new file mode 100644 index 0000000..9738354 --- /dev/null +++ b/src/components/navigation/right/MemberSidebar.tsx @@ -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> + ); +} diff --git a/src/components/ui/ComboBox.tsx b/src/components/ui/ComboBox.tsx index de2796c..ef9ba9f 100644 --- a/src/components/ui/ComboBox.tsx +++ b/src/components/ui/ComboBox.tsx @@ -5,4 +5,12 @@ export default styled.select` border-radius: 2px; color: var(--secondary-foreground); background: var(--secondary-background); + + border: none; + outline: 2px solid transparent; + transition: outline-color 0.2s ease-in-out; + + &:focus { + outline-color: var(--accent); + } `; diff --git a/src/components/ui/InputBox.tsx b/src/components/ui/InputBox.tsx index b27e6c5..ac37464 100644 --- a/src/components/ui/InputBox.tsx +++ b/src/components/ui/InputBox.tsx @@ -8,18 +8,21 @@ export default styled.input<Props>` z-index: 1; padding: 8px 16px; border-radius: 6px; + color: var(--foreground); - border: 2px solid transparent; background: var(--primary-background); 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 { background: var(--secondary-background); } &:focus { - border: 2px solid var(--accent); + outline: 2px solid var(--accent); } ${(props) => diff --git a/src/components/ui/TextArea.tsx b/src/components/ui/TextArea.tsx index cb4bc81..7cdde08 100644 --- a/src/components/ui/TextArea.tsx +++ b/src/components/ui/TextArea.tsx @@ -7,23 +7,39 @@ import styled, { css } from "styled-components"; export interface TextAreaProps { code?: boolean; 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>` width: 100%; resize: none; display: block; - border-radius: 4px; - padding: ${ props => props.padding ?? 16 }px; - color: var(--foreground); - border: 2px solid transparent; background: var(--secondary-background); - transition: border-color .2s ease-in-out; + 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; + border: ${TEXT_AREA_BORDER_WIDTH}px solid transparent; + ` } &:focus { outline: none; - border: 2px solid var(--accent); + + ${ props => !props.hideBorder && css` + border: ${TEXT_AREA_BORDER_WIDTH}px solid var(--accent); + ` } } ${ props => props.code ? css` diff --git a/src/context/revoltjs/FileUploads.tsx b/src/context/revoltjs/FileUploads.tsx index 61df463..d485250 100644 --- a/src/context/revoltjs/FileUploads.tsx +++ b/src/context/revoltjs/FileUploads.tsx @@ -1,18 +1,15 @@ -// ! FIXME: also TEMP CODE -// ! RE-WRITE WITH STYLED-COMPONENTS - import { Text } from "preact-i18n"; import { takeError } from "./util"; import classNames from "classnames"; +import { AppContext } from "./RevoltClient"; import styles from './FileUploads.module.scss'; import Axios, { AxiosRequestConfig } from "axios"; import { useContext, useState } from "preact/hooks"; -import { Edit, Plus, X } from "@styled-icons/feather"; import Preloader from "../../components/ui/Preloader"; import { determineFileSize } from "../../lib/fileSize"; import IconButton from '../../components/ui/IconButton'; +import { Edit, Plus, X, XCircle } from "@styled-icons/feather"; import { useIntermediate } from "../intermediate/Intermediate"; -import { AppContext } from "./RevoltClient"; type Props = { maxFileSize: number @@ -20,6 +17,7 @@ type Props = { fileType: 'backgrounds' | 'icons' | 'avatars' | 'attachments' | 'banners' } & ( { behaviour: 'ask', onChange: (file: File) => void } | + { behaviour: 'multi', onChange: (files: File[]) => void } | { behaviour: 'upload', onUpload: (id: string) => Promise<void> } ) & ( { 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; } +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) { const { fileType, maxFileSize, remove } = props; const { openScreen } = useIntermediate(); @@ -50,35 +68,25 @@ export function FileUploader(props: Props) { function onClick() { if (uploading) return; - const input = document.createElement("input"); - input.type = "file"; - - input.onchange = async e => { + grabFiles(maxFileSize, async files => { setUploading(true); try { - const files = (e.target as any)?.files; - if (files && files[0]) { - let file = files[0]; - - if (file.size > maxFileSize) { - return openScreen({ id: "error", error: "FileTooLarge" }); - } - - if (props.behaviour === 'ask') { - await props.onChange(file); - } else { - await props.onUpload(await uploadFile(client.configuration!.features.autumn.url, fileType, file)); - } + if (props.behaviour === 'multi') { + props.onChange(files); + } else if (props.behaviour === 'ask') { + props.onChange(files[0]); + } else { + await props.onUpload(await uploadFile(client.configuration!.features.autumn.url, fileType, files[0])); } } catch (err) { return openScreen({ id: "error", error: takeError(err) }); } finally { setUploading(false); } - }; - - input.click(); + }, () => + openScreen({ id: "error", error: "FileTooLarge" }), + props.behaviour === 'multi'); } function removeOrUpload() { @@ -139,7 +147,7 @@ export function FileUploader(props: Props) { if (attached) return remove(); onClick(); }}> - { attached ? <X size={size} /> : <Plus size={size} />} + { uploading ? <XCircle size={size} /> : attached ? <X size={size} /> : <Plus size={size} />} </IconButton> ) } diff --git a/src/context/revoltjs/Notifications.tsx b/src/context/revoltjs/Notifications.tsx index 09b8eaf..0845a62 100644 --- a/src/context/revoltjs/Notifications.tsx +++ b/src/context/revoltjs/Notifications.tsx @@ -1,8 +1,8 @@ import { decodeTime } from "ulid"; import { AppContext } from "./RevoltClient"; +import { useTranslation } from "../../lib/i18n"; import { Users } from "revolt.js/dist/api/objects"; import { useContext, useEffect } from "preact/hooks"; -import { IntlContext, translate } from "preact-i18n"; import { connectState } from "../../redux/connector"; import { playSound } from "../../assets/sounds/Audio"; 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) { - const { intl } = useContext(IntlContext) as any; + const translate = useTranslation(); const showNotification = props.options?.desktopEnabled ?? false; // const playIncoming = props.options?.soundEnabled ?? true; // const playOutgoing = props.options?.outgoingSoundEnabled ?? true; @@ -88,45 +88,37 @@ function Notifier(props: Props) { } else { let users = client.users; switch (msg.content.type) { - // ! FIXME: update to support new replacements 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": - body = `${users.get(msg.content.id)?.username} ${translate( - "app.main.channel.system.user_left", - "", - intl.dictionary - )} (${translate( - "app.main.channel.system.added_by", - "", - intl.dictionary - )} ${users.get(msg.content.by)?.username})`; + body = translate( + `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 } + ); icon = client.users.getAvatarURL(msg.content.id, { max_side: 256 }); break; + case "user_joined": case "user_left": - body = `${users.get(msg.content.id)?.username} ${translate( - "app.main.channel.system.user_left", - "", - intl.dictionary - )}`; + case "user_kicked": + case "user_banned": + body = translate( + `app.main.channel.system.${msg.content.type}`, + { user: users.get(msg.content.id)?.username } + ); icon = client.users.getAvatarURL(msg.content.id, { max_side: 256 }); break; case "channel_renamed": - body = `${users.get(msg.content.by)?.username} ${translate( - "app.main.channel.system.channel_renamed", - "", - intl.dictionary - )} ${msg.content.name}`; + body = translate( + `app.main.channel.system.channel_renamed`, + { user: users.get(msg.content.by)?.username, name: 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 }); break; } @@ -173,20 +165,10 @@ function Notifier(props: Props) { let event; switch (user.relationship) { case Users.Relationship.Incoming: - event = translate( - "notifications.sent_request", - "", - intl.dictionary, - { person: user.username } - ); + event = translate("notifications.sent_request", { person: user.username }); break; case Users.Relationship.Friend: - event = translate( - "notifications.now_friends", - "", - intl.dictionary, - { person: user.username } - ); + event = translate("notifications.now_friends", { person: user.username }); break; default: return; diff --git a/src/lib/TextAreaAutoSize.tsx b/src/lib/TextAreaAutoSize.tsx index 2b1fd73..deca435 100644 --- a/src/lib/TextAreaAutoSize.tsx +++ b/src/lib/TextAreaAutoSize.tsx @@ -1,6 +1,5 @@ -import styled from "styled-components"; -import TextArea, { TextAreaProps } from "../components/ui/TextArea"; -import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks"; +import TextArea, { DEFAULT_LINE_HEIGHT, DEFAULT_TEXT_AREA_PADDING, TextAreaProps, TEXT_AREA_BORDER_WIDTH } from "../components/ui/TextArea"; +import { useEffect, useRef } from "preact/hooks"; type TextAreaAutoSizeProps = Omit<JSX.HTMLAttributes<HTMLTextAreaElement>, 'style' | 'value'> & TextAreaProps & { autoFocus?: boolean, @@ -9,51 +8,15 @@ type TextAreaAutoSizeProps = Omit<JSX.HTMLAttributes<HTMLTextAreaElement>, 'styl 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) { - const { autoFocus, minHeight, maxRows, value, padding, children, as, ...textAreaProps } = props; - - const heightPadding = (padding ?? 0) * 2; - const minimumHeight = (minHeight ?? lineHeight) + heightPadding; + const { autoFocus, minHeight, maxRows, value, padding, lineHeight, hideBorder, children, as, ...textAreaProps } = props; + const line = lineHeight ?? DEFAULT_LINE_HEIGHT; - var height = Math.max(Math.min(value.split('\n').length, maxRows ?? Infinity) * lineHeight + heightPadding, minimumHeight); + const heightPadding = ((padding ?? DEFAULT_TEXT_AREA_PADDING) + (hideBorder ? 0 : TEXT_AREA_BORDER_WIDTH)) * 2; + 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); 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(() => { autoFocus && ref.current.focus(); }, [value]); @@ -89,17 +52,12 @@ export default function TextAreaAutoSize(props: TextAreaAutoSizeProps) { return () => document.body.removeEventListener("keydown", keyDown); }, [ref]); - return <> - <TextArea - ref={ref} - value={value} - padding={padding} - style={{ height }} - {...textAreaProps} /> - {/*<Ghost><div ref={ghost}> - { props.value.split('\n') - .map(x => `\u0020${x}`) - .join('\n') } - </div></Ghost>*/} - </>; + return <TextArea + ref={ref} + value={value} + padding={padding} + style={{ height }} + hideBorder={hideBorder} + lineHeight={lineHeight} + {...textAreaProps} />; } diff --git a/src/lib/i18n.tsx b/src/lib/i18n.tsx index 5f270b2..a58dff1 100644 --- a/src/lib/i18n.tsx +++ b/src/lib/i18n.tsx @@ -1,4 +1,4 @@ -import { IntlContext } from "preact-i18n"; +import { IntlContext, translate } from "preact-i18n"; import { useContext } from "preact/hooks"; import { Children } from "../types/Preact"; @@ -52,3 +52,8 @@ export function TextReact({ id, fields }: Props) { 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); +} diff --git a/src/pages/channels/Channel.tsx b/src/pages/channels/Channel.tsx index 5e4ec84..8d32b3b 100644 --- a/src/pages/channels/Channel.tsx +++ b/src/pages/channels/Channel.tsx @@ -1,10 +1,15 @@ import styled from "styled-components"; +import { useState } from "preact/hooks"; +import ChannelHeader from "./ChannelHeader"; 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 { useRenderState } from "../../lib/renderer/Singleton"; +import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice"; 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` flex-grow: 1; @@ -21,26 +26,30 @@ const ChannelContent = styled.div` flex-direction: column; `; -export default function Channel() { - const { channel: id } = useParams<{ channel: string }>(); - +export function Channel({ id }: { id: string }) { const ctx = useForceUpdate(); const channel = useChannel(id, ctx); if (!channel) return null; - // const view = useRenderState(id); + const [ showMembers, setMembers ] = useState(true); return ( <> - <Header placement="primary"> - Channel - </Header> + <ChannelHeader channel={channel} toggleSidebar={() => setMembers(!showMembers)} /> <ChannelMain> <ChannelContent> <MessageArea id={id} /> + <TypingIndicator id={channel._id} /> + <JumpToBottom id={id} /> <MessageBox channel={channel} /> </ChannelContent> + { !isTouchscreenDevice && showMembers && <MemberSidebar channel={channel} /> } </ChannelMain> </> ) } + +export default function() { + const { channel } = useParams<{ channel: string }>(); + return <Channel id={channel} key={channel} />; +} diff --git a/src/pages/channels/ChannelHeader.tsx b/src/pages/channels/ChannelHeader.tsx new file mode 100644 index 0000000..929ac9f --- /dev/null +++ b/src/pages/channels/ChannelHeader.tsx @@ -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> + ) +} diff --git a/src/pages/channels/messaging/MessageArea.tsx b/src/pages/channels/messaging/MessageArea.tsx index b041229..6e60966 100644 --- a/src/pages/channels/messaging/MessageArea.tsx +++ b/src/pages/channels/messaging/MessageArea.tsx @@ -23,6 +23,7 @@ const Area = styled.div` > div { display: flex; min-height: 100%; + padding-bottom: 20px; flex-direction: column; justify-content: flex-end; } diff --git a/src/pages/channels/messaging/MessageRenderer.tsx b/src/pages/channels/messaging/MessageRenderer.tsx index 9e690e7..b1b5493 100644 --- a/src/pages/channels/messaging/MessageRenderer.tsx +++ b/src/pages/channels/messaging/MessageRenderer.tsx @@ -160,7 +160,7 @@ function MessageRenderer({ id, state, queue }: Props) { ); } - render.push(<div>end</div>); + // render.push(<div>end</div>); } else { render.push( <RequiresOnline> diff --git a/src/pages/settings/channel/Overview.tsx b/src/pages/settings/channel/Overview.tsx index a73a3fd..30c36b9 100644 --- a/src/pages/settings/channel/Overview.tsx +++ b/src/pages/settings/channel/Overview.tsx @@ -3,7 +3,7 @@ import styles from "./Panes.module.scss"; import Button from "../../../components/ui/Button"; import { Channels } from "revolt.js/dist/api/objects"; 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 { AppContext } from "../../../context/revoltjs/RevoltClient"; 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.servers.channel_description" /> } </h3> - <TextArea - // maxRows={10} - // minHeight={60} + <TextAreaAutoSize + maxRows={10} + minHeight={60} maxLength={1024} value={description} placeholder={"Add a description..."} diff --git a/src/pages/settings/panes/Appearance.tsx b/src/pages/settings/panes/Appearance.tsx index 4fa442e..8ee5dd4 100644 --- a/src/pages/settings/panes/Appearance.tsx +++ b/src/pages/settings/panes/Appearance.tsx @@ -2,10 +2,10 @@ import { Text } from "preact-i18n"; import styles from "./Panes.module.scss"; import { debounce } from "../../../lib/debounce"; import Button from "../../../components/ui/Button"; -import TextArea from "../../../components/ui/TextArea"; import InputBox from "../../../components/ui/InputBox"; import { connectState } from "../../../redux/connector"; import { WithDispatcher } from "../../../redux/reducers"; +import TextAreaAutoSize from "../../../lib/TextAreaAutoSize"; import ColourSwatches from "../../../components/ui/ColourSwatches"; import { EmojiPacks, Settings } from "../../../redux/reducers/settings"; import { Theme, ThemeContext, ThemeOptions } from "../../../context/Theme"; @@ -267,19 +267,13 @@ export function Component(props: Props & WithDispatcher) { <h3> <Text id="app.settings.pages.appearance.custom_css" /> </h3> - <TextArea - // maxRows={20} - // minHeight={480} + <TextAreaAutoSize + maxRows={20} + minHeight={480} code value={css} - onChange={ev => setCSS(ev.currentTarget.value)} - /> + onChange={ev => setCSS(ev.currentTarget.value)} /> </details> - - {/*<h3> - <Text id="app.settings.pages.appearance.sync" /> - </h3> - <p>Coming soon!</p>*/} </div> ); } diff --git a/src/pages/settings/server/Overview.tsx b/src/pages/settings/server/Overview.tsx index 3c8e2ec..d6d0c28 100644 --- a/src/pages/settings/server/Overview.tsx +++ b/src/pages/settings/server/Overview.tsx @@ -2,8 +2,8 @@ import { Text } from "preact-i18n"; import styles from './Panes.module.scss'; import Button from "../../../components/ui/Button"; import { Servers } from "revolt.js/dist/api/objects"; -import TextArea from "../../../components/ui/TextArea"; import InputBox from "../../../components/ui/InputBox"; +import TextAreaAutoSize from "../../../lib/TextAreaAutoSize"; import { useContext, useEffect, useState } from "preact/hooks"; import { AppContext } from "../../../context/revoltjs/RevoltClient"; import { FileUploader } from "../../../context/revoltjs/FileUploads"; @@ -65,9 +65,9 @@ export function Overview({ server }: Props) { <h3> <Text id="app.main.servers.description" /> </h3> - <TextArea - // maxRows={10} - // minHeight={60} + <TextAreaAutoSize + maxRows={10} + minHeight={60} maxLength={1024} value={description} placeholder={"Add a topic..."} -- GitLab