mirror of
https://github.com/YuzuZensai/VRC-Circle.git
synced 2026-09-13 10:58:59 +00:00
✨ feat: Add friend functionality and refactor user context menus
This commit is contained in:
@@ -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)),
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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": "ยกเลิกการเป็นเพื่อน"
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
Reference in New Issue
Block a user