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": case "darwin":
return ["/Applications/Unity Hub.app"]; return ["/Applications/Unity Hub.app"];
default: 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)), "user:search": (query) => guard(() => users.searchUsers(query)),
"friends:list": () => guard(() => friends.listFriends()), "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) => "world:byUser": (userId) =>
guard(async () => { guard(async () => {
+21
View File
@@ -3,6 +3,7 @@ import { requireActiveClient } from "./client";
import { toUserProfile } from "./mappers"; import { toUserProfile } from "./mappers";
import { currentUser, userCache } from "./userService"; import { currentUser, userCache } from "./userService";
import { cacheKeys, policies } from "../cache/policies"; import { cacheKeys, policies } from "../cache/policies";
import { entityStore } from "../store/entityStore";
const order: Record<string, number> = { const order: Record<string, number> = {
"join me": 0, "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 { CodeBlock } from "./CodeBlock";
export { Card } from "./Card"; export { Card } from "./Card";
export { HoverImage } from "./HoverImage"; export { HoverImage } from "./HoverImage";
export { ContextMenu } from "./ContextMenu";
export type { ContextMenuEntry, ContextMenuItem } from "./ContextMenu";
export { LABEL_HEADING } from "./styles"; export { LABEL_HEADING } from "./styles";
@@ -1,10 +1,14 @@
import { useMemo, useState, type ReactNode } from "react"; import { useMemo, useState, useCallback, type ReactNode } from "react";
import { ChevronDown, ChevronRight } from "lucide-react"; import { ChevronDown, ChevronRight, UserMinus, Send, LogIn } 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 } from "../../components/ui"; import { Badge, PresenceAvatar, Modal, 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";
interface InstanceSection { interface InstanceSection {
key: string; key: string;
@@ -13,7 +17,19 @@ interface InstanceSection {
members: UserProfile[]; members: UserProfile[];
} }
interface ContextMenuState {
x: number;
y: number;
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 friends = useFriends(); const friends = useFriends();
const self = useSelf(); const self = useSelf();
const selfId = self?.id; const selfId = self?.id;
@@ -79,12 +95,92 @@ export function FriendsSidebar({ onOpen }: { onOpen: (id: string) => void }) {
const selfAlone = const selfAlone =
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 [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 ( 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">
<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)]"> <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> </span>
</header> </header>
@@ -92,7 +188,7 @@ export function FriendsSidebar({ onOpen }: { onOpen: (id: string) => void }) {
{selfAlone ? <FriendRow friend={self} onOpen={onOpen} isSelf selfChrome /> : null} {selfAlone ? <FriendRow friend={self} onOpen={onOpen} isSelf selfChrome /> : null}
{friends.length === 0 ? ( {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) => ( {instances.map((inst) => (
@@ -110,6 +206,7 @@ export function FriendsSidebar({ onOpen }: { onOpen: (id: string) => void }) {
onOpen={onOpen} onOpen={onOpen}
hideLocation hideLocation
isSelf={f.id === selfId} isSelf={f.id === selfId}
onContextMenu={f.id !== selfId ? openContextMenu : undefined}
/> />
))} ))}
</Section> </Section>
@@ -117,32 +214,69 @@ export function FriendsSidebar({ onOpen }: { onOpen: (id: string) => void }) {
{alone.length ? ( {alone.length ? (
<Section <Section
title={<span className="truncate">Online</span>} title={<span className="truncate">{t("nav:friends.sectionOnline")}</span>}
count={alone.length} count={alone.length}
open={!collapsed.has("online")} open={!collapsed.has("online")}
onToggle={() => toggle("online")} onToggle={() => toggle("online")}
> >
{alone.map((f) => ( {alone.map((f) => (
<FriendRow key={f.id} friend={f} onOpen={onOpen} /> <FriendRow
key={f.id}
friend={f}
onOpen={onOpen}
onContextMenu={openContextMenu}
/>
))} ))}
</Section> </Section>
) : null} ) : null}
{offline.length ? ( {offline.length ? (
<Section <Section
title={<span className="truncate">Offline</span>} title={<span className="truncate">{t("nav:friends.sectionOffline")}</span>}
count={offline.length} count={offline.length}
open={!collapsed.has("offline")} open={!collapsed.has("offline")}
onToggle={() => toggle("offline")} onToggle={() => toggle("offline")}
> >
{offline.map((f) => ( {offline.map((f) => (
<FriendRow key={f.id} friend={f} onOpen={onOpen} dim /> <FriendRow
key={f.id}
friend={f}
onOpen={onOpen}
dim
onContextMenu={openContextMenu}
/>
))} ))}
</Section> </Section>
) : null} ) : null}
</> </>
)} )}
</div> </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> </aside>
); );
} }
@@ -194,10 +328,11 @@ function Section({
} }
function InstanceTitle({ worldId, instanceId }: { worldId: string; instanceId: string }) { function InstanceTitle({ worldId, instanceId }: { worldId: string; instanceId: string }) {
const t = useT();
const worldName = useWorldName(worldId); const worldName = useWorldName(worldId);
return ( 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> <span className="shrink-0 normal-case text-faint">(#{instanceId})</span>
</> </>
); );
@@ -210,6 +345,7 @@ function FriendRow({
isSelf, isSelf,
selfChrome, selfChrome,
hideLocation, hideLocation,
onContextMenu,
}: { }: {
friend: UserProfile; friend: UserProfile;
onOpen: (id: string) => void; onOpen: (id: string) => void;
@@ -217,17 +353,20 @@ function FriendRow({
isSelf?: boolean; isSelf?: boolean;
selfChrome?: boolean; selfChrome?: boolean;
hideLocation?: boolean; hideLocation?: boolean;
onContextMenu?: (e: React.MouseEvent, friend: UserProfile) => void;
}) { }) {
const t = useT();
const status = statusMeta[isOnline(friend) || isSelf ? friend.status : "offline"]; const status = statusMeta[isOnline(friend) || isSelf ? friend.status : "offline"];
const sub = friend.statusDescription || status.label; const sub = friend.statusDescription || status.label;
const parsed = parseLocation(friend.location); const parsed = parseLocation(friend.location);
const worldName = useWorldName(parsed?.worldId); const worldName = useWorldName(parsed?.worldId);
const location = parsed const location = parsed
? `${worldName ? `in ${worldName}` : "In a world"} (#${parsed.instanceId})` ? `${worldName ? `in ${worldName}` : t("nav:friends.inAWorld")} (#${parsed.instanceId})`
: locationLabel(friend.location); : locationLabel(friend.location);
return ( return (
<button <button
onClick={() => onOpen(isSelf ? "me" : friend.id)} onClick={() => onOpen(isSelf ? "me" : friend.id)}
onContextMenu={onContextMenu ? (e) => onContextMenu(e, friend) : undefined}
title={friend.displayName} title={friend.displayName}
className={[ 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", "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,12 +444,14 @@ function UnityStatusBody({ status }: { status: UnityStatus }) {
); );
} }
const MATCH_META: Record< const MATCH_META: Record<UnityStatus["match"], { key: string; icon: typeof Check; color: string }> =
UnityStatus["match"], {
{ key: string; icon: typeof Check; color: string }
> = {
ok: { key: "settings:unity.match.ok", icon: Check, color: "var(--status-active)" }, ok: { key: "settings:unity.match.ok", icon: Check, color: "var(--status-active)" },
missing: { key: "settings:unity.match.missing", icon: AlertTriangle, color: "var(--status-ask)" }, missing: {
key: "settings:unity.match.missing",
icon: AlertTriangle,
color: "var(--status-ask)",
},
"no-editor": { key: "settings:unity.match.noEditor", icon: X, color: "var(--danger)" }, "no-editor": { key: "settings:unity.match.noEditor", icon: X, color: "var(--danger)" },
unknown: { key: "settings:unity.match.unknown", icon: AlertTriangle, color: "var(--muted)" }, unknown: { key: "settings:unity.match.unknown", icon: AlertTriangle, color: "var(--muted)" },
}; };
+4
View File
@@ -47,6 +47,10 @@ export const api = {
}, },
friends: { friends: {
list: () => call("friends:list"), 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: { world: {
byUser: (userId: string) => call("world:byUser", userId), byUser: (userId: string) => call("world:byUser", userId),
+20 -1
View File
@@ -7,5 +7,24 @@
"debug": "Debug", "debug": "Debug",
"hideNav": "Hide navigation", "hideNav": "Hide navigation",
"showNav": "Show 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": "デバッグ", "debug": "デバッグ",
"hideNav": "ナビゲーションを隠す", "hideNav": "ナビゲーションを隠す",
"showNav": "ナビゲーションを表示", "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": "ดีบัก", "debug": "ดีบัก",
"hideNav": "ซ่อนแถบนำทาง", "hideNav": "ซ่อนแถบนำทาง",
"showNav": "แสดงแถบนำทาง", "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[]>; "user:search": (query: string) => IpcResult<UserProfile[]>;
"friends:list": () => 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:byUser": (userId: string) => IpcResult<World[]>;
"world:search": (query: string) => IpcResult<World[]>; "world:search": (query: string) => IpcResult<World[]>;