From 851b696f67759264e42304fefdcbadee8c819815 Mon Sep 17 00:00:00 2001 From: Yuzu Date: Sun, 28 Jun 2026 20:12:39 +0700 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20Add=20friend=20functionalit?= =?UTF-8?q?y=20and=20refactor=20user=20context=20menus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/ipc/handlers.ts | 1 + src/main/vrchat/friendsService.ts | 5 + .../src/features/friends/FriendsSidebar.tsx | 105 +------------- .../src/features/friends/useUserMenu.tsx | 131 ++++++++++++++++++ .../src/features/profile/ProfileView.tsx | 106 +++++++++++++- .../src/features/search/SearchView.tsx | 52 ++++++- src/renderer/src/lib/api.ts | 1 + src/renderer/src/lib/i18n/locales/en/nav.json | 3 + src/renderer/src/lib/i18n/locales/ja/nav.json | 3 + src/renderer/src/lib/i18n/locales/th/nav.json | 3 + src/shared/ipc.ts | 1 + 11 files changed, 306 insertions(+), 105 deletions(-) create mode 100644 src/renderer/src/features/friends/useUserMenu.tsx diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index e10ec2c..d132553 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -37,6 +37,7 @@ const handlers = { "user:search": (query) => guard(() => users.searchUsers(query)), "friends:list": () => guard(() => friends.listFriends()), + "friends:add": (userId) => guard(() => friends.addFriend(userId)), "friends:unfriend": (userId) => guard(() => friends.unfriend(userId)), "friends:invite": (p) => guard(() => friends.inviteUser(p.userId, p.instanceLocation)), "friends:requestInvite": (userId) => guard(() => friends.requestInvite(userId)), diff --git a/src/main/vrchat/friendsService.ts b/src/main/vrchat/friendsService.ts index 8f0f569..7587a8b 100644 --- a/src/main/vrchat/friendsService.ts +++ b/src/main/vrchat/friendsService.ts @@ -37,6 +37,11 @@ export async function listFriends(): Promise { }); } +export async function addFriend(userId: string): Promise { + const vrc = requireActiveClient(); + await vrc.friend({ path: { userId }, throwOnError: true }); +} + export async function unfriend(userId: string): Promise { const vrc = requireActiveClient(); await vrc.unfriend({ path: { userId }, throwOnError: true }); diff --git a/src/renderer/src/features/friends/FriendsSidebar.tsx b/src/renderer/src/features/friends/FriendsSidebar.tsx index f6116c0..490daf0 100644 --- a/src/renderer/src/features/friends/FriendsSidebar.tsx +++ b/src/renderer/src/features/friends/FriendsSidebar.tsx @@ -1,14 +1,12 @@ import { useMemo, useState, useCallback, type ReactNode } from "react"; -import { ChevronDown, ChevronRight, UserMinus, Send, LogIn } from "lucide-react"; -import { Trans } from "react-i18next"; +import { ChevronDown, ChevronRight } from "lucide-react"; import { isOnline, locationLabel, statusMeta } from "../../lib/vrchat"; -import { Badge, PresenceAvatar, Modal, ContextMenu } from "../../components/ui"; -import type { ContextMenuEntry } from "../../components/ui"; +import { Badge, PresenceAvatar, ContextMenu } 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"; +import { useUserMenu } from "./useUserMenu"; interface InstanceSection { key: string; @@ -23,11 +21,6 @@ interface ContextMenuState { friend: UserProfile; } -interface UnfriendState { - friend: UserProfile; - loading: boolean; -} - export function FriendsSidebar({ onOpen }: { onOpen: (id: string) => void }) { const t = useT(); const friends = useFriends(); @@ -96,7 +89,7 @@ export function FriendsSidebar({ onOpen }: { onOpen: (id: string) => void }) { self && isOnline(self) && !instances.some((i) => i.members.some((m) => m.id === selfId)); const [contextMenu, setContextMenu] = useState(null); - const [unfriendState, setUnfriendState] = useState(null); + const { buildItems, modal } = useUserMenu(); const openContextMenu = useCallback((e: React.MouseEvent, friend: UserProfile) => { e.preventDefault(); @@ -104,77 +97,6 @@ export function FriendsSidebar({ onOpen }: { onOpen: (id: string) => void }) { 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 ( ); } diff --git a/src/renderer/src/features/friends/useUserMenu.tsx b/src/renderer/src/features/friends/useUserMenu.tsx new file mode 100644 index 0000000..d1e7e67 --- /dev/null +++ b/src/renderer/src/features/friends/useUserMenu.tsx @@ -0,0 +1,131 @@ +import { useState } from "react"; +import { UserMinus, UserPlus, Send, LogIn, User } from "lucide-react"; +import { Trans } from "react-i18next"; +import { Modal } from "../../components/ui"; +import type { ContextMenuEntry } from "../../components/ui"; +import { useSelf } from "../../store/social"; +import { parseLocation, type UserProfile } from "../../../../shared/types/user"; +import { api, errorMessage } from "../../lib/api"; +import { useT } from "../../lib/i18n"; + +interface UnfriendState { + friend: UserProfile; + loading: boolean; +} + +interface BuildOptions { + includeProfile?: boolean; + onOpen?: (id: string) => void; +} + +export function useUserMenu() { + const t = useT(); + const self = useSelf(); + const [unfriend, setUnfriend] = useState(null); + + const buildItems = (user: UserProfile, opts: BuildOptions = {}): ContextMenuEntry[] => { + const items: ContextMenuEntry[] = []; + + if (opts.includeProfile && opts.onOpen) { + items.push({ + label: t("nav:friends.contextMenu.viewProfile"), + icon: , + onClick: () => opts.onOpen!(user.id), + }); + } + + if (user.isSelf) return items; + + if (!user.isFriend) { + items.push({ + label: t("nav:friends.contextMenu.addFriend"), + icon: , + onClick: () => void api.friends.add(user.id), + }); + return items; + } + + const selfLocation = self ? parseLocation(self.location) : null; + const friendLocation = parseLocation(user.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; + + if (canInvite) { + items.push({ + label: t("nav:friends.contextMenu.invite"), + icon: , + onClick: async () => { + try { + await api.friends.invite(user.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(user.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: () => setUnfriend({ friend: user, loading: false }), + }); + + return items; + }; + + const confirmUnfriend = async () => { + if (!unfriend) return; + setUnfriend((s) => s && { ...s, loading: true }); + try { + await api.friends.unfriend(unfriend.friend.id); + setUnfriend(null); + } catch (err) { + console.error("Unfriend failed:", errorMessage(err, "Failed to unfriend")); + setUnfriend((s) => s && { ...s, loading: false }); + } + }; + + const modal = ( + setUnfriend(null)} + title={t("nav:friends.unfriendModal.title")} + danger + icon={} + confirmLabel={t("nav:friends.unfriendModal.confirm")} + onConfirm={confirmUnfriend} + confirmLoading={unfriend?.loading} + > + ]} + /> + + ); + + return { buildItems, modal }; +} diff --git a/src/renderer/src/features/profile/ProfileView.tsx b/src/renderer/src/features/profile/ProfileView.tsx index 1203252..01107b9 100644 --- a/src/renderer/src/features/profile/ProfileView.tsx +++ b/src/renderer/src/features/profile/ProfileView.tsx @@ -1,6 +1,22 @@ -import { useState } from "react"; +import { useCallback, useState } from "react"; +import { Check, MoreHorizontal, UserPlus } from "lucide-react"; import { type UserProfile } from "../../../../shared/types/user"; -import { Avatar, Banner, Fact, LinkPill, Section, Skeleton, Tabs, Tag } from "../../components/ui"; +import { api, errorMessage } from "../../lib/api"; +import { useT } from "../../lib/i18n"; +import { + Avatar, + Banner, + Button, + ContextMenu, + Fact, + IconButton, + LinkPill, + Section, + Skeleton, + Tabs, + Tag, +} from "../../components/ui"; +import { useUserMenu } from "../friends/useUserMenu"; import { avatarOf, bannerOf, @@ -29,7 +45,12 @@ export function ProfileView({ target }: { target: "me" | string }) { } function ProfileCard({ profile }: { profile: UserProfile }) { + const t = useT(); const [tab, setTab] = useState("overview"); + const [menu, setMenu] = useState<{ x: number; y: number } | null>(null); + const canAdd = !profile.isSelf && !profile.isFriend; + const addFriend = useAddFriend(profile.id); + const { buildItems, modal } = useUserMenu(); const trust = trustMeta[profile.trustRank]; const online = isOnline(profile); const status = statusMeta[profile.status]; @@ -59,7 +80,7 @@ function ProfileCard({ profile }: { profile: UserProfile }) { /> -
+

{profile.displayName}

{profile.isSelf ? You : null} @@ -96,6 +117,24 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
) : null}
+ + {canAdd ? ( +
+ +
+ ) : profile.isFriend && !profile.isSelf ? ( +
+ { + const r = e.currentTarget.getBoundingClientRect(); + setMenu({ x: r.right, y: r.bottom }); + }} + > + + +
+ ) : null}
@@ -252,10 +291,71 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
) : null} + + {menu ? ( + setMenu(null)} + /> + ) : null} + + {modal} ); } +type AddFriendState = "idle" | "sending" | "sent"; + +interface AddFriend { + state: AddFriendState; + error?: string; + send: () => void; +} + +function useAddFriend(userId: string): AddFriend { + const [state, setState] = useState("idle"); + const [error, setError] = useState(); + + const send = useCallback(() => { + setState("sending"); + setError(undefined); + api.friends + .add(userId) + .then(() => setState("sent")) + .catch((err) => { + setError(errorMessage(err, "Couldn't send friend request.")); + setState("idle"); + }); + }, [userId]); + + return { state, error, send }; +} + +function AddFriendButton({ add }: { add: AddFriend }) { + const t = useT(); + + if (add.state === "sent") { + return ( + + ); + } + + return ( +
+ + {add.error ? {add.error} : null} +
+ ); +} + type TabId = "overview" | "worlds" | "favorites" | "groups"; function ProfileSkeleton() { diff --git a/src/renderer/src/features/search/SearchView.tsx b/src/renderer/src/features/search/SearchView.tsx index 23354e6..6496fb4 100644 --- a/src/renderer/src/features/search/SearchView.tsx +++ b/src/renderer/src/features/search/SearchView.tsx @@ -4,19 +4,37 @@ import type { UserProfile } from "../../../../shared/types/user"; import type { World } from "../../../../shared/types/world"; import { api } from "../../lib/api"; import { compactNumber } from "../../lib/format"; -import { Avatar, Button, Card, Field, HoverImage, Tabs, Tag } from "../../components/ui"; +import { + Avatar, + Button, + Card, + ContextMenu, + Field, + HoverImage, + Tabs, + Tag, +} from "../../components/ui"; import { useNav } from "../navigation/NavContext"; +import { useUserMenu } from "../friends/useUserMenu"; import { avatarOf, trustMeta } from "../../lib/vrchat"; +interface UserMenu { + x: number; + y: number; + user: UserProfile; +} + type SearchTab = "users" | "worlds"; export function SearchView() { const { openUser, openWorld } = useNav(); + const { buildItems, modal } = useUserMenu(); const [tab, setTab] = useState("users"); const [query, setQuery] = useState(""); const [busy, setBusy] = useState(false); const [users, setUsers] = useState(null); const [worlds, setWorlds] = useState(null); + const [menu, setMenu] = useState(null); async function submit(e: FormEvent) { e.preventDefault(); @@ -77,7 +95,15 @@ export function SearchView() { ) : tab === "users" ? (
{(results as UserProfile[]).map((u) => ( - openUser(u.id)} /> + openUser(u.id)} + onContextMenu={(e) => { + e.preventDefault(); + setMenu({ x: e.clientX, y: e.clientY, user: u }); + }} + /> ))}
) : ( @@ -88,14 +114,34 @@ export function SearchView() { )} + + {menu ? ( + setMenu(null)} + /> + ) : null} + + {modal} ); } -function UserResult({ user, onOpen }: { user: UserProfile; onOpen: () => void }) { +function UserResult({ + user, + onOpen, + onContextMenu, +}: { + user: UserProfile; + onOpen: () => void; + onContextMenu: (e: React.MouseEvent) => void; +}) { return (