1
0
mirror of https://github.com/spacebarchat/client.git synced 2024-11-22 02:12:38 +01:00

implement more modals, and user context menu kick/ban

This commit is contained in:
Puyodead1 2023-12-15 23:37:53 -05:00
parent c598875a75
commit c048563b53
No known key found for this signature in database
GPG Key ID: A4FA4FEC0DD353FC
26 changed files with 315 additions and 67 deletions

View File

@ -10,6 +10,7 @@
"@fontsource/roboto": "^4.5.8",
"@fontsource/roboto-mono": "^5.0.16",
"@hcaptcha/react-hcaptcha": "^1.9.2",
"@hookform/resolvers": "^3.3.2",
"@mattjennings/react-modal-stack": "^1.0.4",
"@mdi/js": "^7.3.67",
"@mdi/react": "^1.6.1",
@ -65,7 +66,8 @@
"remark-gfm": "^3.0.1",
"reoverlay": "^1.0.3",
"styled-components": "^5.3.11",
"use-resize-observer": "^9.1.0"
"use-resize-observer": "^9.1.0",
"yup": "^1.3.3"
},
"devDependencies": {
"@craco/craco": "^7.1.0",

View File

@ -23,6 +23,9 @@ dependencies:
'@hcaptcha/react-hcaptcha':
specifier: ^1.9.2
version: 1.9.2(react-dom@18.2.0)(react@18.2.0)
'@hookform/resolvers':
specifier: ^3.3.2
version: 3.3.2(react-hook-form@7.49.0)
'@mattjennings/react-modal-stack':
specifier: ^1.0.4
version: 1.0.4(react@18.2.0)
@ -191,6 +194,9 @@ dependencies:
use-resize-observer:
specifier: ^9.1.0
version: 9.1.0(react-dom@18.2.0)(react@18.2.0)
yup:
specifier: ^1.3.3
version: 1.3.3
devDependencies:
'@craco/craco':
@ -2551,6 +2557,14 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
/@hookform/resolvers@3.3.2(react-hook-form@7.49.0):
resolution: {integrity: sha512-Tw+GGPnBp+5DOsSg4ek3LCPgkBOuOgS5DsDV7qsWNH9LZc433kgsWICjlsh2J9p04H2K66hsXPPb9qn9ILdUtA==}
peerDependencies:
react-hook-form: ^7.0.0
dependencies:
react-hook-form: 7.49.0(react@18.2.0)
dev: false
/@humanwhocodes/config-array@0.11.11:
resolution: {integrity: sha512-N2brEuAadi0CcdeMXUkhbZB84eskAc8MEX1By6qEchoVywSgXPIjou4rYsl0V3Hj0ZnuGycGCjdNgockbzeWNA==}
engines: {node: '>=10.10.0'}
@ -11202,6 +11216,10 @@ packages:
object-assign: 4.1.1
react-is: 16.13.1
/property-expr@2.0.6:
resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==}
dev: false
/property-information@5.6.0:
resolution: {integrity: sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==}
dependencies:
@ -12893,6 +12911,10 @@ packages:
resolution: {integrity: sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==}
dev: true
/tiny-case@1.0.3:
resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==}
dev: false
/tmpl@1.0.5:
resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==}
dev: true
@ -12913,6 +12935,10 @@ packages:
engines: {node: '>=0.6'}
dev: true
/toposort@2.0.2:
resolution: {integrity: sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==}
dev: false
/tough-cookie@4.1.3:
resolution: {integrity: sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==}
engines: {node: '>=6'}
@ -13052,6 +13078,11 @@ packages:
engines: {node: '>=10'}
dev: true
/type-fest@2.19.0:
resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==}
engines: {node: '>=12.20'}
dev: false
/type-is@1.6.18:
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
engines: {node: '>= 0.6'}
@ -14071,6 +14102,15 @@ packages:
engines: {node: '>=10'}
dev: true
/yup@1.3.3:
resolution: {integrity: sha512-v8QwZSsHH2K3/G9WSkp6mZKO+hugKT1EmnMqLNUcfu51HU9MDyhlETT/JgtzprnrnQHPWsjc6MUDMBp/l9fNnw==}
dependencies:
property-expr: 2.0.6
tiny-case: 1.0.3
toposort: 2.0.2
type-fest: 2.19.0
dev: false
/zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
dev: false

View File

