mirror of
https://github.com/YuzuZensai/VRC-Circle.git
synced 2026-09-13 19:08:52 +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)),
|
||||
|
||||
"friends:list": () => guard(() => friends.listFriends()),
|
||||
"friends:add": (userId) => guard(() => friends.addFriend(userId)),
|
||||
"friends:unfriend": (userId) => guard(() => friends.unfriend(userId)),
|
||||
"friends:invite": (p) => guard(() => friends.inviteUser(p.userId, p.instanceLocation)),
|
||||
"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> {
|
||||
const vrc = requireActiveClient();
|
||||
await vrc.unfriend({ path: { userId }, throwOnError: true });
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { useMemo, useState, useCallback, type ReactNode } from "react";
|
||||
import { ChevronDown, ChevronRight, UserMinus, Send, LogIn } from "lucide-react";
|
||||
import { Trans } from "react-i18next";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { isOnline, locationLabel, statusMeta } from "../../lib/vrchat";
|
||||
import { Badge, PresenceAvatar, Modal, ContextMenu } from "../../components/ui";
|
||||
import type { ContextMenuEntry } from "../../components/ui";
|
||||
import { Badge, PresenceAvatar, ContextMenu } 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";
|
||||
import { useUserMenu } from "./useUserMenu";
|
||||
|
||||
interface InstanceSection {
|
||||
key: string;
|
||||
@@ -23,11 +21,6 @@ interface ContextMenuState {
|
||||
friend: UserProfile;
|
||||
}
|
||||
|
||||
interface UnfriendState {
|
||||
friend: UserProfile;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function FriendsSidebar({ onOpen }: { onOpen: (id: string) => void }) {
|
||||
const t = useT();
|
||||
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));
|
||||
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
@@ -104,77 +97,6 @@ export function FriendsSidebar({ onOpen }: { onOpen: (id: string) => void }) {
|
||||
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">
|
||||
@@ -256,27 +178,12 @@ export function FriendsSidebar({ onOpen }: { onOpen: (id: string) => void }) {
|
||||
<ContextMenu
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
items={buildMenuItems(contextMenu.friend)}
|
||||
items={buildItems(contextMenu.friend, { includeProfile: true, onOpen })}
|
||||
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>
|
||||
{modal}
|
||||
</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 { 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 {
|
||||
avatarOf,
|
||||
bannerOf,
|
||||
@@ -29,7 +45,12 @@ export function ProfileView({ target }: { target: "me" | string }) {
|
||||
}
|
||||
|
||||
function ProfileCard({ profile }: { profile: UserProfile }) {
|
||||
const t = useT();
|
||||
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 online = isOnline(profile);
|
||||
const status = statusMeta[profile.status];
|
||||
@@ -59,7 +80,7 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
|
||||
/>
|
||||
</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">
|
||||
<h2 className="text-[32px] font-bold tracking-[-0.6px]">{profile.displayName}</h2>
|
||||
{profile.isSelf ? <Tag color="var(--accent)">You</Tag> : null}
|
||||
@@ -96,6 +117,24 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
|
||||
</div>
|
||||
) : null}
|
||||
</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 className={`${COL_WIDE} mt-6 flex flex-col gap-5`}>
|
||||
@@ -252,10 +291,71 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{menu ? (
|
||||
<ContextMenu
|
||||
x={menu.x}
|
||||
y={menu.y}
|
||||
items={buildItems(profile)}
|
||||
onClose={() => setMenu(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{modal}
|
||||
</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";
|
||||
|
||||
function ProfileSkeleton() {
|
||||
|
||||
@@ -4,19 +4,37 @@ import type { UserProfile } from "../../../../shared/types/user";
|
||||
import type { World } from "../../../../shared/types/world";
|
||||
import { api } from "../../lib/api";
|
||||
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 { useUserMenu } from "../friends/useUserMenu";
|
||||
import { avatarOf, trustMeta } from "../../lib/vrchat";
|
||||
|
||||
interface UserMenu {
|
||||
x: number;
|
||||
y: number;
|
||||
user: UserProfile;
|
||||
}
|
||||
|
||||
type SearchTab = "users" | "worlds";
|
||||
|
||||
export function SearchView() {
|
||||
const { openUser, openWorld } = useNav();
|
||||
const { buildItems, modal } = useUserMenu();
|
||||
const [tab, setTab] = useState<SearchTab>("users");
|
||||
const [query, setQuery] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [users, setUsers] = useState<UserProfile[] | null>(null);
|
||||
const [worlds, setWorlds] = useState<World[] | null>(null);
|
||||
const [menu, setMenu] = useState<UserMenu | null>(null);
|
||||
|
||||
async function submit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -77,7 +95,15 @@ export function SearchView() {
|
||||
) : tab === "users" ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{(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>
|
||||
) : (
|
||||
@@ -88,14 +114,34 @@ export function SearchView() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{menu ? (
|
||||
<ContextMenu
|
||||
x={menu.x}
|
||||
y={menu.y}
|
||||
items={buildItems(menu.user, { includeProfile: true, onOpen: openUser })}
|
||||
onClose={() => setMenu(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{modal}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserResult({ user, onOpen }: { user: UserProfile; onOpen: () => void }) {
|
||||
function UserResult({
|
||||
user,
|
||||
onOpen,
|
||||
onContextMenu,
|
||||
}: {
|
||||
user: UserProfile;
|
||||
onOpen: () => void;
|
||||
onContextMenu: (e: React.MouseEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Avatar src={avatarOf(user)} name={user.displayName} size={36} />
|
||||
|
||||
@@ -47,6 +47,7 @@ export const api = {
|
||||
},
|
||||
friends: {
|
||||
list: () => call("friends:list"),
|
||||
add: (userId: string) => call("friends:add", userId),
|
||||
unfriend: (userId: string) => call("friends:unfriend", userId),
|
||||
invite: (userId: string, instanceLocation: string) =>
|
||||
call("friends:invite", { userId, instanceLocation }),
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
"sectionOffline": "Offline",
|
||||
"contextMenu": {
|
||||
"viewProfile": "View Profile",
|
||||
"actions": "Actions",
|
||||
"addFriend": "Add Friend",
|
||||
"requestSent": "Request sent",
|
||||
"invite": "Invite",
|
||||
"requestInvite": "Request Invite",
|
||||
"unfriend": "Unfriend"
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
"sectionOffline": "オフライン",
|
||||
"contextMenu": {
|
||||
"viewProfile": "プロフィールを見る",
|
||||
"actions": "操作",
|
||||
"addFriend": "フレンド追加",
|
||||
"requestSent": "リクエスト送信済み",
|
||||
"invite": "招待する",
|
||||
"requestInvite": "招待をリクエスト",
|
||||
"unfriend": "フレンド解除"
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
"sectionOffline": "ออฟไลน์",
|
||||
"contextMenu": {
|
||||
"viewProfile": "ดูโปรไฟล์",
|
||||
"actions": "การทำงาน",
|
||||
"addFriend": "เพิ่มเพื่อน",
|
||||
"requestSent": "ส่งคำขอแล้ว",
|
||||
"invite": "ชวนเข้าห้อง",
|
||||
"requestInvite": "ขอคำเชิญ",
|
||||
"unfriend": "ยกเลิกการเป็นเพื่อน"
|
||||
|
||||
@@ -41,6 +41,7 @@ export interface IpcRequests {
|
||||
"user:search": (query: string) => IpcResult<UserProfile[]>;
|
||||
|
||||
"friends:list": () => IpcResult<UserProfile[]>;
|
||||
"friends:add": (userId: string) => IpcResult<void>;
|
||||
"friends:unfriend": (userId: string) => IpcResult<void>;
|
||||
"friends:invite": (p: { userId: string; instanceLocation: string }) => IpcResult<void>;
|
||||
"friends:requestInvite": (userId: string) => IpcResult<void>;
|
||||
|
||||
Reference in New Issue
Block a user