Port friends menu over.

This commit is contained in:
Paul 2021-06-19 20:00:30 +01:00
parent 0ff78787a8
commit 0a0c00fe58
18 changed files with 452 additions and 30 deletions

View file

@ -5,7 +5,8 @@
"build": "rimraf build && tsc && vite build",
"preview": "vite preview",
"lint": "eslint 'src/**/*.{js,jsx,ts,tsx}'",
"fmt": "prettier --write 'src/**/*.{js,jsx,ts,tsx}'"
"fmt": "prettier --write 'src/**/*.{js,jsx,ts,tsx}'",
"typecheck": "tsc --noEmit"
},
"eslintConfig": {
"parser": "@typescript-eslint/parser",
@ -54,6 +55,7 @@
"markdown-it-emoji": "^2.0.0",
"markdown-it-sub": "^1.0.0",
"markdown-it-sup": "^1.0.0",
"preact-context-menu": "^0.1.5",
"preact-i18n": "^2.4.0-preactx",
"prettier": "^2.3.1",
"prismjs": "^1.23.0",

View file

@ -17,7 +17,7 @@ export default styled.div<Props>`
flex-shrink: 0;
align-items: center;
background-color: var(--primary-background);
background-color: var(--primary-header);
background-size: cover !important;
background-position: center !important;
@ -27,6 +27,7 @@ export default styled.div<Props>`
` }
${ props => props.placement === 'secondary' && css`
background-color: var(--secondary-header);
padding: 14px;
` }
`;

View file

@ -0,0 +1,43 @@
import styled, { css } from "styled-components";
interface Props {
type?: 'default' | 'circle'
}
const normal = `var(--secondary-foreground)`;
const hover = `var(--foreground)`;
export default styled.div<Props>`
z-index: 1;
display: grid;
cursor: pointer;
place-items: center;
fill: ${normal};
color: ${normal};
stroke: ${normal};
a {
color: ${normal};
}
&:hover {
fill: ${hover};
color: ${hover};
stroke: ${hover};
a {
color: ${hover};
}
}
${ props => props.type === 'circle' && css`
padding: 4px;
border-radius: 50%;
background-color: var(--secondary-header);
&:hover {
background-color: var(--primary-header);
}
` }
`;

View file

@ -2,7 +2,7 @@ import Button from "./Button";
import classNames from "classnames";
import { Children } from "../../types/Preact";
import { createPortal, useEffect } from "preact/compat";
import styled, { keyframes } from "styled-components";
import styled, { css, keyframes } from "styled-components";
const open = keyframes`
0% {opacity: 0;}
@ -48,6 +48,26 @@ const ModalContainer = styled.div`
`;
const ModalContent = styled.div<{ [key in 'attachment' | 'noBackground' | 'border']?: boolean }>`
border-radius: 8px;
text-overflow: ellipsis;
h3 {
margin-top: 0;
}
${ props => !props.noBackground && css`
padding: 1.5em;
background: var(--secondary-header);
` }
${ props => props.attachment && css`
border-radius: 8px 8px 0 0;
` }
${ props => props.border && css`
border-radius: 10px;
border: 2px solid var(--secondary-background);
` }
`;
const ModalActions = styled.div`
@ -64,7 +84,8 @@ export interface Action {
text: Children;
onClick: () => void;
confirmation?: boolean;
style?: 'default' | 'contrast' | 'error' | 'contrast-error';
contrast?: boolean;
error?: boolean;
}
interface Props {
@ -123,7 +144,9 @@ export default function Modal(props: Props) {
{props.actions && (
<ModalActions>
{props.actions.map(x => (
<Button style={x.style ?? "contrast"}
<Button
contrast={x.contrast ?? true}
error={x.error ?? false}
onClick={x.onClick}
disabled={props.disabled}>
{x.text}

View file

@ -1,9 +1,10 @@
import styled, { css } from "styled-components";
import { Children } from "../../types/Preact";
import { Text } from 'preact-i18n';
interface Props {
error?: string;
block?: boolean;
error?: Children;
children?: Children;
type?: "default" | "subtle" | "error";
}
@ -45,7 +46,9 @@ export default function Overline(props: Props) {
<OverlineBase {...props}>
{props.children}
{props.children && props.error && <> &middot; </>}
{props.error && <Overline type="error">{props.error}</Overline>}
{props.error && <Overline type="error">
<Text id={`error.${props.error}`}>{props.error}</Text>
</Overline>}
</OverlineBase>
);
}

View file

@ -27,7 +27,7 @@ export type Screen =
{ type: "ban_member", target: Servers.Server, user: string }
)) |
({ id: "special_input" } & (
{ type: "create_group" | "create_server" | "set_custom_status" } |
{ type: "create_group" | "create_server" | "set_custom_status" | "add_friend" } |
{ type: "create_channel", server: string }
))
| {

View file

@ -1,12 +1,12 @@
import { Screen } from "./Intermediate";
import { ErrorModal } from "./modals/Error";
import { InputModal } from "./modals/Input";
import { PromptModal } from "./modals/Prompt";
import { SignedOutModal } from "./modals/SignedOut";
import { ClipboardModal } from "./modals/Clipboard";
import { OnboardingModal } from "./modals/Onboarding";
import { ModifyAccountModal } from "./modals/ModifyAccount";
import { InputModal, SpecialInputModal } from "./modals/Input";
import { PromptModal, SpecialPromptModal } from "./modals/Prompt";
export interface Props {
screen: Screen;
@ -19,12 +19,8 @@ export default function Modals({ screen, openScreen }: Props) {
switch (screen.id) {
case "_prompt":
return <PromptModal onClose={onClose} {...screen} />;
case "special_prompt":
return <SpecialPromptModal onClose={onClose} {...screen} />;
case "_input":
return <InputModal onClose={onClose} {...screen} />;
case "special_input":
return <SpecialInputModal onClose={onClose} {...screen} />;
case "error":
return <ErrorModal onClose={onClose} {...screen} />;
case "signed_out":

View file

@ -2,6 +2,8 @@ import { IntermediateContext, useIntermediate } from "./Intermediate";
import { useContext } from "preact/hooks";
import { UserPicker } from "./popovers/UserPicker";
import { SpecialInputModal } from "./modals/Input";
import { SpecialPromptModal } from "./modals/Prompt";
import { UserProfile } from "./popovers/UserProfile";
import { ImageViewer } from "./popovers/ImageViewer";
import { ChannelInfo } from "./popovers/ChannelInfo";
@ -21,6 +23,10 @@ export default function Popovers() {
return <ImageViewer {...screen} onClose={onClose} />;
case "channel_info":
return <ChannelInfo {...screen} onClose={onClose} />;
case "special_prompt":
return <SpecialPromptModal onClose={onClose} {...screen} />;
case "special_input":
return <SpecialInputModal onClose={onClose} {...screen} />;
}
return null;

View file

@ -12,7 +12,7 @@ import { AppContext } from "../../revoltjs/RevoltClient";
interface Props {
onClose: () => void;
question: Children;
field: Children;
field?: Children;
defaultValue?: string;
callback: (value: string) => Promise<void>;
}
@ -53,9 +53,9 @@ export function InputModal({
]}
onClose={onClose}
>
<Overline error={error} block>
{ field ? <Overline error={error} block>
{field}
</Overline>
</Overline> : (error && <Overline error={error} type="error" block />) }
<InputBox
value={value}
onChange={e => setValue(e.currentTarget.value)}
@ -65,7 +65,7 @@ export function InputModal({
}
type SpecialProps = { onClose: () => void } & (
{ type: "create_group" | "create_server" | "set_custom_status" } |
{ type: "create_group" | "create_server" | "set_custom_status" | "add_friend" } |
{ type: "create_channel", server: string }
)
@ -144,6 +144,15 @@ export function SpecialInputModal(props: SpecialProps) {
}
/>;
}
case "add_friend": {
return <InputModal
onClose={onClose}
question={"Add Friend"}
callback={username =>
client.users.addFriend(username)
}
/>;
}
default: return null;
}
}

View file

@ -1,7 +1,7 @@
import { Text } from "preact-i18n";
import styles from './Prompt.module.scss';
import { Children } from "../../../types/Preact";
import { IntermediateContext, useIntermediate } from "../Intermediate";
import { useIntermediate } from "../Intermediate";
import InputBox from "../../../components/ui/InputBox";
import Overline from "../../../components/ui/Overline";
import UserIcon from "../../../components/common/UserIcon";
@ -82,7 +82,8 @@ export function SpecialPromptModal(props: SpecialProps) {
actions={[
{
confirmation: true,
style: 'contrast-error',
contrast: true,
error: true,
text: <Text id="app.special.modals.actions.delete" />,
onClick: async () => {
setProcessing(true);
@ -162,7 +163,8 @@ export function SpecialPromptModal(props: SpecialProps) {
actions={[
{
text: <Text id="app.special.modals.actions.kick" />,
style: 'contrast-error',
contrast: true,
error: true,
confirmation: true,
onClick: async () => {
setProcessing(true);
@ -200,7 +202,8 @@ export function SpecialPromptModal(props: SpecialProps) {
actions={[
{
text: <Text id="app.special.modals.actions.ban" />,
style: 'contrast-error',
contrast: true,
error: true,
confirmation: true,
onClick: async () => {
setProcessing(true);

View file

@ -0,0 +1,4 @@
export const stopPropagation = (ev: JSX.TargetedMouseEvent<HTMLDivElement>, _consume?: any) => {
ev.preventDefault();
ev.stopPropagation();
};

View file

@ -1,12 +1,22 @@
import { Docked, OverlappingPanels } from "react-overlapping-panels";
import { isTouchscreenDevice } from "../lib/isTouchscreenDevice";
import Popovers from "../context/intermediate/Popovers";
import { Switch, Route } from "react-router-dom";
import styled from "styled-components";
import LeftSidebar from "../components/navigation/LeftSidebar";
import RightSidebar from "../components/navigation/RightSidebar";
import Home from './home/Home';
import Popovers from "../context/intermediate/Popovers";
import Friends from "./friends/Friends";
const Routes = styled.div`
min-width: 0;
display: flex;
overflow: hidden;
flex-direction: column;
background: var(--primary-background);
`;
export default function App() {
return (
@ -16,12 +26,76 @@ export default function App() {
leftPanel={{ width: 292, component: <LeftSidebar /> }}
rightPanel={{ width: 240, component: <RightSidebar /> }}
docked={isTouchscreenDevice ? Docked.None : Docked.Left}>
<Switch>
<Route path="/">
<Home />
</Route>
</Switch>
<Routes>
<Switch>
<Route path="/friends">
<Friends />
</Route>
<Route path="/">
<Home />
</Route>
</Switch>
</Routes>
<Popovers />
</OverlappingPanels>
);
};
/**
*
* <Route path="/channel/:channel/message/:message">
<ChannelWrapper />
</Route>
<Route path="/server/:server/channel/:channel/settings/:page">
<ChannelSettings key="channel_settings" />
</Route>
<Route path="/server/:server/channel/:channel/settings">
<ChannelSettings key="channel_settings" />
</Route>
<Route path="/server/:server/settings/:page">
<ServerSettings key="channel_settings" />
</Route>
<Route path="/server/:server/settings">
<ServerSettings key="channel_settings" />
</Route>
<Route path="/channel/:channel/settings/:page">
<ChannelSettings key="channel_settings" />
</Route>
<Route path="/channel/:channel/settings">
<ChannelSettings key="channel_settings" />
</Route>
<Route path="/settings/:page">
<Settings key="settings" />
</Route>
<Route path="/settings">
<Settings key="settings" />
</Route>
<Route path="/server/:server/channel/:channel">
<ChannelWrapper />
</Route>
<Route path="/server/:server" />
<Route path="/channel/:channel">
<ChannelWrapper />
</Route>
<Route path="/friends">
<Friends />
</Route>
<Route path="/dev">
<Developer />
</Route>
<Route path="/open/:id">
<Open />
</Route>
{/*<Route path="/invite/:code">
<OpenInvite />
</Route>
<Route path="/">
<Home />
</Route>
*/

View file

@ -0,0 +1,71 @@
.list {
padding: 16px;
user-select: none;
overflow-y: scroll;
&[data-empty="true"] {
img {
height: 120px;
border-radius: 8px;
}
gap: 16px;
height: 100%;
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
}
}
.friend {
padding: 10px;
display: flex;
border-radius: 5px;
align-items: center;
flex-direction: row;
cursor: pointer;
&:hover {
background: var(--secondary-background);
:global(.button) {
background-color: var(--primary-background);
}
}
.name {
flex-grow: 1;
margin: 0 12px;
font-size: 16px;
display: flex;
flex-direction: column;
justify-content: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.subtext {
font-size: 12px;
color: var(--tertiary-foreground);
}
}
.actions {
display: flex;
gap: 12px;
> div {
height: 32px;
width: 32px;
}
}
}
//! FIXME: Move this to the Header component, do this:
// 1. Check if header has topic, if yes, flex-grow: 0 on the title.
// 2. If header has no topic (example: friends page), flex-grow 1 on the header title.
.title {
flex-grow: 1;
}

View file

@ -0,0 +1,90 @@
import { Text } from "preact-i18n";
import { Link } from "react-router-dom";
import styles from "./Friend.module.scss";
import { useContext } from "preact/hooks";
import { Children } from "../../types/Preact";
import { X, Plus, Mail } from "@styled-icons/feather";
import UserIcon from "../../components/common/UserIcon";
import IconButton from "../../components/ui/IconButton";
import { attachContextMenu } from "preact-context-menu";
import { User, Users } from "revolt.js/dist/api/objects";
import UserStatus from '../../components/common/UserStatus';
import { stopPropagation } from "../../lib/stopPropagation";
import { AppContext } from "../../context/revoltjs/RevoltClient";
import { useIntermediate } from "../../context/intermediate/Intermediate";
interface Props {
user: User;
}
export function Friend({ user }: Props) {
const client = useContext(AppContext);
const { openScreen } = useIntermediate();
const actions: Children[] = [];
let subtext: Children = null;
if (user.relationship === Users.Relationship.Friend) {
subtext = <UserStatus user={user} />
actions.push(
<IconButton type="circle"
onClick={stopPropagation}>
<Link to={'/open/' + user._id}>
<Mail size={20} />
</Link>
</IconButton>
);
}
if (user.relationship === Users.Relationship.Incoming) {
actions.push(
<IconButton type="circle"
onClick={ev => stopPropagation(ev, client.users.addFriend(user.username))}>
<Plus size={24} />
</IconButton>
);
subtext = <Text id="app.special.friends.incoming" />;
}
if (user.relationship === Users.Relationship.Outgoing) {
subtext = <Text id="app.special.friends.outgoing" />;
}
if (
user.relationship === Users.Relationship.Friend ||
user.relationship === Users.Relationship.Outgoing ||
user.relationship === Users.Relationship.Incoming
) {
actions.push(
<IconButton type="circle"
onClick={ev => stopPropagation(ev, client.users.removeFriend(user._id))}>
<X size={24} />
</IconButton>
);
}
if (user.relationship === Users.Relationship.Blocked) {
actions.push(
<IconButton type="circle"
onClick={ev => stopPropagation(ev, client.users.unblockUser(user._id))}>
<X size={24} />
</IconButton>
);
}
return (
<div className={styles.friend}
onClick={() => openScreen({ id: 'profile', user_id: user._id })}
onContextMenu={attachContextMenu('Menu', { user: user._id })}>
<UserIcon target={user} size={32} status />
<div className={styles.name}>
<span>@{user.username}</span>
{subtext && (
<span className={styles.subtext}>{subtext}</span>
)}
</div>
<div className={styles.actions}>{actions}</div>
</div>
);
}

View file

@ -0,0 +1,85 @@
import styles from "./Friend.module.scss";
import { UserPlus } from "@styled-icons/feather";
import { Friend } from "./Friend";
import { Text } from "preact-i18n";
import Header from "../../components/ui/Header";
import Overline from "../../components/ui/Overline";
import IconButton from "../../components/ui/IconButton";
import { useUsers } from "../../context/revoltjs/hooks";
import { User, Users } from "revolt.js/dist/api/objects";
import { useIntermediate } from "../../context/intermediate/Intermediate";
export default function Friends() {
const { openScreen } = useIntermediate();
const users = useUsers() as User[];
users.sort((a, b) => a.username.localeCompare(b.username));
const pending = users.filter(
x =>
x.relationship === Users.Relationship.Incoming ||
x.relationship === Users.Relationship.Outgoing
);
const friends = users.filter(
x => x.relationship === Users.Relationship.Friend
);
const blocked = users.filter(
x => x.relationship === Users.Relationship.Blocked
);
return (
<>
<Header placement="primary">
<div className={styles.title}>
<Text id="app.navigation.tabs.friends" />
</div>
<div className="actions">
<IconButton onClick={() => openScreen({ id: 'special_input', type: 'add_friend' })}>
<UserPlus size={24} />
</IconButton>
</div>
</Header>
<div
className={styles.list}
data-empty={
pending.length + friends.length + blocked.length === 0
}
>
{pending.length + friends.length + blocked.length === 0 && (
<>
<img src="https://img.insrt.uk/xexu7/XOPoBUTI47.png/raw" />
<Text id="app.special.friends.nobody" />
</>
)}
{pending.length > 0 && (
<Overline type="subtle">
<Text id="app.special.friends.pending" /> {" "}
{pending.length}
</Overline>
)}
{pending.map(y => (
<Friend key={y._id} user={y} />
))}
{friends.length > 0 && (
<Overline type="subtle">
<Text id="app.navigation.tabs.friends" /> {" "}
{friends.length}
</Overline>
)}
{friends.map(y => (
<Friend key={y._id} user={y} />
))}
{blocked.length > 0 && (
<Overline type="subtle">
<Text id="app.special.friends.blocked" /> {" "}
{blocked.length}
</Overline>
)}
{blocked.map(y => (
<Friend key={y._id} user={y} />
))}
</div>
</>
);
}

View file

@ -1,3 +1,8 @@
:disabled {
opacity: 0.5;
pointer-events: none;
}
::-webkit-scrollbar {
width: 3px;
height: 3px;

View file

@ -1,4 +1,4 @@
import { VNode } from "preact";
export type Child = VNode | string | false | undefined;
export type Child = VNode | string | number | boolean | undefined | null;
export type Children = Child | Child[] | Children[];

View file

@ -3038,6 +3038,13 @@ postcss@^8.3.0:
nanoid "^3.1.23"
source-map-js "^0.6.2"
preact-context-menu@^0.1.5:
version "0.1.5"
resolved "https://registry.yarnpkg.com/preact-context-menu/-/preact-context-menu-0.1.5.tgz#51d13b0eceed8bd53493f2bdbce36e7c74ff8575"
integrity sha512-cQxcOf4w8MZAQtLpQeX80TQ2TzSKD/z6rnV+654xiHzqQp2pSV+qE0IEZLkUNz88ZmtpIy6GnB/6pCnCGjS4Ww==
dependencies:
preact "^10.4.6"
preact-i18n@^2.4.0-preactx:
version "2.4.0-preactx"
resolved "https://registry.yarnpkg.com/preact-i18n/-/preact-i18n-2.4.0-preactx.tgz#fbcb2e3ae22744c7fef5a102db2ef7506057d082"
@ -3051,7 +3058,7 @@ preact-markup@^2.0.0:
resolved "https://registry.yarnpkg.com/preact-markup/-/preact-markup-2.1.1.tgz#0451e7eed1dac732d7194c34a7f16ff45a2cfdd7"
integrity sha512-8JL2p36mzK8XkspOyhBxUSPjYwMxDM0L5BWBZWxsZMVW8WsGQrYQDgVuDKkRspt2hwrle+Cxr/053hpc9BJwfw==
preact@^10.0.0, preact@^10.5.13:
preact@^10.0.0, preact@^10.4.6, preact@^10.5.13:
version "10.5.13"
resolved "https://registry.yarnpkg.com/preact/-/preact-10.5.13.tgz#85f6c9197ecd736ce8e3bec044d08fd1330fa019"
integrity sha512-q/vlKIGNwzTLu+jCcvywgGrt+H/1P/oIRSD6mV4ln3hmlC+Aa34C7yfPI4+5bzW8pONyVXYS7SvXosy2dKKtWQ==