desktop: native News, Friends, and Alerts pages; retire the browser-linkout Community tab
Some checks failed
Server CI / Lint (push) Failing after 9m53s
Server CI / Typecheck (push) Successful in 11m40s

The News page was a hardcoded "under construction" stub, and the Friends/
Alerts header icons did nothing -- both replaced with real Vue pages/
widgets backed by the server's existing news/notifications/community APIs,
reached through a generic authenticated REST bridge (community_api.rs:
api_get/api_post/api_delete) and a websocket bridge for live chat/presence
(community_ws.rs, mirrors the reqwest_websocket pattern remote.rs already
uses for the auth-code exchange).

- News: GET /api/v1/client/news, rendered with micromark, with real
  loading/empty/error states.
- Alerts: GET/POST /api/v1/notifications/*, polled (not the notifications
  websocket -- see notifications.ts for why), unread badge + mark
  one/all read.
- Friends: full friends composable (list/requests/search/presence) plus a
  header dropdown and a /community/friends management page.
- Chat: /community/chat, rooms (server/global/title/DM) over the live
  community websocket with a REST history/read-state load on room switch.
- Achievements: per-game card on the library detail page and a per-user
  summary on the new read-only profile page (/community/profile/:username).
- The Community tab's "opens in your system browser" page is gone;
  /community is now a real in-app hub (activity feed + server list) linking
  into friends/chat.

Also fixes a real auth gap found by actually running the built client
against the live server: notifications/community routes are gated by
aclManager.getUserIdACL, which only accepts a session cookie or an opaque
Bearer APIToken -- never the client's own short-lived signed JWT
(generate_authorization_header). The client already had a bridge for this
(POST /api/v1/client/user/webtoken, JWT-authenticated, mints an opaque
token), but CLIENT_WEBTOKEN_ACLS never granted it the notifications/
community scopes, so the exchange succeeded and the minted token still got
403'd on every one of these routes. Extended that ACL list
(04.auth-init.ts) and switched community_api.rs/community_ws.rs to mint and
use that webtoken instead of the JWT for these specific calls.

Client version bumped to 0.4.2 (tauri.conf.json).
This commit is contained in:
wdunn001 2026-08-03 19:42:50 -04:00
parent e1fa7e16c5
commit e8d3df032c
24 changed files with 2260 additions and 110 deletions

View file

@ -0,0 +1,41 @@
<template>
<div v-if="rooms.length > 0" class="pt-3">
<p class="px-4 pb-1 text-[11px] uppercase tracking-wide text-zinc-500 font-semibold">
{{ label }}
</p>
<button
v-for="room in rooms"
:key="room.id"
@click="$emit('select', room.id)"
class="w-full flex items-center gap-x-2 px-4 py-2 text-left hover:bg-zinc-800/60 transition"
:class="active === room.id ? 'bg-zinc-800' : ''"
>
<CommunityAvatar v-if="room.kind === 'direct'" :url="room.otherUser?.avatarUrl ?? null" />
<HashtagIcon v-else class="h-5 w-5 text-zinc-500 shrink-0" />
<span class="text-sm text-zinc-200 truncate flex-1">{{ room.name }}</span>
<span
v-if="room.unreadCount > 0"
class="text-[11px] bg-blue-500 text-zinc-950 rounded-full min-w-[1.1rem] h-[1.1rem] px-1 text-center leading-[1.1rem] font-semibold"
>
{{ room.unreadCount }}
</span>
</button>
</div>
</template>
<script setup lang="ts">
import { HashtagIcon } from "@heroicons/vue/20/solid";
defineProps<{
label: string;
active?: string;
rooms: Array<{
id: string;
name: string;
kind: string;
unreadCount: number;
otherUser: { avatarUrl: string | null } | null;
}>;
}>();
defineEmits<{ select: [roomId: string] }>();
</script>

View file

@ -0,0 +1,26 @@
<template>
<img
v-if="url"
:src="useObject(url)"
:class="['rounded-sm bg-zinc-800 object-cover shrink-0', sizeClass]"
/>
<div
v-else
:class="['rounded-sm bg-zinc-800 flex items-center justify-center text-zinc-500 shrink-0', sizeClass]"
>
<UserIcon class="h-1/2 w-1/2" />
</div>
</template>
<script setup lang="ts">
import { UserIcon } from "@heroicons/vue/20/solid";
const props = withDefaults(
defineProps<{ url?: string | null; size?: "sm" | "md" | "lg" }>(),
{ size: "sm" },
);
const sizeClass = computed(() =>
props.size === "lg" ? "w-16 h-16" : props.size === "md" ? "w-10 h-10" : "w-7 h-7",
);
</script>

View file

@ -30,13 +30,11 @@
<ol class="inline-flex gap-3">
<HeaderProtonSupportWidget />
<HeaderQueueWidget :object="currentQueueObject" />
<li v-for="(item, itemIdx) in quickActions">
<HeaderWidget
@click="item.action"
:notifications="item.notifications"
>
<component class="h-5" :is="item.icon" />
</HeaderWidget>
<li>
<HeaderFriendsWidget />
</li>
<li>
<HeaderNotificationsWidget />
</li>
<OfflineHeaderWidget v-if="state?.status === AppStatus.Offline" />
<HeaderUserWidget />
@ -48,9 +46,10 @@
</template>
<script setup lang="ts">
import { BellIcon, UserGroupIcon } from "@heroicons/vue/16/solid";
import { AppStatus, type NavigationItem, type QuickActionNav } from "../types";
import { AppStatus, type NavigationItem } from "../types";
import HeaderWidget from "./HeaderWidget.vue";
import HeaderFriendsWidget from "./HeaderFriendsWidget.vue";
import HeaderNotificationsWidget from "./HeaderNotificationsWidget.vue";
import { getCurrentWindow } from "@tauri-apps/api/window";
const window = getCurrentWindow();
@ -81,17 +80,6 @@ const navigation: Array<NavigationItem> = [
const { currentNavigation } = useCurrentNavigationIndex(navigation);
const quickActions: Array<QuickActionNav> = [
{
icon: UserGroupIcon,
action: async () => {},
},
{
icon: BellIcon,
action: async () => {},
},
];
const queue = useQueueState();
const currentQueueObject = computed(() => queue.value.queue.at(0));
</script>

View file

@ -0,0 +1,144 @@
<template>
<Menu as="div" class="relative inline-block">
<MenuButton>
<HeaderWidget :notifications="incomingCount > 0 ? incomingCount : undefined">
<UserGroupIcon class="h-5" />
</HeaderWidget>
</MenuButton>
<transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<MenuItems
class="absolute bg-zinc-900 right-0 top-10 z-50 w-96 origin-top-right focus:outline-none shadow-md rounded-md overflow-hidden"
>
<div class="flex items-center justify-between px-4 py-3 border-b border-zinc-800">
<h3 class="text-sm font-semibold text-zinc-100">Friends</h3>
<NuxtLink to="/community/friends" class="text-xs text-blue-500 hover:text-blue-400">
Manage
</NuxtLink>
</div>
<div class="max-h-96 overflow-y-auto">
<div v-if="!loaded" class="px-4 py-6 text-center text-sm text-zinc-500">
Loading&hellip;
</div>
<div v-else-if="error" class="px-4 py-4 text-xs text-red-500">
Couldn't load friends: {{ error }}
</div>
<template v-else>
<div v-if="friends.incomingRequests.length > 0" class="px-4 pt-3 pb-1">
<p class="text-[11px] uppercase tracking-wide text-zinc-500 font-semibold">
Requests
</p>
</div>
<div
v-for="req in friends.incomingRequests"
:key="req.id"
class="flex items-center gap-x-2 px-4 py-2"
>
<CommunityAvatar :url="req.user.avatarUrl" />
<span class="text-sm text-zinc-200 truncate flex-1">
{{ req.user.displayName }}
</span>
<button
@click="accept(req.id)"
class="text-xs text-green-500 hover:text-green-400 font-semibold"
>
Accept
</button>
<button
@click="decline(req.id)"
class="text-xs text-zinc-500 hover:text-zinc-300 font-semibold"
>
Decline
</button>
</div>
<div class="px-4 pt-3 pb-1" v-if="friends.friends.length > 0">
<p class="text-[11px] uppercase tracking-wide text-zinc-500 font-semibold">
{{ onlineCount }} online
</p>
</div>
<NuxtLink
v-for="friend in sortedFriends"
:key="friend.id"
:to="`/community/profile/${friend.username}`"
class="flex items-center gap-x-2 px-4 py-2 hover:bg-zinc-800/60"
>
<div class="relative">
<CommunityAvatar :url="friend.avatarUrl" />
<span
class="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-zinc-900"
:class="activity[friend.id]?.online ? 'bg-green-500' : 'bg-zinc-600'"
/>
</div>
<div class="min-w-0">
<p class="text-sm text-zinc-200 truncate">{{ friend.displayName }}</p>
<p class="text-xs text-zinc-500 truncate">
{{
activity[friend.id]?.playing
? `Playing ${activity[friend.id]?.playing?.name}`
: activity[friend.id]?.online
? "Online"
: "Offline"
}}
</p>
</div>
</NuxtLink>
<div
v-if="friends.friends.length === 0 && friends.incomingRequests.length === 0"
class="px-4 py-6 text-center text-sm text-zinc-500"
>
No friends yet.
<NuxtLink to="/community/friends" class="text-blue-500 hover:text-blue-400 block mt-1">
Find people to add
</NuxtLink>
</div>
</template>
</div>
</MenuItems>
</transition>
</Menu>
</template>
<script setup lang="ts">
import { Menu, MenuButton, MenuItems } from "@headlessui/vue";
import { UserGroupIcon } from "@heroicons/vue/20/solid";
import HeaderWidget from "./HeaderWidget.vue";
import { useFriends } from "~/composables/friends";
const { friends, activity, loaded, error, onlineCount, incomingCount, refresh, acceptRequest, declineRequest } =
useFriends();
refresh();
// Light polling for presence -- the friends REST list doesn't ride the
// community websocket (that's reserved for chat + presence broadcast to
// avoid every open dropdown independently subscribing/unsubscribing to
// presence:friends); this keeps "online now"/"playing X" reasonably fresh
// without wiring a second consumer onto the socket.
const interval = setInterval(refresh, 30_000);
onUnmounted(() => clearInterval(interval));
const sortedFriends = computed(() =>
[...friends.value.friends].sort((a, b) => {
const aOnline = activity.value[a.id]?.online ? 1 : 0;
const bOnline = activity.value[b.id]?.online ? 1 : 0;
return bOnline - aOnline;
}),
);
async function accept(id: string) {
await acceptRequest(id);
}
async function decline(id: string) {
await declineRequest(id);
}
</script>

View file

@ -0,0 +1,95 @@
<template>
<Menu as="div" class="relative inline-block">
<MenuButton>
<HeaderWidget :notifications="unreadCount > 0 ? unreadCount : undefined">
<BellIcon class="h-5" />
</HeaderWidget>
</MenuButton>
<transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<MenuItems
class="absolute bg-zinc-900 right-0 top-10 z-50 w-96 origin-top-right focus:outline-none shadow-md rounded-md overflow-hidden"
>
<div class="flex items-center justify-between px-4 py-3 border-b border-zinc-800">
<h3 class="text-sm font-semibold text-zinc-100">Notifications</h3>
<button
v-if="unreadCount > 0"
@click="markAllRead()"
class="text-xs text-blue-500 hover:text-blue-400"
>
Mark all read
</button>
</div>
<div class="max-h-96 overflow-y-auto">
<div v-if="!loaded" class="px-4 py-6 text-center text-sm text-zinc-500">
Loading&hellip;
</div>
<div v-else-if="error" class="px-4 py-4 text-xs text-red-500">
Couldn't load notifications: {{ error }}
</div>
<div
v-else-if="notifications.length === 0"
class="px-4 py-6 text-center text-sm text-zinc-500"
>
You're all caught up.
</div>
<button
v-else
v-for="n in notifications"
:key="n.id"
@click="() => !n.read && markRead(n.id)"
class="w-full text-left px-4 py-3 border-b border-zinc-800/60 last:border-0 hover:bg-zinc-800/60 transition"
:class="!n.read ? 'bg-blue-500/5' : ''"
>
<div class="flex items-start gap-x-2">
<div
class="mt-1 h-1.5 w-1.5 rounded-full shrink-0"
:class="n.read ? 'bg-transparent' : 'bg-blue-500'"
/>
<div class="min-w-0">
<p class="text-sm font-medium text-zinc-200 truncate">{{ n.title }}</p>
<p class="text-xs text-zinc-400 mt-0.5 line-clamp-2">{{ n.description }}</p>
<p class="text-[11px] text-zinc-600 mt-1">{{ formatDate(n.created) }}</p>
</div>
</div>
</button>
</div>
</MenuItems>
</transition>
</Menu>
</template>
<script setup lang="ts">
import { Menu, MenuButton, MenuItems } from "@headlessui/vue";
import { BellIcon } from "@heroicons/vue/20/solid";
import HeaderWidget from "./HeaderWidget.vue";
import { useNotifications } from "~/composables/notifications";
const { notifications, loaded, error, unreadCount, startPolling, markRead, markAllRead } =
useNotifications();
startPolling();
function formatDate(iso: string) {
try {
const date = new Date(iso);
const diffMs = Date.now() - date.getTime();
const diffMin = Math.round(diffMs / 60000);
if (diffMin < 1) return "just now";
if (diffMin < 60) return `${diffMin}m ago`;
const diffHr = Math.round(diffMin / 60);
if (diffHr < 24) return `${diffHr}h ago`;
return date.toLocaleDateString();
} catch {
return iso;
}
}
</script>

View file

@ -25,7 +25,7 @@
>
<div class="flex-col gap-y-2">
<NuxtLink
to="/id/me"
:to="`/community/profile/${state.user.username}`"
class="transition inline-flex items-center w-full py-3 px-4 hover:bg-zinc-800"
>
<div class="inline-flex items-center text-zinc-300">

View file

@ -0,0 +1,31 @@
import { invoke } from "@tauri-apps/api/core";
// Thin wrappers over the generic Rust bridge (community_api.rs): the client
// passes the server's own `/api/v1/...` path, Rust resolves the base URL +
// signs the request with the same auth every other client call uses. See
// desktop/src-tauri/src/community_api.rs for why this is generic rather than
// one typed Tauri command per endpoint.
//
// On a non-2xx response the Rust side rejects with a stringified
// RemoteAccessError::InvalidResponse whose message is the server's own
// `statusMessage`/`message` -- callers can show `(e as string)` directly.
export function apiGet<T = any>(
path: string,
query?: Record<string, string | number | boolean | undefined>,
): Promise<T> {
const q = query
? Object.entries(query)
.filter(([, v]) => v !== undefined)
.map(([k, v]) => [k, String(v)] as [string, string])
: undefined;
return invoke<T>("api_get", { path, query: q });
}
export function apiPost<T = any>(path: string, body?: unknown): Promise<T> {
return invoke<T>("api_post", { path, body: body ?? {} });
}
export function apiDelete<T = any>(path: string): Promise<T> {
return invoke<T>("api_delete", { path });
}

View file

@ -0,0 +1,169 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
// Frontend half of the community websocket bridge
// (desktop/src-tauri/src/community_ws.rs). One shared connection for the
// whole app (chat rooms, live chat.message frames, presence updates,
// friend-request pushes), matching chatService's `{t, d}` envelope exactly
// (server/server/api/v1/community/ws.get.ts).
//
// `useState` makes this a Nuxt-style singleton: every component that calls
// useCommunityWs() shares the same reactive state and the same single
// underlying socket, rather than each page opening its own connection.
export interface ChatMessageDTO {
id: string;
roomId: string;
kind: "text" | "system";
body: string;
deleted: boolean;
clientNonce: string | null;
replyToId: string | null;
sender: {
id: string;
username: string;
displayName: string;
avatarUrl: string | null;
} | null;
createdAt: string;
editedAt: string | null;
}
interface PresenceUpdate {
userId: string;
state: "online" | "offline";
}
interface FriendRequestPush {
status: "pending" | "accepted" | "declined";
userId: string;
}
type WsListener = (envelope: { t: string; d: any }) => void;
interface CommunityWsInternal {
connected: boolean;
connecting: boolean;
subscribedTopics: Set<string>;
listeners: Set<WsListener>;
initialized: boolean;
}
// Not part of the reactive useState -- a Set doesn't play well with Vue's
// deep reactivity/serialization and none of this needs to trigger renders
// directly (callers derive their own reactive state from message events).
const internal: CommunityWsInternal = {
connected: false,
connecting: false,
subscribedTopics: new Set(),
listeners: new Set(),
initialized: false,
};
const wsConnected = () => useState<boolean>("community-ws-connected", () => false);
async function ensureConnected() {
if (internal.connected || internal.connecting) return;
internal.connecting = true;
try {
await invoke("community_ws_connect");
} catch (e) {
console.error("community ws: connect failed", e);
internal.connecting = false;
return;
}
internal.connecting = false;
}
function initOnce() {
if (internal.initialized) return;
internal.initialized = true;
listen<string>("community/ws-message", (event) => {
let envelope: { t: string; d: any };
try {
envelope = JSON.parse(event.payload);
} catch {
return;
}
if (envelope.t === "pong" || envelope.t === "notice") {
if (envelope.t === "notice") console.warn("community ws notice:", envelope.d);
return;
}
for (const listener of internal.listeners) listener(envelope);
});
listen("community/ws-closed", () => {
internal.connected = false;
wsConnected().value = false;
internal.subscribedTopics.clear();
// Reconnect with a short, fixed backoff -- chat is supposed to feel
// live, so retry promptly rather than leaving the user silently
// disconnected until they navigate away and back.
setTimeout(() => {
ensureConnected();
}, 3000);
});
// A connect can race a page mount; mark connected optimistically once the
// invoke resolves (community_ws_connect only returns Ok after the upgrade
// handshake succeeds).
}
export function useCommunityWs() {
initOnce();
async function connect() {
await ensureConnected();
internal.connected = true;
wsConnected().value = true;
}
function send(t: string, d: unknown = {}) {
invoke("community_ws_send", { payload: JSON.stringify({ t, d }) }).catch((e) => {
console.error("community ws: send failed", e);
});
}
function subscribe(topics: string[]) {
const fresh = topics.filter((t) => !internal.subscribedTopics.has(t));
if (fresh.length === 0) return;
for (const t of fresh) internal.subscribedTopics.add(t);
send("sub", { topics: fresh });
}
function unsubscribe(topics: string[]) {
for (const t of topics) internal.subscribedTopics.delete(t);
send("unsub", { topics });
}
function onMessage(listener: WsListener) {
internal.listeners.add(listener);
return () => internal.listeners.delete(listener);
}
function sendChatMessage(roomId: string, body: string, clientNonce?: string) {
send("chat.send", { roomId, body, clientNonce });
}
function sendTyping(roomId: string) {
send("chat.typing", { roomId });
}
function sendRead(roomId: string, lastReadMessageId: string) {
send("chat.read", { roomId, lastReadMessageId });
}
return {
connected: wsConnected(),
connect,
subscribe,
unsubscribe,
onMessage,
sendChatMessage,
sendTyping,
sendRead,
};
}
export type { PresenceUpdate, FriendRequestPush };

View file

@ -0,0 +1,123 @@
import { apiDelete, apiGet, apiPost } from "./api";
export interface CommunityUserSummary {
id: string;
username: string;
displayName: string;
avatarUrl: string | null;
}
export interface FriendshipRequestSummary {
id: string;
createdAt: string;
user: CommunityUserSummary;
}
export interface FriendsListResult {
friends: CommunityUserSummary[];
incomingRequests: FriendshipRequestSummary[];
outgoingRequests: FriendshipRequestSummary[];
}
export interface FriendPlaying {
platform: "drop" | "romm";
id: string;
name: string;
}
export interface FriendActivityEntry extends CommunityUserSummary {
online: boolean;
playing: FriendPlaying | null;
}
function friendsState() {
return useState<FriendsListResult>("friends-list", () => ({
friends: [],
incomingRequests: [],
outgoingRequests: [],
}));
}
function activityState() {
return useState<Record<string, FriendActivityEntry>>("friends-activity", () => ({}));
}
function loadedState() {
return useState<boolean>("friends-loaded", () => false);
}
function errorState() {
return useState<string | undefined>("friends-error", () => undefined);
}
export function useFriends() {
const friends = friendsState();
const activity = activityState();
const loaded = loadedState();
const error = errorState();
const onlineCount = computed(
() => Object.values(activity.value).filter((f) => f.online).length,
);
const incomingCount = computed(() => friends.value.incomingRequests.length);
async function refresh() {
try {
const [list, act] = await Promise.all([
apiGet<FriendsListResult>("api/v1/community/friends"),
apiGet<{ friends: FriendActivityEntry[]; presenceAvailable: boolean }>(
"api/v1/community/friends/activity",
),
]);
friends.value = list;
const map: Record<string, FriendActivityEntry> = {};
for (const f of act.friends) map[f.id] = f;
activity.value = map;
error.value = undefined;
} catch (e) {
error.value = String(e);
} finally {
loaded.value = true;
}
}
async function sendRequest(username: string) {
const result = await apiPost<{ status: string }>("api/v1/community/friends/requests", {
username,
});
await refresh();
return result;
}
async function acceptRequest(requestId: string) {
await apiPost(`api/v1/community/friends/requests/${requestId}/accept`);
await refresh();
}
async function declineRequest(requestId: string) {
await apiPost(`api/v1/community/friends/requests/${requestId}/decline`);
await refresh();
}
async function removeFriend(userId: string) {
await apiDelete(`api/v1/community/friends/${userId}`);
await refresh();
}
async function searchUsers(query: string): Promise<CommunityUserSummary[]> {
if (!query.trim()) return [];
return apiGet<CommunityUserSummary[]>("api/v1/community/users/search", { q: query });
}
return {
friends,
activity,
loaded,
error,
onlineCount,
incomingCount,
refresh,
sendRequest,
acceptRequest,
declineRequest,
removeFriend,
searchUsers,
};
}

View file

@ -0,0 +1,94 @@
// Notifications: polling, not the websocket. `/api/v1/notifications/ws`
// exists server-side, but it authenticates the same way the community
// websocket does (Authorization header on the upgrade request, unreachable
// from a plain browser WebSocket) and would need its own Rust bridge
// identical in shape to community_ws.rs. Given the community chat socket
// was the hard requirement ("do not poll for chat") and notifications was
// explicitly flagged as an acceptable-to-poll first pass, this polls
// `GET /api/v1/notifications` on an interval instead of standing up a
// second websocket bridge. Revisit if live push turns out to matter here
// too -- the Rust-side pattern to copy is already written.
import { apiGet, apiPost } from "./api";
export interface DropNotification {
id: string;
userId: string;
nonce: string | null;
created: string;
title: string;
description: string;
actions: string[];
read: boolean;
acls: string[];
}
const POLL_INTERVAL_MS = 20_000;
function notificationsState() {
return useState<DropNotification[]>("notifications-list", () => []);
}
function loadedState() {
return useState<boolean>("notifications-loaded", () => false);
}
function errorState() {
return useState<string | undefined>("notifications-error", () => undefined);
}
function pollHandle() {
return useState<ReturnType<typeof setInterval> | undefined>(
"notifications-poll-handle",
() => undefined,
);
}
export function useNotifications() {
const notifications = notificationsState();
const loaded = loadedState();
const error = errorState();
const unreadCount = computed(
() => notifications.value.filter((n) => !n.read).length,
);
async function refresh() {
try {
const result = await apiGet<DropNotification[]>("api/v1/notifications");
notifications.value = result;
error.value = undefined;
} catch (e) {
error.value = String(e);
} finally {
loaded.value = true;
}
}
function startPolling() {
if (pollHandle().value) return;
refresh();
pollHandle().value = setInterval(refresh, POLL_INTERVAL_MS);
}
async function markRead(id: string) {
const target = notifications.value.find((n) => n.id === id);
if (target) target.read = true; // optimistic
try {
await apiPost(`api/v1/notifications/${id}/read`);
} catch (e) {
error.value = String(e);
await refresh();
}
}
async function markAllRead() {
const prior = notifications.value.map((n) => ({ ...n }));
for (const n of notifications.value) n.read = true; // optimistic
try {
await apiPost("api/v1/notifications/readall");
} catch (e) {
notifications.value = prior;
error.value = String(e);
}
}
return { notifications, loaded, error, unreadCount, refresh, startPolling, markRead, markAllRead };
}

View file

@ -1,72 +0,0 @@
<template>
<div class="grow w-full h-full flex items-center justify-center">
<div class="flex flex-col items-center max-w-md text-center">
<UserGroupIcon class="h-12 w-12 text-blue-600" aria-hidden="true" />
<div class="mt-3">
<h1 class="text-3xl font-semibold font-display leading-6 text-zinc-100">
Community
</h1>
<div class="mt-4">
<p class="text-sm text-zinc-400">
Your profile, library activity, and play sessions live on this
Drop server's community page. It opens in your default browser.
</p>
</div>
</div>
<div class="mt-6 flex flex-col items-center gap-2">
<button
type="button"
:disabled="opening || !communityUrl"
@click="openCommunity"
class="inline-flex items-center gap-x-2 rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:bg-blue-600/50 disabled:cursor-not-allowed"
>
Open Community
<ArrowTopRightOnSquareIcon class="h-4 w-4" aria-hidden="true" />
</button>
<p v-if="error" class="text-xs text-red-500">{{ error }}</p>
<p class="text-xs text-zinc-500">{{ communityUrl }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { UserGroupIcon } from "@heroicons/vue/20/solid";
import { ArrowTopRightOnSquareIcon } from "@heroicons/vue/20/solid";
import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-shell";
// The Community tab was upstream's "Under construction" stub -- see
// homelab-compose's [[drop-upstream-dead-community-stub]] memory. Community
// is NOT a separate service: it's a route on this client's own configured
// Drop server (`{base_url}/community`), so the URL is resolved via the
// existing `gen_drop_url` Tauri command (same one HeaderUserWidget.vue uses
// for the admin link) rather than a hardcoded external domain or a
// Settings-stored URL -- it always follows whatever server the user is
// signed into. Opens in the system browser via the Tauri shell plugin
// (already granted `shell:allow-open` in
// desktop/src-tauri/capabilities/default.json, no capability changes
// needed).
const communityUrl = ref<string | undefined>(undefined);
const opening = ref(false);
const error = ref<string | undefined>(undefined);
try {
communityUrl.value = await invoke<string>("gen_drop_url", { path: "/community" });
} catch (e) {
error.value = `Could not resolve community URL: ${e}`;
}
async function openCommunity() {
if (!communityUrl.value) return;
error.value = undefined;
opening.value = true;
try {
await open(communityUrl.value);
} catch (e) {
error.value = `Could not open browser: ${e}`;
} finally {
opening.value = false;
}
}
</script>

View file

@ -0,0 +1,252 @@
<template>
<div class="w-full h-full flex overflow-hidden">
<!-- Room list -->
<aside
class="w-72 shrink-0 bg-zinc-950/50 backdrop-blur-xl border-r border-zinc-800/50 flex flex-col overflow-hidden"
>
<div class="px-4 py-4 border-b border-zinc-800 flex items-center justify-between">
<h1 class="text-lg font-display font-semibold text-zinc-100">Chat</h1>
<span
class="h-2 w-2 rounded-full"
:class="wsConnected ? 'bg-green-500' : 'bg-zinc-600'"
:title="wsConnected ? 'Connected' : 'Connecting…'"
/>
</div>
<div class="flex-1 overflow-y-auto">
<div v-if="!roomsLoaded" class="px-4 py-6 text-sm text-zinc-500">Loading&hellip;</div>
<div v-else-if="roomsError" class="px-4 py-4 text-xs text-red-500">
Couldn't load rooms: {{ roomsError }}
</div>
<template v-else>
<ChatRoomGroup label="Servers" :rooms="serverRooms" :active="activeRoomId" @select="select" />
<ChatRoomGroup label="General" :rooms="globalRooms" :active="activeRoomId" @select="select" />
<ChatRoomGroup label="Titles" :rooms="titleRooms" :active="activeRoomId" @select="select" />
<ChatRoomGroup label="Direct messages" :rooms="dmRooms" :active="activeRoomId" @select="select" />
</template>
</div>
</aside>
<!-- Message pane -->
<div class="flex-1 flex flex-col overflow-hidden">
<div v-if="!activeRoom" class="flex-1 flex items-center justify-center">
<div class="flex flex-col items-center gap-y-4 text-center">
<div class="p-4 rounded-xl bg-zinc-700/50 backdrop-blur-sm">
<ChatBubbleLeftRightIcon class="size-12 text-zinc-400" />
</div>
<div>
<h3 class="text-xl font-display font-semibold text-zinc-100">Pick a room</h3>
<p class="mt-1 text-sm text-zinc-400">Choose a room from the sidebar to start chatting</p>
</div>
</div>
</div>
<template v-else>
<div class="px-6 py-4 border-b border-zinc-800">
<h2 class="text-base font-semibold text-zinc-100">{{ activeRoom.name }}</h2>
<p v-if="activeRoom.topic" class="text-xs text-zinc-500 mt-0.5">{{ activeRoom.topic }}</p>
</div>
<div ref="messageListEl" class="flex-1 overflow-y-auto px-6 py-4 flex flex-col gap-y-3">
<div v-if="messagesLoading" class="text-sm text-zinc-500 text-center py-8">Loading messages&hellip;</div>
<div v-else-if="messages.length === 0" class="text-sm text-zinc-500 text-center py-8">
No messages yet. Say hello.
</div>
<div
v-for="msg in messages"
:key="msg.id"
class="flex gap-x-2"
:class="msg.kind === 'system' ? 'justify-center' : ''"
>
<template v-if="msg.kind === 'system'">
<p class="text-xs text-zinc-600 italic">{{ msg.body }}</p>
</template>
<template v-else>
<CommunityAvatar :url="msg.sender?.avatarUrl ?? null" />
<div class="min-w-0">
<div class="flex items-baseline gap-x-2">
<span class="text-sm font-semibold text-zinc-200">
{{ msg.sender?.displayName ?? "Unknown" }}
</span>
<span class="text-[11px] text-zinc-600">{{ formatTime(msg.createdAt) }}</span>
<span v-if="msg.pending" class="text-[11px] text-zinc-600 italic">sending&hellip;</span>
</div>
<p class="text-sm text-zinc-300 whitespace-pre-wrap break-words">{{ msg.body }}</p>
</div>
</template>
</div>
</div>
<div class="px-6 py-4 border-t border-zinc-800">
<form @submit.prevent="send" class="flex gap-x-2">
<input
v-model="draft"
type="text"
placeholder="Message&hellip;"
class="flex-1 rounded-md bg-zinc-800 border-0 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-500 focus:ring-2 focus:ring-blue-600 focus:outline-none"
/>
<button
type="submit"
:disabled="!draft.trim()"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500 disabled:opacity-50"
>
Send
</button>
</form>
</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { ChatBubbleLeftRightIcon } from "@heroicons/vue/24/outline";
import { apiGet } from "~/composables/api";
import { useCommunityWs, type ChatMessageDTO } from "~/composables/community-ws";
interface ChatRoomSummary {
id: string;
kind: "server" | "title" | "direct" | "global";
name: string;
topic: string | null;
serverKey: string | null;
gameId: string | null;
communityTitleId: string | null;
otherUser: { id: string; username: string; displayName: string; avatarUrl: string | null } | null;
lastMessageAt: string | null;
unreadCount: number;
}
interface DisplayMessage extends ChatMessageDTO {
pending?: boolean;
}
const route = useRoute();
const router = useRouter();
const state = useAppState();
const { connected: wsConnected, connect, subscribe, onMessage, sendChatMessage, sendRead } =
useCommunityWs();
const rooms = ref<ChatRoomSummary[]>([]);
const roomsLoaded = ref(false);
const roomsError = ref<string | undefined>(undefined);
const activeRoomId = ref<string | undefined>(undefined);
const messages = ref<DisplayMessage[]>([]);
const messagesLoading = ref(false);
const draft = ref("");
const messageListEl = ref<HTMLElement | null>(null);
const serverRooms = computed(() => rooms.value.filter((r) => r.kind === "server"));
const globalRooms = computed(() => rooms.value.filter((r) => r.kind === "global"));
const titleRooms = computed(() => rooms.value.filter((r) => r.kind === "title"));
const dmRooms = computed(() => rooms.value.filter((r) => r.kind === "direct"));
const activeRoom = computed(() => rooms.value.find((r) => r.id === activeRoomId.value));
async function loadRooms() {
try {
const result = await apiGet<{ rooms: ChatRoomSummary[] }>("api/v1/community/chat/rooms");
rooms.value = result.rooms;
roomsError.value = undefined;
} catch (e) {
roomsError.value = String(e);
} finally {
roomsLoaded.value = true;
}
}
async function select(roomId: string) {
activeRoomId.value = roomId;
router.replace({ query: { ...route.query, room: roomId } });
messagesLoading.value = true;
messages.value = [];
subscribe([`room:${roomId}`]);
try {
const result = await apiGet<{ messages: ChatMessageDTO[] }>(
`api/v1/community/chat/rooms/${roomId}/messages`,
);
messages.value = result.messages;
await nextTick();
scrollToBottom();
const last = messages.value.at(-1);
if (last) sendRead(roomId, last.id);
} catch (e) {
roomsError.value = String(e);
} finally {
messagesLoading.value = false;
}
}
function scrollToBottom() {
if (messageListEl.value) messageListEl.value.scrollTop = messageListEl.value.scrollHeight;
}
function formatTime(iso: string) {
try {
return new Date(iso).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
} catch {
return iso;
}
}
function send() {
const body = draft.value.trim();
if (!body || !activeRoomId.value) return;
const nonce = crypto.randomUUID();
messages.value.push({
id: `pending-${nonce}`,
roomId: activeRoomId.value,
kind: "text",
body,
deleted: false,
clientNonce: nonce,
replyToId: null,
sender: state.value?.user
? {
id: state.value.user.id ?? "",
username: state.value.user.username,
displayName: state.value.user.displayName,
avatarUrl: null,
}
: null,
createdAt: new Date().toISOString(),
editedAt: null,
pending: true,
});
sendChatMessage(activeRoomId.value, body, nonce);
draft.value = "";
nextTick(scrollToBottom);
}
onMessage((envelope) => {
if (envelope.t === "chat.message") {
const msg: ChatMessageDTO = envelope.d;
// Replace our own optimistic/pending entry (matched by clientNonce) with
// the server-confirmed row; otherwise append if it's for the open room.
const pendingIdx = messages.value.findIndex(
(m) => m.pending && m.clientNonce && m.clientNonce === msg.clientNonce,
);
if (pendingIdx !== -1) {
messages.value.splice(pendingIdx, 1, msg);
} else if (msg.roomId === activeRoomId.value) {
messages.value.push(msg);
}
if (msg.roomId === activeRoomId.value) {
nextTick(scrollToBottom);
sendRead(msg.roomId, msg.id);
} else {
const room = rooms.value.find((r) => r.id === msg.roomId);
if (room) room.unreadCount += 1;
}
} else if (envelope.t === "friend.request") {
// Surfaced via the friends widget/page polling; nothing to render here.
}
});
onMounted(async () => {
await connect();
await loadRooms();
const requested = route.query.room as string | undefined;
const initial = requested ?? globalRooms.value[0]?.id ?? rooms.value[0]?.id;
if (initial) await select(initial);
});
</script>

View file

@ -0,0 +1,247 @@
<template>
<div class="w-full h-full overflow-y-auto">
<div class="max-w-3xl mx-auto px-8 py-10">
<h1 class="text-3xl font-semibold font-display leading-6 text-zinc-100">Friends</h1>
<!-- Add friend -->
<div class="mt-6 bg-zinc-800/50 rounded-xl p-4">
<label class="text-sm font-medium text-zinc-300">Add a friend</label>
<div class="mt-2 flex gap-x-2">
<input
v-model="searchQuery"
@input="debouncedSearch"
type="text"
placeholder="Search by username&hellip;"
class="flex-1 rounded-md bg-zinc-900 border-0 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-500 focus:ring-2 focus:ring-blue-600 focus:outline-none"
/>
</div>
<div v-if="searchResults.length > 0" class="mt-3 flex flex-col gap-y-1">
<div
v-for="user in searchResults"
:key="user.id"
class="flex items-center gap-x-3 rounded-md px-2 py-2 hover:bg-zinc-800"
>
<CommunityAvatar :url="user.avatarUrl" />
<div class="min-w-0 flex-1">
<p class="text-sm text-zinc-100 truncate">{{ user.displayName }}</p>
<p class="text-xs text-zinc-500 truncate">@{{ user.username }}</p>
</div>
<button
@click="send(user.username)"
:disabled="sending === user.username"
class="rounded-md bg-blue-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-500 disabled:opacity-50"
>
{{ sending === user.username ? "Sending&hellip;" : "Add" }}
</button>
</div>
</div>
<p v-if="searchQuery.trim() && searchResults.length === 0 && searchDone" class="mt-2 text-xs text-zinc-500">
No users found.
</p>
<p v-if="requestFeedback" class="mt-2 text-xs" :class="requestError ? 'text-red-500' : 'text-green-500'">
{{ requestFeedback }}
</p>
</div>
<div v-if="error" class="mt-6 rounded-md bg-red-600/10 p-4 text-sm text-red-500">
Couldn't load friends: {{ error }}
</div>
<!-- Incoming requests -->
<section v-if="friends.incomingRequests.length > 0" class="mt-8">
<h2 class="text-sm font-semibold uppercase tracking-wide text-zinc-500 mb-3">
Incoming requests ({{ friends.incomingRequests.length }})
</h2>
<div class="flex flex-col gap-y-2">
<div
v-for="req in friends.incomingRequests"
:key="req.id"
class="flex items-center gap-x-3 bg-zinc-800/50 rounded-lg p-3"
>
<CommunityAvatar :url="req.user.avatarUrl" size="md" />
<div class="min-w-0 flex-1">
<p class="text-sm text-zinc-100 truncate">{{ req.user.displayName }}</p>
<p class="text-xs text-zinc-500">@{{ req.user.username }}</p>
</div>
<button
@click="acceptRequest(req.id)"
class="rounded-md bg-green-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-green-500"
>
Accept
</button>
<button
@click="declineRequest(req.id)"
class="rounded-md bg-zinc-700 px-3 py-1.5 text-xs font-semibold text-zinc-200 hover:bg-zinc-600"
>
Decline
</button>
</div>
</div>
</section>
<!-- Outgoing requests -->
<section v-if="friends.outgoingRequests.length > 0" class="mt-8">
<h2 class="text-sm font-semibold uppercase tracking-wide text-zinc-500 mb-3">
Outgoing requests ({{ friends.outgoingRequests.length }})
</h2>
<div class="flex flex-col gap-y-2">
<div
v-for="req in friends.outgoingRequests"
:key="req.id"
class="flex items-center gap-x-3 bg-zinc-800/50 rounded-lg p-3"
>
<CommunityAvatar :url="req.user.avatarUrl" size="md" />
<div class="min-w-0 flex-1">
<p class="text-sm text-zinc-100 truncate">{{ req.user.displayName }}</p>
<p class="text-xs text-zinc-500">@{{ req.user.username }} &middot; pending</p>
</div>
<button
@click="removeFriend(req.user.id)"
class="rounded-md bg-zinc-700 px-3 py-1.5 text-xs font-semibold text-zinc-200 hover:bg-zinc-600"
>
Cancel
</button>
</div>
</div>
</section>
<!-- Friends list -->
<section class="mt-8">
<h2 class="text-sm font-semibold uppercase tracking-wide text-zinc-500 mb-3">
Friends ({{ friends.friends.length }})
</h2>
<div v-if="!loaded" class="text-sm text-zinc-500">Loading&hellip;</div>
<div v-else-if="friends.friends.length === 0" class="text-sm text-zinc-500">
No friends yet. Search above to send a request.
</div>
<div v-else class="flex flex-col gap-y-2">
<div
v-for="friend in friends.friends"
:key="friend.id"
class="flex items-center gap-x-3 bg-zinc-800/50 rounded-lg p-3"
>
<div class="relative">
<CommunityAvatar :url="friend.avatarUrl" size="md" />
<span
class="absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-zinc-900"
:class="activity[friend.id]?.online ? 'bg-green-500' : 'bg-zinc-600'"
/>
</div>
<div class="min-w-0 flex-1">
<NuxtLink
:to="`/community/profile/${friend.username}`"
class="text-sm text-zinc-100 hover:text-blue-400 truncate block"
>
{{ friend.displayName }}
</NuxtLink>
<p class="text-xs text-zinc-500 truncate">
{{
activity[friend.id]?.playing
? `Playing ${activity[friend.id]?.playing?.name}`
: activity[friend.id]?.online
? "Online"
: "Offline"
}}
</p>
</div>
<button
@click="openDm(friend)"
class="rounded-md bg-zinc-700 px-3 py-1.5 text-xs font-semibold text-zinc-200 hover:bg-zinc-600"
>
Message
</button>
<button
@click="removeFriend(friend.id)"
class="rounded-md bg-zinc-800 px-3 py-1.5 text-xs font-semibold text-zinc-400 hover:bg-red-600/20 hover:text-red-400"
>
Remove
</button>
</div>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { useFriends, type CommunityUserSummary } from "~/composables/friends";
import { apiPost } from "~/composables/api";
const {
friends,
activity,
loaded,
error,
refresh,
sendRequest,
acceptRequest: acceptRequestApi,
declineRequest: declineRequestApi,
removeFriend: removeFriendApi,
searchUsers,
} = useFriends();
const router = useRouter();
refresh();
const searchQuery = ref("");
const searchResults = ref<CommunityUserSummary[]>([]);
const searchDone = ref(false);
const sending = ref<string | undefined>(undefined);
const requestFeedback = ref<string | undefined>(undefined);
const requestError = ref(false);
let searchTimer: ReturnType<typeof setTimeout> | undefined;
function debouncedSearch() {
searchDone.value = false;
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(async () => {
try {
searchResults.value = await searchUsers(searchQuery.value);
} catch (e) {
searchResults.value = [];
} finally {
searchDone.value = true;
}
}, 300);
}
async function send(username: string) {
sending.value = username;
requestFeedback.value = undefined;
requestError.value = false;
try {
const result = await sendRequest(username);
requestFeedback.value =
result.status === "accepted"
? `You and ${username} are now friends.`
: result.status === "already-friends"
? `You're already friends with ${username}.`
: `Friend request sent to ${username}.`;
searchResults.value = searchResults.value.filter((u) => u.username !== username);
} catch (e) {
requestError.value = true;
requestFeedback.value = String(e);
} finally {
sending.value = undefined;
}
}
async function acceptRequest(id: string) {
await acceptRequestApi(id);
}
async function declineRequest(id: string) {
await declineRequestApi(id);
}
async function removeFriend(userId: string) {
await removeFriendApi(userId);
}
async function openDm(friend: CommunityUserSummary) {
const { roomId } = await apiPost<{ roomId: string }>(
`api/v1/community/chat/dm/${friend.id}`,
);
router.push({ path: "/community/chat", query: { room: roomId } });
}
</script>

View file

@ -0,0 +1,143 @@
<template>
<div class="w-full h-full overflow-y-auto">
<div class="max-w-5xl mx-auto px-8 py-10">
<div class="flex items-center justify-between">
<h1 class="text-3xl font-semibold font-display leading-6 text-zinc-100">Community</h1>
<div class="flex gap-x-2">
<NuxtLink
to="/community/friends"
class="rounded-md bg-zinc-800 px-4 py-2 text-sm font-semibold text-zinc-200 hover:bg-zinc-700"
>
Friends
</NuxtLink>
<NuxtLink
to="/community/chat"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500"
>
Open chat
</NuxtLink>
</div>
</div>
<div v-if="error" class="mt-6 rounded-md bg-red-600/10 p-4 text-sm text-red-500">
Couldn't load community activity: {{ error }}
</div>
<section class="mt-8">
<h2 class="text-sm font-semibold uppercase tracking-wide text-zinc-500 mb-3">
Playing now
</h2>
<div v-if="loading" class="text-sm text-zinc-500">Loading&hellip;</div>
<div v-else-if="activity.nowPlaying.length === 0" class="text-sm text-zinc-500">
Nobody's playing right now.
</div>
<div v-else class="grid grid-cols-2 md:grid-cols-4 gap-3">
<NuxtLink
v-for="entry in activity.nowPlaying"
:key="entry.sessionId"
:to="`/community/profile/${entry.username}`"
class="flex items-center gap-x-3 bg-zinc-800/50 rounded-lg p-3 hover:bg-zinc-800"
>
<CommunityAvatar :url="entry.avatarUrl" />
<div class="min-w-0">
<p class="text-sm text-zinc-200 truncate">{{ entry.displayName }}</p>
<p class="text-xs text-zinc-500 truncate">{{ entry.titleName }}</p>
</div>
</NuxtLink>
</div>
</section>
<section class="mt-8">
<h2 class="text-sm font-semibold uppercase tracking-wide text-zinc-500 mb-3">
Recently played
</h2>
<div v-if="!loading && activity.recentlyPlayed.length === 0" class="text-sm text-zinc-500">
No recent activity yet.
</div>
<div v-else class="grid grid-cols-2 md:grid-cols-4 gap-3">
<NuxtLink
v-for="entry in activity.recentlyPlayed"
:key="entry.sessionId"
:to="`/community/profile/${entry.username}`"
class="flex items-center gap-x-3 bg-zinc-800/50 rounded-lg p-3 hover:bg-zinc-800"
>
<CommunityAvatar :url="entry.avatarUrl" />
<div class="min-w-0">
<p class="text-sm text-zinc-200 truncate">{{ entry.displayName }}</p>
<p class="text-xs text-zinc-500 truncate">played {{ entry.titleName }}</p>
</div>
</NuxtLink>
</div>
</section>
<section class="mt-8">
<h2 class="text-sm font-semibold uppercase tracking-wide text-zinc-500 mb-3">
Persistent-world servers
</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<NuxtLink
v-for="server in KNOWN_SERVERS"
:key="server.key"
:to="{ path: '/community/chat', query: { room: 'server' } }"
@click.prevent="openServerRoom(server.key)"
class="bg-zinc-800/50 rounded-lg p-4 hover:bg-zinc-800"
>
<p class="text-sm font-semibold text-zinc-200">{{ server.name }}</p>
<p class="text-xs text-zinc-500 mt-1">{{ server.topic }}</p>
</NuxtLink>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { apiGet, apiPost } from "~/composables/api";
interface ActivityEntry {
sessionId: string;
username: string;
displayName: string;
avatarUrl: string | null;
platform: "drop" | "romm";
titleId: string;
titleName: string;
coverUrl: string | null;
deepLink: string;
startedAt: string;
endedAt: string | null;
}
const KNOWN_SERVERS = [
{ key: "eqemu", name: "EverQuest (EQEmu)", topic: "everquest.quasarke.net — Titanium/RoF2, login port 5999" },
{ key: "wow", name: "WoW (TrinityCore, WotLK 3.3.5a)", topic: "wow.quasarke.net — client build 12340" },
{ key: "daoc", name: "Dark Age of Camelot (OpenDAoC)", topic: "daoc.quasarke.net — three-realm RvR" },
];
const router = useRouter();
const activity = ref<{ nowPlaying: ActivityEntry[]; recentlyPlayed: ActivityEntry[] }>({
nowPlaying: [],
recentlyPlayed: [],
});
const loading = ref(true);
const error = ref<string | undefined>(undefined);
async function load() {
loading.value = true;
try {
activity.value = await apiGet("api/v1/community/activity");
error.value = undefined;
} catch (e) {
error.value = String(e);
} finally {
loading.value = false;
}
}
async function openServerRoom(key: string) {
const { roomId } = await apiPost<{ roomId: string }>(`api/v1/community/chat/rooms/server/${key}`);
router.push({ path: "/community/chat", query: { room: roomId } });
}
onMounted(load);
</script>

View file

@ -0,0 +1,233 @@
<template>
<div class="w-full h-full overflow-y-auto">
<div class="max-w-4xl mx-auto px-8 py-10">
<div v-if="loading" class="text-sm text-zinc-500">Loading&hellip;</div>
<div v-else-if="error" class="rounded-md bg-red-600/10 p-4 text-sm text-red-500">
Couldn't load this profile: {{ error }}
</div>
<template v-else-if="profile">
<div class="flex items-center gap-x-4">
<CommunityAvatar :url="profile.avatarUrl" size="lg" />
<div>
<h1 class="text-2xl font-display font-semibold text-zinc-100">
{{ profile.displayName }}
<span
v-if="profile.admin"
class="ml-2 align-middle text-[11px] uppercase tracking-wide text-blue-400 bg-blue-500/10 rounded-full px-2 py-0.5"
>
Admin
</span>
</h1>
<p class="text-sm text-zinc-500">@{{ profile.username }}</p>
</div>
<div class="ml-auto flex gap-x-2">
<button
v-if="!isSelf"
@click="messageFriend"
class="rounded-md bg-zinc-800 px-3 py-1.5 text-xs font-semibold text-zinc-200 hover:bg-zinc-700"
>
Message
</button>
</div>
</div>
<p class="mt-6 text-xs text-zinc-600">
Display name and avatar are managed by the Drop server (no client-side
editor exists for these yet). Playtime and achievements below are
computed server-side from the same telemetry that powers the desktop
launcher.
</p>
<div class="mt-6 grid grid-cols-3 gap-4">
<div class="bg-zinc-800/50 rounded-xl p-4">
<p class="text-2xl font-semibold text-zinc-100">{{ profile.titlesTracked }}</p>
<p class="text-xs text-zinc-500">Titles tracked</p>
</div>
<div class="bg-zinc-800/50 rounded-xl p-4">
<p class="text-2xl font-semibold text-zinc-100">{{ formatHours(profile.totalSecondsTracked) }}</p>
<p class="text-xs text-zinc-500">Total playtime</p>
</div>
<div class="bg-zinc-800/50 rounded-xl p-4">
<p class="text-2xl font-semibold text-zinc-100">
{{ achievements ? `${achievements.totalUnlocked} / ${achievements.totalDefinitions}` : "—" }}
</p>
<p class="text-xs text-zinc-500">Achievements unlocked</p>
</div>
</div>
<section class="mt-8" v-if="profile.recentlyPlayed.length > 0">
<h2 class="text-sm font-semibold uppercase tracking-wide text-zinc-500 mb-3">
Recently played
</h2>
<div class="grid grid-cols-5 gap-3">
<div v-for="entry in profile.recentlyPlayed" :key="entry.id" class="text-center">
<div class="aspect-[3/4] rounded-lg overflow-hidden bg-zinc-800">
<img v-if="entry.coverUrl" :src="coverSrc(entry)" class="w-full h-full object-cover" />
</div>
<p class="mt-1 text-xs text-zinc-400 truncate">{{ entry.name }}</p>
<p class="text-[11px] text-zinc-600">{{ formatHours(entry.seconds) }}</p>
</div>
</div>
</section>
<section class="mt-8" v-if="profile.mostPlayed.length > 0">
<h2 class="text-sm font-semibold uppercase tracking-wide text-zinc-500 mb-3">
Most played
</h2>
<div class="grid grid-cols-5 gap-3">
<div v-for="entry in profile.mostPlayed" :key="entry.id" class="text-center">
<div class="aspect-[3/4] rounded-lg overflow-hidden bg-zinc-800">
<img v-if="entry.coverUrl" :src="coverSrc(entry)" class="w-full h-full object-cover" />
</div>
<p class="mt-1 text-xs text-zinc-400 truncate">{{ entry.name }}</p>
<p class="text-[11px] text-zinc-600">{{ formatHours(entry.seconds) }}</p>
</div>
</div>
</section>
<section class="mt-8" v-if="achievements && achievements.games.length > 0">
<h2 class="text-sm font-semibold uppercase tracking-wide text-zinc-500 mb-3">
Achievements by game
</h2>
<div class="flex flex-col gap-y-2">
<div
v-for="game in achievements.games"
:key="game.gameId"
class="flex items-center gap-x-3 bg-zinc-800/50 rounded-lg p-3"
>
<img
v-if="game.coverUrl"
:src="useObject(game.coverUrl.replace('/api/v1/object/', ''))"
class="w-8 h-10 rounded object-cover shrink-0"
/>
<div class="min-w-0 flex-1">
<p class="text-sm text-zinc-200 truncate">{{ game.gameName }}</p>
<div class="mt-1 h-1.5 rounded-full bg-zinc-700 overflow-hidden">
<div
class="h-full bg-blue-500"
:style="{ width: `${(game.unlockedCount / Math.max(game.total, 1)) * 100}%` }"
/>
</div>
</div>
<p class="text-xs text-zinc-500 shrink-0">{{ game.unlockedCount }} / {{ game.total }}</p>
</div>
</div>
</section>
<div
v-if="
profile.recentlyPlayed.length === 0 &&
profile.mostPlayed.length === 0 &&
(!achievements || achievements.games.length === 0)
"
class="mt-10 text-sm text-zinc-500"
>
No tracked playtime or achievements yet.
</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { apiGet, apiPost } from "~/composables/api";
interface ProfilePlaytimeEntry {
id: string;
platform: "drop" | "romm";
name: string;
coverUrl: string | null;
deepLink: string;
seconds: number;
lastPlayedAt: string;
}
interface CommunityProfile {
username: string;
displayName: string;
avatarUrl: string | null;
admin: boolean;
recentlyPlayed: ProfilePlaytimeEntry[];
mostPlayed: ProfilePlaytimeEntry[];
totalSecondsTracked: number;
titlesTracked: number;
}
interface ProfileAchievementGameSummary {
gameId: string;
gameName: string;
coverUrl: string | null;
total: number;
unlockedCount: number;
}
interface ProfileAchievementsResult {
username: string;
totalUnlocked: number;
totalDefinitions: number;
games: ProfileAchievementGameSummary[];
}
const route = useRoute();
const router = useRouter();
const state = useAppState();
const profile = ref<CommunityProfile | undefined>(undefined);
const achievements = ref<ProfileAchievementsResult | undefined>(undefined);
const loading = ref(true);
const error = ref<string | undefined>(undefined);
const isSelf = computed(() => state.value?.user?.username === route.params.username);
function formatHours(seconds: number) {
const hours = seconds / 3600;
if (hours < 1) return `${Math.round(seconds / 60)}m`;
return `${hours.toFixed(1)}h`;
}
function coverSrc(entry: ProfilePlaytimeEntry) {
if (!entry.coverUrl) return undefined;
// Drop covers come back as `/api/v1/object/<id>` -- useObject wants the
// bare object id (it builds the tauri://object protocol URL itself).
const id = entry.coverUrl.startsWith("/api/v1/object/")
? entry.coverUrl.slice("/api/v1/object/".length)
: entry.coverUrl;
return useObject(id);
}
async function load() {
loading.value = true;
error.value = undefined;
const username = route.params.username as string;
try {
const [profileResult, achievementsResult] = await Promise.all([
apiGet<CommunityProfile>(`api/v1/community/profile/${username}`),
apiGet<ProfileAchievementsResult>(`api/v1/community/profile/${username}/achievements`).catch(
() => undefined,
),
]);
profile.value = profileResult;
achievements.value = achievementsResult;
} catch (e) {
error.value = String(e);
} finally {
loading.value = false;
}
}
async function messageFriend() {
// Resolve username -> id via search (profile responses don't carry the
// user id, only username -- the friends/search routes do).
const results = await apiGet<Array<{ id: string; username: string }>>(
"api/v1/community/users/search",
{ q: route.params.username as string },
);
const match = results.find((u) => u.username === route.params.username);
if (!match) return;
const { roomId } = await apiPost<{ roomId: string }>(`api/v1/community/chat/dm/${match.id}`);
router.push({ path: "/community/chat", query: { room: roomId } });
}
watch(() => route.params.username, load);
onMounted(load);
</script>

View file

@ -191,6 +191,57 @@
</div>
</div>
</div>
<div class="bg-zinc-800/50 rounded-xl p-6 backdrop-blur-sm">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-display font-semibold text-zinc-100">
Achievements
</h2>
<span v-if="achievements && achievements.total > 0" class="text-xs text-zinc-500">
{{ achievements.unlockedCount }} / {{ achievements.total }}
</span>
</div>
<div v-if="achievementsLoading" class="text-sm text-zinc-500">Loading&hellip;</div>
<div v-else-if="achievementsError" class="text-xs text-red-500">
Couldn't load achievements: {{ achievementsError }}
</div>
<div
v-else-if="!achievements || achievements.total === 0"
class="text-sm text-zinc-500"
>
No achievements catalog imported for this game yet.
</div>
<template v-else>
<div class="h-1.5 rounded-full bg-zinc-700 overflow-hidden mb-4">
<div
class="h-full bg-blue-500"
:style="{ width: `${(achievements.unlockedCount / achievements.total) * 100}%` }"
/>
</div>
<div class="grid grid-cols-4 gap-3 max-h-96 overflow-y-auto custom-scrollbar">
<div
v-for="ach in achievements.achievements.filter((a) => !a.isHidden || a.unlocked)"
:key="ach.id"
class="flex flex-col items-center text-center"
:title="ach.description ?? undefined"
>
<div
class="w-14 h-14 rounded-lg overflow-hidden bg-zinc-900 flex items-center justify-center"
:class="!ach.unlocked ? 'opacity-40 grayscale' : ''"
>
<img
v-if="ach.unlocked ? ach.iconUrl : (ach.iconGrayUrl ?? ach.iconUrl)"
:src="useObject(objectId(ach.unlocked ? ach.iconUrl : (ach.iconGrayUrl ?? ach.iconUrl)))"
class="w-full h-full object-cover"
/>
<TrophyIcon v-else class="h-6 w-6 text-zinc-600" />
</div>
<p class="mt-1 text-[11px] text-zinc-400 line-clamp-2">{{ ach.displayName }}</p>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
@ -662,7 +713,9 @@ import {
PhotoIcon,
PlayIcon,
InformationCircleIcon,
TrophyIcon,
} from "@heroicons/vue/20/solid";
import { apiGet } from "~/composables/api";
import { BuildingStorefrontIcon } from "@heroicons/vue/24/outline";
import {
ArrowDownTrayIcon,
@ -873,6 +926,54 @@ function previousImage() {
}
const fullscreenImage = ref<string | null>(null);
interface AchievementDisplayEntry {
id: string;
apiName: string;
displayName: string;
description: string | null;
iconUrl: string | null;
iconGrayUrl: string | null;
isHidden: boolean;
unlocked: boolean;
unlockedAt: string | null;
}
interface GameAchievementsResult {
gameId: string;
gameName: string;
steamAppId: number;
total: number;
unlockedCount: number;
achievements: AchievementDisplayEntry[];
}
const achievements = ref<GameAchievementsResult | undefined>(undefined);
const achievementsLoading = ref(true);
const achievementsError = ref<string | undefined>(undefined);
// Achievement icons come back from the object store as `/api/v1/object/<id>`
// -- useObject() wants the bare object id, it builds the tauri://object
// protocol URL itself.
function objectId(url: string | null) {
if (!url) return "";
return url.startsWith("/api/v1/object/") ? url.slice("/api/v1/object/".length) : url;
}
async function loadAchievements() {
achievementsLoading.value = true;
try {
achievements.value = await apiGet<GameAchievementsResult>(
`api/v1/community/games/${game.id}/achievements`,
);
achievementsError.value = undefined;
} catch (e) {
achievementsError.value = String(e);
} finally {
achievementsLoading.value = false;
}
}
onMounted(loadAchievements);
</script>
<style scoped>

View file

@ -1,25 +1,168 @@
<template>
<div class="grow w-full h-full flex items-center justify-center">
<div class="flex flex-col items-center">
<WrenchScrewdriverIcon
class="h-12 w-12 text-blue-600"
aria-hidden="true"
/>
<div class="mt-3 text-center sm:mt-5">
<h1 class="text-3xl font-semibold font-display leading-6 text-zinc-100">
Under construction
</h1>
<div class="mt-4">
<p class="text-sm text-zinc-400 max-w-lg">
This page hasn't been implemented yet.
<div class="w-full h-full overflow-y-auto">
<div class="max-w-3xl mx-auto px-8 py-10">
<h1 class="text-3xl font-semibold font-display leading-6 text-zinc-100 mb-8">
News
</h1>
<div v-if="loading" class="flex items-center justify-center py-24">
<div role="status">
<svg
aria-hidden="true"
class="w-8 h-8 text-zinc-700 animate-spin fill-blue-600"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<span class="sr-only">Loading...</span>
</div>
</div>
<div v-else-if="error" class="rounded-md bg-red-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
Couldn't load news: {{ error }}
</h3>
</div>
</div>
</div>
<div
v-else-if="articles.length === 0"
class="flex flex-col items-center gap-y-4 py-24 text-center"
>
<div class="p-4 rounded-xl bg-zinc-700/50 backdrop-blur-sm">
<NewspaperIcon class="size-12 text-zinc-400" aria-hidden="true" />
</div>
<div>
<h3 class="text-xl font-display font-semibold text-zinc-100">No posts yet</h3>
<p class="mt-1 text-sm text-zinc-400">
There's nothing posted on this server yet. Check back later.
</p>
</div>
</div>
<div v-else class="flex flex-col gap-8">
<article
v-for="article in articles"
:key="article.id"
class="border-b border-zinc-800 pb-8 last:border-0"
>
<img
v-if="article.imageObjectId"
:src="useObject(article.imageObjectId)"
class="w-full aspect-[3/1] object-cover rounded-lg mb-4"
/>
<div class="flex items-center gap-x-2 text-xs text-zinc-500">
<span>{{ formatDate(article.publishedAt) }}</span>
<span v-if="article.author">&middot;</span>
<span v-if="article.author">{{ article.author.displayName }}</span>
</div>
<h2 class="mt-1 text-2xl font-display font-semibold text-zinc-100">
{{ article.title }}
</h2>
<p class="mt-2 text-sm text-zinc-400">{{ article.description }}</p>
<div
class="mt-4 prose prose-invert prose-blue max-w-none text-zinc-300"
v-html="renderedContent(article)"
/>
<div
v-if="article.tags?.length"
class="mt-4 flex flex-wrap gap-2"
>
<span
v-for="tag in article.tags"
:key="tag.id ?? tag.name"
class="rounded-full bg-zinc-800 px-2.5 py-0.5 text-xs text-zinc-400"
>
{{ tag.name }}
</span>
</div>
</article>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
WrenchScrewdriverIcon,
} from "@heroicons/vue/20/solid";
import { NewspaperIcon, XCircleIcon } from "@heroicons/vue/20/solid";
import { micromark } from "micromark";
import { apiGet } from "~/composables/api";
interface NewsAuthor {
id: string;
displayName: string;
}
interface NewsTag {
id?: string;
name: string;
}
interface NewsArticle {
id: string;
title: string;
description: string;
content: string;
imageObjectId: string | null;
publishedAt: string;
author: NewsAuthor | null;
tags?: NewsTag[];
}
const articles = ref<NewsArticle[]>([]);
const loading = ref(true);
const error = ref<string | undefined>(undefined);
function formatDate(iso: string) {
try {
return new Date(iso).toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
});
} catch {
return iso;
}
}
function renderedContent(article: NewsArticle) {
try {
return micromark(article.content ?? "");
} catch {
return article.content ?? "";
}
}
async function load() {
loading.value = true;
error.value = undefined;
try {
// Client-facing news route (server/server/api/v1/client/news/index.get.ts)
// -- goes through the same client-auth path fetch_library/fetch_game use,
// rather than the plain user-session `api/v1/news` the marketing site uses.
articles.value = await apiGet<NewsArticle[]>("api/v1/client/news", {
order: "desc",
});
} catch (e) {
error.value = String(e);
} finally {
loading.value = false;
}
}
onMounted(load);
</script>

View file

@ -1443,6 +1443,7 @@ dependencies = [
"filetime",
"futures-core",
"futures-lite",
"futures-util",
"games",
"gethostname",
"hex 0.4.3",

View file

@ -44,6 +44,7 @@ download_manager = { path = "./download_manager", version = "0.1.0" } # download
filetime = "0.2.25"
futures-core = "0.3.31"
futures-lite = "2.6.0"
futures-util = "0.3.31"
games = { path = "./games", version = "0.1.0" } # games
gethostname = "1.0.1"
hex = "0.4.3"

View file

@ -0,0 +1,184 @@
//! Generic authenticated JSON bridge for the "everything the server's
//! community web pages can do, the launcher should be able to do too" work
//! (News, Friends, Chat REST fallback, Presence, Profile, Achievements,
//! Notifications).
//!
//! Rather than hand-writing one typed Tauri command per server route (dozens
//! of near-identical GET/POST/DELETE wrappers for endpoints that already
//! return exactly the JSON shape the frontend wants), these three commands
//! are thin, path-driven passthroughs. The frontend passes the server's own
//! `/api/v1/...` path; auth, base-URL resolution, and error surfacing stay
//! in one place.
//!
//! ## Auth: this can't reuse `generate_authorization_header()`
//!
//! Every OTHER client call (`fetch_drop_object`, the M2 ingest pings in
//! `community.rs`) signs requests with a short-lived JWT
//! (`generate_authorization_header`), verified server-side by
//! `defineClientEventHandler`'s client-certificate check. Notifications and
//! the M3 community routes (friends/chat/presence/profile/achievements) are
//! gated by `aclManager.getUserIdACL` instead, which only understands a
//! browser session cookie or an opaque `Bearer <APIToken.token>` -- it does
//! a literal `prisma.aPIToken.findUnique({ where: { token } })` lookup, so
//! the JWT (a fresh, ~60-char signed string that is never a row in that
//! table) always misses and the route 403s. Confirmed by running the built
//! client against the live server before this file added the exchange
//! below: `GET /api/v1/notifications` and `GET /api/v1/community/friends`
//! both came back "403, Server Error" through this exact bridge, while
//! `GET /api/v1/client/news` (a `defineClientEventHandler` route, JWT-only)
//! worked immediately.
//!
//! The bridge is `POST /api/v1/client/user/webtoken`
//! (`server/server/api/v1/client/user/webtoken.post.ts`) -- itself a
//! `defineClientEventHandler` route, so it accepts the JWT -- which mints a
//! real `APIToken` row (`mode: Client`) scoped to `CLIENT_WEBTOKEN_ACLS`
//! (`server/server/plugins/04.auth-init.ts`) and hands back its opaque
//! token string. That list needed extending with the notifications/
//! community scopes this file's routes need (done in the same change as
//! this file); without that server-side grant, the exchange still succeeds
//! but the minted token is just as unauthorized as the JWT was. This module
//! fetches one such token lazily and caches it for the process's lifetime
//! (tokens here have no `expiresAt`, so this doesn't need refresh logic) --
//! see `web_token()` below.
//!
//! Non-2xx responses are surfaced as `RemoteAccessError::InvalidResponse`
//! carrying the server's own `{statusCode, statusMessage, message}` error
//! body (h3's `createError` shape, used by every route this bridges to) so
//! the frontend gets the real server message, not a generic failure.
use std::sync::{LazyLock, nonpoison::Mutex};
use remote::{
error::{DropServerError, RemoteAccessError},
requests::{generate_url, make_authenticated_post_json},
utils::DROP_CLIENT_ASYNC,
};
use serde_json::Value;
use url::Url;
static WEB_TOKEN: LazyLock<Mutex<Option<String>>> = LazyLock::new(|| Mutex::new(None));
/// Exchange the client's JWT for an opaque Bearer webtoken (see module
/// docs), minting a fresh one on first use per process and reusing it after
/// that. `force` bypasses the cache -- used for a single retry if a call
/// made with the cached token still comes back 401/403 (e.g. it was
/// revoked server-side).
async fn web_token(force: bool) -> Result<String, RemoteAccessError> {
if !force {
if let Some(cached) = WEB_TOKEN.lock().clone() {
return Ok(cached);
}
}
let url = generate_url(&["api/v1/client/user/webtoken"], &[])?;
let resp = make_authenticated_post_json(url, &Value::Object(Default::default())).await?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(RemoteAccessError::InvalidResponse(DropServerError {
status_code: status.as_u16() as usize,
status_message: "Failed to mint web token".to_string(),
message: format!("webtoken exchange failed: {status} {text}"),
}));
}
let token: String = resp.json().await?;
*WEB_TOKEN.lock() = Some(token.clone());
Ok(token)
}
async fn bearer_request(
method: reqwest::Method,
url: Url,
body: Option<&Value>,
) -> Result<reqwest::Response, RemoteAccessError> {
let token = web_token(false).await?;
let mut req = DROP_CLIENT_ASYNC
.request(method.clone(), url.clone())
.header("Authorization", format!("Bearer {token}"));
if let Some(b) = body {
req = req.json(b);
}
let resp = req.send().await?;
// The cached token may have been minted before a server-side ACL grant
// landed, or revoked out-of-band -- one retry with a freshly-minted
// token before giving up, rather than getting permanently stuck on a
// stale cache for the rest of the process's life.
if resp.status().as_u16() == 401 || resp.status().as_u16() == 403 {
let fresh = web_token(true).await?;
let mut retry = DROP_CLIENT_ASYNC
.request(method, url)
.header("Authorization", format!("Bearer {fresh}"));
if let Some(b) = body {
retry = retry.json(b);
}
return Ok(retry.send().await?);
}
Ok(resp)
}
async fn parse_response(resp: reqwest::Response) -> Result<Value, RemoteAccessError> {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
let body: Value = if text.trim().is_empty() {
Value::Null
} else {
serde_json::from_str(&text).unwrap_or(Value::Null)
};
if !status.is_success() {
let message = body
.get("statusMessage")
.or_else(|| body.get("message"))
.and_then(Value::as_str)
.unwrap_or("Request failed.")
.to_string();
return Err(RemoteAccessError::InvalidResponse(DropServerError {
status_code: status.as_u16() as usize,
status_message: message.clone(),
message,
}));
}
Ok(body)
}
/// GET `{drop-server}/{path}`, e.g. `path = "api/v1/notifications"`.
/// `query` is a flat list of `(key, value)` pairs.
#[tauri::command]
pub async fn api_get(
path: String,
query: Option<Vec<(String, String)>>,
) -> Result<Value, RemoteAccessError> {
let query = query.unwrap_or_default();
let query_refs: Vec<(&str, &str)> =
query.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let url = generate_url(&[path.as_str()], &query_refs)?;
let resp = bearer_request(reqwest::Method::GET, url, None).await?;
parse_response(resp).await
}
/// POST `{drop-server}/{path}` with an optional JSON body.
#[tauri::command]
pub async fn api_post(path: String, body: Option<Value>) -> Result<Value, RemoteAccessError> {
let url = generate_url(&[path.as_str()], &[])?;
let payload = body.unwrap_or_else(|| Value::Object(Default::default()));
let resp = bearer_request(reqwest::Method::POST, url, Some(&payload)).await?;
parse_response(resp).await
}
/// DELETE `{drop-server}/{path}`.
#[tauri::command]
pub async fn api_delete(path: String) -> Result<Value, RemoteAccessError> {
let url = generate_url(&[path.as_str()], &[])?;
let resp = bearer_request(reqwest::Method::DELETE, url, None).await?;
parse_response(resp).await
}
/// Exposed so `community_ws.rs` can authenticate the websocket handshake
/// with the same webtoken (that route is also `aclManager`-gated, not
/// JWT-based -- see this module's header comment).
pub async fn get_web_token() -> Result<String, RemoteAccessError> {
web_token(false).await
}

View file

@ -0,0 +1,166 @@
//! Bridge for the community websocket (`/api/v1/community/ws`, one endpoint
//! for live chat delivery AND presence broadcast -- see the server's
//! `server/server/api/v1/community/ws.get.ts`).
//!
//! The server authenticates the websocket handshake by reading the
//! `Authorization` header off the upgrade request (same short-lived signed
//! JWT every other authenticated call uses, `generate_authorization_header`).
//! A webview's native `WebSocket` API cannot set that header, so the socket
//! is opened here in Rust -- same `reqwest_websocket` + `.upgrade().send()`
//! pattern `remote.rs`'s `auth_initiate_code` already uses for the
//! auth-code-exchange websocket -- and bridged to the frontend over Tauri
//! events/commands instead of exposing a raw socket to JS:
//!
//! - incoming frames -> emitted as `community/ws-message` (raw JSON text,
//! parsed frontend-side so the Rust layer stays a dumb pipe)
//! - outgoing frames -> `community_ws_send` enqueues onto an mpsc channel
//! the connection task drains
//! - `community_ws_connect` is idempotent: a second call while already
//! connected is a no-op, so pages can call it on mount without needing
//! to track connection state themselves
//! - on disconnect (server closed, network error) the task emits
//! `community/ws-closed` and clears its own connected flag; the
//! frontend composable (`composables/community-ws.ts`) reconnects on
//! that event with a short backoff.
use std::sync::{
LazyLock,
atomic::{AtomicBool, Ordering},
nonpoison::Mutex,
};
use futures_lite::StreamExt;
use futures_util::SinkExt;
use log::{debug, warn};
use remote::{error::RemoteAccessError, utils::DROP_CLIENT_WS_CLIENT};
use reqwest_websocket::{Message, RequestBuilderExt};
use tauri::AppHandle;
use tokio::sync::mpsc::{self, UnboundedSender};
use url::Url;
use utils::app_emit;
use database::borrow_db_checked;
use crate::community_api::get_web_token;
static COMMUNITY_WS_TX: LazyLock<Mutex<Option<UnboundedSender<String>>>> =
LazyLock::new(|| Mutex::new(None));
static COMMUNITY_WS_CONNECTED: AtomicBool = AtomicBool::new(false);
#[tauri::command]
pub async fn community_ws_connect(app: AppHandle) -> Result<(), RemoteAccessError> {
if COMMUNITY_WS_CONNECTED.swap(true, Ordering::SeqCst) {
debug!("community ws: already connected, skipping");
return Ok(());
}
let base_url = {
let db = borrow_db_checked();
Url::parse(&db.base_url)?
};
let ws_url = base_url.join("/api/v1/community/ws")?;
// Same aclManager-gated route as the REST community endpoints -- the
// handshake needs the exchanged webtoken, not the client's own JWT (see
// community_api.rs's module docs for why).
let token = match get_web_token().await {
Ok(t) => t,
Err(e) => {
COMMUNITY_WS_CONNECTED.store(false, Ordering::SeqCst);
*COMMUNITY_WS_TX.lock() = None;
return Err(e);
}
};
let (tx, mut rx) = mpsc::unbounded_channel::<String>();
*COMMUNITY_WS_TX.lock() = Some(tx);
let response = DROP_CLIENT_WS_CLIENT
.get(ws_url)
.header("Authorization", format!("Bearer {token}"))
.upgrade()
.send()
.await;
let response = match response {
Ok(r) => r,
Err(e) => {
COMMUNITY_WS_CONNECTED.store(false, Ordering::SeqCst);
*COMMUNITY_WS_TX.lock() = None;
return Err(e.into());
}
};
let mut websocket = match response.into_websocket().await {
Ok(ws) => ws,
Err(e) => {
COMMUNITY_WS_CONNECTED.store(false, Ordering::SeqCst);
*COMMUNITY_WS_TX.lock() = None;
return Err(e.into());
}
};
tauri::async_runtime::spawn(async move {
loop {
tokio::select! {
incoming = websocket.try_next() => {
match incoming {
Ok(Some(Message::Text(text))) => {
app_emit!(&app, "community/ws-message", text);
}
Ok(Some(_)) => {
// Binary/ping/pong frames -- the protocol here is
// text-JSON only (chatService's envelope), nothing
// else is meaningful to the frontend.
}
Ok(None) => {
debug!("community ws: server closed the connection");
break;
}
Err(e) => {
warn!("community ws: read error: {e}");
break;
}
}
}
outgoing = rx.recv() => {
match outgoing {
Some(text) => {
if let Err(e) = websocket.send(Message::Text(text)).await {
warn!("community ws: send error: {e}");
break;
}
}
None => {
// Sender dropped (e.g. community_ws_disconnect) --
// nothing left to write, but keep reading until
// the server hangs up too.
}
}
}
}
}
COMMUNITY_WS_CONNECTED.store(false, Ordering::SeqCst);
*COMMUNITY_WS_TX.lock() = None;
app_emit!(&app, "community/ws-closed", ());
});
Ok(())
}
/// Enqueue a raw JSON-text frame (already serialized frontend-side, matching
/// chatService's `{t, d}` envelope) for the open community websocket.
#[tauri::command]
pub fn community_ws_send(payload: String) -> Result<(), String> {
let guard = COMMUNITY_WS_TX.lock();
match &*guard {
Some(tx) => tx.send(payload).map_err(|e| e.to_string()),
None => Err("Not connected to the community websocket.".to_string()),
}
}
#[tauri::command]
pub fn community_ws_connected() -> bool {
COMMUNITY_WS_CONNECTED.load(Ordering::SeqCst)
}

View file

@ -63,6 +63,8 @@ mod client;
mod cloud_saves_commands;
mod collections;
mod community;
mod community_api;
mod community_ws;
mod download_manager;
mod downloads;
mod games;
@ -73,6 +75,8 @@ mod settings;
mod updates;
use client::*;
use community_api::*;
use community_ws::*;
use download_manager::*;
use downloads::*;
use games::*;
@ -247,6 +251,15 @@ pub fn run() {
gen_drop_url,
fetch_drop_object,
check_online,
// Community (news, friends, chat, presence, profile, achievements,
// notifications) -- generic authenticated REST bridge + the
// community websocket bridge, see community_api.rs/community_ws.rs.
api_get,
api_post,
api_delete,
community_ws_connect,
community_ws_send,
community_ws_connected,
// Library
fetch_library,
fetch_game,

View file

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0",
"productName": "Drop Desktop Client (Quasarke Edition)",
"version": "0.4.1",
"version": "0.4.2",
"identifier": "net.quasarke.drop.client",
"build": {
"beforeDevCommand": "pnpm run -C main dev --port 1432",

View file

@ -17,6 +17,33 @@ export const CLIENT_WEBTOKEN_ACLS: UserACL = [
"library:add",
"library:remove",
// Desktop client native News/Friends/Alerts pages (replacing the old
// "opens in your system browser" Community tab, see
// desktop/src-tauri/src/community_api.rs). These routes are gated by
// aclManager.getUserIdACL, which only understands a session cookie or an
// opaque Bearer APIToken -- NOT the client's own short-lived signed JWT
// (generate_authorization_header). The desktop client exchanges its JWT
// for one of these webtokens (POST /api/v1/client/user/webtoken, the
// route right above this file's usage) and then uses it as a normal
// Bearer token for everything below, exactly like a browser session
// would. Without these scopes here, that exchange still succeeds but the
// minted token can't actually call any of these routes -- confirmed by
// running the built desktop client against this server and seeing a
// clean 403 on GET /api/v1/notifications and GET /api/v1/community/friends
// before this list was extended.
"notifications:read",
"notifications:mark",
"notifications:listen",
"notifications:delete",
"community:read",
"community:friends:read",
"community:friends:write",
"community:chat:read",
"community:chat:write",
"community:chat:listen",
"community:achievements:read",
];
export default defineNitroPlugin(async () => {