feat: Add friend functionality and refactor user context menus

This commit is contained in:
2026-06-28 20:12:39 +07:00
parent e10e002593
commit 851b696f67
11 changed files with 306 additions and 105 deletions
+1
View File
@@ -37,6 +37,7 @@ const handlers = {
"user:search": (query) => guard(() => users.searchUsers(query)), "user:search": (query) => guard(() => users.searchUsers(query)),
"friends:list": () => guard(() => friends.listFriends()), "friends:list": () => guard(() => friends.listFriends()),
"friends:add": (userId) => guard(() => friends.addFriend(userId)),
"friends:unfriend": (userId) => guard(() => friends.unfriend(userId)), "friends:unfriend": (userId) => guard(() => friends.unfriend(userId)),
"friends:invite": (p) => guard(() => friends.inviteUser(p.userId, p.instanceLocation)), "friends:invite": (p) => guard(() => friends.inviteUser(p.userId, p.instanceLocation)),
"friends:requestInvite": (userId) => guard(() => friends.requestInvite(userId)), "friends:requestInvite": (userId) => guard(() => friends.requestInvite(userId)),
+5
View File
@@ -37,6 +37,11 @@ export async function listFriends(): Promise<UserProfile[]> {
}); });
} }
export async function addFriend(userId: string): Promise<void> {
const vrc = requireActiveClient();
await vrc.friend({ path: { userId }, throwOnError: true });
}
export async function unfriend(userId: string): Promise<void> { export async function unfriend(userId: string): Promise<void> {
const vrc = requireActiveClient(); const vrc = requireActiveClient();
await vrc.unfriend({ path: { userId }, throwOnError: true }); await vrc.unfriend({ path: { userId }, throwOnError: true });
@@ -1,14 +1,12 @@
import { useMemo, useState, useCallback, type ReactNode } from "react"; import { useMemo, useState, useCallback, type ReactNode } from "react";
import { ChevronDown, ChevronRight, UserMinus, Send, LogIn } from "lucide-react"; import { ChevronDown, ChevronRight } from "lucide-react";
import { Trans } from "react-i18next";
import { isOnline, locationLabel, statusMeta } from "../../lib/vrchat"; import { isOnline, locationLabel, statusMeta } from "../../lib/vrchat";
import { Badge, PresenceAvatar, Modal, ContextMenu } from "../../components/ui"; import { Badge, PresenceAvatar, ContextMenu } from "../../components/ui";
import type { ContextMenuEntry } from "../../components/ui";
import { useFriends, useSelf } from "../../store/social"; import { useFriends, useSelf } from "../../store/social";
import { useWorldName } from "../../store/worlds"; import { useWorldName } from "../../store/worlds";
import { parseLocation, type UserProfile } from "../../../../shared/types/user"; import { parseLocation, type UserProfile } from "../../../../shared/types/user";
import { api, errorMessage } from "../../lib/api";
import { useT } from "../../lib/i18n"; import { useT } from "../../lib/i18n";
import { useUserMenu } from "./useUserMenu";
interface InstanceSection { interface InstanceSection {
key: string; key: string;
@@ -23,11 +21,6 @@ interface ContextMenuState {
friend: UserProfile; friend: UserProfile;
} }
interface UnfriendState {
friend: UserProfile;
loading: boolean;
}
export function FriendsSidebar({ onOpen }: { onOpen: (id: string) => void }) { export function FriendsSidebar({ onOpen }: { onOpen: (id: string) => void }) {
const t = useT(); const t = useT();
const friends = useFriends(); 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)); self && isOnline(self) && !instances.some((i) => i.members.some((m) => m.id === selfId));
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null); const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const [unfriendState, setUnfriendState] = useState<UnfriendState | null>(null); const { buildItems, modal } = useUserMenu();
const openContextMenu = useCallback((e: React.MouseEvent, friend: UserProfile) => { const openContextMenu = useCallback((e: React.MouseEvent, friend: UserProfile) => {
e.preventDefault(); e.preventDefault();
@@ -104,77 +97,6 @@ export function FriendsSidebar({ onOpen }: { onOpen: (id: string) => void }) {
setContextMenu({ x: e.clientX, y: e.clientY, friend }); 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: <Send size={14} />,
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: <LogIn size={14} />,
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: <UserMinus size={14} />,
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 ( return (
<aside className="friendsbar flex h-full flex-col overflow-hidden border-l border-border bg-surface"> <aside className="friendsbar flex h-full flex-col overflow-hidden border-l border-border bg-surface">
<header className="friendsbar__header flex items-baseline gap-2 border-b border-border px-4 py-3.5 text-[13px] font-bold"> <header className="friendsbar__header flex items-baseline gap-2 border-b border-border px-4 py-3.5 text-[13px] font-bold">
@@ -256,27 +178,12 @@ export function FriendsSidebar({ onOpen }: { onOpen: (id: string) => void }) {
<ContextMenu <ContextMenu
x={contextMenu.x} x={contextMenu.x}
y={contextMenu.y} y={contextMenu.y}
items={buildMenuItems(contextMenu.friend)} items={buildItems(contextMenu.friend, { includeProfile: true, onOpen })}
onClose={() => setContextMenu(null)} onClose={() => setContextMenu(null)}
/> />
) : null} ) : null}
<Modal {modal}
open={unfriendState !== null}
onClose={() => setUnfriendState(null)}
title={t("nav:friends.unfriendModal.title")}
danger
icon={<UserMinus size={18} />}
confirmLabel={t("nav:friends.unfriendModal.confirm")}
onConfirm={handleUnfriendConfirm}
confirmLoading={unfriendState?.loading}
>
<Trans
i18nKey="nav:friends.unfriendModal.body"
values={{ name: unfriendState?.friend.displayName }}
components={[<strong className="font-semibold text-text" />]}
/>
</Modal>
</aside> </aside>
); );
} }
@@ -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<UnfriendState | null>(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: <User size={14} />,
onClick: () => opts.onOpen!(user.id),
});
}
if (user.isSelf) return items;
if (!user.isFriend) {
items.push({
label: t("nav:friends.contextMenu.addFriend"),
icon: <UserPlus size={14} />,
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: <Send size={14} />,
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: <LogIn size={14} />,
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: <UserMinus size={14} />,
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 = (
<Modal
open={unfriend !== null}
onClose={() => setUnfriend(null)}
title={t("nav:friends.unfriendModal.title")}
danger
icon={<UserMinus size={18} />}
confirmLabel={t("nav:friends.unfriendModal.confirm")}
onConfirm={confirmUnfriend}
confirmLoading={unfriend?.loading}
>
<Trans
i18nKey="nav:friends.unfriendModal.body"
values={{ name: unfriend?.friend.displayName }}
components={[<strong className="font-semibold text-text" />]}
/>
</Modal>
);
return { buildItems, modal };
}
@@ -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 { 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 { import {
avatarOf, avatarOf,
bannerOf, bannerOf,
@@ -29,7 +45,12 @@ export function ProfileView({ target }: { target: "me" | string }) {
} }
function ProfileCard({ profile }: { profile: UserProfile }) { function ProfileCard({ profile }: { profile: UserProfile }) {
const t = useT();
const [tab, setTab] = useState<TabId>("overview"); const [tab, setTab] = useState<TabId>("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 trust = trustMeta[profile.trustRank];
const online = isOnline(profile); const online = isOnline(profile);
const status = statusMeta[profile.status]; const status = statusMeta[profile.status];
@@ -59,7 +80,7 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
/> />
</div> </div>
<div className="min-w-0 pb-2"> <div className="min-w-0 flex-1 pb-2">
<div className="flex flex-wrap items-center gap-2.5"> <div className="flex flex-wrap items-center gap-2.5">
<h2 className="text-[32px] font-bold tracking-[-0.6px]">{profile.displayName}</h2> <h2 className="text-[32px] font-bold tracking-[-0.6px]">{profile.displayName}</h2>
{profile.isSelf ? <Tag color="var(--accent)">You</Tag> : null} {profile.isSelf ? <Tag color="var(--accent)">You</Tag> : null}
@@ -96,6 +117,24 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
</div> </div>
) : null} ) : null}
</div> </div>
{canAdd ? (
<div className="shrink-0 pb-2">
<AddFriendButton add={addFriend} />
</div>
) : profile.isFriend && !profile.isSelf ? (
<div className="shrink-0 pb-2">
<IconButton
aria-label={t("nav:friends.contextMenu.actions")}
onClick={(e) => {
const r = e.currentTarget.getBoundingClientRect();
setMenu({ x: r.right, y: r.bottom });
}}
>
<MoreHorizontal size={18} />
</IconButton>
</div>
) : null}
</div> </div>
<div className={`${COL_WIDE} mt-6 flex flex-col gap-5`}> <div className={`${COL_WIDE} mt-6 flex flex-col gap-5`}>
@@ -252,10 +291,71 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
</div> </div>
) : null} ) : null}
</div> </div>
{menu ? (
<ContextMenu
x={menu.x}
y={menu.y}
items={buildItems(profile)}
onClose={() => setMenu(null)}
/>
) : null}
{modal}
</article> </article>
); );
} }
type AddFriendState = "idle" | "sending" | "sent";
interface AddFriend {
state: AddFriendState;
error?: string;
send: () => void;
}
function useAddFriend(userId: string): AddFriend {
const [state, setState] = useState<AddFriendState>("idle");
const [error, setError] = useState<string>();
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 (
<Button variant="ghost" disabled>
<Check size={16} />
{t("nav:friends.contextMenu.requestSent")}
</Button>
);
}
return (
<div className="flex flex-col items-end gap-1.5">
<Button onClick={add.send} loading={add.state === "sending"}>
{add.state === "sending" ? null : <UserPlus size={16} />}
{t("nav:friends.contextMenu.addFriend")}
</Button>
{add.error ? <span className="text-xs text-danger">{add.error}</span> : null}
</div>
);
}
type TabId = "overview" | "worlds" | "favorites" | "groups"; type TabId = "overview" | "worlds" | "favorites" | "groups";
function ProfileSkeleton() { function ProfileSkeleton() {
@@ -4,19 +4,37 @@ import type { UserProfile } from "../../../../shared/types/user";
import type { World } from "../../../../shared/types/world"; import type { World } from "../../../../shared/types/world";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { compactNumber } from "../../lib/format"; 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 { useNav } from "../navigation/NavContext";
import { useUserMenu } from "../friends/useUserMenu";
import { avatarOf, trustMeta } from "../../lib/vrchat"; import { avatarOf, trustMeta } from "../../lib/vrchat";
interface UserMenu {
x: number;
y: number;
user: UserProfile;
}
type SearchTab = "users" | "worlds"; type SearchTab = "users" | "worlds";
export function SearchView() { export function SearchView() {
const { openUser, openWorld } = useNav(); const { openUser, openWorld } = useNav();
const { buildItems, modal } = useUserMenu();
const [tab, setTab] = useState<SearchTab>("users"); const [tab, setTab] = useState<SearchTab>("users");
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [users, setUsers] = useState<UserProfile[] | null>(null); const [users, setUsers] = useState<UserProfile[] | null>(null);
const [worlds, setWorlds] = useState<World[] | null>(null); const [worlds, setWorlds] = useState<World[] | null>(null);
const [menu, setMenu] = useState<UserMenu | null>(null);
async function submit(e: FormEvent) { async function submit(e: FormEvent) {
e.preventDefault(); e.preventDefault();
@@ -77,7 +95,15 @@ export function SearchView() {
) : tab === "users" ? ( ) : tab === "users" ? (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
{(results as UserProfile[]).map((u) => ( {(results as UserProfile[]).map((u) => (
<UserResult key={u.id} user={u} onOpen={() => openUser(u.id)} /> <UserResult
key={u.id}
user={u}
onOpen={() => openUser(u.id)}
onContextMenu={(e) => {
e.preventDefault();
setMenu({ x: e.clientX, y: e.clientY, user: u });
}}
/>
))} ))}
</div> </div>
) : ( ) : (
@@ -88,14 +114,34 @@ export function SearchView() {
</div> </div>
)} )}
</div> </div>
{menu ? (
<ContextMenu
x={menu.x}
y={menu.y}
items={buildItems(menu.user, { includeProfile: true, onOpen: openUser })}
onClose={() => setMenu(null)}
/>
) : null}
{modal}
</div> </div>
); );
} }
function UserResult({ user, onOpen }: { user: UserProfile; onOpen: () => void }) { function UserResult({
user,
onOpen,
onContextMenu,
}: {
user: UserProfile;
onOpen: () => void;
onContextMenu: (e: React.MouseEvent) => void;
}) {
return ( return (
<button <button
onClick={onOpen} onClick={onOpen}
onContextMenu={onContextMenu}
className="flex items-center gap-3 rounded-lg border border-border bg-surface p-2.5 text-left transition-colors hover:border-accent" className="flex items-center gap-3 rounded-lg border border-border bg-surface p-2.5 text-left transition-colors hover:border-accent"
> >
<Avatar src={avatarOf(user)} name={user.displayName} size={36} /> <Avatar src={avatarOf(user)} name={user.displayName} size={36} />
+1
View File
@@ -47,6 +47,7 @@ export const api = {
}, },
friends: { friends: {
list: () => call("friends:list"), list: () => call("friends:list"),
add: (userId: string) => call("friends:add", userId),
unfriend: (userId: string) => call("friends:unfriend", userId), unfriend: (userId: string) => call("friends:unfriend", userId),
invite: (userId: string, instanceLocation: string) => invite: (userId: string, instanceLocation: string) =>
call("friends:invite", { userId, instanceLocation }), call("friends:invite", { userId, instanceLocation }),
@@ -17,6 +17,9 @@
"sectionOffline": "Offline", "sectionOffline": "Offline",
"contextMenu": { "contextMenu": {
"viewProfile": "View Profile", "viewProfile": "View Profile",
"actions": "Actions",
"addFriend": "Add Friend",
"requestSent": "Request sent",
"invite": "Invite", "invite": "Invite",
"requestInvite": "Request Invite", "requestInvite": "Request Invite",
"unfriend": "Unfriend" "unfriend": "Unfriend"
@@ -17,6 +17,9 @@
"sectionOffline": "オフライン", "sectionOffline": "オフライン",
"contextMenu": { "contextMenu": {
"viewProfile": "プロフィールを見る", "viewProfile": "プロフィールを見る",
"actions": "操作",
"addFriend": "フレンド追加",
"requestSent": "リクエスト送信済み",
"invite": "招待する", "invite": "招待する",
"requestInvite": "招待をリクエスト", "requestInvite": "招待をリクエスト",
"unfriend": "フレンド解除" "unfriend": "フレンド解除"
@@ -17,6 +17,9 @@
"sectionOffline": "ออฟไลน์", "sectionOffline": "ออฟไลน์",
"contextMenu": { "contextMenu": {
"viewProfile": "ดูโปรไฟล์", "viewProfile": "ดูโปรไฟล์",
"actions": "การทำงาน",
"addFriend": "เพิ่มเพื่อน",
"requestSent": "ส่งคำขอแล้ว",
"invite": "ชวนเข้าห้อง", "invite": "ชวนเข้าห้อง",
"requestInvite": "ขอคำเชิญ", "requestInvite": "ขอคำเชิญ",
"unfriend": "ยกเลิกการเป็นเพื่อน" "unfriend": "ยกเลิกการเป็นเพื่อน"
+1
View File
@@ -41,6 +41,7 @@ export interface IpcRequests {
"user:search": (query: string) => IpcResult<UserProfile[]>; "user:search": (query: string) => IpcResult<UserProfile[]>;
"friends:list": () => IpcResult<UserProfile[]>; "friends:list": () => IpcResult<UserProfile[]>;
"friends:add": (userId: string) => IpcResult<void>;
"friends:unfriend": (userId: string) => IpcResult<void>; "friends:unfriend": (userId: string) => IpcResult<void>;
"friends:invite": (p: { userId: string; instanceLocation: string }) => IpcResult<void>; "friends:invite": (p: { userId: string; instanceLocation: string }) => IpcResult<void>;
"friends:requestInvite": (userId: string) => IpcResult<void>; "friends:requestInvite": (userId: string) => IpcResult<void>;