diff --git a/src/main/game/unity.ts b/src/main/game/unity.ts index d72ebe1..d06fd6f 100644 --- a/src/main/game/unity.ts +++ b/src/main/game/unity.ts @@ -29,7 +29,11 @@ function hubBinaries(): string[] { case "darwin": return ["/Applications/Unity Hub.app"]; default: - return ["/usr/bin/unityhub", "/opt/unityhub/unityhub", join(home, ".local", "bin", "unityhub")]; + return [ + "/usr/bin/unityhub", + "/opt/unityhub/unityhub", + join(home, ".local", "bin", "unityhub"), + ]; } } diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index fef3cdb..e10ec2c 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -37,6 +37,9 @@ const handlers = { "user:search": (query) => guard(() => users.searchUsers(query)), "friends:list": () => guard(() => friends.listFriends()), + "friends:unfriend": (userId) => guard(() => friends.unfriend(userId)), + "friends:invite": (p) => guard(() => friends.inviteUser(p.userId, p.instanceLocation)), + "friends:requestInvite": (userId) => guard(() => friends.requestInvite(userId)), "world:byUser": (userId) => guard(async () => { diff --git a/src/main/vrchat/friendsService.ts b/src/main/vrchat/friendsService.ts index 4c83e40..8f0f569 100644 --- a/src/main/vrchat/friendsService.ts +++ b/src/main/vrchat/friendsService.ts @@ -3,6 +3,7 @@ import { requireActiveClient } from "./client"; import { toUserProfile } from "./mappers"; import { currentUser, userCache } from "./userService"; import { cacheKeys, policies } from "../cache/policies"; +import { entityStore } from "../store/entityStore"; const order: Record = { "join me": 0, @@ -35,3 +36,23 @@ export async function listFriends(): Promise { ); }); } + +export async function unfriend(userId: string): Promise { + const vrc = requireActiveClient(); + await vrc.unfriend({ path: { userId }, throwOnError: true }); + entityStore.removeFriend(userId); +} + +export async function inviteUser(userId: string, instanceLocation: string): Promise { + const vrc = requireActiveClient(); + await vrc.inviteUser({ + path: { userId }, + body: { instanceId: instanceLocation }, + throwOnError: true, + }); +} + +export async function requestInvite(userId: string): Promise { + const vrc = requireActiveClient(); + await vrc.requestInvite({ path: { userId }, throwOnError: true }); +} diff --git a/src/renderer/src/components/ui/ContextMenu.tsx b/src/renderer/src/components/ui/ContextMenu.tsx new file mode 100644 index 0000000..854e5f9 --- /dev/null +++ b/src/renderer/src/components/ui/ContextMenu.tsx @@ -0,0 +1,92 @@ +import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"; +import { createPortal } from "react-dom"; + +export interface ContextMenuItem { + label: string; + icon?: ReactNode; + onClick: () => void; + danger?: boolean; + disabled?: boolean; +} + +export interface ContextMenuSeparator { + separator: true; +} + +export type ContextMenuEntry = ContextMenuItem | ContextMenuSeparator; + +function isSeparator(entry: ContextMenuEntry): entry is ContextMenuSeparator { + return "separator" in entry; +} + +interface ContextMenuProps { + x: number; + y: number; + items: ContextMenuEntry[]; + onClose: () => void; +} + +export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) { + const menuRef = useRef(null); + const [pos, setPos] = useState({ left: x, top: y }); + + useLayoutEffect(() => { + const el = menuRef.current; + if (!el) return; + const { width, height } = el.getBoundingClientRect(); + setPos({ + left: Math.min(x, window.innerWidth - width - 8), + top: Math.min(y, window.innerHeight - height - 8), + }); + }, [x, y]); + + useEffect(() => { + const onDown = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) onClose(); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("mousedown", onDown); + window.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDown); + window.removeEventListener("keydown", onKey); + }; + }, [onClose]); + + return createPortal( +
+
+ {items.map((entry, i) => + isSeparator(entry) ? ( +
+ ) : ( + + ), + )} +
+
, + document.body, + ); +} diff --git a/src/renderer/src/components/ui/index.ts b/src/renderer/src/components/ui/index.ts index 30f3092..d24f88c 100644 --- a/src/renderer/src/components/ui/index.ts +++ b/src/renderer/src/components/ui/index.ts @@ -21,4 +21,6 @@ export { LinkPill } from "./LinkPill"; export { CodeBlock } from "./CodeBlock"; export { Card } from "./Card"; export { HoverImage } from "./HoverImage"; +export { ContextMenu } from "./ContextMenu"; +export type { ContextMenuEntry, ContextMenuItem } from "./ContextMenu"; export { LABEL_HEADING } from "./styles"; diff --git a/src/renderer/src/features/friends/FriendsSidebar.tsx b/src/renderer/src/features/friends/FriendsSidebar.tsx index b422404..f6116c0 100644 --- a/src/renderer/src/features/friends/FriendsSidebar.tsx +++ b/src/renderer/src/features/friends/FriendsSidebar.tsx @@ -1,10 +1,14 @@ -import { useMemo, useState, type ReactNode } from "react"; -import { ChevronDown, ChevronRight } from "lucide-react"; +import { useMemo, useState, useCallback, type ReactNode } from "react"; +import { ChevronDown, ChevronRight, UserMinus, Send, LogIn } from "lucide-react"; +import { Trans } from "react-i18next"; import { isOnline, locationLabel, statusMeta } from "../../lib/vrchat"; -import { Badge, PresenceAvatar } from "../../components/ui"; +import { Badge, PresenceAvatar, Modal, ContextMenu } from "../../components/ui"; +import type { ContextMenuEntry } from "../../components/ui"; import { useFriends, useSelf } from "../../store/social"; import { useWorldName } from "../../store/worlds"; import { parseLocation, type UserProfile } from "../../../../shared/types/user"; +import { api, errorMessage } from "../../lib/api"; +import { useT } from "../../lib/i18n"; interface InstanceSection { key: string; @@ -13,7 +17,19 @@ interface InstanceSection { members: UserProfile[]; } +interface ContextMenuState { + x: number; + y: number; + friend: UserProfile; +} + +interface UnfriendState { + friend: UserProfile; + loading: boolean; +} + export function FriendsSidebar({ onOpen }: { onOpen: (id: string) => void }) { + const t = useT(); const friends = useFriends(); const self = useSelf(); const selfId = self?.id; @@ -79,12 +95,92 @@ export function FriendsSidebar({ onOpen }: { onOpen: (id: string) => void }) { const selfAlone = self && isOnline(self) && !instances.some((i) => i.members.some((m) => m.id === selfId)); + const [contextMenu, setContextMenu] = useState(null); + const [unfriendState, setUnfriendState] = useState(null); + + const openContextMenu = useCallback((e: React.MouseEvent, friend: UserProfile) => { + e.preventDefault(); + e.stopPropagation(); + setContextMenu({ x: e.clientX, y: e.clientY, friend }); + }, []); + + const buildMenuItems = (friend: UserProfile): ContextMenuEntry[] => { + const selfLocation = self ? parseLocation(self.location) : null; + const friendLocation = parseLocation(friend.location); + const selfInstanceKey = selfLocation + ? `${selfLocation.worldId}:${selfLocation.instanceId}` + : null; + const friendInstanceKey = friendLocation + ? `${friendLocation.worldId}:${friendLocation.instanceId}` + : null; + + const canInvite = selfLocation !== null && self?.location != null; + const canRequestInvite = friendInstanceKey !== null && selfInstanceKey !== friendInstanceKey; + + const items: ContextMenuEntry[] = [ + { + label: t("nav:friends.contextMenu.viewProfile"), + onClick: () => onOpen(friend.id), + }, + ]; + + if (canInvite) { + items.push({ + label: t("nav:friends.contextMenu.invite"), + icon: , + onClick: async () => { + try { + await api.friends.invite(friend.id, self!.location!); + } catch (err) { + console.error("Invite failed:", errorMessage(err, "Failed to send invite")); + } + }, + }); + } + + if (canRequestInvite) { + items.push({ + label: t("nav:friends.contextMenu.requestInvite"), + icon: , + onClick: async () => { + try { + await api.friends.requestInvite(friend.id); + } catch (err) { + console.error("Request invite failed:", errorMessage(err, "Failed to request invite")); + } + }, + }); + } + + items.push({ separator: true }); + items.push({ + label: t("nav:friends.contextMenu.unfriend"), + icon: , + danger: true, + onClick: () => setUnfriendState({ friend, loading: false }), + }); + + return items; + }; + + const handleUnfriendConfirm = async () => { + if (!unfriendState) return; + setUnfriendState((s) => s && { ...s, loading: true }); + try { + await api.friends.unfriend(unfriendState.friend.id); + setUnfriendState(null); + } catch (err) { + console.error("Unfriend failed:", errorMessage(err, "Failed to unfriend")); + setUnfriendState((s) => s && { ...s, loading: false }); + } + }; + return (
+ + {contextMenu ? ( + setContextMenu(null)} + /> + ) : null} + + setUnfriendState(null)} + title={t("nav:friends.unfriendModal.title")} + danger + icon={} + confirmLabel={t("nav:friends.unfriendModal.confirm")} + onConfirm={handleUnfriendConfirm} + confirmLoading={unfriendState?.loading} + > + ]} + /> + ); } @@ -194,10 +328,11 @@ function Section({ } function InstanceTitle({ worldId, instanceId }: { worldId: string; instanceId: string }) { + const t = useT(); const worldName = useWorldName(worldId); return ( <> - {worldName ?? "In a world"} + {worldName ?? t("nav:friends.inAWorld")} (#{instanceId}) ); @@ -210,6 +345,7 @@ function FriendRow({ isSelf, selfChrome, hideLocation, + onContextMenu, }: { friend: UserProfile; onOpen: (id: string) => void; @@ -217,17 +353,20 @@ function FriendRow({ isSelf?: boolean; selfChrome?: boolean; hideLocation?: boolean; + onContextMenu?: (e: React.MouseEvent, friend: UserProfile) => void; }) { + const t = useT(); const status = statusMeta[isOnline(friend) || isSelf ? friend.status : "offline"]; const sub = friend.statusDescription || status.label; const parsed = parseLocation(friend.location); const worldName = useWorldName(parsed?.worldId); const location = parsed - ? `${worldName ? `in ${worldName}` : "In a world"} (#${parsed.instanceId})` + ? `${worldName ? `in ${worldName}` : t("nav:friends.inAWorld")} (#${parsed.instanceId})` : locationLabel(friend.location); return (