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":
|
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"),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,15 +444,17 @@ 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)" },
|
||||||
};
|
};
|
||||||
|
|
||||||
function AboutSection() {
|
function AboutSection() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "解除する"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "ยกเลิกเพื่อน"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[]>;
|
||||||
|
|||||||
Reference in New Issue
Block a user