drop/desktop/main/components/HeaderFriendsWidget.vue
wdunn001 e8d3df032c
Some checks failed
Server CI / Lint (push) Failing after 9m53s
Server CI / Typecheck (push) Successful in 11m40s
desktop: native News, Friends, and Alerts pages; retire the browser-linkout Community tab
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).
2026-08-03 19:42:50 -04:00

144 lines
5.4 KiB
Vue

<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>