mirror of
https://github.com/revoltchat/revite.git
synced 2024-11-25 16:40:58 -05:00
Port navigation.
This commit is contained in:
parent
5aa8f30e14
commit
5b77ed439f
25 changed files with 1341 additions and 42 deletions
|
@ -36,6 +36,7 @@
|
||||||
"@types/styled-components": "^5.1.10",
|
"@types/styled-components": "^5.1.10",
|
||||||
"@typescript-eslint/eslint-plugin": "^4.27.0",
|
"@typescript-eslint/eslint-plugin": "^4.27.0",
|
||||||
"@typescript-eslint/parser": "^4.27.0",
|
"@typescript-eslint/parser": "^4.27.0",
|
||||||
|
"classnames": "^2.3.1",
|
||||||
"dayjs": "^1.10.5",
|
"dayjs": "^1.10.5",
|
||||||
"detect-browser": "^5.2.0",
|
"detect-browser": "^5.2.0",
|
||||||
"eslint": "^7.28.0",
|
"eslint": "^7.28.0",
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { useContext } from "preact/hooks";
|
import { useContext } from "preact/hooks";
|
||||||
import { Hash } from "@styled-icons/feather";
|
import { Hash } from "@styled-icons/feather";
|
||||||
import IconBase, { IconBaseProps } from "./IconBase";
|
|
||||||
import { Channels } from "revolt.js/dist/api/objects";
|
import { Channels } from "revolt.js/dist/api/objects";
|
||||||
|
import { ImageIconBase, IconBaseProps } from "./IconBase";
|
||||||
import { AppContext } from "../../context/revoltjs/RevoltClient";
|
import { AppContext } from "../../context/revoltjs/RevoltClient";
|
||||||
|
|
||||||
interface Props extends IconBaseProps<Channels.GroupChannel | Channels.TextChannel> {
|
interface Props extends IconBaseProps<Channels.GroupChannel | Channels.TextChannel> {
|
||||||
|
@ -9,10 +9,10 @@ interface Props extends IconBaseProps<Channels.GroupChannel | Channels.TextChann
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallback = '/assets/group.png';
|
const fallback = '/assets/group.png';
|
||||||
export default function ChannelIcon(props: Props & Omit<JSX.SVGAttributes<SVGSVGElement>, keyof Props>) {
|
export default function ChannelIcon(props: Props & Omit<JSX.HTMLAttributes<HTMLImageElement>, keyof Props>) {
|
||||||
const { client } = useContext(AppContext);
|
const { client } = useContext(AppContext);
|
||||||
|
|
||||||
const { size, target, attachment, isServerChannel: server, animate, children, as, ...svgProps } = props;
|
const { size, target, attachment, isServerChannel: server, animate, children, as, ...imgProps } = props;
|
||||||
const iconURL = client.generateFileURL(target?.icon ?? attachment, { max_side: 256 }, animate);
|
const iconURL = client.generateFileURL(target?.icon ?? attachment, { max_side: 256 }, animate);
|
||||||
const isServerChannel = server || target?.channel_type === 'TextChannel';
|
const isServerChannel = server || target?.channel_type === 'TextChannel';
|
||||||
|
|
||||||
|
@ -25,21 +25,18 @@ export default function ChannelIcon(props: Props & Omit<JSX.SVGAttributes<SVGSVG
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IconBase {...svgProps}
|
// ! fixme: replace fallback with <picture /> + <source />
|
||||||
|
<ImageIconBase {...imgProps}
|
||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
viewBox="0 0 32 32"
|
square={isServerChannel}
|
||||||
square={isServerChannel}>
|
src={iconURL ?? fallback}
|
||||||
<foreignObject x="0" y="0" width="32" height="32">
|
|
||||||
<img src={iconURL ?? fallback}
|
|
||||||
onError={ e => {
|
onError={ e => {
|
||||||
let el = e.currentTarget;
|
let el = e.currentTarget;
|
||||||
if (el.src !== fallback) {
|
if (el.src !== fallback) {
|
||||||
el.src = fallback
|
el.src = fallback
|
||||||
}
|
}
|
||||||
}} />
|
}} />
|
||||||
</foreignObject>
|
|
||||||
</IconBase>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,7 +9,11 @@ export interface IconBaseProps<T> {
|
||||||
animate?: boolean;
|
animate?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default styled.svg<{ square?: boolean }>`
|
interface IconModifiers {
|
||||||
|
square?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default styled.svg<IconModifiers>`
|
||||||
img {
|
img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
@ -20,3 +24,11 @@ export default styled.svg<{ square?: boolean }>`
|
||||||
` }
|
` }
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const ImageIconBase = styled.img<IconModifiers>`
|
||||||
|
object-fit: cover;
|
||||||
|
|
||||||
|
${ props => !props.square && css`
|
||||||
|
border-radius: 50%;
|
||||||
|
` }
|
||||||
|
`;
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { useContext } from "preact/hooks";
|
import { useContext } from "preact/hooks";
|
||||||
import { Server } from "revolt.js/dist/api/objects";
|
import { Server } from "revolt.js/dist/api/objects";
|
||||||
import IconBase, { IconBaseProps } from "./IconBase";
|
import { IconBaseProps, ImageIconBase } from "./IconBase";
|
||||||
import { AppContext } from "../../context/revoltjs/RevoltClient";
|
import { AppContext } from "../../context/revoltjs/RevoltClient";
|
||||||
|
|
||||||
interface Props extends IconBaseProps<Server> {
|
interface Props extends IconBaseProps<Server> {
|
||||||
|
@ -19,10 +19,10 @@ const ServerText = styled.div`
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const fallback = '/assets/group.png';
|
const fallback = '/assets/group.png';
|
||||||
export default function ServerIcon(props: Props & Omit<JSX.SVGAttributes<SVGSVGElement>, keyof Props>) {
|
export default function ServerIcon(props: Props & Omit<JSX.HTMLAttributes<HTMLImageElement>, keyof Props>) {
|
||||||
const { client } = useContext(AppContext);
|
const { client } = useContext(AppContext);
|
||||||
|
|
||||||
const { target, attachment, size, animate, server_name, children, as, ...svgProps } = props;
|
const { target, attachment, size, animate, server_name, children, as, ...imgProps } = props;
|
||||||
const iconURL = client.generateFileURL(target?.icon ?? attachment, { max_side: 256 }, animate);
|
const iconURL = client.generateFileURL(target?.icon ?? attachment, { max_side: 256 }, animate);
|
||||||
|
|
||||||
if (typeof iconURL === 'undefined') {
|
if (typeof iconURL === 'undefined') {
|
||||||
|
@ -38,20 +38,16 @@ export default function ServerIcon(props: Props & Omit<JSX.SVGAttributes<SVGSVGE
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IconBase {...svgProps}
|
<ImageIconBase {...imgProps}
|
||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
viewBox="0 0 32 32">
|
src={iconURL}
|
||||||
<foreignObject x="0" y="0" width="32" height="32">
|
|
||||||
<img src={iconURL}
|
|
||||||
onError={ e => {
|
onError={ e => {
|
||||||
let el = e.currentTarget;
|
let el = e.currentTarget;
|
||||||
if (el.src !== fallback) {
|
if (el.src !== fallback) {
|
||||||
el.src = fallback
|
el.src = fallback
|
||||||
}
|
}
|
||||||
}} />
|
}} />
|
||||||
</foreignObject>
|
|
||||||
</IconBase>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
79
src/components/common/UserHeader.tsx
Normal file
79
src/components/common/UserHeader.tsx
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
import { User } from "revolt.js";
|
||||||
|
import Header from "../ui/Header";
|
||||||
|
import UserIcon from "./UserIcon";
|
||||||
|
import UserStatus from './UserStatus';
|
||||||
|
import styled from "styled-components";
|
||||||
|
import { Localizer } from 'preact-i18n';
|
||||||
|
import { Settings } from "@styled-icons/feather";
|
||||||
|
import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice";
|
||||||
|
|
||||||
|
const HeaderBase = styled.div`
|
||||||
|
gap: 0;
|
||||||
|
flex-grow: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
* {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: -2px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: User
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserHeader({ user }: Props) {
|
||||||
|
function openPresenceSelector() {
|
||||||
|
// openContextMenu("Status");
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeClipboard(a: string) {
|
||||||
|
alert('unimplemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Header placement="secondary">
|
||||||
|
<UserIcon
|
||||||
|
target={user}
|
||||||
|
size={32}
|
||||||
|
status
|
||||||
|
onClick={openPresenceSelector}
|
||||||
|
/>
|
||||||
|
<HeaderBase>
|
||||||
|
<Localizer>
|
||||||
|
{/*<Tooltip content={<Text id="app.special.copy_username" />}>*/}
|
||||||
|
<span className="username"
|
||||||
|
onClick={() => writeClipboard(user.username)}>
|
||||||
|
@{user.username}
|
||||||
|
</span>
|
||||||
|
{/*</Tooltip>*/}
|
||||||
|
</Localizer>
|
||||||
|
<span className="status"
|
||||||
|
onClick={openPresenceSelector}>
|
||||||
|
<UserStatus user={user} />
|
||||||
|
</span>
|
||||||
|
</HeaderBase>
|
||||||
|
{ !isTouchscreenDevice && <div className="actions">
|
||||||
|
{/*<IconButton to="/settings">*/}
|
||||||
|
<Settings size={24} />
|
||||||
|
{/*</IconButton>*/}
|
||||||
|
</div> }
|
||||||
|
</Header>
|
||||||
|
)
|
||||||
|
}
|
|
@ -52,7 +52,8 @@ export default function UserIcon(props: Props & Omit<JSX.SVGAttributes<SVGSVGEle
|
||||||
const { client } = useContext(AppContext);
|
const { client } = useContext(AppContext);
|
||||||
|
|
||||||
const { target, attachment, size, voice, status, animate, children, as, ...svgProps } = props;
|
const { target, attachment, size, voice, status, animate, children, as, ...svgProps } = props;
|
||||||
const iconURL = client.generateFileURL(target?.avatar ?? attachment, { max_side: 256 }, animate);
|
const iconURL = client.generateFileURL(target?.avatar ?? attachment, { max_side: 256 }, animate)
|
||||||
|
?? client.users.getDefaultAvatarURL(target!._id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IconBase {...svgProps}
|
<IconBase {...svgProps}
|
||||||
|
|
31
src/components/common/UserStatus.tsx
Normal file
31
src/components/common/UserStatus.tsx
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
import { User } from "revolt.js";
|
||||||
|
import { Text } from "preact-i18n";
|
||||||
|
import { Users } from "revolt.js/dist/api/objects";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserStatus({ user }: Props) {
|
||||||
|
if (user.online) {
|
||||||
|
if (user.status?.text) {
|
||||||
|
return <>{user.status?.text}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.status?.presence === Users.Presence.Busy) {
|
||||||
|
return <Text id="app.status.busy" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.status?.presence === Users.Presence.Idle) {
|
||||||
|
return <Text id="app.status.idle" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.status?.presence === Users.Presence.Invisible) {
|
||||||
|
return <Text id="app.status.offline" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Text id="app.status.online" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Text id="app.status.offline" />;
|
||||||
|
}
|
32
src/components/navigation/LeftSidebar.tsx
Normal file
32
src/components/navigation/LeftSidebar.tsx
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import { Route, Switch } from "react-router";
|
||||||
|
import SidebarBase from "./SidebarBase";
|
||||||
|
|
||||||
|
import ServerListSidebar from "./left/ServerListSidebar";
|
||||||
|
import ServerSidebar from "./left/ServerSidebar";
|
||||||
|
import HomeSidebar from "./left/HomeSidebar";
|
||||||
|
|
||||||
|
export default function LeftSidebar() {
|
||||||
|
return (
|
||||||
|
<SidebarBase>
|
||||||
|
<Switch>
|
||||||
|
<Route path="/settings" />
|
||||||
|
<Route path="/server/:server/channel/:channel">
|
||||||
|
<ServerListSidebar />
|
||||||
|
<ServerSidebar />
|
||||||
|
</Route>
|
||||||
|
<Route path="/server/:server">
|
||||||
|
<ServerListSidebar />
|
||||||
|
<ServerSidebar />
|
||||||
|
</Route>
|
||||||
|
<Route path="/channel/:channel">
|
||||||
|
<ServerListSidebar />
|
||||||
|
<HomeSidebar />
|
||||||
|
</Route>
|
||||||
|
<Route path="/">
|
||||||
|
<ServerListSidebar />
|
||||||
|
<HomeSidebar />
|
||||||
|
</Route>
|
||||||
|
</Switch>
|
||||||
|
</SidebarBase>
|
||||||
|
);
|
||||||
|
};
|
20
src/components/navigation/RightSidebar.tsx
Normal file
20
src/components/navigation/RightSidebar.tsx
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import { Route, Switch } from "react-router";
|
||||||
|
import SidebarBase from "./SidebarBase";
|
||||||
|
|
||||||
|
// 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> */ }
|
||||||
|
</Switch>
|
||||||
|
</SidebarBase>
|
||||||
|
);
|
||||||
|
};
|
8
src/components/navigation/SidebarBase.tsx
Normal file
8
src/components/navigation/SidebarBase.tsx
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import styled from "styled-components";
|
||||||
|
|
||||||
|
export default styled.div`
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
user-select: none;
|
||||||
|
flex-direction: row;
|
||||||
|
`;
|
157
src/components/navigation/items/ButtonItem.tsx
Normal file
157
src/components/navigation/items/ButtonItem.tsx
Normal file
|
@ -0,0 +1,157 @@
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import styles from "./Item.module.scss";
|
||||||
|
import UserIcon from '../../common/UserIcon';
|
||||||
|
import { Localizer, Text } from "preact-i18n";
|
||||||
|
import { X, Zap } from "@styled-icons/feather";
|
||||||
|
import UserStatus from '../../common/UserStatus';
|
||||||
|
import { Children } from "../../../types/Preact";
|
||||||
|
import ChannelIcon from '../../common/ChannelIcon';
|
||||||
|
import { Channels, Users } from "revolt.js/dist/api/objects";
|
||||||
|
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
|
||||||
|
|
||||||
|
interface CommonProps {
|
||||||
|
active?: boolean
|
||||||
|
alert?: 'unread' | 'mention'
|
||||||
|
alertCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserProps = CommonProps & {
|
||||||
|
user: Users.User,
|
||||||
|
context?: Channels.Channel,
|
||||||
|
channel?: Channels.DirectMessageChannel
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserButton({ active, alert, alertCount, user, context, channel }: UserProps) {
|
||||||
|
// const { openScreen } = useContext(IntermediateContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames(styles.item, styles.user)}
|
||||||
|
data-active={active}
|
||||||
|
data-alert={typeof alert === 'string'}
|
||||||
|
data-online={typeof channel !== 'undefined' || (user.online && user.status?.presence !== Users.Presence.Invisible)}
|
||||||
|
/*onContextMenu={attachContextMenu('Menu', {
|
||||||
|
user: user._id,
|
||||||
|
channel: channel?._id,
|
||||||
|
unread: alert,
|
||||||
|
contextualChannel: context?._id
|
||||||
|
})}*/
|
||||||
|
>
|
||||||
|
<div className={styles.avatar}>
|
||||||
|
<UserIcon target={user} size={32} status />
|
||||||
|
</div>
|
||||||
|
<div className={styles.name}>
|
||||||
|
<div>{user.username}</div>
|
||||||
|
{
|
||||||
|
<div className={styles.subText}>
|
||||||
|
{ channel?.last_message && alert ? (
|
||||||
|
channel.last_message.short
|
||||||
|
) : (
|
||||||
|
<UserStatus user={user} />
|
||||||
|
) }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div className={styles.button}>
|
||||||
|
{ context?.channel_type === "Group" &&
|
||||||
|
context.owner === user._id && (
|
||||||
|
<Localizer>
|
||||||
|
{/*<Tooltip
|
||||||
|
content={
|
||||||
|
<Text id="app.main.groups.owner" />
|
||||||
|
}
|
||||||
|
>*/}
|
||||||
|
<Zap size={20} />
|
||||||
|
{/*</Tooltip>*/}
|
||||||
|
</Localizer>
|
||||||
|
)}
|
||||||
|
{alert && <div className={styles.alert} data-style={alert}>{ alertCount }</div>}
|
||||||
|
{ !isTouchscreenDevice && channel &&
|
||||||
|
/*<IconButton
|
||||||
|
className={styles.icon}
|
||||||
|
style="default"
|
||||||
|
onClick={() => openScreen({ id: 'special_prompt', type: 'close_dm', target: channel })}
|
||||||
|
>*/
|
||||||
|
<X size={24} />
|
||||||
|
/*</IconButton>*/
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChannelProps = CommonProps & {
|
||||||
|
channel: Channels.Channel,
|
||||||
|
user?: Users.User
|
||||||
|
compact?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChannelButton({ active, alert, alertCount, channel, user, compact }: ChannelProps) {
|
||||||
|
if (channel.channel_type === 'SavedMessages') throw "Invalid channel type.";
|
||||||
|
if (channel.channel_type === 'DirectMessage') {
|
||||||
|
if (typeof user === 'undefined') throw "No user provided.";
|
||||||
|
return <UserButton {...{ active, alert, channel, user }} />
|
||||||
|
}
|
||||||
|
|
||||||
|
//const { openScreen } = useContext(IntermediateContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-active={active}
|
||||||
|
data-alert={typeof alert === 'string'}
|
||||||
|
className={classNames(styles.item, { [styles.compact]: compact })}
|
||||||
|
//onContextMenu={attachContextMenu('Menu', { channel: channel._id })}>
|
||||||
|
>
|
||||||
|
<div className={styles.avatar}>
|
||||||
|
<ChannelIcon target={channel} size={compact ? 24 : 32} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.name}>
|
||||||
|
<div>{channel.name}</div>
|
||||||
|
{ channel.channel_type === 'Group' &&
|
||||||
|
<div className={styles.subText}>
|
||||||
|
{(channel.last_message && alert) ? (
|
||||||
|
channel.last_message.short
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
id="quantities.members"
|
||||||
|
plural={channel.recipients.length}
|
||||||
|
fields={{ count: channel.recipients.length }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div className={styles.button}>
|
||||||
|
{alert && <div className={styles.alert} data-style={alert}>{ alertCount }</div>}
|
||||||
|
{!isTouchscreenDevice && channel.channel_type === "Group" && (
|
||||||
|
/*<IconButton
|
||||||
|
className={styles.icon}
|
||||||
|
style="default"
|
||||||
|
onClick={() => openScreen({ id: 'special_prompt', type: 'leave_group', target: channel })}
|
||||||
|
>*/
|
||||||
|
<X size={24} />
|
||||||
|
/*</IconButton>*/
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ButtonProps = CommonProps & {
|
||||||
|
onClick?: () => void
|
||||||
|
children?: Children
|
||||||
|
className?: string
|
||||||
|
compact?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ButtonItem({ active, alert, alertCount, onClick, className, children, compact }: ButtonProps) {
|
||||||
|
return (
|
||||||
|
<div className={classNames(styles.item, { [styles.compact]: compact, [styles.normal]: !compact }, className)}
|
||||||
|
onClick={onClick}
|
||||||
|
data-active={active}
|
||||||
|
data-alert={typeof alert === 'string'}>
|
||||||
|
<div className={styles.content}>{ children }</div>
|
||||||
|
{alert && <div className={styles.alert} data-style={alert}>{ alertCount }</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
35
src/components/navigation/items/ConnectionStatus.tsx
Normal file
35
src/components/navigation/items/ConnectionStatus.tsx
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
import { Text } from "preact-i18n";
|
||||||
|
import Banner from "../../ui/Banner";
|
||||||
|
import { useContext } from "preact/hooks";
|
||||||
|
import { AppContext, ClientStatus } from "../../../context/revoltjs/RevoltClient";
|
||||||
|
|
||||||
|
export default function ConnectionStatus() {
|
||||||
|
const { status } = useContext(AppContext);
|
||||||
|
|
||||||
|
if (status === ClientStatus.OFFLINE) {
|
||||||
|
return (
|
||||||
|
<Banner>
|
||||||
|
<Text id="app.special.status.offline" />
|
||||||
|
</Banner>
|
||||||
|
);
|
||||||
|
} else if (status === ClientStatus.DISCONNECTED) {
|
||||||
|
return (
|
||||||
|
<Banner>
|
||||||
|
<Text id="app.special.status.disconnected" />
|
||||||
|
</Banner>
|
||||||
|
);
|
||||||
|
} else if (status === ClientStatus.CONNECTING) {
|
||||||
|
return (
|
||||||
|
<Banner>
|
||||||
|
<Text id="app.special.status.connecting" />
|
||||||
|
</Banner>
|
||||||
|
);
|
||||||
|
} else if (status === ClientStatus.RECONNECTING) {
|
||||||
|
return (
|
||||||
|
<Banner>
|
||||||
|
<Text id="app.special.status.reconnecting" />
|
||||||
|
</Banner>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
303
src/components/navigation/items/Item.module.scss
Normal file
303
src/components/navigation/items/Item.module.scss
Normal file
|
@ -0,0 +1,303 @@
|
||||||
|
.item {
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
padding: 0 8px;
|
||||||
|
user-select: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
transition: .1s ease-in-out background-color;
|
||||||
|
|
||||||
|
color: var(--tertiary-foreground);
|
||||||
|
stroke: var(--tertiary-foreground);
|
||||||
|
|
||||||
|
&.normal {
|
||||||
|
height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.compact {
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.user {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: .15s ease opacity;
|
||||||
|
|
||||||
|
&[data-online="true"],
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
div {
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
transition: color .1s ease-in-out;
|
||||||
|
|
||||||
|
&.content {
|
||||||
|
gap: 8px;
|
||||||
|
flex-grow: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.avatar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.name {
|
||||||
|
flex-grow: 1;
|
||||||
|
display: flex;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: .90625rem;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.subText {
|
||||||
|
margin-top: -1px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: .6875rem;
|
||||||
|
color: var(--tertiary-foreground);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
opacity: 0;
|
||||||
|
display: none;
|
||||||
|
transition: .1s ease-in-out opacity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(.compact):hover {
|
||||||
|
div.button .alert {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.button svg {
|
||||||
|
opacity: 1;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-active="true"] {
|
||||||
|
cursor: default;
|
||||||
|
background: var(--hover);
|
||||||
|
|
||||||
|
.unread {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-alert="true"], &[data-active="true"], &:hover {
|
||||||
|
color: var(--foreground);
|
||||||
|
stroke: var(--foreground);
|
||||||
|
|
||||||
|
.subText {
|
||||||
|
color: var(--secondary-foreground) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
margin: 9px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--foreground);
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
place-items: center;
|
||||||
|
|
||||||
|
&[data-style="mention"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
color: white;
|
||||||
|
background: var(--error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ! FIXME: check if anything is missing, then remove this block
|
||||||
|
.olditem {
|
||||||
|
display: flex;
|
||||||
|
user-select: none;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
gap: 8px;
|
||||||
|
height: 48px;
|
||||||
|
padding: 0 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-sizing: content-box;
|
||||||
|
transition: .1s ease background-color;
|
||||||
|
|
||||||
|
color: var(--tertiary-foreground);
|
||||||
|
stroke: var(--tertiary-foreground);
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 32px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 10px 0;
|
||||||
|
box-sizing: content-box;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div {
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
transition: color .1s ease-in-out;
|
||||||
|
|
||||||
|
&.content {
|
||||||
|
gap: 8px;
|
||||||
|
flex-grow: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.name {
|
||||||
|
flex-grow: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-size: .90625rem;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
.subText {
|
||||||
|
font-size: .6875rem;
|
||||||
|
margin-top: -1px;
|
||||||
|
color: var(--tertiary-foreground);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.unread {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
margin: 9px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
opacity: 0;
|
||||||
|
display: none;
|
||||||
|
transition: 0.1s ease opacity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-active="true"] {
|
||||||
|
color: var(--foreground);
|
||||||
|
stroke: var(--foreground);
|
||||||
|
background: var(--hover);
|
||||||
|
cursor: default;
|
||||||
|
|
||||||
|
.subText {
|
||||||
|
color: var(--secondary-foreground) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unread {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-alert="true"] {
|
||||||
|
color: var(--secondary-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-type="user"] {
|
||||||
|
opacity: 0.4;
|
||||||
|
color: var(--foreground);
|
||||||
|
transition: 0.15s ease opacity;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&[data-online="true"],
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
//background: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-size="compact"] {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
height: 32px;
|
||||||
|
transition: border-inline-start .1s ease-in-out;
|
||||||
|
border-inline-start: 4px solid transparent;
|
||||||
|
|
||||||
|
&[data-active="true"] {
|
||||||
|
border-inline-start: 4px solid var(--accent);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-size="small"] {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
height: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--hover);
|
||||||
|
|
||||||
|
div.button .unread {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.button .icon {
|
||||||
|
opacity: 1;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}*/
|
160
src/components/navigation/left/HomeSidebar.tsx
Normal file
160
src/components/navigation/left/HomeSidebar.tsx
Normal file
|
@ -0,0 +1,160 @@
|
||||||
|
import { Localizer, Text } from "preact-i18n";
|
||||||
|
import { useContext, useLayoutEffect } from "preact/hooks";
|
||||||
|
import { Home, Users, Tool, Settings, Save } from "@styled-icons/feather";
|
||||||
|
|
||||||
|
import { Link, Redirect, useHistory, useLocation, useParams } from "react-router-dom";
|
||||||
|
import { WithDispatcher } from "../../../redux/reducers";
|
||||||
|
import { Unreads } from "../../../redux/reducers/unreads";
|
||||||
|
import { connectState } from "../../../redux/connector";
|
||||||
|
import { AppContext } from "../../../context/revoltjs/RevoltClient";
|
||||||
|
import { useChannels, useForceUpdate, useUsers } from "../../../context/revoltjs/hooks";
|
||||||
|
import { User } from "revolt.js";
|
||||||
|
import { Users as UsersNS } from 'revolt.js/dist/api/objects';
|
||||||
|
import { mapChannelWithUnread, useUnreads } from "./common";
|
||||||
|
import { Channels } from "revolt.js/dist/api/objects";
|
||||||
|
import UserIcon from '../../common/UserIcon';
|
||||||
|
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
|
||||||
|
import ConnectionStatus from '../items/ConnectionStatus';
|
||||||
|
import UserStatus from '../../common/UserStatus';
|
||||||
|
import ButtonItem, { ChannelButton } from '../items/ButtonItem';
|
||||||
|
import styled from "styled-components";
|
||||||
|
import Header from '../../ui/Header';
|
||||||
|
import UserHeader from "../../common/UserHeader";
|
||||||
|
import Category from '../../ui/Category';
|
||||||
|
import PaintCounter from "../../../lib/PaintCounter";
|
||||||
|
|
||||||
|
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);
|
||||||
|
const { channel } = useParams<{ channel: string }>();
|
||||||
|
// const { openScreen, writeClipboard } = useContext(IntermediateContext);
|
||||||
|
|
||||||
|
const ctx = useForceUpdate();
|
||||||
|
const users = useUsers(undefined, ctx);
|
||||||
|
const channels = useChannels(undefined, ctx);
|
||||||
|
|
||||||
|
const obj = channels.find(x => x?._id === channel);
|
||||||
|
if (channel && !obj) return <Redirect to="/" />;
|
||||||
|
if (obj) useUnreads({ ...props, channel: obj });
|
||||||
|
|
||||||
|
const channelsArr = (channels
|
||||||
|
.filter(
|
||||||
|
x => x && (x.channel_type === "Group" || (x.channel_type === 'DirectMessage' && x.active))
|
||||||
|
) as (Channels.GroupChannel | Channels.DirectMessageChannel)[])
|
||||||
|
.map(x => mapChannelWithUnread(x, props.unreads));
|
||||||
|
|
||||||
|
channelsArr.sort((b, a) => a.timestamp.localeCompare(b.timestamp));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HomeBase>
|
||||||
|
<UserHeader user={client.user!} />
|
||||||
|
<ConnectionStatus />
|
||||||
|
<HomeList>
|
||||||
|
{!isTouchscreenDevice && (
|
||||||
|
<>
|
||||||
|
<Link to="/">
|
||||||
|
<ButtonItem active={pathname === "/"}>
|
||||||
|
<Home size={20} />
|
||||||
|
<span><Text id="app.navigation.tabs.home" /></span>
|
||||||
|
</ButtonItem>
|
||||||
|
</Link>
|
||||||
|
<Link to="/friends">
|
||||||
|
<ButtonItem
|
||||||
|
active={pathname === "/friends"}
|
||||||
|
alert={
|
||||||
|
typeof users.find(
|
||||||
|
user =>
|
||||||
|
user?.relationship ===
|
||||||
|
UsersNS.Relationship.Incoming
|
||||||
|
) !== "undefined" ? 'unread' : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Users size={20} />
|
||||||
|
<span><Text id="app.navigation.tabs.friends" /></span>
|
||||||
|
</ButtonItem>
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Link to="/open/saved">
|
||||||
|
<ButtonItem active={obj?.channel_type === "SavedMessages"}>
|
||||||
|
<Save size={20} />
|
||||||
|
<span><Text id="app.navigation.tabs.saved" /></span>
|
||||||
|
</ButtonItem>
|
||||||
|
</Link>
|
||||||
|
{import.meta.env.DEV && (
|
||||||
|
<Link to="/dev">
|
||||||
|
<ButtonItem active={pathname === "/dev"}>
|
||||||
|
<Tool size={20} />
|
||||||
|
<span><Text id="app.navigation.tabs.dev" /></span>
|
||||||
|
</ButtonItem>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<Localizer>
|
||||||
|
<Category
|
||||||
|
text={
|
||||||
|
(
|
||||||
|
<Text id="app.main.categories.conversations" />
|
||||||
|
) as any
|
||||||
|
}
|
||||||
|
action={() => /*openScreen({ id: "special_input", type: "create_group" })*/{}}
|
||||||
|
/>
|
||||||
|
</Localizer>
|
||||||
|
{channelsArr.length === 0 && <img src="/assets/images/placeholder.svg" />}
|
||||||
|
{channelsArr.map(x => {
|
||||||
|
let user;
|
||||||
|
if (x.channel_type === 'DirectMessage') {
|
||||||
|
let recipient = client.channels.getRecipient(x._id);
|
||||||
|
user = users.find(x => x!._id === recipient);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link to={`/channel/${x._id}`}>
|
||||||
|
<ChannelButton
|
||||||
|
user={user}
|
||||||
|
channel={x}
|
||||||
|
alert={x.unread}
|
||||||
|
alertCount={x.alertCount}
|
||||||
|
active={x._id === channel}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<PaintCounter />
|
||||||
|
</HomeList>
|
||||||
|
</HomeBase>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connectState(
|
||||||
|
HomeSidebar,
|
||||||
|
state => {
|
||||||
|
return {
|
||||||
|
unreads: state.unreads
|
||||||
|
};
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
);
|
215
src/components/navigation/left/ServerListSidebar.tsx
Normal file
215
src/components/navigation/left/ServerListSidebar.tsx
Normal file
|
@ -0,0 +1,215 @@
|
||||||
|
import { useContext } from "preact/hooks";
|
||||||
|
import { PlusCircle } from "@styled-icons/feather";
|
||||||
|
import { Channel, Servers } from "revolt.js/dist/api/objects";
|
||||||
|
import { Link, useLocation, useParams } from "react-router-dom";
|
||||||
|
import { useChannels, useForceUpdate, useServers } from "../../../context/revoltjs/hooks";
|
||||||
|
import { mapChannelWithUnread } from "./common";
|
||||||
|
import { Unreads } from "../../../redux/reducers/unreads";
|
||||||
|
import { connectState } from "../../../redux/connector";
|
||||||
|
import styled, { css } from "styled-components";
|
||||||
|
import { Children } from "../../../types/Preact";
|
||||||
|
import LineDivider from "../../ui/LineDivider";
|
||||||
|
import ServerIcon from "../../common/ServerIcon";
|
||||||
|
import PaintCounter from "../../../lib/PaintCounter";
|
||||||
|
|
||||||
|
function Icon({ children, unread, size }: { children: Children, unread?: 'mention' | 'unread', size: number }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
aria-hidden="true"
|
||||||
|
viewBox="0 0 32 32"
|
||||||
|
>
|
||||||
|
<foreignObject x="0" y="0" width="32" height="32">
|
||||||
|
{ children }
|
||||||
|
</foreignObject>
|
||||||
|
{unread === 'unread' && (
|
||||||
|
<circle
|
||||||
|
cx="27"
|
||||||
|
cy="27"
|
||||||
|
r="5"
|
||||||
|
fill={"white"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{unread === 'mention' && (
|
||||||
|
<circle
|
||||||
|
cx="27"
|
||||||
|
cy="27"
|
||||||
|
r="5"
|
||||||
|
fill={"red"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ServersBase = styled.div`
|
||||||
|
width: 52px;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ServerList = styled.div`
|
||||||
|
flex-grow: 1;
|
||||||
|
display: flex;
|
||||||
|
overflow-y: scroll;
|
||||||
|
padding-bottom: 48px;
|
||||||
|
flex-direction: column;
|
||||||
|
border-inline-end: 2px solid var(--sidebar-active);
|
||||||
|
|
||||||
|
scrollbar-width: none;
|
||||||
|
|
||||||
|
> :first-child > svg {
|
||||||
|
margin: 6px 0 6px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 0px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ServerEntry = styled.div<{ active: boolean, invert?: boolean }>`
|
||||||
|
height: 44px;
|
||||||
|
padding: 4px;
|
||||||
|
margin: 2px 0 2px 4px;
|
||||||
|
|
||||||
|
border-top-left-radius: 4px;
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
${ props => props.active && css`
|
||||||
|
background: var(--sidebar-active);
|
||||||
|
` }
|
||||||
|
|
||||||
|
${ props => props.active && props.invert && css`
|
||||||
|
img {
|
||||||
|
filter: saturate(0) brightness(10);
|
||||||
|
}
|
||||||
|
` }
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
unreads: Unreads;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServerListSidebar({ unreads }: Props) {
|
||||||
|
const ctx = useForceUpdate();
|
||||||
|
const activeServers = useServers(undefined, ctx) as Servers.Server[];
|
||||||
|
const channels = (useChannels(undefined, ctx) as Channel[])
|
||||||
|
.map(x => mapChannelWithUnread(x, unreads));
|
||||||
|
|
||||||
|
const unreadChannels = channels.filter(x => x.unread)
|
||||||
|
.map(x => x._id);
|
||||||
|
|
||||||
|
const servers = activeServers.map(server => {
|
||||||
|
let alertCount = 0;
|
||||||
|
for (let id of server.channels) {
|
||||||
|
let channel = channels.find(x => x._id === id);
|
||||||
|
if (channel?.alertCount) {
|
||||||
|
alertCount += channel.alertCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...server,
|
||||||
|
unread: (typeof server.channels.find(x => unreadChannels.includes(x)) !== 'undefined' ?
|
||||||
|
( alertCount > 0 ? 'mention' : 'unread' ) : undefined) as 'mention' | 'unread' | undefined,
|
||||||
|
alertCount
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const path = useLocation().pathname;
|
||||||
|
const { server: server_id } = useParams<{ server?: string }>();
|
||||||
|
const server = servers.find(x => x!._id == server_id);
|
||||||
|
|
||||||
|
// const { openScreen } = useContext(IntermediateContext);
|
||||||
|
|
||||||
|
let homeUnread: 'mention' | 'unread' | undefined;
|
||||||
|
let alertCount = 0;
|
||||||
|
for (let x of channels) {
|
||||||
|
if (((x.channel_type === 'DirectMessage' && x.active) || x.channel_type === 'Group') && x.unread) {
|
||||||
|
homeUnread = 'unread';
|
||||||
|
alertCount += x.alertCount ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alertCount > 0) homeUnread = 'mention';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ServersBase>
|
||||||
|
<ServerList>
|
||||||
|
<Link to={`/`}>
|
||||||
|
<ServerEntry invert
|
||||||
|
active={typeof server === 'undefined' && !path.startsWith('/invite')}>
|
||||||
|
<Icon size={36} unread={homeUnread}>
|
||||||
|
<img src="/assets/app_icon.png" />
|
||||||
|
</Icon>
|
||||||
|
</ServerEntry>
|
||||||
|
</Link>
|
||||||
|
<LineDivider />
|
||||||
|
{
|
||||||
|
servers.map(entry =>
|
||||||
|
<Link to={`/server/${entry!._id}`}>
|
||||||
|
<ServerEntry
|
||||||
|
active={entry!._id === server?._id}
|
||||||
|
//onContextMenu={attachContextMenu('Menu', { server: entry!._id })}>
|
||||||
|
>
|
||||||
|
<Icon size={36} unread={entry.unread}>
|
||||||
|
<ServerIcon size={32} target={entry} />
|
||||||
|
</Icon>
|
||||||
|
</ServerEntry>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
<PaintCounter small />
|
||||||
|
</ServerList>
|
||||||
|
</ServersBase>
|
||||||
|
/*<div className={styles.servers}>
|
||||||
|
<div className={styles.list}>
|
||||||
|
<Link to={`/`}>
|
||||||
|
<div className={styles.entry}
|
||||||
|
data-active={typeof server === 'undefined' && !path.startsWith('/invite')}>
|
||||||
|
<Icon size={36} unread={homeUnread} alertCount={alertCount}>
|
||||||
|
<div className={styles.logo} />
|
||||||
|
</Icon>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<LineDivider className={styles.divider} />
|
||||||
|
{
|
||||||
|
servers.map(entry =>
|
||||||
|
<Link to={`/server/${entry!._id}`}>
|
||||||
|
<div className={styles.entry}
|
||||||
|
data-active={entry!._id === server?._id}
|
||||||
|
onContextMenu={attachContextMenu('Menu', { server: entry!._id })}>
|
||||||
|
<Icon size={36} unread={entry.unread}>
|
||||||
|
<ServerIcon id={entry!._id} size={32} />
|
||||||
|
</Icon>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div className={styles.overlay}>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<IconButton onClick={() => openScreen({ id: 'special_input', type: 'create_server' })}>
|
||||||
|
<PlusCircle size={36} />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div> */
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default connectState(
|
||||||
|
ServerListSidebar,
|
||||||
|
state => {
|
||||||
|
return {
|
||||||
|
unreads: state.unreads
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
78
src/components/navigation/left/ServerSidebar.tsx
Normal file
78
src/components/navigation/left/ServerSidebar.tsx
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { Settings } from "@styled-icons/feather";
|
||||||
|
import { Redirect, useParams } from "react-router";
|
||||||
|
import { ChannelButton } from "../items/ButtonItem";
|
||||||
|
import { Channels } from "revolt.js/dist/api/objects";
|
||||||
|
import { ServerPermission } from "revolt.js/dist/api/permissions";
|
||||||
|
import { Unreads } from "../../../redux/reducers/unreads";
|
||||||
|
import { WithDispatcher } from "../../../redux/reducers";
|
||||||
|
import { useChannels, useForceUpdate, useServer, useServerPermission } from "../../../context/revoltjs/hooks";
|
||||||
|
import { mapChannelWithUnread, useUnreads } from "./common";
|
||||||
|
import Header from '../../ui/Header';
|
||||||
|
import ConnectionStatus from '../items/ConnectionStatus';
|
||||||
|
import { connectState } from "../../../redux/connector";
|
||||||
|
import PaintCounter from "../../../lib/PaintCounter";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
unreads: Unreads;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServerSidebar(props: Props & WithDispatcher) {
|
||||||
|
const { server: server_id, channel: channel_id } = useParams<{ server?: string, channel?: string }>();
|
||||||
|
const ctx = useForceUpdate();
|
||||||
|
|
||||||
|
const server = useServer(server_id, ctx);
|
||||||
|
if (!server) return <Redirect to="/" />;
|
||||||
|
|
||||||
|
const permissions = useServerPermission(server._id, ctx);
|
||||||
|
const channels = (useChannels(server.channels, ctx)
|
||||||
|
.filter(entry => typeof entry !== 'undefined') as Readonly<Channels.TextChannel>[])
|
||||||
|
.map(x => mapChannelWithUnread(x, props.unreads));
|
||||||
|
|
||||||
|
const channel = channels.find(x => x?._id === channel_id);
|
||||||
|
if (channel) useUnreads({ ...props, channel }, ctx);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Header placement="secondary" background style={{ background: `url('${ctx.client.servers.getBannerURL(server._id, { width: 480 }, true)}')` }}>
|
||||||
|
<div>
|
||||||
|
{ server.name }
|
||||||
|
</div>
|
||||||
|
{ (permissions & ServerPermission.ManageServer) > 0 && <div className="actions">
|
||||||
|
{/*<IconButton to={`/server/${server._id}/settings`}>*/}
|
||||||
|
<Settings size={24} />
|
||||||
|
{/*</IconButton>*/}
|
||||||
|
</div> }
|
||||||
|
</Header>
|
||||||
|
<ConnectionStatus />
|
||||||
|
<div
|
||||||
|
//onContextMenu={attachContextMenu('Menu', { server_list: server._id })}>
|
||||||
|
>
|
||||||
|
{channels.map(entry => {
|
||||||
|
return (
|
||||||
|
<Link to={`/server/${server._id}/channel/${entry._id}`}>
|
||||||
|
<ChannelButton
|
||||||
|
key={entry._id}
|
||||||
|
channel={entry}
|
||||||
|
active={channel?._id === entry._id}
|
||||||
|
alert={entry.unread}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<PaintCounter small />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connectState(
|
||||||
|
ServerSidebar,
|
||||||
|
state => {
|
||||||
|
return {
|
||||||
|
unreads: state.unreads
|
||||||
|
};
|
||||||
|
},
|
||||||
|
true
|
||||||
|
);
|
74
src/components/navigation/left/common.ts
Normal file
74
src/components/navigation/left/common.ts
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
import { Channel } from "revolt.js";
|
||||||
|
import { useLayoutEffect } from "preact/hooks";
|
||||||
|
import { WithDispatcher } from "../../../redux/reducers";
|
||||||
|
import { Unreads } from "../../../redux/reducers/unreads";
|
||||||
|
import { HookContext, useForceUpdate } from "../../../context/revoltjs/hooks";
|
||||||
|
|
||||||
|
type UnreadProps = WithDispatcher & {
|
||||||
|
channel: Channel;
|
||||||
|
unreads: Unreads;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUnreads({ channel, unreads, dispatcher }: UnreadProps, context?: HookContext) {
|
||||||
|
const ctx = useForceUpdate(context);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
function checkUnread(target?: Channel) {
|
||||||
|
if (!target) return;
|
||||||
|
if (target._id !== channel._id) return;
|
||||||
|
if (target?.channel_type === "SavedMessages") return;
|
||||||
|
|
||||||
|
const unread = unreads[channel._id]?.last_id;
|
||||||
|
if (target.last_message) {
|
||||||
|
const message = typeof target.last_message === 'string' ? target.last_message : target.last_message._id;
|
||||||
|
if (!unread || (unread && message.localeCompare(unread) > 0)) {
|
||||||
|
dispatcher({
|
||||||
|
type: "UNREADS_MARK_READ",
|
||||||
|
channel: channel._id,
|
||||||
|
message,
|
||||||
|
request: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkUnread(channel);
|
||||||
|
|
||||||
|
ctx.client.channels.addListener("mutation", checkUnread);
|
||||||
|
return () => ctx.client.channels.removeListener("mutation", checkUnread);
|
||||||
|
}, [channel, unreads]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapChannelWithUnread(channel: Channel, unreads: Unreads) {
|
||||||
|
let last_message_id;
|
||||||
|
if (channel.channel_type === 'DirectMessage' || channel.channel_type === 'Group') {
|
||||||
|
last_message_id = channel.last_message?._id;
|
||||||
|
} else if (channel.channel_type === 'TextChannel') {
|
||||||
|
last_message_id = channel.last_message;
|
||||||
|
} else {
|
||||||
|
return { ...channel, unread: undefined, alertCount: undefined, timestamp: channel._id };
|
||||||
|
}
|
||||||
|
|
||||||
|
let unread: 'mention' | 'unread' | undefined;
|
||||||
|
let alertCount: undefined | number;
|
||||||
|
if (last_message_id && unreads) {
|
||||||
|
const u = unreads[channel._id];
|
||||||
|
if (u) {
|
||||||
|
if (u.mentions && u.mentions.length > 0) {
|
||||||
|
alertCount = u.mentions.length;
|
||||||
|
unread = 'mention';
|
||||||
|
} else if (u.last_id && last_message_id.localeCompare(u.last_id) > 0) {
|
||||||
|
unread = 'unread';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
unread = 'unread';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...channel,
|
||||||
|
timestamp: last_message_id ?? channel._id,
|
||||||
|
unread,
|
||||||
|
alertCount
|
||||||
|
};
|
||||||
|
}
|
45
src/components/ui/Category.tsx
Normal file
45
src/components/ui/Category.tsx
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
import styled, { css } from "styled-components";
|
||||||
|
import { Children } from "../../types/Preact";
|
||||||
|
import { Plus } from "@styled-icons/feather";
|
||||||
|
|
||||||
|
const CategoryBase = styled.div<Pick<Props, 'variant'>>`
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
stroke: var(--foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
${ props => props.variant === 'uniform' && css`
|
||||||
|
padding-top: 6px;
|
||||||
|
` }
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
text: Children;
|
||||||
|
action?: () => void;
|
||||||
|
variant?: 'default' | 'uniform';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Category(props: Props) {
|
||||||
|
return (
|
||||||
|
<CategoryBase>
|
||||||
|
{props.text}
|
||||||
|
{props.action && (
|
||||||
|
<Plus size={16} onClick={props.action} />
|
||||||
|
)}
|
||||||
|
</CategoryBase>
|
||||||
|
);
|
||||||
|
};
|
32
src/components/ui/Header.tsx
Normal file
32
src/components/ui/Header.tsx
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import styled, { css } from "styled-components";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
background?: boolean;
|
||||||
|
placement: 'primary' | 'secondary'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default styled.div<Props>`
|
||||||
|
height: 56px;
|
||||||
|
font-weight: 600;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
gap: 10px;
|
||||||
|
flex: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
padding: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
background-color: var(--primary-background);
|
||||||
|
background-size: cover !important;
|
||||||
|
background-position: center !important;
|
||||||
|
|
||||||
|
${ props => props.background && css`
|
||||||
|
height: 120px;
|
||||||
|
align-items: flex-end;
|
||||||
|
` }
|
||||||
|
|
||||||
|
${ props => props.placement === 'secondary' && css`
|
||||||
|
padding: 14px;
|
||||||
|
` }
|
||||||
|
`;
|
|
@ -3,7 +3,7 @@ import { Channels, Servers, Users } from "revolt.js/dist/api/objects";
|
||||||
import { Client, PermissionCalculator } from 'revolt.js';
|
import { Client, PermissionCalculator } from 'revolt.js';
|
||||||
import { AppContext } from "./RevoltClient";
|
import { AppContext } from "./RevoltClient";
|
||||||
|
|
||||||
interface HookContext {
|
export interface HookContext {
|
||||||
client: Client,
|
client: Client,
|
||||||
forceUpdate: () => void
|
forceUpdate: () => void
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,11 +2,17 @@ import { useState } from "preact/hooks";
|
||||||
|
|
||||||
const counts: { [key: string]: number } = {};
|
const counts: { [key: string]: number } = {};
|
||||||
|
|
||||||
export default function PaintCounter() {
|
export default function PaintCounter({ small }: { small?: boolean }) {
|
||||||
|
if (import.meta.env.PROD) return null;
|
||||||
|
|
||||||
const [uniqueId] = useState('' + Math.random());
|
const [uniqueId] = useState('' + Math.random());
|
||||||
const count = counts[uniqueId] ?? 0;
|
const count = counts[uniqueId] ?? 0;
|
||||||
counts[uniqueId] = count + 1;
|
counts[uniqueId] = count + 1;
|
||||||
return (
|
return (
|
||||||
<span>Painted {count + 1} time(s).</span>
|
<span>
|
||||||
|
{ small ? <>P: { count + 1 }</> : <>
|
||||||
|
Painted {count + 1} time(s).
|
||||||
|
</> }
|
||||||
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +1,20 @@
|
||||||
import { OverlappingPanels } from "react-overlapping-panels";
|
import { Docked, OverlappingPanels } from "react-overlapping-panels";
|
||||||
|
import { isTouchscreenDevice } from "../lib/isTouchscreenDevice";
|
||||||
import { Switch, Route } from "react-router-dom";
|
import { Switch, Route } from "react-router-dom";
|
||||||
|
|
||||||
|
import LeftSidebar from "../components/navigation/LeftSidebar";
|
||||||
|
import RightSidebar from "../components/navigation/RightSidebar";
|
||||||
|
|
||||||
import Home from './home/Home';
|
import Home from './home/Home';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<OverlappingPanels
|
<OverlappingPanels
|
||||||
width="100vw"
|
width="100vw"
|
||||||
height="100%">
|
height="100%"
|
||||||
|
leftPanel={{ width: 292, component: <LeftSidebar /> }}
|
||||||
|
rightPanel={{ width: 240, component: <RightSidebar /> }}
|
||||||
|
docked={isTouchscreenDevice ? Docked.None : Docked.Left}>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path="/">
|
<Route path="/">
|
||||||
<Home />
|
<Home />
|
||||||
|
|
|
@ -2,19 +2,22 @@
|
||||||
|
|
||||||
import { State } from ".";
|
import { State } from ".";
|
||||||
import { h } from "preact";
|
import { h } from "preact";
|
||||||
// import { memo } from "preact/compat";
|
import { memo } from "preact/compat";
|
||||||
import { connect, ConnectedComponent } from "react-redux";
|
import { connect, ConnectedComponent } from "react-redux";
|
||||||
|
|
||||||
export function connectState<T>(
|
export function connectState<T>(
|
||||||
component: (props: any) => h.JSX.Element | null,
|
component: (props: any) => h.JSX.Element | null,
|
||||||
mapKeys: (state: State, props: T) => any,
|
mapKeys: (state: State, props: T) => any,
|
||||||
useDispatcher?: boolean
|
useDispatcher?: boolean,
|
||||||
|
memoize?: boolean
|
||||||
): ConnectedComponent<(props: any) => h.JSX.Element | null, T> {
|
): ConnectedComponent<(props: any) => h.JSX.Element | null, T> {
|
||||||
return (
|
let c = (
|
||||||
useDispatcher
|
useDispatcher
|
||||||
? connect(mapKeys, (dispatcher) => {
|
? connect(mapKeys, (dispatcher) => {
|
||||||
return { dispatcher };
|
return { dispatcher };
|
||||||
})
|
})
|
||||||
: connect(mapKeys)
|
: connect(mapKeys)
|
||||||
)(component); //(memo(component));
|
)(component);
|
||||||
|
|
||||||
|
return memoize ? memo(c) : c;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
@import "elements";
|
@import "elements";
|
||||||
@import "fonts";
|
@import "fonts";
|
||||||
@import "page";
|
@import "page";
|
||||||
|
|
||||||
|
@import "react-overlapping-panels/dist"
|
||||||
|
|
|
@ -1597,6 +1597,11 @@ chalk@^4.0.0:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents "~2.3.2"
|
fsevents "~2.3.2"
|
||||||
|
|
||||||
|
classnames@^2.3.1:
|
||||||
|
version "2.3.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.1.tgz#dfcfa3891e306ec1dad105d0e88f4417b8535e8e"
|
||||||
|
integrity sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==
|
||||||
|
|
||||||
color-convert@^1.9.0:
|
color-convert@^1.9.0:
|
||||||
version "1.9.3"
|
version "1.9.3"
|
||||||
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
|
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
|
||||||
|
|
Loading…
Reference in a new issue