diff --git a/src/components/common/UserHeader.tsx b/src/components/common/UserHeader.tsx index eae71db9..6a8265e3 100644 --- a/src/components/common/UserHeader.tsx +++ b/src/components/common/UserHeader.tsx @@ -1,11 +1,17 @@ +import Tooltip from "./Tooltip"; import { User } from "revolt.js"; import Header from "../ui/Header"; import UserIcon from "./UserIcon"; +import { Text } from "preact-i18n"; import UserStatus from './UserStatus'; import styled from "styled-components"; import { Localizer } from 'preact-i18n'; +import { Link } from "react-router-dom"; +import IconButton from "../ui/IconButton"; import { Settings } from "@styled-icons/feather"; +import { openContextMenu } from "preact-context-menu"; import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice"; +import { useIntermediate } from "../../context/intermediate/Intermediate"; const HeaderBase = styled.div` gap: 0; @@ -39,12 +45,10 @@ interface Props { } export default function UserHeader({ user }: Props) { - function openPresenceSelector() { - // openContextMenu("Status"); - } + const { writeClipboard } = useIntermediate(); - function writeClipboard(a: string) { - alert('unimplemented'); + function openPresenceSelector() { + openContextMenu("Status"); } return ( @@ -57,12 +61,12 @@ export default function UserHeader({ user }: Props) { /> - {/*}>*/} + }> writeClipboard(user.username)}> @{user.username} - {/**/} + @@ -70,9 +74,11 @@ export default function UserHeader({ user }: Props) { { !isTouchscreenDevice &&
- {/**/} - - {/**/} + + + + +
} ) diff --git a/src/components/navigation/left/HomeSidebar.tsx b/src/components/navigation/left/HomeSidebar.tsx index cc4d6dcb..bf1b99ce 100644 --- a/src/components/navigation/left/HomeSidebar.tsx +++ b/src/components/navigation/left/HomeSidebar.tsx @@ -22,6 +22,7 @@ import Header from '../../ui/Header'; import UserHeader from "../../common/UserHeader"; import Category from '../../ui/Category'; import PaintCounter from "../../../lib/PaintCounter"; +import { useIntermediate } from "../../../context/intermediate/Intermediate"; type Props = WithDispatcher & { unreads: Unreads; @@ -50,7 +51,7 @@ function HomeSidebar(props: Props) { const { pathname } = useLocation(); const client = useContext(AppContext); const { channel } = useParams<{ channel: string }>(); - // const { openScreen, writeClipboard } = useContext(IntermediateContext); + const { openScreen, writeClipboard } = useIntermediate(); const ctx = useForceUpdate(); const users = useUsers(undefined, ctx); @@ -119,7 +120,7 @@ function HomeSidebar(props: Props) { ) as any } - action={() => /*openScreen({ id: "special_input", type: "create_group" })*/{}} + action={() => openScreen({ id: "special_input", type: "create_group" })} /> {channelsArr.length === 0 && } diff --git a/src/lib/ContextMenus.tsx b/src/lib/ContextMenus.tsx new file mode 100644 index 00000000..c1d35ba0 --- /dev/null +++ b/src/lib/ContextMenus.tsx @@ -0,0 +1,656 @@ +import { Text } from "preact-i18n"; +import { useContext } from "preact/hooks"; +import { useHistory } from "react-router-dom"; +import { Attachment, Channels, Message, Servers, Users } from "revolt.js/dist/api/objects"; +import { + ContextMenu, + ContextMenuWithData, + MenuItem +} from "preact-context-menu"; +import { ChannelPermission, ServerPermission, UserPermission } from "revolt.js/dist/api/permissions"; +import { QueuedMessage } from "../redux/reducers/queue"; +import { WithDispatcher } from "../redux/reducers"; +import { useIntermediate } from "../context/intermediate/Intermediate"; +import { AppContext, ClientStatus, StatusContext } from "../context/revoltjs/RevoltClient"; +import { takeError } from "../context/revoltjs/util"; +import { useChannel, useChannelPermission, useForceUpdate, useServer, useServerPermission, useUser, useUserPermission } from "../context/revoltjs/hooks"; +import { Children } from "../types/Preact"; +import LineDivider from "../components/ui/LineDivider"; +import { connectState } from "../redux/connector"; + +interface ContextMenuData { + user?: string; + server?: string; + server_list?: string; + channel?: string; + message?: Message; + + unread?: boolean; + queued?: QueuedMessage; + contextualChannel?: string; +} + +type Action = + | { action: "copy_id"; id: string } + | { action: "copy_selection" } + | { action: "copy_text"; content: string } + | { action: "mark_as_read"; channel: Channels.Channel } + | { action: "retry_message"; message: QueuedMessage } + | { action: "cancel_message"; message: QueuedMessage } + | { action: "mention"; user: string } + | { action: "quote_message"; content: string } + | { action: "edit_message"; id: string } + | { action: "delete_message"; target: Channels.Message } + | { action: "open_file"; attachment: Attachment } + | { action: "save_file"; attachment: Attachment } + | { action: "copy_file_link"; attachment: Attachment } + | { action: "open_link"; link: string } + | { action: "copy_link"; link: string } + | { action: "remove_member"; channel: string; user: string } + | { action: "kick_member"; target: Servers.Server; user: string } + | { action: "ban_member"; target: Servers.Server; user: string } + | { action: "view_profile"; user: string } + | { action: "message_user"; user: string } + | { action: "block_user"; user: string } + | { action: "unblock_user"; user: string } + | { action: "add_friend"; user: string } + | { action: "remove_friend"; user: string } + | { action: "cancel_friend"; user: string } + | { action: "set_presence"; presence: Users.Presence } + | { action: "set_status" } + | { action: "clear_status" } + | { action: "create_channel"; server: string } + | { action: "create_invite"; target: Channels.GroupChannel | Channels.TextChannel } + | { action: "leave_group"; target: Channels.GroupChannel } + | { action: "delete_channel"; target: Channels.TextChannel } + | { action: "close_dm"; target: Channels.DirectMessageChannel } + | { action: "leave_server"; target: Servers.Server } + | { action: "delete_server"; target: Servers.Server } + | { action: "open_channel_settings", id: string } + | { action: "open_server_settings", id: string } + | { action: "open_server_channel_settings", server: string, id: string }; + +function ContextMenus(props: WithDispatcher) { + const { openScreen, writeClipboard } = useIntermediate(); + const client = useContext(AppContext); + const userId = client.user!._id; + const status = useContext(StatusContext); + const isOnline = status === ClientStatus.ONLINE; + const history = useHistory(); + + function contextClick(data?: Action) { + if (typeof data === "undefined") return; + + (async () => { + switch (data.action) { + case "copy_id": + writeClipboard(data.id); + break; + case "copy_selection": + writeClipboard(document.getSelection()?.toString() ?? ''); + break; + case "mark_as_read": + if (data.channel.channel_type === 'SavedMessages') return; + props.dispatcher({ + type: "UNREADS_MARK_READ", + channel: data.channel._id, + message: data.channel.channel_type === 'TextChannel' ? data.channel.last_message : data.channel.last_message._id, + request: true + }); + break; + + case "retry_message": + { + const nonce = data.message.id; + const fail = (error: any) => + props.dispatcher({ + type: "QUEUE_FAIL", + nonce, + error + }); + + client.channels + .sendMessage( + data.message.channel, + { + content: data.message.data.content as string, + nonce + } + ) + .catch(fail); + + props.dispatcher({ + type: "QUEUE_START", + nonce + }); + } + break; + + case "cancel_message": + { + props.dispatcher({ + type: "QUEUE_REMOVE", + nonce: data.message.id + }); + } + break; + + case "mention": + { + // edit draft + /*InternalEventEmitter.emit( + "append_messagebox", + `<@${data.user}>`, + "mention" + );*/ + } + break; + + case "copy_text": + writeClipboard(data.content); + break; + case "quote_message": + { + // edit draft + /*InternalEventEmitter.emit( + "append_messagebox", + data.content, + "quote" + );*/ + } + break; + + case "edit_message": + { + // InternalEventEmitter.emit("edit_message", data.id); + } + break; + + case "open_file": + { + window + .open( + client.generateFileURL(data.attachment), + "_blank" + ) + ?.focus(); + } + break; + + case "save_file": + { + window.open( + // ! FIXME: do this from revolt.js + client.generateFileURL(data.attachment)?.replace('attachments', 'attachments/download'), + "_blank" + ); + } + break; + + case "copy_file_link": + { + const { _id, filename } = data.attachment; + writeClipboard( + // ! FIXME: do from r.js + client.generateFileURL(data.attachment) + '/${encodeURI(filename)}', + ); + } + break; + + case "open_link": + { + window.open(data.link, "_blank")?.focus(); + } + break; + + case "copy_link": + { + writeClipboard(data.link); + } + break; + + case "remove_member": + { + client.channels.removeMember(data.channel, data.user); + } + break; + + case "view_profile": + openScreen({ id: 'profile', user_id: data.user }); + break; + + case "message_user": + { + const channel = await client.users.openDM(data.user); + if (channel) { + history.push(`/channel/${channel._id}`); + } + } + break; + + case "add_friend": + { + let user = client.users.get(data.user); + if (user) { + await client.users.addFriend(user.username); + } + } + break; + + case "block_user": + await client.users.blockUser(data.user); + break; + case "unblock_user": + await client.users.unblockUser(data.user); + break; + case "remove_friend": + case "cancel_friend": + await client.users.removeFriend(data.user); + break; + + case "set_presence": + { + await client.users.editUser({ + status: { + ...client.user?.status, + presence: data.presence + } + }); + } + break; + + case "set_status": openScreen({ id: "special_input", type: "set_custom_status" }); break; + + case "clear_status": + { + let { text, ...status } = client.user?.status ?? {}; + await client.users.editUser({ status }); + } + break; + + case "leave_group": + case "close_dm": + case "leave_server": + case "delete_channel": + case "delete_server": + case "delete_message": + // @ts-expect-error + case "create_invite": openScreen({ id: "special_prompt", type: data.action, target: data.target }); break; + + case "ban_member": + case "kick_member": openScreen({ id: "special_prompt", type: data.action, target: data.target, user: data.user }); break; + + case "create_channel": openScreen({ id: "special_input", type: "create_channel", server: data.server }); break; + + case "open_channel_settings": history.push(`/channel/${data.id}/settings`); break; + case "open_server_channel_settings": history.push(`/server/${data.server}/channel/${data.id}/settings`); break; + case "open_server_settings": history.push(`/server/${data.id}/settings`); break; + } + })().catch(err => { + openScreen({ id: "error", error: takeError(err) }); + }); + } + + return ( + <> + + {({ + user: uid, + channel: cid, + server: sid, + message, + server_list, + queued, + unread, + contextualChannel: cxid + }: ContextMenuData) => { + const forceUpdate = useForceUpdate(); + const elements: Children[] = []; + var lastDivider = false; + + function generateAction( + action: Action, + locale?: string, + disabled?: boolean, + tip?: Children + ) { + lastDivider = false; + elements.push( + + + { tip &&
+ { tip } +
} +
+ ); + } + + function pushDivider() { + if (lastDivider || elements.length === 0) return; + lastDivider = true; + elements.push(); + } + + if (server_list) { + let permissions = useServerPermission(server_list, forceUpdate); + if (permissions & ServerPermission.ManageChannels) generateAction({ action: 'create_channel', server: server_list }); + if (permissions & ServerPermission.ManageServer) generateAction({ action: 'open_server_settings', id: server_list }); + + return elements; + } + + if (document.getSelection()?.toString().length ?? 0 > 0) { + generateAction({ action: "copy_selection" }, undefined, undefined, ); + pushDivider(); + } + + const channel = useChannel(cid, forceUpdate); + const contextualChannel = useChannel(cxid, forceUpdate); + const targetChannel = channel ?? contextualChannel; + + const user = useUser(uid, forceUpdate); + const server = useServer(targetChannel?.channel_type === 'TextChannel' ? targetChannel.server : sid, forceUpdate); + + const channelPermissions = targetChannel ? useChannelPermission(targetChannel._id, forceUpdate) : 0; + const serverPermissions = server ? useServerPermission(server._id, forceUpdate) : ( + targetChannel?.channel_type === 'TextChannel' ? useServerPermission(targetChannel.server, forceUpdate) : 0 + ); + const userPermissions = user ? useUserPermission(user._id, forceUpdate) : 0; + + if (channel && unread) { + generateAction( + { action: "mark_as_read", channel }, + undefined, + true + ); + } + + if (contextualChannel) { + if (user && user._id !== userId) { + generateAction({ + action: "mention", + user: user._id + }); + + pushDivider(); + } + } + + if (user) { + let actions: string[]; + switch (user.relationship) { + case Users.Relationship.User: actions = []; break; + case Users.Relationship.Friend: + actions = [ + "remove_friend", + "block_user" + ]; + break; + case Users.Relationship.Incoming: + actions = ["add_friend", "block_user"]; + break; + case Users.Relationship.Outgoing: + actions = ["cancel_friend", "block_user"]; + break; + case Users.Relationship.Blocked: + actions = ["unblock_user"]; + break; + case Users.Relationship.BlockedOther: + actions = ["block_user"]; + break; + case Users.Relationship.None: + default: + actions = ["add_friend", "block_user"]; + } + + if (userPermissions & UserPermission.ViewProfile) { + generateAction({ action: 'view_profile', user: user._id }); + } + + if (user._id !== userId && userPermissions & UserPermission.SendMessage) { + generateAction({ action: 'message_user', user: user._id }); + } + + for (const action of actions) { + generateAction({ + action: action as any, + user: user._id + }); + } + } + + if (contextualChannel) { + if (contextualChannel.channel_type === "Group" && uid) { + if ( + contextualChannel.owner === userId && + userId !== uid + ) { + generateAction({ + action: "remove_member", + channel: contextualChannel._id, + user: uid + }); + } + } + + if (server && uid && userId !== uid && uid !== server.owner) { + if (serverPermissions & ServerPermission.KickMembers) + generateAction({ action: "kick_member", target: server, user: uid }); + + if (serverPermissions & ServerPermission.BanMembers) + generateAction({ action: "ban_member", target: server, user: uid }); + } + } + + if (queued) { + generateAction({ + action: "retry_message", + message: queued + }); + + generateAction({ + action: "cancel_message", + message: queued + }); + } + + if (message && !queued) { + if ( + typeof message.content === "string" && + message.content.length > 0 + ) { + generateAction({ + action: "quote_message", + content: message.content + }); + generateAction({ + action: "copy_text", + content: message.content + }); + } + + if (message.author === userId) { + generateAction({ + action: "edit_message", + id: message._id + }); + } + + if (message.author === userId || + channelPermissions & ChannelPermission.ManageMessages) { + generateAction({ + action: "delete_message", + target: message + }); + } + + if (message.attachments) { + pushDivider(); + const { metadata } = message.attachments[0]; + const { type } = metadata; + + generateAction( + { + action: "open_file", + attachment: message.attachments[0] + }, + type === "Image" + ? "open_image" + : type === "Video" + ? "open_video" + : "open_file" + ); + + generateAction( + { + action: "save_file", + attachment: message.attachments[0] + }, + type === "Image" + ? "save_image" + : type === "Video" + ? "save_video" + : "save_file" + ); + + generateAction( + { + action: "copy_file_link", + attachment: message.attachments[0] + }, + "copy_link" + ); + } + + if (document.activeElement?.tagName === "A") { + let link = document.activeElement.getAttribute( + "href" + ); + if (link) { + pushDivider(); + generateAction({ action: "open_link", link }); + generateAction({ action: "copy_link", link }); + } + } + } + + let id = server?._id ?? channel?._id ?? user?._id ?? message?._id; + if (id) { + pushDivider(); + + if (channel) { + switch (channel.channel_type) { + case 'Group': + // ! generateAction({ action: "create_invite", target: channel }); FIXME: add support for group invites + generateAction({ action: "open_channel_settings", id: channel._id }, "open_group_settings"); + generateAction({ action: "leave_group", target: channel }, "leave_group"); + break; + case 'DirectMessage': + generateAction({ action: "close_dm", target: channel }); + break; + case 'TextChannel': + // ! FIXME: add permission for invites + generateAction({ action: "create_invite", target: channel }); + + if (serverPermissions & ServerPermission.ManageServer) + generateAction({ action: "open_server_channel_settings", server: channel.server, id: channel._id }, "open_channel_settings"); + + if (serverPermissions & ServerPermission.ManageChannels) + generateAction({ action: "delete_channel", target: channel }); + + break; + } + } + + if (sid && server) { + if (serverPermissions & ServerPermission.ManageServer) + generateAction({ action: "open_server_settings", id: server._id }, "open_server_settings"); + + if (userId === server.owner) { + generateAction({ action: "delete_server", target: server }, "delete_server"); + } else { + generateAction({ action: "leave_server", target: server }, "leave_server"); + } + } + + generateAction( + { action: "copy_id", id }, + sid ? "copy_sid" : + cid ? "copy_cid" : + message ? "copy_mid" : "copy_uid" + ); + } + + return elements; + }} +
+ + @{client.user?.username} + + +
+ + + +
+ + + +
+ + + +
+ + + + + + + {client.user?.status?.text && ( + + + + )} + + + ); +} + +export default connectState( + ContextMenus, + () => { + return {}; + }, + true +); diff --git a/src/lib/PaintCounter.tsx b/src/lib/PaintCounter.tsx index 668ce682..487a015c 100644 --- a/src/lib/PaintCounter.tsx +++ b/src/lib/PaintCounter.tsx @@ -2,8 +2,8 @@ import { useState } from "preact/hooks"; const counts: { [key: string]: number } = {}; -export default function PaintCounter({ small }: { small?: boolean }) { - if (import.meta.env.PROD) return null; +export default function PaintCounter({ small, always }: { small?: boolean, always?: boolean }) { + if (import.meta.env.PROD && !always) return null; const [uniqueId] = useState('' + Math.random()); const count = counts[uniqueId] ?? 0; diff --git a/src/pages/App.tsx b/src/pages/App.tsx index 4648649f..f0ff58d3 100644 --- a/src/pages/App.tsx +++ b/src/pages/App.tsx @@ -1,14 +1,17 @@ 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 Popovers from "../context/intermediate/Popovers"; +import ContextMenus from "../lib/ContextMenus"; + import LeftSidebar from "../components/navigation/LeftSidebar"; import RightSidebar from "../components/navigation/RightSidebar"; import Home from './home/Home'; import Friends from "./friends/Friends"; +import Developer from "./developer/Developer"; const Routes = styled.div` min-width: 0; @@ -28,6 +31,10 @@ export default function App() { docked={isTouchscreenDevice ? Docked.None : Docked.Left}> + + + + @@ -37,6 +44,7 @@ export default function App() { + ); diff --git a/src/pages/developer/Developer.tsx b/src/pages/developer/Developer.tsx new file mode 100644 index 00000000..e7a5cbc0 --- /dev/null +++ b/src/pages/developer/Developer.tsx @@ -0,0 +1,39 @@ +import { useContext } from "preact/hooks"; +import Header from "../../components/ui/Header"; +import PaintCounter from "../../lib/PaintCounter"; +import { AppContext } from "../../context/revoltjs/RevoltClient"; +import { useUserPermission } from "../../context/revoltjs/hooks"; + +export default function Developer() { + // const voice = useContext(VoiceContext); + const client = useContext(AppContext); + const userPermission = useUserPermission(client.user!._id); + + return ( +
+
Developer Tab
+
+ +
+
+ User ID: {client.user!._id}
+ Permission against self: {userPermission}
+
+
+ {/* + Voice Status: {VoiceStatus[voice.status]} + +
+ + Voice Room ID: {voice.roomId || "undefined"} + +
+ + Voice Participants: [ + {Array.from(voice.participants.keys()).join(", ")}] + +
*/} +
+
+ ); +} diff --git a/src/pages/home/Home.module.scss b/src/pages/home/Home.module.scss new file mode 100644 index 00000000..1210ee80 --- /dev/null +++ b/src/pages/home/Home.module.scss @@ -0,0 +1,28 @@ +.home { + user-select: none; + + h3 { + margin: 1em 0; + font-size: 48px; + text-align: center; + + img { + height: 36px; + } + } + + ul { + margin: auto; + display: block; + font-size: 18px; + text-align: center; + + li { + list-style: lower-greek; + } + } +} + +[data-light="true"] .home svg { + filter: invert(100%); +} diff --git a/src/pages/home/Home.tsx b/src/pages/home/Home.tsx index 9ccb04f0..78e0348c 100644 --- a/src/pages/home/Home.tsx +++ b/src/pages/home/Home.tsx @@ -1,9 +1,35 @@ -import PaintCounter from "../../lib/PaintCounter"; +import styles from "./Home.module.scss"; +import { Link } from "react-router-dom"; + +import { Text } from "preact-i18n"; +import Header from "../../components/ui/Header"; +// import WideLogo from "../../../../../assets/wide.svg"; export default function Home() { return ( -
- +
+
+

+ {/**/} +

+
    +
  • + Go to your friends list. +
  • +
  • + Give feedback. +
  • +
  • + Join testers server. +
  • +
  • + View{" "} + + source code + + . +
  • +
); } diff --git a/src/styles/_context-menu.scss b/src/styles/_context-menu.scss new file mode 100644 index 00000000..159efac9 --- /dev/null +++ b/src/styles/_context-menu.scss @@ -0,0 +1,58 @@ +.preact-context-menu .context-menu { + z-index: 100; + min-width: 180px; + padding: 6px 8px; + user-select: none; + border-radius: 4px; + color: var(--secondary-foreground); + background: var(--primary-background) !important; + box-shadow: 0px 0px 8px 8px rgba(0, 0, 0, 0.05); + + > span { + gap: 6px; + margin: 2px 0; + display: flex; + padding: 6px 8px; + border-radius: 3px; + font-size: .875rem; + align-items: center; + white-space: nowrap; + + &:not([data-disabled="true"]) { + cursor: pointer; + + &:hover { + background: var(--secondary-background); + } + } + + .tip { + flex-grow: 1; + font-size: .650rem; + text-align: right; + color: var(--tertiary-foreground); + } + } + + .indicator { + width: 8px; + height: 8px; + border-radius: 50%; + + &.online { + background: var(--status-online); + } + + &.idle { + background: var(--status-away); + } + + &.busy { + background: var(--status-busy); + } + + &.invisible { + background: var(--status-invisible); + } + } +} diff --git a/src/styles/index.scss b/src/styles/index.scss index 7869c3d3..4508966e 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -1,3 +1,4 @@ +@import "context-menu"; @import "elements"; @import "fonts"; @import "page";