diff --git a/package.json b/package.json index 7ae716823925338d25045fbf86cfb55b4dee7573..1048e99af21ec4c6887ecefd858b1bd91310a10d 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@types/styled-components": "^5.1.10", "@typescript-eslint/eslint-plugin": "^4.27.0", "@typescript-eslint/parser": "^4.27.0", + "classnames": "^2.3.1", "dayjs": "^1.10.5", "detect-browser": "^5.2.0", "eslint": "^7.28.0", diff --git a/src/components/common/ChannelIcon.tsx b/src/components/common/ChannelIcon.tsx index 8be9fe8085e234444d9c20ddf99116f83029176a..af245c3f90d73235bb41a8fcdf91ec68ac9a0401 100644 --- a/src/components/common/ChannelIcon.tsx +++ b/src/components/common/ChannelIcon.tsx @@ -1,7 +1,7 @@ import { useContext } from "preact/hooks"; import { Hash } from "@styled-icons/feather"; -import IconBase, { IconBaseProps } from "./IconBase"; import { Channels } from "revolt.js/dist/api/objects"; +import { ImageIconBase, IconBaseProps } from "./IconBase"; import { AppContext } from "../../context/revoltjs/RevoltClient"; 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'; -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 { 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 isServerChannel = server || target?.channel_type === 'TextChannel'; @@ -25,21 +25,18 @@ export default function ChannelIcon(props: Props & Omit<JSX.SVGAttributes<SVGSVG } return ( - <IconBase {...svgProps} + // ! fixme: replace fallback with <picture /> + <source /> + <ImageIconBase {...imgProps} width={size} height={size} aria-hidden="true" - viewBox="0 0 32 32" - square={isServerChannel}> - <foreignObject x="0" y="0" width="32" height="32"> - <img src={iconURL ?? fallback} - onError={ e => { - let el = e.currentTarget; - if (el.src !== fallback) { - el.src = fallback - } - } } /> - </foreignObject> - </IconBase> + square={isServerChannel} + src={iconURL ?? fallback} + onError={ e => { + let el = e.currentTarget; + if (el.src !== fallback) { + el.src = fallback + } + }} /> ); } diff --git a/src/components/common/IconBase.tsx b/src/components/common/IconBase.tsx index fb1f75bdb6bf450576ee0d7e4c5c60318a5e6c16..bebc0a8f8b23c57fa6d829af27c29c2358122207 100644 --- a/src/components/common/IconBase.tsx +++ b/src/components/common/IconBase.tsx @@ -9,7 +9,11 @@ export interface IconBaseProps<T> { animate?: boolean; } -export default styled.svg<{ square?: boolean }>` +interface IconModifiers { + square?: boolean +} + +export default styled.svg<IconModifiers>` img { width: 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%; + ` } +`; diff --git a/src/components/common/ServerIcon.tsx b/src/components/common/ServerIcon.tsx index 1cf0297f445bb440d6a4ea14c4902f046e9e3002..b9ea81182fbd7ffe0270e1e550908cff2e5e9f37 100644 --- a/src/components/common/ServerIcon.tsx +++ b/src/components/common/ServerIcon.tsx @@ -1,7 +1,7 @@ import styled from "styled-components"; import { useContext } from "preact/hooks"; import { Server } from "revolt.js/dist/api/objects"; -import IconBase, { IconBaseProps } from "./IconBase"; +import { IconBaseProps, ImageIconBase } from "./IconBase"; import { AppContext } from "../../context/revoltjs/RevoltClient"; interface Props extends IconBaseProps<Server> { @@ -19,10 +19,10 @@ const ServerText = styled.div` `; 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 { 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); if (typeof iconURL === 'undefined') { @@ -38,20 +38,16 @@ export default function ServerIcon(props: Props & Omit<JSX.SVGAttributes<SVGSVGE } return ( - <IconBase {...svgProps} + <ImageIconBase {...imgProps} width={size} height={size} aria-hidden="true" - viewBox="0 0 32 32"> - <foreignObject x="0" y="0" width="32" height="32"> - <img src={iconURL} - onError={ e => { - let el = e.currentTarget; - if (el.src !== fallback) { - el.src = fallback - } - }} /> - </foreignObject> - </IconBase> + src={iconURL} + onError={ e => { + let el = e.currentTarget; + if (el.src !== fallback) { + el.src = fallback + } + }} /> ); } diff --git a/src/components/common/UserHeader.tsx b/src/components/common/UserHeader.tsx new file mode 100644 index 0000000000000000000000000000000000000000..eae71db9a3ae59d0190261af7c3b539a00b57f8f --- /dev/null +++ b/src/components/common/UserHeader.tsx @@ -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> + ) +} diff --git a/src/components/common/UserIcon.tsx b/src/components/common/UserIcon.tsx index 71e861ddeee95e749605660d519557929d5abbce..124d75719ba3806bf54f0e8f416775bd2ad326df 100644 --- a/src/components/common/UserIcon.tsx +++ b/src/components/common/UserIcon.tsx @@ -52,7 +52,8 @@ export default function UserIcon(props: Props & Omit<JSX.SVGAttributes<SVGSVGEle const { client } = useContext(AppContext); 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 ( <IconBase {...svgProps} diff --git a/src/components/common/UserStatus.tsx b/src/components/common/UserStatus.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5a525517ecbc4878d132c5c93e02143537639abe --- /dev/null +++ b/src/components/common/UserStatus.tsx @@ -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" />; +} diff --git a/src/components/navigation/LeftSidebar.tsx b/src/components/navigation/LeftSidebar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6852a848c9b506463a1320456dcd88a94f02de4f --- /dev/null +++ b/src/components/navigation/LeftSidebar.tsx @@ -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> + ); +}; diff --git a/src/components/navigation/RightSidebar.tsx b/src/components/navigation/RightSidebar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..775f58340938c53d1a0af9c583d80ff888ae8c0e --- /dev/null +++ b/src/components/navigation/RightSidebar.tsx @@ -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> + ); +}; diff --git a/src/components/navigation/SidebarBase.tsx b/src/components/navigation/SidebarBase.tsx new file mode 100644 index 0000000000000000000000000000000000000000..677c9145b229363327f68344e3e62dcf34d97e41 --- /dev/null +++ b/src/components/navigation/SidebarBase.tsx @@ -0,0 +1,8 @@ +import styled from "styled-components"; + +export default styled.div` + height: 100%; + display: flex; + user-select: none; + flex-direction: row; +`; diff --git a/src/components/navigation/items/ButtonItem.tsx b/src/components/navigation/items/ButtonItem.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0acfbf02d2638fb2a5cffd32b044b3c5c21a9571 --- /dev/null +++ b/src/components/navigation/items/ButtonItem.tsx @@ -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> + ) +} diff --git a/src/components/navigation/items/ConnectionStatus.tsx b/src/components/navigation/items/ConnectionStatus.tsx new file mode 100644 index 0000000000000000000000000000000000000000..88a886c7b373b3907291f9457299cc92d506d90f --- /dev/null +++ b/src/components/navigation/items/ConnectionStatus.tsx @@ -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; +} diff --git a/src/components/navigation/items/Item.module.scss b/src/components/navigation/items/Item.module.scss new file mode 100644 index 0000000000000000000000000000000000000000..d06e98f4909055e2f217a05fc30b8ea3edf87093 --- /dev/null +++ b/src/components/navigation/items/Item.module.scss @@ -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; + } + } +}*/ diff --git a/src/components/navigation/left/HomeSidebar.tsx b/src/components/navigation/left/HomeSidebar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4e767e3a82476def36e8330c359efa8accdcfd15 --- /dev/null +++ b/src/components/navigation/left/HomeSidebar.tsx @@ -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 +); diff --git a/src/components/navigation/left/ServerListSidebar.tsx b/src/components/navigation/left/ServerListSidebar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7ff3e74ddd35f4a93f6c87ddb412e2494f243358 --- /dev/null +++ b/src/components/navigation/left/ServerListSidebar.tsx @@ -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 + }; + } +); diff --git a/src/components/navigation/left/ServerSidebar.tsx b/src/components/navigation/left/ServerSidebar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e5008550d55a40c775a39cce174514bd901dd77c --- /dev/null +++ b/src/components/navigation/left/ServerSidebar.tsx @@ -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 +); diff --git a/src/components/navigation/left/common.ts b/src/components/navigation/left/common.ts new file mode 100644 index 0000000000000000000000000000000000000000..208f795036ba2e4caf8d951fb4d87b04cf771674 --- /dev/null +++ b/src/components/navigation/left/common.ts @@ -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 + }; +} diff --git a/src/components/ui/Category.tsx b/src/components/ui/Category.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4b6712259dfe23f94d56acd7292ccab365145b7e --- /dev/null +++ b/src/components/ui/Category.tsx @@ -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> + ); +}; diff --git a/src/components/ui/Header.tsx b/src/components/ui/Header.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6a97dd3aeeee46c1a2f5b48c3e156117fa57e7d6 --- /dev/null +++ b/src/components/ui/Header.tsx @@ -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; + ` } +`; diff --git a/src/context/revoltjs/hooks.ts b/src/context/revoltjs/hooks.ts index 3f100b0388f8e4cc731e7438bd1e751f28194c33..00f8eb703ca89dcd1ba33db5437c6c9806a660d6 100644 --- a/src/context/revoltjs/hooks.ts +++ b/src/context/revoltjs/hooks.ts @@ -3,7 +3,7 @@ import { Channels, Servers, Users } from "revolt.js/dist/api/objects"; import { Client, PermissionCalculator } from 'revolt.js'; import { AppContext } from "./RevoltClient"; -interface HookContext { +export interface HookContext { client: Client, forceUpdate: () => void } diff --git a/src/lib/PaintCounter.tsx b/src/lib/PaintCounter.tsx index 81251722d3889c0e66753014886693777803c62f..e5ca30844aa251df611f93f54b8b53d96e599293 100644 --- a/src/lib/PaintCounter.tsx +++ b/src/lib/PaintCounter.tsx @@ -2,11 +2,17 @@ import { useState } from "preact/hooks"; 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 count = counts[uniqueId] ?? 0; counts[uniqueId] = count + 1; return ( - <span>Painted {count + 1} time(s).</span> + <span> + { small ? <>P: { count + 1 }</> : <> + Painted {count + 1} time(s). + </> } + </span> ) } diff --git a/src/pages/App.tsx b/src/pages/App.tsx index 6cfc6d0f0475606f6b6479792670f4de9e34914b..484a73dd4a71c4fd6d902039fc241f68439e2910 100644 --- a/src/pages/App.tsx +++ b/src/pages/App.tsx @@ -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 LeftSidebar from "../components/navigation/LeftSidebar"; +import RightSidebar from "../components/navigation/RightSidebar"; + import Home from './home/Home'; export default function App() { return ( <OverlappingPanels width="100vw" - height="100%"> + height="100%" + leftPanel={{ width: 292, component: <LeftSidebar /> }} + rightPanel={{ width: 240, component: <RightSidebar /> }} + docked={isTouchscreenDevice ? Docked.None : Docked.Left}> <Switch> <Route path="/"> <Home /> diff --git a/src/redux/connector.tsx b/src/redux/connector.tsx index ffe71c790b862ae89c9eb7f03619f406aec57fcf..06ee1d91a6163a22c9947f43d5f6121065075ac3 100644 --- a/src/redux/connector.tsx +++ b/src/redux/connector.tsx @@ -2,19 +2,22 @@ import { State } from "."; import { h } from "preact"; -// import { memo } from "preact/compat"; +import { memo } from "preact/compat"; import { connect, ConnectedComponent } from "react-redux"; export function connectState<T>( component: (props: any) => h.JSX.Element | null, mapKeys: (state: State, props: T) => any, - useDispatcher?: boolean + useDispatcher?: boolean, + memoize?: boolean ): ConnectedComponent<(props: any) => h.JSX.Element | null, T> { - return ( + let c = ( useDispatcher ? connect(mapKeys, (dispatcher) => { return { dispatcher }; }) : connect(mapKeys) - )(component); //(memo(component)); + )(component); + + return memoize ? memo(c) : c; } diff --git a/src/styles/index.scss b/src/styles/index.scss index d07896c1c562b7cac9570ba8b3e77ee6e703a3c5..7869c3d3aeb57b63e648a860c975148a722a80b7 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -1,3 +1,5 @@ @import "elements"; @import "fonts"; @import "page"; + +@import "react-overlapping-panels/dist" diff --git a/yarn.lock b/yarn.lock index 76c2a99285a8816571f9463a2c871f71af58859a..3906148e109ebd7cd2ff5d12ec8a87745ecec379 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1597,6 +1597,11 @@ chalk@^4.0.0: optionalDependencies: 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: version "1.9.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"