diff --git a/src/components/MemberList/MemberListItem.tsx b/src/components/MemberList/MemberListItem.tsx index b19dcc3..ebb8fc4 100644 --- a/src/components/MemberList/MemberListItem.tsx +++ b/src/components/MemberList/MemberListItem.tsx @@ -79,7 +79,7 @@ function MemberListItem({ item }: Props) { contextMenu.onContextMenu(e, { user: item.user!, member: item })} + onContextMenu={(e) => contextMenu.onContextMenu(e, { type: "user", user: item.user!, member: item })} > diff --git a/src/components/contextMenus/MessageContextMenu.tsx b/src/components/contextMenus/MessageContextMenu.tsx new file mode 100644 index 0000000..8308ee3 --- /dev/null +++ b/src/components/contextMenus/MessageContextMenu.tsx @@ -0,0 +1,54 @@ +import { modalController } from "../../controllers/modals"; +import { useAppStore } from "../../stores/AppStore"; +import Message from "../../stores/objects/Message"; +import { ContextMenu, ContextMenuButton, ContextMenuDivider } from "./ContextMenu"; + +interface MenuProps { + message: Message; +} + +function MessageContextMenu({ message }: MenuProps) { + const app = useAppStore(); + + function copyRaw() { + navigator.clipboard.writeText(message.content); + } + + async function deleteMessage(e: MouseEvent) { + if (e.shiftKey) { + await message.delete(); + } else { + modalController.push({ + type: "delete_message", + target: message as Message, + }); + } + } + + function copyId() { + navigator.clipboard.writeText(message.id); + } + + return ( + + + Reply + + + Copy Raw Text + + + {message.channel.hasPermission("MANAGE_MESSAGES") && message instanceof Message && ( + + Delete Message + + )} + + + Copy ID + + + ); +} + +export default MessageContextMenu; diff --git a/src/components/contextMenus/UserContextMenu.tsx b/src/components/contextMenus/UserContextMenu.tsx index d908012..a954063 100644 --- a/src/components/contextMenus/UserContextMenu.tsx +++ b/src/components/contextMenus/UserContextMenu.tsx @@ -77,7 +77,7 @@ function UserContextMenu({ user, member }: MenuProps) { }} onClick={copyId} > - Copy user ID + Copy ID ); diff --git a/src/components/messaging/Message.tsx b/src/components/messaging/Message.tsx index 152f9b0..e8fb901 100644 --- a/src/components/messaging/Message.tsx +++ b/src/components/messaging/Message.tsx @@ -1,5 +1,6 @@ import { observer } from "mobx-react-lite"; -import { memo } from "react"; +import { memo, useContext } from "react"; +import { ContextMenuContext } from "../../contexts/ContextMenuContext"; import { useAppStore } from "../../stores/AppStore"; import { MessageLike } from "../../stores/objects/Message"; import { QueuedMessageStatus } from "../../stores/objects/QueuedMessage"; @@ -18,6 +19,7 @@ interface Props { function Message({ message, header }: Props) { const app = useAppStore(); + const contextMenuContext = useContext(ContextMenuContext); const guild = message.guild_id ? app.guilds.get(message.guild_id) : undefined; const isEveryoneMentioned = "mention_everyone" in message && message.mention_everyone; @@ -43,20 +45,31 @@ function Message({ message, header }: Props) { )} - + contextMenuContext.onContextMenu(e, { + type: "message", + message: message, + }) + } > - {message.content && } - + + {message.content && } + - {"attachments" in message && - message.attachments.map((attachment, index) => ( - - ))} - {"embeds" in message && - message.embeds?.map((embed, index) => )} - {"files" in message && message.files?.length !== 0 && } + {"attachments" in message && + message.attachments.map((attachment, index) => ( + + ))} + {"embeds" in message && + message.embeds?.map((embed, index) => )} + {"files" in message && message.files?.length !== 0 && ( + + )} + ); diff --git a/src/components/messaging/MessageAuthor.tsx b/src/components/messaging/MessageAuthor.tsx index 4926628..f9d5bfb 100644 --- a/src/components/messaging/MessageAuthor.tsx +++ b/src/components/messaging/MessageAuthor.tsx @@ -30,7 +30,7 @@ function MessageAuthor({ message }: Props) { const onContextMenu = async (e: React.MouseEvent) => { e.preventDefault(); const member = await app.guilds.get(message.guild_id!)?.members.fetch(message.author.id); - contextMenu.onContextMenu(e, { user: message.author, member }); + contextMenu.onContextMenu(e, { type: "user", user: message.author, member }); }; React.useEffect(() => { diff --git a/src/components/modals/DeleteMessageModal.tsx b/src/components/modals/DeleteMessageModal.tsx new file mode 100644 index 0000000..9e19c98 --- /dev/null +++ b/src/components/modals/DeleteMessageModal.tsx @@ -0,0 +1,34 @@ +import { ModalProps, modalController } from "../../controllers/modals"; +import { Modal } from "./ModalComponents"; + +export function DeleteMessageModal({ target, ...props }: ModalProps<"delete_message">) { + return ( + { + modalController.pop("close"); + }, + children: Cancel, + palette: "link", + size: "small", + confirmation: true, + }, + { + onClick: async () => { + await target.delete(); + modalController.pop("close"); + }, + children: Delete, + palette: "danger", + size: "small", + }, + ]} + > +
message preview
+
+ ); +} diff --git a/src/components/modals/index.ts b/src/components/modals/index.ts index db738ae..08916cb 100644 --- a/src/components/modals/index.ts +++ b/src/components/modals/index.ts @@ -3,6 +3,7 @@ export * from "./AttachmentPreviewModal"; export * from "./BanMemberModal"; export * from "./CreateInviteModal"; export * from "./CreateServerModal"; +export * from "./DeleteMessageModal"; export * from "./ErrorModal"; export * from "./ForgotPasswordModal"; export * from "./JoinServerModal"; diff --git a/src/contexts/ContextMenuContext.tsx b/src/contexts/ContextMenuContext.tsx index 0e7437c..217c066 100644 --- a/src/contexts/ContextMenuContext.tsx +++ b/src/contexts/ContextMenuContext.tsx @@ -1,21 +1,27 @@ /* 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 useContextMenu, { ContextMenuComponents } from "../hooks/useContextMenu"; import GuildMember from "../stores/objects/GuildMember"; +import { MessageLike } from "../stores/objects/Message"; import User from "../stores/objects/User"; -interface MenuProps { - user: User; - member?: GuildMember; -} +export type ContextMenuProps = + | { + type: "user"; + user: User; + member?: GuildMember; + } + | { + type: "message"; + message: MessageLike; + }; export type ContextMenuContextType = { setReferenceElement: ReturnType["refs"]["setReference"]; - onContextMenu: (e: React.MouseEvent, props: MenuProps) => void; + onContextMenu: (e: React.MouseEvent, props: ContextMenuProps) => void; close: () => void; - open: (user: User) => void; + open: (props: ContextMenuProps) => void; }; // @ts-expect-error not specifying a default value here @@ -23,14 +29,18 @@ 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 contextMenu = useContextMenu(); - const open = (user: User) => { - contextMenu.open({ - user, - }); + const open = (props: ContextMenuProps) => { + contextMenu.open(props); }; + const Component = contextMenu.props + ? ContextMenuComponents[contextMenu.props.type] + : () => { + return null; + }; + return ( = ({ children }) => { style={contextMenu.floatingStyles} {...contextMenu.getFloatingProps()} > - + )} diff --git a/src/controllers/modals/ModalController.tsx b/src/controllers/modals/ModalController.tsx index 3750155..5c6e087 100644 --- a/src/controllers/modals/ModalController.tsx +++ b/src/controllers/modals/ModalController.tsx @@ -6,6 +6,7 @@ import { BanMemberModal, CreateInviteModal, CreateServerModal, + DeleteMessageModal, ErrorModal, JoinServerModal, KickMemberModal, @@ -158,7 +159,7 @@ export const modalController = new ModalControllerExtended({ join_server: JoinServerModal, // create_bot: CreateBot, // custom_status: CustomStatus, - // delete_message: DeleteMessage, + delete_message: DeleteMessageModal, error: ErrorModal, // image_viewer: ImageViewer, kick_member: KickMemberModal, diff --git a/src/controllers/modals/types.ts b/src/controllers/modals/types.ts index 0261bb5..c713360 100644 --- a/src/controllers/modals/types.ts +++ b/src/controllers/modals/types.ts @@ -2,6 +2,7 @@ import Channel from "../../stores/objects/Channel"; import GuildMember from "../../stores/objects/GuildMember"; +import Message from "../../stores/objects/Message"; export type Modal = { key?: string; @@ -32,6 +33,10 @@ export type Modal = { type: "ban_member"; target: GuildMember; } + | { + type: "delete_message"; + target: Message; + } ); export type ModalProps = Modal & { type: T } & { diff --git a/src/hooks/useContextMenu.tsx b/src/hooks/useContextMenu.tsx index f7ba247..3c638bf 100644 --- a/src/hooks/useContextMenu.tsx +++ b/src/hooks/useContextMenu.tsx @@ -1,16 +1,20 @@ 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"; +import MessageContextMenu from "../components/contextMenus/MessageContextMenu"; +import UserContextMenu from "../components/contextMenus/UserContextMenu"; +import { ContextMenuProps } from "../contexts/ContextMenuContext"; -interface MenuProps { - user: User; - member?: GuildMember; -} +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Components = Record>; -export default function (type: "user") { +export const ContextMenuComponents: Components = { + user: UserContextMenu, + message: MessageContextMenu, +}; + +export default function () { const [isOpen, setIsOpen] = useState(false); - const [props, setProps] = useState(null); + const [props, setProps] = useState(null); const data = useFloating({ placement: "right-start", @@ -47,7 +51,7 @@ export default function (type: "user") { const interactions = useInteractions([dismiss, role]); - const open = (props: MenuProps) => { + const open = (props: ContextMenuProps) => { setProps(props); setIsOpen(true); }; @@ -56,7 +60,7 @@ export default function (type: "user") { setIsOpen(false); }; - function onContextMenu(e: React.MouseEvent, props: MenuProps) { + function onContextMenu(e: React.MouseEvent, props: ContextMenuProps) { e.preventDefault(); data.refs.setPositionReference({ diff --git a/src/stores/ChannelStore.ts b/src/stores/ChannelStore.ts index 60b9bf3..153b4f9 100644 --- a/src/stores/ChannelStore.ts +++ b/src/stores/ChannelStore.ts @@ -53,4 +53,8 @@ export default class ChannelStore { sortPosition(channels: Channel[]) { return channels.sort((a, b) => (a.position ?? 0) - (b.position ?? 0)); } + + has(id: string) { + return this.channels.has(id); + } } diff --git a/src/stores/objects/Message.ts b/src/stores/objects/Message.ts index cf733de..7fe8e50 100644 --- a/src/stores/objects/Message.ts +++ b/src/stores/objects/Message.ts @@ -21,6 +21,7 @@ import { } from "@spacebarchat/spacebar-api-types/v9"; import { action, makeObservable, observable } from "mobx"; import AppStore from "../AppStore"; +import Channel from "./Channel"; import MessageBase from "./MessageBase"; import QueuedMessage, { QueuedMessageData } from "./QueuedMessage"; @@ -31,7 +32,8 @@ export default class Message extends MessageBase { /** * ID of the channel the message was sent in */ - channel_id: Snowflake; + // channel_id: Snowflake; + channel: Channel; /** * When this message was edited (or null if never) */ @@ -205,7 +207,8 @@ export default class Message extends MessageBase { super(app, data); this.id = data.id; - this.channel_id = data.channel_id; + // this.channel_id = data.channel_id; + this.channel = this.app.channels.get(data.channel_id)!; // this.member = message.member ? new GuildMember(message.member) : undefined; this.content = data.content; this.timestamp = new Date(data.timestamp); @@ -250,6 +253,6 @@ export default class Message extends MessageBase { } async delete() { - await this.app.rest.delete(Routes.channelMessage(this.channel_id, this.id)); + await this.app.rest.delete(Routes.channelMessage(this.channel.id, this.id)); } }