@ -79,7 +79,7 @@ function MemberListItem({ item }: Props) {
<ListItem
key={item.user?.id}
ref={contextMenu.setReferenceElement}
onContextMenu={(e) => contextMenu.onContextMenu(e, { user: item.user! })}
onContextMenu={(e) => contextMenu.onContextMenu(e, { user: item.user!, member: item })}
>
<Container>
<Wrapper offline={presence?.status === PresenceUpdateStatus.Offline}>

View File

@ -21,16 +21,23 @@ export const ContextMenu = styled.div`
export const ContextMenuDivider = styled.div`
height: 1px;
margin: 4px;
background: var(--text);
background: var(--text-disabled);
`;
export const ContextMenuItem = styled("a")`
export const ContextMenuItem = styled("button")`
display: block;
padding: 6px 8px;
border-radius: 4px;
font-size: 14px;
margin: 2px 0;
cursor: pointer;
cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
opacity: ${(props) => (props.disabled ? 0.5 : 1)};
// remove default button styles
border: none;
background: none;
color: inherit;
outline: none;
`;
const ButtonBase = styled(ContextMenuItem)<{ destructive?: boolean }>`
@ -53,14 +60,15 @@ const ButtonBase = styled(ContextMenuItem)<{ destructive?: boolean }>`
type ButtonProps = ComponentProps<typeof ContextMenuItem> & {
icon?: IconProps["icon"];
iconProps?: Omit<IconProps, "icon" | "size">;
destructive?: boolean;
};
export function ContextMenuButton(props: ButtonProps) {
export function ContextMenuButton({ icon, children, iconProps, ...props }: ButtonProps) {
return (
<ButtonBase {...props}>
<span>{props.children}</span>
{props.icon && <Icon icon={props.icon} size="18px" />}
<span>{children}</span>
{icon && <Icon icon={icon} {...iconProps} size="18px" />}
</ButtonBase>
);
}

View File

@ -1,13 +1,16 @@
// loosely based on https://github.com/revoltchat/frontend/blob/master/components/app/menus/UserContextMenu.tsx
import { modalController } from "../../controllers/modals";
import GuildMember from "../../stores/objects/GuildMember";
import User from "../../stores/objects/User";
import { ContextMenu, ContextMenuButton, ContextMenuDivider } from "./ContextMenu";
interface MenuProps {
user: User;
member?: GuildMember;
}
function UserContextMenu({ user }: MenuProps) {
function UserContextMenu({ user, member }: MenuProps) {
/**
* Copy user id to clipboard
*/
@ -15,14 +18,61 @@ function UserContextMenu({ user }: MenuProps) {
navigator.clipboard.writeText(user.id);
}
/**
* Open kick modal
*/
function kick() {
if (!member) return;
modalController.push({
type: "kick_member",
target: member,
});
}
/**
* Open ban modal
*/
function ban() {
if (!member) return;
modalController.push({
type: "ban_member",
target: member,
});
}
return (
<ContextMenu>
<ContextMenuButton icon="mdiAt" destructive>
Mention
<ContextMenuButton disabled>Profile</ContextMenuButton>
<ContextMenuButton disabled>Mention</ContextMenuButton>
<ContextMenuButton disabled>Message</ContextMenuButton>
<ContextMenuDivider />
<ContextMenuButton disabled>Change Nickname</ContextMenuButton>
<ContextMenuButton disabled>Add Friend</ContextMenuButton>
<ContextMenuButton disabled>Block</ContextMenuButton>
<ContextMenuDivider />
<ContextMenuButton destructive onClick={kick}>
Kick {member?.nick ?? user.username}
</ContextMenuButton>
<ContextMenuButton destructive onClick={ban}>
Ban {member?.nick ?? user.username}
</ContextMenuButton>
<ContextMenuDivider />
<ContextMenuButton disabled icon="mdiChevronRight">
Roles
</ContextMenuButton>
<ContextMenuDivider />
<ContextMenuButton icon="mdiIdentifier" onClick={copyId}>
<ContextMenuButton
icon="mdiIdentifier"
iconProps={{
style: {
filter: "invert(100%)",
background: "black",
borderRadius: "4px",
},
}}
onClick={copyId}
>
Copy user ID
</ContextMenuButton>
</ContextMenu>

View File

@ -3,12 +3,12 @@ import { ModalProps, modalController } from "../../controllers/modals";
import Button from "../Button";
import { Modal } from "./ModalComponents";
export const ActionWrapper = styled.div`
const ActionWrapper = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
`;
function AddServerModal({ ...props }: ModalProps<"add_server">) {
export function AddServerModal({ ...props }: ModalProps<"add_server">) {
return (
<Modal {...props} title="Add a Guild" description="Lorem ipsum dolor sit amet, consectetur adipiscing elit.">
<ActionWrapper>
@ -37,5 +37,3 @@ function AddServerModal({ ...props }: ModalProps<"add_server">) {
</Modal>
);
}
export default AddServerModal;

View File

@ -1,5 +1,3 @@
function AttachmentPreviewModal() {
export function AttachmentPreviewModal() {
return null;
}
export default AttachmentPreviewModal;

View File

@ -0,0 +1,72 @@
import { yupResolver } from "@hookform/resolvers/yup";
import { useForm } from "react-hook-form";
import styled from "styled-components";
import * as yup from "yup";
import { ModalProps, modalController } from "../../controllers/modals";
import { Modal } from "./ModalComponents";
const DescriptionText = styled.p`
font-size: 16px;
font-weight: var(--font-weight-regular);
color: var(--text-header-secondary);
margin-top: 8px;
`;
const schema = yup
.object({
reason: yup.string(),
})
.required();
export function BanMemberModal({ target, ...props }: ModalProps<"ban_member">) {
const {
register,
control,
setError,
handleSubmit,
formState: { errors, disabled, isLoading, isSubmitting },
} = useForm({
resolver: yupResolver(schema),
});
const isDisabled = disabled || isLoading || isSubmitting;
return (
<Modal
{...props}
title={`Ban '${target.user?.username}'`}
description={
<DescriptionText>
Are you sure you want to ban <b>@{target.user?.username}</b>? They won't be able to rejoin unless
they are unbanned.
</DescriptionText>
}
actions={[
{
onClick: () => console.log("kick"),
children: <span>Kick</span>,
palette: "danger",
confirmation: true,
disabled: isDisabled,
},
{
onClick: () => modalController.pop("close"),
children: <span>Cancel</span>,
palette: "link",
disabled: isDisabled,
},
]}
>
<img
src="https://media1.tenor.com/m/TG5OF7UkLasAAAAd/thanos-infinity.gif"
loading="lazy"
alt="Thanos Snap GIF"
height={300}
style={{
objectFit: "contain",
}}
/>
<span>reason form</span>
</Modal>
);
}

View File

@ -85,7 +85,7 @@ interface FormValues extends APICreateInvite {
code: string;
}
function CreateInviteModal({ target, ...props }: ModalProps<"create_invite">) {
export function CreateInviteModal({ target, ...props }: ModalProps<"create_invite">) {
const logger = useLogger("CreateInviteModal");
const app = useAppStore();
const [maxAge, setMaxAge] = React.useState(ExpiryOptions.DAY_7);
@ -290,5 +290,3 @@ function CreateInviteModal({ target, ...props }: ModalProps<"create_invite">) {
</Modal>
);
}
export default CreateInviteModal;

View File

@ -11,11 +11,6 @@ import { Input, InputErrorText, InputLabel, InputWrapper, LabelWrapper } from ".
import { TextDivider } from "../Divider";
import { InputContainer, Modal } from "./ModalComponents";
export const ModalHeader = styled.div`
margin-bottom: 30px;
padding: 24px 24px 0;
`;
const UploadIcon = styled.div`
padding-top: 4;
display: flex;
@ -49,7 +44,7 @@ type FormValues = {
name: string;
};
function CreateServerModal({ ...props }: ModalProps<"create_server">) {
export function CreateServerModal({ ...props }: ModalProps<"create_server">) {
const app = useAppStore();
const logger = useLogger("CreateServerModal");
const [selectedFile, setSelectedFile] = React.useState<File>();
@ -210,5 +205,3 @@ function CreateServerModal({ ...props }: ModalProps<"create_server">) {
</Modal>
);
}
export default CreateServerModal;

View File

@ -1,7 +1,7 @@
import { ModalProps } from "../../controllers/modals/types";
import { Modal } from "./ModalComponents";
function ErrorModal({ error, ...props }: ModalProps<"error">) {
export function ErrorModal({ error, ...props }: ModalProps<"error">) {
return (
<Modal
{...props}
@ -20,5 +20,3 @@ function ErrorModal({ error, ...props }: ModalProps<"error">) {
</Modal>
);
}
export default ErrorModal;

View File

@ -1,5 +1,3 @@
function ForgotPasswordModal() {
export function ForgotPasswordModal() {
return null;
}
export default ForgotPasswordModal;

View File

@ -10,10 +10,6 @@ import { Input, InputErrorText, InputLabel, LabelWrapper } from "../AuthComponen
import { TextDivider } from "../Divider";
import { Modal } from "./ModalComponents";
export const ModalHeader = styled.div`
padding: 16px;
`;
const InviteInputContainer = styled.div`
display: flex;
flex-direction: column;
@ -23,7 +19,7 @@ type FormValues = {
code: string;
};
function JoinServerModal({ ...props }: ModalProps<"join_server">) {
export function JoinServerModal({ ...props }: ModalProps<"join_server">) {
const logger = useLogger("JoinServerModal");
const app = useAppStore();
const navigate = useNavigate();
@ -137,5 +133,3 @@ function JoinServerModal({ ...props }: ModalProps<"join_server">) {
</Modal>
);
}
export default JoinServerModal;

View File

@ -0,0 +1,63 @@
import { yupResolver } from "@hookform/resolvers/yup";
import { useForm } from "react-hook-form";
import styled from "styled-components";
import * as yup from "yup";
import { ModalProps, modalController } from "../../controllers/modals";
import { Modal } from "./ModalComponents";
const DescriptionText = styled.p`
font-size: 16px;
font-weight: var(--font-weight-regular);
color: var(--text-header-secondary);
margin-top: 8px;
`;
const schema = yup
.object({
reason: yup.string(),
})
.required();
export function KickMemberModal({ target, ...props }: ModalProps<"kick_member">) {
const {
register,
control,
setError,
handleSubmit,
formState: { errors, disabled, isLoading, isSubmitting },
} = useForm({
resolver: yupResolver(schema),
});
const isDisabled = disabled || isLoading || isSubmitting;
return (
<Modal
{...props}
title={`Kick ${target.user?.username} from Guild`}
description={
<DescriptionText>
Are you sure you want to kick <b>@{target.user?.username}</b> from the guild? They will be able to
rejoin again with a new invite.
</DescriptionText>
}
actions={[
{
onClick: () => console.log("kick"),
children: <span>Kick</span>,
palette: "danger",
confirmation: true,
disabled: isDisabled,
},
{
onClick: () => modalController.pop("close"),
children: <span>Cancel</span>,
palette: "link",
disabled: isDisabled,
},
]}
>
<span>reason form</span>
</Modal>
);
}

View File

@ -1,5 +0,0 @@
function KickModal() {
return null;
}
export default KickModal;

View File

@ -1,5 +1,3 @@
function LeaveServerModal() {
export function LeaveServerModal() {
return null;
}
export default LeaveServerModal;

View File

@ -17,7 +17,7 @@ interface ModalProps {
onClose?: (force: boolean) => void;
signal?: "close" | "confirm" | "cancel";
title?: string;
description?: string;
description?: React.ReactNode;
transparent?: boolean;
nonDismissable?: boolean;
maxWidth?: string;
@ -214,8 +214,16 @@ export function Modal(props: ModalProps) {
</div>
{(props.title || props.description) && (
<ModalHeader>
{props.title && <ModalHeaderText>{props.title}</ModalHeaderText>}
{props.description && <ModalSubHeaderText>{props.description}</ModalSubHeaderText>}
{props.title && typeof props.title === "string" ? (
<ModalHeaderText>{props.title}</ModalHeaderText>
) : (
props.title
)}
{props.description && typeof props.description === "string" ? (
<ModalSubHeaderText>{props.description}</ModalSubHeaderText>
) : (
props.description
)}
</ModalHeader>
)}
<ModalContentContainer {...props}>{props.children}</ModalContentContainer>

View File

@ -1,7 +1,3 @@
import { observer } from "mobx-react-lite";
function SettingsModal() {
export function SettingsModal() {
return null;
}
export default observer(SettingsModal);

View File

@ -0,0 +1,11 @@
export * from "./AddServerModal";
export * from "./AttachmentPreviewModal";
export * from "./BanMemberModal";
export * from "./CreateInviteModal";
export * from "./CreateServerModal";
export * from "./ErrorModal";
export * from "./ForgotPasswordModal";
export * from "./JoinServerModal";
export * from "./KickMemberModal";
export * from "./LeaveServerModal";
export * from "./SettingsModal";

View File

@ -3,10 +3,12 @@ import { FloatingPortal, useFloating } from "@floating-ui/react";
import React from "react";
import UserContextMenu from "../components/contextMenus/UserContextMenu";
import useContextMenu from "../hooks/useContextMenu";
import GuildMember from "../stores/objects/GuildMember";
import User from "../stores/objects/User";
interface MenuProps {
user: User;
member?: GuildMember;
}
export type ContextMenuContextType = {

View File

@ -6,10 +6,13 @@ import { rgbToHsl } from "../utils/Utils";
const font: ThemeFont["font"] = {
weight: {
thin: 100,
// extraLight: 200,
light: 300,
regular: 400,
medium: 500,
// semiBold: 600,
bold: 700,
// extraBold: 800,
black: 900,
},
family: "Roboto, Arial, Helvetica, sans-serif",
@ -69,10 +72,13 @@ export type ThemeFont = {
font: {
weight: {
thin?: number;
extraLight?: number;
light?: number;
regular?: number;
medium?: number;
semiBold?: number;
bold?: number;
extraBold?: number;
black?: number;
};
family: string;

View File

@ -1,11 +1,15 @@
// adapted from https://github.com/revoltchat/revite/blob/master/src/controllers/modals/ModalController.tsx
import { action, computed, makeObservable, observable } from "mobx";
import AddServerModal from "../../components/modals/AddServerModal";
import CreateInviteModal from "../../components/modals/CreateInviteModal";
import CreateServerModal from "../../components/modals/CreateServerModal";
import ErrorModal from "../../components/modals/ErrorModal";
import JoinServerModal from "../../components/modals/JoinServerModal";
import {
AddServerModal,
BanMemberModal,
CreateInviteModal,
CreateServerModal,
ErrorModal,
JoinServerModal,
KickMemberModal,
} from "../../components/modals";
import { Modal } from "./types";
function randomUUID() {
@ -95,6 +99,7 @@ class ModalController<T extends Modal> {
<>
{this.stack.map((modal) => {
const Component = this.components[modal.type];
if (!Component) return null;
return <Component {...modal} onClose={() => this.remove(modal.key!)} />;
})}
</>
@ -132,7 +137,7 @@ class ModalControllerExtended extends ModalController<Modal> {
export const modalController = new ModalControllerExtended({
add_server: AddServerModal,
// add_friend: AddFriend,
// ban_member: BanMember,
ban_member: BanMemberModal,
// changelog: Changelog,
// channel_info: ChannelInfo,
// clipboard: Clipboard,
@ -156,7 +161,7 @@ export const modalController = new ModalControllerExtended({
// delete_message: DeleteMessage,
error: ErrorModal,
// image_viewer: ImageViewer,
// kick_member: KickMember,
kick_member: KickMemberModal,
// link_warning: LinkWarning,
// mfa_flow: MFAFlow,
// mfa_recovery: MFARecovery,

View File

@ -1,6 +1,7 @@
// adapted from https://github.com/revoltchat/revite/blob/master/src/controllers/modals/types.ts
import Channel from "../../stores/objects/Channel";
import GuildMember from "../../stores/objects/GuildMember";
export type Modal = {
key?: string;
@ -23,6 +24,14 @@ export type Modal = {
type: "create_invite";
target: Channel;
}
| {
type: "kick_member";
target: GuildMember;
}
| {
type: "ban_member";
target: GuildMember;
}
);
export type ModalProps<T extends Modal["type"]> = Modal & { type: T } & {

View File

@ -1,9 +1,11 @@
import { autoUpdate, flip, offset, shift, useDismiss, useFloating, useInteractions, useRole } from "@floating-ui/react";
import { useMemo, useState } from "react";
import GuildMember from "../stores/objects/GuildMember";
import User from "../stores/objects/User";
interface MenuProps {
user: User;
member?: GuildMember;
}
export default function (type: "user") {

View File

@ -18,9 +18,12 @@ textarea {
margin: 0;
}
html *:not(code) {
font-family: var(--font-family);
}
body {
margin: 0;
font-family: var(--font-family);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow: hidden;

View File

@ -11,6 +11,7 @@ import "@fontsource/roboto/400.css";
import "@fontsource/roboto/500.css";
import "@fontsource/roboto/700.css";
import "@fontsource/roboto/900.css";
import dayjs from "dayjs";
import calendar from "dayjs/plugin/calendar";
import relativeTime from "dayjs/plugin/relativeTime";