mirror of
https://github.com/YuzuZensai/VRC-Circle.git
synced 2026-09-13 19:08:52 +00:00
✨ feat: FriendList context menu
This commit is contained in:
@@ -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"),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "解除する"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "ยกเลิกเพื่อน"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[]>;
|
||||
|
||||
Reference in New Issue
Block a user