diff --git a/src/components/MemberList/MemberListItem.tsx b/src/components/MemberList/MemberListItem.tsx index 4589870..f968bb5 100644 --- a/src/components/MemberList/MemberListItem.tsx +++ b/src/components/MemberList/MemberListItem.tsx @@ -1,5 +1,7 @@ import { PresenceUpdateStatus } from "@spacebarchat/spacebar-api-types/v9"; +import { useContext } from "react"; import styled from "styled-components"; +import { ContextMenuContext } from "../../contexts/ContextMenuContext"; import { useAppStore } from "../../stores/AppStore"; import GuildMember from "../../stores/objects/GuildMember"; import Avatar from "../Avatar"; @@ -62,6 +64,7 @@ interface Props { function MemberListItem({ item }: Props) { const app = useAppStore(); const presence = app.presences.get(item.user!.id); + const contextMenu = useContext(ContextMenuContext); return ( - + contextMenu.onContextMenu(e, { user: item.user! })} + > diff --git a/src/components/contextMenus/ContextMenu.tsx b/src/components/contextMenus/ContextMenu.tsx new file mode 100644 index 0000000..b51bfa0 --- /dev/null +++ b/src/components/contextMenus/ContextMenu.tsx @@ -0,0 +1,66 @@ +// modified from https://github.com/revoltchat/frontend/blob/master/components/app/menus/ContextMenu.tsx +// changed some styling + +import { ComponentProps } from "react"; +import styled from "styled-components"; +import Icon, { IconProps } from "../Icon"; + +export const ContextMenu = styled.div` + display: flex; + flex-direction: column; + padding: 6px 8px; + + overflow: hidden; + border-radius: 4px; + background: var(--background-tertiary); + color: var(--text); + + box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.5); +`; + +export const ContextMenuDivider = styled.div` + height: 1px; + margin: 4px; + background: var(--text); +`; + +export const ContextMenuItem = styled("a")` + display: block; + padding: 6px 8px; + border-radius: 4px; + font-size: 14px; + margin: 2px 0; + cursor: pointer; +`; + +const ButtonBase = styled(ContextMenuItem)<{ destructive?: boolean }>` + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + + > span { + margin-top: 1px; + } + + &:hover { + background: ${(props) => (props.destructive ? "var(--danger)" : "var(--primary)")}; + ${(props) => (props.destructive ? `color: var(--text)` : "")} + } + + ${(props) => (props.destructive ? `fill: var(--danger); color: var(--danger)` : "")} +`; + +type ButtonProps = ComponentProps & { + icon?: IconProps["icon"]; + destructive?: boolean; +}; + +export function ContextMenuButton(props: ButtonProps) { + return ( + + {props.children} + {props.icon && } + + ); +} diff --git a/src/components/contextMenus/UserContextMenu.tsx b/src/components/contextMenus/UserContextMenu.tsx new file mode 100644 index 0000000..2981c51 --- /dev/null +++ b/src/components/contextMenus/UserContextMenu.tsx @@ -0,0 +1,32 @@ +// loosely based on https://github.com/revoltchat/frontend/blob/master/components/app/menus/UserContextMenu.tsx + +import User from "../../stores/objects/User"; +import { ContextMenu, ContextMenuButton, ContextMenuDivider } from "./ContextMenu"; + +interface MenuProps { + user: User; +} + +function UserContextMenu({ user }: MenuProps) { + /** + * Copy user id to clipboard + */ + function copyId() { + navigator.clipboard.writeText(user.id); + } + + return ( + + + Mention + + + + + Copy user ID + + + ); +} + +export default UserContextMenu; diff --git a/src/contexts/ContextMenuContext.tsx b/src/contexts/ContextMenuContext.tsx new file mode 100644 index 0000000..3277ede --- /dev/null +++ b/src/contexts/ContextMenuContext.tsx @@ -0,0 +1,56 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { FloatingPortal, useFloating } from "@floating-ui/react"; +import React from "react"; +import UserContextMenu from "../components/contextMenus/UserContextMenu"; +import useContextMenu from "../hooks/useContextMenu"; +import User from "../stores/objects/User"; + +interface MenuProps { + user: User; +} + +export type ContextMenuContextType = { + setReferenceElement: ReturnType["refs"]["setReference"]; + onContextMenu: (e: React.MouseEvent, props: MenuProps) => void; + close: () => void; + open: (user: User) => void; +}; + +// @ts-expect-error not specifying a default value here +export const ContextMenuContext = React.createContext(); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const ContextMenuContextProvider: React.FC = ({ children }) => { + const contextMenu = useContextMenu("user"); + + const open = (user: User) => { + contextMenu.open({ + user, + }); + }; + + return ( + + {children} + + {contextMenu.isOpen && ( +
+ +
+ )} +
+
+ ); +}; diff --git a/src/hooks/useContextMenu.tsx b/src/hooks/useContextMenu.tsx new file mode 100644 index 0000000..b91cea7 --- /dev/null +++ b/src/hooks/useContextMenu.tsx @@ -0,0 +1,92 @@ +import { autoUpdate, flip, offset, shift, useDismiss, useFloating, useInteractions, useRole } from "@floating-ui/react"; +import { useMemo, useState } from "react"; +import User from "../stores/objects/User"; + +interface MenuProps { + user: User; +} + +export default function (type: "user") { + const [isOpen, setIsOpen] = useState(false); + const [props, setProps] = useState(null); + + const data = useFloating({ + placement: "right-start", + strategy: "fixed", + open: isOpen && props !== null, + onOpenChange: setIsOpen, + whileElementsMounted: autoUpdate, + middleware: [ + offset(5), + flip({ + fallbackPlacements: ["left-start"], + }), + shift({ + padding: 8, + }), + ], + }); + + const context = data.context; + + const role = useRole(context, { role: "menu" }); + const dismiss = useDismiss(context); + // const listNavigation = useListNavigation(context, { + // listRef: listItemsRef, + // onNavigate: setActiveIndex, + // activeIndex + // }); + // const typeahead = useTypeahead(context, { + // enabled: isOpen, + // listRef: listContentRef, + // onMatch: setActiveIndex, + // activeIndex + // }); + + const interactions = useInteractions([dismiss, role]); + + const open = (props: MenuProps) => { + setProps(props); + setIsOpen(true); + }; + + const close = () => { + setIsOpen(false); + }; + + function onContextMenu(e: React.MouseEvent, props: MenuProps) { + e.preventDefault(); + + data.refs.setPositionReference({ + getBoundingClientRect() { + return { + width: 0, + height: 0, + x: e.clientX, + y: e.clientY, + top: e.clientY, + right: e.clientX, + bottom: e.clientY, + left: e.clientX, + }; + }, + }); + + setProps(props); + setIsOpen(true); + } + + return useMemo( + () => ({ + isOpen, + props, + setProps, + open, + close, + onContextMenu, + ...interactions, + ...data, + }), + [isOpen, props, setProps, open, close, onContextMenu, interactions, data], + ); +} diff --git a/src/index.tsx b/src/index.tsx index 7983116..f99972d 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -19,6 +19,7 @@ import { BrowserRouter } from "react-router-dom"; import { ErrorBoundaryContext } from "react-use-error-boundary"; import App from "./App"; import { BannerContextProvider } from "./contexts/BannerContext"; +import { ContextMenuContextProvider } from "./contexts/ContextMenuContext"; import Theme from "./contexts/Theme"; import ModalRenderer from "./controllers/modals/ModalRenderer"; import "./index.css"; @@ -31,8 +32,10 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - - + + + +