diff --git a/desktop/main/composables/api.ts b/desktop/main/composables/api.ts
new file mode 100644
index 00000000..19d5d14a
--- /dev/null
+++ b/desktop/main/composables/api.ts
@@ -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
(
+ path: string,
+ query?: Record,
+): Promise {
+ const q = query
+ ? Object.entries(query)
+ .filter(([, v]) => v !== undefined)
+ .map(([k, v]) => [k, String(v)] as [string, string])
+ : undefined;
+ return invoke("api_get", { path, query: q });
+}
+
+export function apiPost(path: string, body?: unknown): Promise {
+ return invoke("api_post", { path, body: body ?? {} });
+}
+
+export function apiDelete(path: string): Promise {
+ return invoke("api_delete", { path });
+}
diff --git a/desktop/main/composables/community-ws.ts b/desktop/main/composables/community-ws.ts
new file mode 100644
index 00000000..41ce8307
--- /dev/null
+++ b/desktop/main/composables/community-ws.ts
@@ -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;
+ listeners: Set;
+ 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("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("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 };
diff --git a/desktop/main/composables/friends.ts b/desktop/main/composables/friends.ts
new file mode 100644
index 00000000..94346acc
--- /dev/null
+++ b/desktop/main/composables/friends.ts
@@ -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("friends-list", () => ({
+ friends: [],
+ incomingRequests: [],
+ outgoingRequests: [],
+ }));
+}
+function activityState() {
+ return useState>("friends-activity", () => ({}));
+}
+function loadedState() {
+ return useState("friends-loaded", () => false);
+}
+function errorState() {
+ return useState("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("api/v1/community/friends"),
+ apiGet<{ friends: FriendActivityEntry[]; presenceAvailable: boolean }>(
+ "api/v1/community/friends/activity",
+ ),
+ ]);
+ friends.value = list;
+ const map: Record = {};
+ 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 {
+ if (!query.trim()) return [];
+ return apiGet("api/v1/community/users/search", { q: query });
+ }
+
+ return {
+ friends,
+ activity,
+ loaded,
+ error,
+ onlineCount,
+ incomingCount,
+ refresh,
+ sendRequest,
+ acceptRequest,
+ declineRequest,
+ removeFriend,
+ searchUsers,
+ };
+}
diff --git a/desktop/main/composables/notifications.ts b/desktop/main/composables/notifications.ts
new file mode 100644
index 00000000..df5552ea
--- /dev/null
+++ b/desktop/main/composables/notifications.ts
@@ -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("notifications-list", () => []);
+}
+function loadedState() {
+ return useState("notifications-loaded", () => false);
+}
+function errorState() {
+ return useState("notifications-error", () => undefined);
+}
+function pollHandle() {
+ return useState | 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("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 };
+}
diff --git a/desktop/main/pages/community.vue b/desktop/main/pages/community.vue
deleted file mode 100644
index e3e4a495..00000000
--- a/desktop/main/pages/community.vue
+++ /dev/null
@@ -1,72 +0,0 @@
-
-
-
-
-
-
- Community
-
-
-
- Your profile, library activity, and play sessions live on this
- Drop server's community page. It opens in your default browser.
-
-
-
-
-
- Open Community
-
-
-
{{ error }}
-
{{ communityUrl }}
-
-
-
-
-
diff --git a/desktop/main/pages/community/chat.vue b/desktop/main/pages/community/chat.vue
new file mode 100644
index 00000000..8bfd7fd3
--- /dev/null
+++ b/desktop/main/pages/community/chat.vue
@@ -0,0 +1,252 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Pick a room
+
Choose a room from the sidebar to start chatting
+
+
+
+
+
+
{{ activeRoom.name }}
+
{{ activeRoom.topic }}
+
+
+
+
Loading messages…
+
+ No messages yet. Say hello.
+
+
+
+ {{ msg.body }}
+
+
+
+
+
+
+ {{ msg.sender?.displayName ?? "Unknown" }}
+
+ {{ formatTime(msg.createdAt) }}
+ sending…
+
+
{{ msg.body }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/desktop/main/pages/community/friends.vue b/desktop/main/pages/community/friends.vue
new file mode 100644
index 00000000..644bb372
--- /dev/null
+++ b/desktop/main/pages/community/friends.vue
@@ -0,0 +1,247 @@
+
+
+
+
Friends
+
+
+
+
Add a friend
+
+
+
+
+
+
+
+
{{ user.displayName }}
+
@{{ user.username }}
+
+
+ {{ sending === user.username ? "Sending…" : "Add" }}
+
+
+
+
+ No users found.
+
+
+ {{ requestFeedback }}
+
+
+
+
+ Couldn't load friends: {{ error }}
+
+
+
+
+
+ Incoming requests ({{ friends.incomingRequests.length }})
+
+
+
+
+
+
{{ req.user.displayName }}
+
@{{ req.user.username }}
+
+
+ Accept
+
+
+ Decline
+
+
+
+
+
+
+
+
+ Outgoing requests ({{ friends.outgoingRequests.length }})
+
+
+
+
+
+
{{ req.user.displayName }}
+
@{{ req.user.username }} · pending
+
+
+ Cancel
+
+
+
+
+
+
+
+
+ Friends ({{ friends.friends.length }})
+
+
+ Loading…
+
+ No friends yet. Search above to send a request.
+
+
+
+
+
+
+
+
+
+ {{ friend.displayName }}
+
+
+ {{
+ activity[friend.id]?.playing
+ ? `Playing ${activity[friend.id]?.playing?.name}`
+ : activity[friend.id]?.online
+ ? "Online"
+ : "Offline"
+ }}
+
+
+
+ Message
+
+
+ Remove
+
+
+
+
+
+
+
+
+
diff --git a/desktop/main/pages/community/index.vue b/desktop/main/pages/community/index.vue
new file mode 100644
index 00000000..a9648cde
--- /dev/null
+++ b/desktop/main/pages/community/index.vue
@@ -0,0 +1,143 @@
+
+
+
+
+
Community
+
+
+ Friends
+
+
+ Open chat
+
+
+
+
+
+ Couldn't load community activity: {{ error }}
+
+
+
+
+ Playing now
+
+ Loading…
+
+ Nobody's playing right now.
+
+
+
+
+
+
{{ entry.displayName }}
+
{{ entry.titleName }}
+
+
+
+
+
+
+
+ Recently played
+
+
+ No recent activity yet.
+
+
+
+
+
+
{{ entry.displayName }}
+
played {{ entry.titleName }}
+
+
+
+
+
+
+
+ Persistent-world servers
+
+
+
+ {{ server.name }}
+ {{ server.topic }}
+
+
+
+
+
+
+
+
diff --git a/desktop/main/pages/community/profile/[username].vue b/desktop/main/pages/community/profile/[username].vue
new file mode 100644
index 00000000..45dcb3c8
--- /dev/null
+++ b/desktop/main/pages/community/profile/[username].vue
@@ -0,0 +1,233 @@
+
+
+
+
Loading…
+
+ Couldn't load this profile: {{ error }}
+
+
+
+
+
+
+
+ {{ profile.displayName }}
+
+ Admin
+
+
+
@{{ profile.username }}
+
+
+
+ Message
+
+
+
+
+
+ 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.
+
+
+
+
+
{{ profile.titlesTracked }}
+
Titles tracked
+
+
+
{{ formatHours(profile.totalSecondsTracked) }}
+
Total playtime
+
+
+
+ {{ achievements ? `${achievements.totalUnlocked} / ${achievements.totalDefinitions}` : "—" }}
+
+
Achievements unlocked
+
+
+
+
+
+ Recently played
+
+
+
+
+
+
+
{{ entry.name }}
+
{{ formatHours(entry.seconds) }}
+
+
+
+
+
+
+ Most played
+
+
+
+
+
+
+
{{ entry.name }}
+
{{ formatHours(entry.seconds) }}
+
+
+
+
+
+
+ Achievements by game
+
+
+
+
+
+
{{ game.gameName }}
+
+
+
{{ game.unlockedCount }} / {{ game.total }}
+
+
+
+
+
+ No tracked playtime or achievements yet.
+
+
+
+
+
+
+
diff --git a/desktop/main/pages/library/[id]/index.vue b/desktop/main/pages/library/[id]/index.vue
index 49807873..48782ac5 100644
--- a/desktop/main/pages/library/[id]/index.vue
+++ b/desktop/main/pages/library/[id]/index.vue
@@ -191,6 +191,57 @@