diff --git a/desktop/main/components/ChatRoomGroup.vue b/desktop/main/components/ChatRoomGroup.vue new file mode 100644 index 00000000..b142cd5f --- /dev/null +++ b/desktop/main/components/ChatRoomGroup.vue @@ -0,0 +1,41 @@ + + + diff --git a/desktop/main/components/CommunityAvatar.vue b/desktop/main/components/CommunityAvatar.vue new file mode 100644 index 00000000..c4338ee9 --- /dev/null +++ b/desktop/main/components/CommunityAvatar.vue @@ -0,0 +1,26 @@ + + + diff --git a/desktop/main/components/Header.vue b/desktop/main/components/Header.vue index 7af7cd23..dc8399bb 100644 --- a/desktop/main/components/Header.vue +++ b/desktop/main/components/Header.vue @@ -30,13 +30,11 @@
    -
  1. - - - +
  2. + +
  3. +
  4. +
  5. @@ -48,9 +46,10 @@ diff --git a/desktop/main/components/HeaderFriendsWidget.vue b/desktop/main/components/HeaderFriendsWidget.vue new file mode 100644 index 00000000..e93e1c0b --- /dev/null +++ b/desktop/main/components/HeaderFriendsWidget.vue @@ -0,0 +1,144 @@ + + + diff --git a/desktop/main/components/HeaderNotificationsWidget.vue b/desktop/main/components/HeaderNotificationsWidget.vue new file mode 100644 index 00000000..71cb7be6 --- /dev/null +++ b/desktop/main/components/HeaderNotificationsWidget.vue @@ -0,0 +1,95 @@ + + + diff --git a/desktop/main/components/HeaderUserWidget.vue b/desktop/main/components/HeaderUserWidget.vue index a11f4470..62821d0f 100644 --- a/desktop/main/components/HeaderUserWidget.vue +++ b/desktop/main/components/HeaderUserWidget.vue @@ -25,7 +25,7 @@ >
    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 @@ - - 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@
    + +
    +
    +

    + Achievements +

    + + {{ achievements.unlockedCount }} / {{ achievements.total }} + +
    + +
    Loading…
    +
    + Couldn't load achievements: {{ achievementsError }} +
    +
    + No achievements catalog imported for this game yet. +
    + +
    @@ -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(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(undefined); +const achievementsLoading = ref(true); +const achievementsError = ref(undefined); + +// Achievement icons come back from the object store as `/api/v1/object/` +// -- 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( + `api/v1/community/games/${game.id}/achievements`, + ); + achievementsError.value = undefined; + } catch (e) { + achievementsError.value = String(e); + } finally { + achievementsLoading.value = false; + } +} + +onMounted(loadAchievements);