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

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