feat: FriendList context menu

This commit is contained in:
2026-06-28 19:02:25 +07:00
parent 07ce777bb9
commit e10e002593
12 changed files with 352 additions and 25 deletions
+5 -1
View File
@@ -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"),
];
}
}
+3
View File
@@ -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 () => {
+21
View File
@@ -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<string, number> = {
"join me": 0,
@@ -35,3 +36,23 @@ export async function listFriends(): Promise<UserProfile[]> {
);
});
}
export async function unfriend(userId: string): Promise<void> {
const vrc = requireActiveClient();
await vrc.unfriend({ path: { userId }, throwOnError: true });
entityStore.removeFriend(userId);
}
export async function inviteUser(userId: string, instanceLocation: string): Promise<void> {
const vrc = requireActiveClient();
await vrc.inviteUser({
path: { userId },
body: { instanceId: instanceLocation },
throwOnError: true,
});
}
export async function requestInvite(userId: string): Promise<void> {
const vrc = requireActiveClient();
await vrc.requestInvite({ path: { userId }, throwOnError: true });
}
@@ -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<HTMLDivElement>(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(
<div
ref={menuRef}
style={{ position: "fixed", left: pos.left, top: pos.top }}
className="animate-rise z-[200] min-w-[180px] origin-top-left rounded-DEFAULT border border-border bg-surface p-1.5 shadow-[var(--shadow-2)]"
>
<div className="flex flex-col gap-px">
{items.map((entry, i) =>
isSeparator(entry) ? (
<div key={i} className="mx-0.5 my-1 h-px bg-border" />
) : (
<button
key={i}
onClick={() => {
entry.onClick();
onClose();
}}
disabled={entry.disabled}
className={[
"flex w-full items-center gap-2.5 rounded-sm px-2.5 py-[7px] text-left text-[12.5px] font-semibold transition-[background,color] duration-[var(--dur)] ease-[var(--ease)] disabled:cursor-default disabled:opacity-40",
entry.danger
? "text-danger hover:bg-[color-mix(in_srgb,var(--danger)_12%,transparent)] hover:text-danger"
: "text-muted hover:bg-surface-2 hover:text-text",
].join(" ")}
>
{entry.icon ? <span className="shrink-0">{entry.icon}</span> : null}
{entry.label}
</button>
),
)}
</div>
</div>,
document.body,
);
}
+2
View File
@@ -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";
@@ -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<ContextMenuState | null>(null);
const [unfriendState, setUnfriendState] = useState<UnfriendState | null>(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: <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 (
<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">
<span className="friendsbar__text">Friends</span>
<span className="friendsbar__text">{t("nav:friends.title")}</span>
<span className="friendsbar__text ml-auto text-[11.5px] font-semibold text-[var(--status-active)]">
{online.length} online
{online.length} {t("nav:friends.online")}
</span>
</header>
@@ -92,7 +188,7 @@ export function FriendsSidebar({ onOpen }: { onOpen: (id: string) => void }) {
{selfAlone ? <FriendRow friend={self} onOpen={onOpen} isSelf selfChrome /> : null}
{friends.length === 0 ? (
<p className="p-3 text-[13px] text-faint">No friends online yet.</p>
<p className="p-3 text-[13px] text-faint">{t("nav:friends.noFriendsOnline")}</p>
) : (
<>
{instances.map((inst) => (
@@ -110,6 +206,7 @@ export function FriendsSidebar({ onOpen }: { onOpen: (id: string) => void }) {
onOpen={onOpen}
hideLocation
isSelf={f.id === selfId}
onContextMenu={f.id !== selfId ? openContextMenu : undefined}
/>
))}
</Section>
@@ -117,32 +214,69 @@ export function FriendsSidebar({ onOpen }: { onOpen: (id: string) => void }) {
{alone.length ? (
<Section
title={<span className="truncate">Online</span>}
title={<span className="truncate">{t("nav:friends.sectionOnline")}</span>}
count={alone.length}
open={!collapsed.has("online")}
onToggle={() => toggle("online")}
>
{alone.map((f) => (
<FriendRow key={f.id} friend={f} onOpen={onOpen} />
<FriendRow
key={f.id}
friend={f}
onOpen={onOpen}
onContextMenu={openContextMenu}
/>
))}
</Section>
) : null}
{offline.length ? (
<Section
title={<span className="truncate">Offline</span>}
title={<span className="truncate">{t("nav:friends.sectionOffline")}</span>}
count={offline.length}
open={!collapsed.has("offline")}
onToggle={() => toggle("offline")}
>
{offline.map((f) => (
<FriendRow key={f.id} friend={f} onOpen={onOpen} dim />
<FriendRow
key={f.id}
friend={f}
onOpen={onOpen}
dim
onContextMenu={openContextMenu}
/>
))}
</Section>
) : null}
</>
)}
</div>
{contextMenu ? (
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
items={buildMenuItems(contextMenu.friend)}
onClose={() => setContextMenu(null)}
/>
) : null}
<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>
);
}
@@ -194,10 +328,11 @@ function Section({
}
function InstanceTitle({ worldId, instanceId }: { worldId: string; instanceId: string }) {
const t = useT();
const worldName = useWorldName(worldId);
return (
<>
<span className="truncate">{worldName ?? "In a world"}</span>
<span className="truncate">{worldName ?? t("nav:friends.inAWorld")}</span>
<span className="shrink-0 normal-case text-faint">(#{instanceId})</span>
</>
);
@@ -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 (
<button
onClick={() => onOpen(isSelf ? "me" : friend.id)}
onContextMenu={onContextMenu ? (e) => onContextMenu(e, friend) : undefined}
title={friend.displayName}
className={[
"friend-row flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-surface-2",
@@ -444,15 +444,17 @@ function UnityStatusBody({ status }: { status: UnityStatus }) {
);
}
const MATCH_META: Record<
UnityStatus["match"],
{ key: string; icon: typeof Check; color: string }
> = {
ok: { key: "settings:unity.match.ok", icon: Check, color: "var(--status-active)" },
missing: { key: "settings:unity.match.missing", icon: AlertTriangle, color: "var(--status-ask)" },
"no-editor": { key: "settings:unity.match.noEditor", icon: X, color: "var(--danger)" },
unknown: { key: "settings:unity.match.unknown", icon: AlertTriangle, color: "var(--muted)" },
};
const MATCH_META: Record<UnityStatus["match"], { key: string; icon: typeof Check; color: string }> =
{
ok: { key: "settings:unity.match.ok", icon: Check, color: "var(--status-active)" },
missing: {
key: "settings:unity.match.missing",
icon: AlertTriangle,
color: "var(--status-ask)",
},
"no-editor": { key: "settings:unity.match.noEditor", icon: X, color: "var(--danger)" },
unknown: { key: "settings:unity.match.unknown", icon: AlertTriangle, color: "var(--muted)" },
};
function AboutSection() {
const { t } = useI18n();
+4
View File
@@ -47,6 +47,10 @@ export const api = {
},
friends: {
list: () => call("friends:list"),
unfriend: (userId: string) => call("friends:unfriend", userId),
invite: (userId: string, instanceLocation: string) =>
call("friends:invite", { userId, instanceLocation }),
requestInvite: (userId: string) => call("friends:requestInvite", userId),
},
world: {
byUser: (userId: string) => call("world:byUser", userId),
+20 -1
View File
@@ -7,5 +7,24 @@
"debug": "Debug",
"hideNav": "Hide navigation",
"showNav": "Show navigation",
"toggleNav": "Toggle navigation sidebar"
"toggleNav": "Toggle navigation sidebar",
"friends": {
"title": "Friends",
"online": "online",
"noFriendsOnline": "No friends online yet.",
"inAWorld": "In a world",
"sectionOnline": "Online",
"sectionOffline": "Offline",
"contextMenu": {
"viewProfile": "View Profile",
"invite": "Invite",
"requestInvite": "Request Invite",
"unfriend": "Unfriend"
},
"unfriendModal": {
"title": "Unfriend",
"body": "Remove <0>{{name}}</0> from your friends list?",
"confirm": "Unfriend"
}
}
}
+20 -1
View File
@@ -7,5 +7,24 @@
"debug": "デバッグ",
"hideNav": "ナビゲーションを隠す",
"showNav": "ナビゲーションを表示",
"toggleNav": "ナビゲーションサイドバーの切り替え"
"toggleNav": "ナビゲーションサイドバーの切り替え",
"friends": {
"title": "フレンド",
"online": "オンライン",
"noFriendsOnline": "オンラインのフレンドがいません。",
"inAWorld": "ワールドにいます",
"sectionOnline": "オンライン",
"sectionOffline": "オフライン",
"contextMenu": {
"viewProfile": "プロフィールを見る",
"invite": "招待する",
"requestInvite": "招待をリクエスト",
"unfriend": "フレンド解除"
},
"unfriendModal": {
"title": "フレンド解除",
"body": "<0>{{name}}</0> をフレンドリストから削除しますか?",
"confirm": "解除する"
}
}
}
+20 -1
View File
@@ -7,5 +7,24 @@
"debug": "ดีบัก",
"hideNav": "ซ่อนแถบนำทาง",
"showNav": "แสดงแถบนำทาง",
"toggleNav": "สลับแถบนำทาง"
"toggleNav": "สลับแถบนำทาง",
"friends": {
"title": "เพื่อน",
"online": "ออนไลน์",
"noFriendsOnline": "ยังไม่มีเพื่อนออนไลน์",
"inAWorld": "อยู่ในเวิลด์",
"sectionOnline": "ออนไลน์",
"sectionOffline": "ออฟไลน์",
"contextMenu": {
"viewProfile": "ดูโปรไฟล์",
"invite": "ชวนเข้าห้อง",
"requestInvite": "ขอคำเชิญ",
"unfriend": "ยกเลิกการเป็นเพื่อน"
},
"unfriendModal": {
"title": "ยกเลิกการเป็นเพื่อน",
"body": "ต้องการลบ <0>{{name}}</0> ออกจากรายชื่อเพื่อนใช่ไหม?",
"confirm": "ยกเลิกเพื่อน"
}
}
}
+3
View File
@@ -41,6 +41,9 @@ export interface IpcRequests {
"user:search": (query: string) => IpcResult<UserProfile[]>;
"friends:list": () => IpcResult<UserProfile[]>;
"friends:unfriend": (userId: string) => IpcResult<void>;
"friends:invite": (p: { userId: string; instanceLocation: string }) => IpcResult<void>;
"friends:requestInvite": (userId: string) => IpcResult<void>;
"world:byUser": (userId: string) => IpcResult<World[]>;
"world:search": (query: string) => IpcResult<World[]>;