feat(spotify): show the current track as an activity
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s

OAuth Authorization Code flow, with tokens kept server-side: refreshing needs
the client secret, so the browser never holds a Spotify token — it asks this
instance what is playing and this instance calls Spotify.

The callback arrives as a plain browser redirect with no Authorization header,
so the OAuth state carries the user id signed with the instance secret and is
compared in constant time; without that, anyone could bind their Spotify
account to another user.

Activities are now tracked per producer. pushActivities replaced the whole
list, so the desktop game detector and Spotify would erase each other — losing
exactly the case this is for, a game and Spotify at once.

Polling backs off when the tab is hidden and keeps the last known track on a
network error rather than reporting 'stopped listening'. A rejected refresh
token (access revoked on Spotify's side) drops the row so the UI stops
claiming a live connection.

Scope is read-only: user-read-currently-playing and user-read-playback-state.

Per the fork's language rule, the new UI ships in en and pt-BR, and this
round also translates the privacy panel.
This commit is contained in:
2026-08-31 12:37:08 -03:00
parent c022f2795f
commit 75316b0882
16 changed files with 4413 additions and 12 deletions
@@ -0,0 +1,9 @@
CREATE TABLE `spotify_connections` (
`user_id` text PRIMARY KEY NOT NULL,
`access_token` text NOT NULL,
`refresh_token` text NOT NULL,
`expires_at` integer NOT NULL,
`spotify_user_id` text,
`created_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
File diff suppressed because it is too large Load Diff
@@ -78,6 +78,13 @@
"when": 1783035334526, "when": 1783035334526,
"tag": "0010_broken_blazing_skull", "tag": "0010_broken_blazing_skull",
"breakpoints": true "breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1788190305853,
"tag": "0011_lethal_bruce_banner",
"breakpoints": true
} }
] ]
} }
+4
View File
@@ -76,6 +76,10 @@ export const config = {
sourceCodeUrl, sourceCodeUrl,
commit, commit,
spotify: {
clientId: envOptional('SPOTIFY_CLIENT_ID'),
clientSecret: envOptional('SPOTIFY_CLIENT_SECRET'),
},
livekit: { livekit: {
url: envOptional('LIVEKIT_URL'), url: envOptional('LIVEKIT_URL'),
apiKey: envOptional('LIVEKIT_API_KEY'), apiKey: envOptional('LIVEKIT_API_KEY'),
+17
View File
@@ -549,3 +549,20 @@ export const inviteRedemptions = sqliteTable('invite_redemptions', {
inviteIdx: index('idx_invite_redemptions_invite_id').on(table.inviteId), inviteIdx: index('idx_invite_redemptions_invite_id').on(table.inviteId),
userIdx: index('idx_invite_redemptions_user_id').on(table.userId), userIdx: index('idx_invite_redemptions_user_id').on(table.userId),
})); }));
/**
* Spotify tokens, one row per user.
*
* Kept server-side on purpose: refreshing requires the client secret, so the
* browser never holds a Spotify token at all — it asks this server what is
* playing and this server talks to Spotify.
*/
export const spotifyConnections = sqliteTable('spotify_connections', {
userId: text('user_id').primaryKey().references(() => users.id, { onDelete: 'cascade' }),
accessToken: text('access_token').notNull(),
refreshToken: text('refresh_token').notNull(),
// Epoch millis at which accessToken stops working.
expiresAt: integer('expires_at').notNull(),
spotifyUserId: text('spotify_user_id'),
createdAt: integer('created_at').notNull(),
});
+2
View File
@@ -15,6 +15,7 @@ import { uploadRoutes } from './routes/uploads.js';
import { filesRoutes } from './routes/files.js'; import { filesRoutes } from './routes/files.js';
import { dmRoutes } from './routes/dm.js'; import { dmRoutes } from './routes/dm.js';
import { livekitRoutes } from './routes/livekit.js'; import { livekitRoutes } from './routes/livekit.js';
import { spotifyRoutes } from './routes/spotify.js';
import { socialRoutes } from './routes/social.js'; import { socialRoutes } from './routes/social.js';
import { settingsRoutes } from './routes/settings.js'; import { settingsRoutes } from './routes/settings.js';
import { utilRoutes } from './routes/utils.js'; import { utilRoutes } from './routes/utils.js';
@@ -126,6 +127,7 @@ async function main(): Promise<void> {
await app.register(filesRoutes); await app.register(filesRoutes);
await app.register(dmRoutes); await app.register(dmRoutes);
await app.register(livekitRoutes); await app.register(livekitRoutes);
await app.register(spotifyRoutes);
await app.register(socialRoutes); await app.register(socialRoutes);
await app.register(settingsRoutes); await app.register(settingsRoutes);
await app.register(utilRoutes); await app.register(utilRoutes);
+212
View File
@@ -0,0 +1,212 @@
import type { FastifyInstance } from 'fastify';
import crypto from 'crypto';
import { eq } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { config } from '../config.js';
import { getOurOrigin } from '../utils/federationAuth.js';
import type { Activity } from '@backspace/shared';
const SPOTIFY_AUTH = 'https://accounts.spotify.com/authorize';
const SPOTIFY_TOKEN = 'https://accounts.spotify.com/api/token';
const SPOTIFY_NOW_PLAYING = 'https://api.spotify.com/v1/me/player/currently-playing';
// Read-only: enough to see the current track, nothing that can control playback
// or read the library.
const SCOPES = 'user-read-currently-playing user-read-playback-state';
/** Refresh this many ms before expiry, so a request never races the deadline. */
const REFRESH_MARGIN_MS = 60_000;
function redirectUri(): string {
return `${getOurOrigin()}/api/connections/spotify/callback`;
}
function isConfigured(): boolean {
return Boolean(config.spotify.clientId && config.spotify.clientSecret);
}
/**
* OAuth `state`, signed with the instance's JWT secret.
*
* The callback arrives as a browser redirect, which carries no Authorization
* header — so the state has to say who started the flow, and be tamper-proof
* or anyone could bind their Spotify account to someone else's user.
*/
function signState(userId: string): string {
const payload = Buffer.from(JSON.stringify({ userId, exp: Date.now() + 10 * 60_000 })).toString('base64url');
const sig = crypto.createHmac('sha256', config.jwtSecret).update(payload).digest('base64url');
return `${payload}.${sig}`;
}
function verifyState(state: string): string | null {
const [payload, sig] = state.split('.');
if (!payload || !sig) return null;
const expected = crypto.createHmac('sha256', config.jwtSecret).update(payload).digest('base64url');
const a = Buffer.from(sig);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
try {
const data = JSON.parse(Buffer.from(payload, 'base64url').toString()) as { userId: string; exp: number };
if (!data.userId || typeof data.exp !== 'number' || data.exp < Date.now()) return null;
return data.userId;
} catch {
return null;
}
}
function basicAuthHeader(): string {
return 'Basic ' + Buffer.from(`${config.spotify.clientId}:${config.spotify.clientSecret}`).toString('base64');
}
/**
* Returns a usable access token, refreshing it first when it is about to
* expire. Returns null when the connection is gone or Spotify rejected the
* refresh token — the caller then treats the user as disconnected.
*/
async function getAccessToken(userId: string): Promise<string | null> {
const db = getDb();
const row = db.select().from(schema.spotifyConnections)
.where(eq(schema.spotifyConnections.userId, userId)).get();
if (!row) return null;
if (row.expiresAt - REFRESH_MARGIN_MS > Date.now()) return row.accessToken;
const res = await fetch(SPOTIFY_TOKEN, {
method: 'POST',
headers: { Authorization: basicAuthHeader(), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: row.refreshToken }),
});
if (!res.ok) {
// A refresh token is rejected when the user revoked access on Spotify's
// side. Drop the row so the UI stops claiming a live connection.
if (res.status === 400 || res.status === 401) {
db.delete(schema.spotifyConnections).where(eq(schema.spotifyConnections.userId, userId)).run();
}
return null;
}
const body = await res.json() as { access_token: string; expires_in: number; refresh_token?: string };
db.update(schema.spotifyConnections).set({
accessToken: body.access_token,
// Spotify only returns a new refresh token sometimes; keep the old one otherwise.
refreshToken: body.refresh_token ?? row.refreshToken,
expiresAt: Date.now() + body.expires_in * 1000,
}).where(eq(schema.spotifyConnections.userId, userId)).run();
return body.access_token;
}
interface SpotifyTrack {
is_playing: boolean;
progress_ms: number | null;
item: {
name: string;
duration_ms: number;
artists: { name: string }[];
album: { name: string; images: { url: string }[] };
external_urls?: { spotify?: string };
} | null;
}
/** Maps Spotify's payload onto the Activity shape the profile card renders. */
function toActivity(track: SpotifyTrack): Activity | null {
if (!track.is_playing || !track.item) return null;
const now = Date.now();
const progress = track.progress_ms ?? 0;
return {
type: 'listening',
name: 'Spotify',
details: track.item.name,
state: track.item.artists.map((a) => a.name).join(', '),
timestamps: { start: now - progress, end: now - progress + track.item.duration_ms },
assets: {
largeImage: track.item.album.images[0]?.url,
largeText: track.item.album.name,
},
url: track.item.external_urls?.spotify,
};
}
export async function spotifyRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/connections/spotify/status', { preHandler: authenticate }, async (request, reply) => {
if (!isConfigured()) return reply.code(200).send({ configured: false, connected: false });
const db = getDb();
const row = db.select({ userId: schema.spotifyConnections.userId })
.from(schema.spotifyConnections)
.where(eq(schema.spotifyConnections.userId, request.userId)).get();
return reply.code(200).send({ configured: true, connected: Boolean(row) });
});
// Returns the URL rather than redirecting: the caller is fetch(), which would
// follow a 302 to Spotify instead of navigating the window there.
app.get('/api/connections/spotify/authorize', { preHandler: authenticate }, async (request, reply) => {
if (!isConfigured()) {
return reply.code(503).send({ error: 'Spotify is not configured on this instance', statusCode: 503 });
}
const params = new URLSearchParams({
client_id: config.spotify.clientId!,
response_type: 'code',
redirect_uri: redirectUri(),
scope: SCOPES,
state: signState(request.userId),
});
return reply.code(200).send({ url: `${SPOTIFY_AUTH}?${params}` });
});
app.get<{ Querystring: { code?: string; state?: string; error?: string } }>(
'/api/connections/spotify/callback',
async (request, reply) => {
const { code, state, error } = request.query;
const settingsUrl = `${getOurOrigin()}/channels/@me?settings=connections`;
if (error || !code || !state) return reply.redirect(`${settingsUrl}&spotify=denied`);
const userId = verifyState(state);
if (!userId) return reply.redirect(`${settingsUrl}&spotify=invalid_state`);
const res = await fetch(SPOTIFY_TOKEN, {
method: 'POST',
headers: { Authorization: basicAuthHeader(), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri() }),
});
if (!res.ok) return reply.redirect(`${settingsUrl}&spotify=exchange_failed`);
const body = await res.json() as { access_token: string; refresh_token: string; expires_in: number };
const db = getDb();
const row = {
userId,
accessToken: body.access_token,
refreshToken: body.refresh_token,
expiresAt: Date.now() + body.expires_in * 1000,
spotifyUserId: null,
createdAt: Date.now(),
};
db.insert(schema.spotifyConnections).values(row)
.onConflictDoUpdate({ target: schema.spotifyConnections.userId, set: row }).run();
return reply.redirect(`${settingsUrl}&spotify=connected`);
},
);
app.get('/api/connections/spotify/now-playing', { preHandler: authenticate }, async (request, reply) => {
const token = await getAccessToken(request.userId);
if (!token) return reply.code(200).send({ activity: null, connected: false });
const res = await fetch(SPOTIFY_NOW_PLAYING, { headers: { Authorization: `Bearer ${token}` } });
// 204 means "nothing playing"; anything else non-OK is a transient problem
// and must not be reported as a lost connection.
if (res.status === 204) return reply.code(200).send({ activity: null, connected: true });
if (!res.ok) return reply.code(200).send({ activity: null, connected: res.status !== 401 });
const track = await res.json() as SpotifyTrack;
return reply.code(200).send({ activity: toActivity(track), connected: true });
});
app.delete('/api/connections/spotify', { preHandler: authenticate }, async (request, reply) => {
const db = getDb();
db.delete(schema.spotifyConnections).where(eq(schema.spotifyConnections.userId, request.userId)).run();
return reply.code(204).send();
});
}
+15
View File
@@ -71,6 +71,7 @@ import type {
AttachProofResponse, AttachProofResponse,
ReattachRequest, ReattachRequest,
ReattachResponse, ReattachResponse,
Activity,
} from '@backspace/shared'; } from '@backspace/shared';
import { getApiForOrigin, getOwnerInstanceForDm } from '../utils/crossStoreResolvers'; import { getApiForOrigin, getOwnerInstanceForDm } from '../utils/crossStoreResolvers';
@@ -281,6 +282,13 @@ export class BackspaceApiClient {
enabled: () => Promise<{ enabled: boolean }>; enabled: () => Promise<{ enabled: boolean }>;
}; };
readonly spotify: {
status: () => Promise<{ configured: boolean; connected: boolean }>;
authorizeUrl: () => Promise<{ url: string }>;
nowPlaying: () => Promise<{ activity: Activity | null; connected: boolean }>;
disconnect: () => Promise<void>;
};
readonly federation: { readonly federation: {
initiatePeering: (data: { remoteOrigin: string }) => Promise<{ peer: FederationPeer; verified?: boolean }>; initiatePeering: (data: { remoteOrigin: string }) => Promise<{ peer: FederationPeer; verified?: boolean }>;
ensurePeered: (data: { remoteOrigin: string }) => Promise<{ peeringStatus: string; peerId?: string; error?: string }>; ensurePeered: (data: { remoteOrigin: string }) => Promise<{ peeringStatus: string; peerId?: string; error?: string }>;
@@ -688,6 +696,13 @@ export class BackspaceApiClient {
}, },
}; };
this.spotify = {
status: () => request<{ configured: boolean; connected: boolean }>('GET', '/connections/spotify/status'),
authorizeUrl: () => request<{ url: string }>('GET', '/connections/spotify/authorize'),
nowPlaying: () => request<{ activity: Activity | null; connected: boolean }>('GET', '/connections/spotify/now-playing'),
disconnect: () => request<void>('DELETE', '/connections/spotify'),
};
this.gif = { this.gif = {
trending: (limit = 30, pos?: string) => { trending: (limit = 30, pos?: string) => {
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -22,6 +22,7 @@ import { UserProfileModal } from '../modals/UserProfileModal';
import { IncomingCallModal } from '../voice/IncomingCallModal'; import { IncomingCallModal } from '../voice/IncomingCallModal';
import { PictureInPicture } from '../voice/PictureInPicture'; import { PictureInPicture } from '../voice/PictureInPicture';
import { SoundController } from '../voice/SoundController'; import { SoundController } from '../voice/SoundController';
import { useSpotifyActivity } from '../../hooks/useSpotifyActivity';
import { GlobalAudioRenderer } from '../voice/GlobalAudioRenderer'; import { GlobalAudioRenderer } from '../voice/GlobalAudioRenderer';
import { NotificationController } from '../NotificationController'; import { NotificationController } from '../NotificationController';
import { UserProfilePopout } from '../ui/UserProfilePopout'; import { UserProfilePopout } from '../ui/UserProfilePopout';
@@ -215,6 +216,7 @@ export function AppLayout() {
const showBootSkeleton = useDelayedLoading(isLoading); const showBootSkeleton = useDelayedLoading(isLoading);
const setCurrentSpace = useSpaceStore((s) => s.setCurrentSpace); const setCurrentSpace = useSpaceStore((s) => s.setCurrentSpace);
const loadSpaceDetail = useSpaceStore((s) => s.loadSpaceDetail); const loadSpaceDetail = useSpaceStore((s) => s.loadSpaceDetail);
useSpotifyActivity();
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel); const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
const loadMessages = useChatStore((s) => s.loadMessages); const loadMessages = useChatStore((s) => s.loadMessages);
const setIsMobile = useUIStore((s) => s.setIsMobile); const setIsMobile = useUIStore((s) => s.setIsMobile);
@@ -1,10 +1,111 @@
import { ConnectedInstances } from '../ConnectedInstances'; import { useEffect, useState } from 'react';
import { api } from '../../../api/client';
import { useT, type TranslationKey } from '../../../i18n';
/** Errors the OAuth callback can hand back in the URL. */
const CALLBACK_ERRORS = ['denied', 'invalid_state', 'exchange_failed'] as const;
type CallbackError = (typeof CALLBACK_ERRORS)[number];
function readCallbackResult(): CallbackError | 'connected' | null {
if (typeof window === 'undefined') return null;
const value = new URLSearchParams(window.location.search).get('spotify');
if (value === 'connected') return 'connected';
return CALLBACK_ERRORS.includes(value as CallbackError) ? (value as CallbackError) : null;
}
export function ConnectionsPanel() { export function ConnectionsPanel() {
const t = useT();
const [configured, setConfigured] = useState(true);
const [connected, setConnected] = useState(false);
const [busy, setBusy] = useState(false);
const [callbackError, setCallbackError] = useState<CallbackError | null>(null);
useEffect(() => {
const result = readCallbackResult();
if (result && result !== 'connected') setCallbackError(result);
// Drop the parameter so a refresh does not replay the old outcome.
if (result && typeof window !== 'undefined') {
const url = new URL(window.location.href);
url.searchParams.delete('spotify');
window.history.replaceState({}, '', url.toString());
}
}, []);
useEffect(() => {
let cancelled = false;
api.spotify.status()
.then((s) => { if (!cancelled) { setConfigured(s.configured); setConnected(s.connected); } })
.catch(() => { /* leave the panel in its default state */ });
return () => { cancelled = true; };
}, []);
const handleConnect = async () => {
setBusy(true);
try {
const { url } = await api.spotify.authorizeUrl();
window.location.href = url;
} catch {
setBusy(false);
}
};
const handleDisconnect = async () => {
setBusy(true);
try {
await api.spotify.disconnect();
setConnected(false);
} finally {
setBusy(false);
}
};
return ( return (
<div className="space-y-5"> <div className="max-w-2xl">
<h2 className="text-lg font-semibold text-txt-primary mb-6">Connections</h2> <h2 className="text-lg font-semibold text-txt-primary mb-6">{t('connections.title')}</h2>
<ConnectedInstances />
<div className="rounded-lg bg-surface-elevated/40 p-4">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" className="text-accent-mint flex-shrink-0" aria-hidden="true">
<path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm4.586 14.424a.623.623 0 0 1-.857.207c-2.348-1.435-5.304-1.76-8.785-.964a.623.623 0 1 1-.277-1.215c3.809-.871 7.077-.496 9.712 1.115a.623.623 0 0 1 .207.857Zm1.223-2.722a.78.78 0 0 1-1.072.257c-2.687-1.652-6.785-2.131-9.965-1.166a.78.78 0 1 1-.452-1.492c3.632-1.102 8.147-.568 11.232 1.329a.78.78 0 0 1 .257 1.072Zm.105-2.835c-3.223-1.914-8.54-2.09-11.617-1.156a.935.935 0 1 1-.542-1.79c3.532-1.072 9.404-.865 13.115 1.338a.935.935 0 0 1-.956 1.608Z" />
</svg>
<span className="text-[15px] font-semibold text-txt-primary">Spotify</span>
{connected && (
<span className="text-[11px] px-1.5 py-0.5 rounded bg-status-online/15 text-status-online font-medium">
{t('connections.spotify.connected')}
</span>
)}
</div>
<p className="text-[13px] text-txt-secondary mt-1">{t('connections.spotify.description')}</p>
<p className="text-[12px] text-txt-tertiary mt-1">{t('connections.spotify.hint')}</p>
</div>
{configured && (
<button
type="button"
onClick={() => void (connected ? handleDisconnect() : handleConnect())}
disabled={busy}
className={`px-3 py-1.5 rounded-md text-[13px] font-medium flex-shrink-0 transition-colors disabled:opacity-50 ${
connected
? 'bg-interactive-muted text-txt-primary hover:brightness-110'
: 'bg-accent-primary text-white hover:brightness-110'
}`}
>
{connected ? t('connections.spotify.disconnect') : t('connections.spotify.connect')}
</button>
)}
</div>
{!configured && (
<p className="text-[12px] text-txt-tertiary mt-3">{t('connections.spotify.notConfigured')}</p>
)}
{callbackError && (
<p className="text-[12px] text-txt-danger mt-3">
{t(`connections.spotify.error.${callbackError}` as TranslationKey)}
</p>
)}
</div>
</div> </div>
); );
} }
@@ -3,8 +3,10 @@ import { useAuthStore } from '../../../stores/authStore';
import { useActivityStore } from '../../../stores/activityStore'; import { useActivityStore } from '../../../stores/activityStore';
import { api } from '../../../api/client'; import { api } from '../../../api/client';
import { Toggle } from '../../ui/Toggle'; import { Toggle } from '../../ui/Toggle';
import { useT } from '../../../i18n';
export function PrivacyPanel() { export function PrivacyPanel() {
const t = useT();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const setUser = useAuthStore((s) => s.setUser); const setUser = useAuthStore((s) => s.setUser);
const showActivity = useActivityStore((s) => s.showActivity); const showActivity = useActivityStore((s) => s.showActivity);
@@ -31,7 +33,7 @@ export function PrivacyPanel() {
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<h2 className="text-lg font-semibold text-txt-primary mb-6">Privacy</h2> <h2 className="text-lg font-semibold text-txt-primary mb-6">{t('privacy.title')}</h2>
<div> <div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5"> <div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
Discovery Discovery
@@ -39,9 +41,9 @@ export function PrivacyPanel() {
<div className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5"> <div className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5">
<div className="flex items-center justify-between py-1"> <div className="flex items-center justify-between py-1">
<div className="flex-1 mr-4"> <div className="flex-1 mr-4">
<div className="text-sm text-txt-primary">Allow others to find my profile</div> <div className="text-sm text-txt-primary">{t('privacy.discoverable.label')}</div>
<div className="text-xs text-txt-tertiary mt-0.5"> <div className="text-xs text-txt-tertiary mt-0.5">
When enabled, your profile appears in Discover People. Others can always add you by exact username. {t('privacy.discoverable.description')}
</div> </div>
</div> </div>
<Toggle enabled={discoverable} onChange={handleToggle} /> <Toggle enabled={discoverable} onChange={handleToggle} />
@@ -60,9 +62,9 @@ export function PrivacyPanel() {
<div className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5"> <div className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5">
<div className="flex items-center justify-between py-1"> <div className="flex items-center justify-between py-1">
<div className="flex-1 mr-4"> <div className="flex-1 mr-4">
<div className="text-sm text-txt-primary">Share Activity Status</div> <div className="text-sm text-txt-primary">{t('privacy.activity.label')}</div>
<div className="text-xs text-txt-tertiary mt-0.5"> <div className="text-xs text-txt-tertiary mt-0.5">
Allow others to see what you're up to, like games you're playing or music you're listening to. {t('privacy.activity.description')}
</div> </div>
</div> </div>
<Toggle <Toggle
@@ -0,0 +1,60 @@
import { useEffect } from 'react';
import { api } from '../api/client';
import { useActivityStore } from '../stores/activityStore';
/** While a track is playing. Short enough that a track change shows up quickly. */
const POLL_CONNECTED_MS = 20_000;
/** While the account is not linked — cheap heartbeat that notices a new link. */
const POLL_IDLE_MS = 60_000;
/**
* Publishes what the user is listening to on Spotify as an activity.
*
* The browser never sees a Spotify token: it asks this instance, which holds
* the credentials and talks to Spotify. Reported under its own source so it
* coexists with the desktop game detector instead of replacing it.
*/
export function useSpotifyActivity(): void {
const showActivity = useActivityStore((s) => s.showActivity);
useEffect(() => {
const setSource = useActivityStore.getState().setSourceActivities;
// The privacy toggle governs this like any other activity source.
if (!showActivity) {
setSource('spotify', []);
return;
}
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const tick = async () => {
let delay = POLL_IDLE_MS;
try {
// Polling a hidden tab burns Spotify's rate limit for a screen nobody
// is looking at; the next visible tick catches up.
if (typeof document === 'undefined' || !document.hidden) {
const { activity, connected } = await api.spotify.nowPlaying();
if (cancelled) return;
setSource('spotify', activity ? [activity] : []);
delay = connected ? POLL_CONNECTED_MS : POLL_IDLE_MS;
} else {
delay = POLL_CONNECTED_MS;
}
} catch {
// Network hiccup or a logged-out session: keep the last known state and
// retry, rather than reporting "stopped listening" on a transient error.
}
if (!cancelled) timer = setTimeout(() => void tick(), delay);
};
void tick();
return () => {
cancelled = true;
if (timer) clearTimeout(timer);
setSource('spotify', []);
};
}, [showActivity]);
}
+19
View File
@@ -33,6 +33,25 @@ export const en = {
'settings.voice.micTest.idle': 'Test your mic without joining a call.', 'settings.voice.micTest.idle': 'Test your mic without joining a call.',
'settings.voice.micTest.failed': 'Could not open the microphone. Check the device and its permission.', 'settings.voice.micTest.failed': 'Could not open the microphone. Check the device and its permission.',
// Settings — privacy
'privacy.title': 'Privacy',
'privacy.discoverable.label': 'Allow others to find my profile',
'privacy.discoverable.description': 'When enabled, your profile appears in Discover People. Others can always add you by exact username.',
'privacy.activity.label': 'Share Activity Status',
'privacy.activity.description': "Allow others to see what you're up to, like games you're playing or music you're listening to.",
// Settings — connections
'connections.title': 'Connections',
'connections.spotify.description': 'Show what you are listening to on your profile.',
'connections.spotify.connect': 'Connect Spotify',
'connections.spotify.disconnect': 'Disconnect',
'connections.spotify.connected': 'Connected',
'connections.spotify.notConfigured': 'This instance has no Spotify credentials configured.',
'connections.spotify.hint': 'Only what is playing is read — playback cannot be controlled.',
'connections.spotify.error.denied': 'Authorisation was cancelled on Spotify.',
'connections.spotify.error.invalid_state': 'The authorisation link expired. Try again.',
'connections.spotify.error.exchange_failed': 'Spotify refused the authorisation. Try again.',
// Profile card // Profile card
'profile.aboutMe': 'About Me', 'profile.aboutMe': 'About Me',
'profile.memberSince': 'Member Since', 'profile.memberSince': 'Member Since',
+19
View File
@@ -32,6 +32,25 @@ export const ptBR: Partial<Dictionary> = {
'settings.voice.micTest.idle': 'Teste seu microfone sem entrar numa call.', 'settings.voice.micTest.idle': 'Teste seu microfone sem entrar numa call.',
'settings.voice.micTest.failed': 'Não foi possível abrir o microfone. Verifique o dispositivo e a permissão.', 'settings.voice.micTest.failed': 'Não foi possível abrir o microfone. Verifique o dispositivo e a permissão.',
// Configurações — privacidade
'privacy.title': 'Privacidade',
'privacy.discoverable.label': 'Permitir que me encontrem',
'privacy.discoverable.description': 'Quando ativado, seu perfil aparece em Descobrir Pessoas. Qualquer um sempre pode te adicionar pelo nome de usuário exato.',
'privacy.activity.label': 'Compartilhar atividade',
'privacy.activity.description': 'Permite que os outros vejam o que você está fazendo, como jogos que está jogando ou música que está ouvindo.',
// Configurações — conexões
'connections.title': 'Conexões',
'connections.spotify.description': 'Mostre no seu perfil o que você está ouvindo.',
'connections.spotify.connect': 'Conectar Spotify',
'connections.spotify.disconnect': 'Desconectar',
'connections.spotify.connected': 'Conectado',
'connections.spotify.notConfigured': 'Esta instância não tem credenciais do Spotify configuradas.',
'connections.spotify.hint': 'Só é lido o que está tocando — não é possível controlar a reprodução.',
'connections.spotify.error.denied': 'A autorização foi cancelada no Spotify.',
'connections.spotify.error.invalid_state': 'O link de autorização expirou. Tente de novo.',
'connections.spotify.error.exchange_failed': 'O Spotify recusou a autorização. Tente de novo.',
// Cartão de perfil // Cartão de perfil
'profile.aboutMe': 'Sobre mim', 'profile.aboutMe': 'Sobre mim',
'profile.memberSince': 'Membro desde', 'profile.memberSince': 'Membro desde',
+3 -3
View File
@@ -10,16 +10,16 @@ export function initActivityBridge(): void {
// Subscribe to future activity changes from main process // Subscribe to future activity changes from main process
unsubscribe = window.backspace.onActivityDetected((activity) => { unsubscribe = window.backspace.onActivityDetected((activity) => {
if (activity) { if (activity) {
useActivityStore.getState().pushActivities([activity as Activity]); useActivityStore.getState().setSourceActivities('desktop', [activity as Activity]);
} else { } else {
useActivityStore.getState().pushActivities([]); useActivityStore.getState().setSourceActivities('desktop', []);
} }
}); });
// Request current state (handles instance-switch: game was already running) // Request current state (handles instance-switch: game was already running)
window.backspace.getCurrentActivity?.().then((activity: unknown) => { window.backspace.getCurrentActivity?.().then((activity: unknown) => {
if (activity) { if (activity) {
useActivityStore.getState().pushActivities([activity as Activity]); useActivityStore.getState().setSourceActivities('desktop', [activity as Activity]);
} }
}).catch(() => {}); }).catch(() => {});
} }
+20
View File
@@ -1,5 +1,6 @@
import { create } from 'zustand'; import { create } from 'zustand';
import type { Activity } from '@backspace/shared'; import type { Activity } from '@backspace/shared';
import { ACTIVITY_LIMITS } from '@backspace/shared/src/activities.js';
import { wsSendAll } from '../hooks/useWebSocket'; import { wsSendAll } from '../hooks/useWebSocket';
let pushTimer: ReturnType<typeof setTimeout> | null = null; let pushTimer: ReturnType<typeof setTimeout> | null = null;
@@ -14,9 +15,18 @@ interface ActivityState {
initActivities: (activityMap: Record<string, Activity[]>) => void; initActivities: (activityMap: Record<string, Activity[]>) => void;
setShowActivity: (show: boolean) => void; setShowActivity: (show: boolean) => void;
pushActivities: (activities: Activity[]) => void; pushActivities: (activities: Activity[]) => void;
setSourceActivities: (source: string, activities: Activity[]) => void;
reset: () => void; reset: () => void;
} }
/**
* Activities kept per producer. The desktop process detector and Spotify report
* independently of each other, and a plain replace would let whichever spoke
* last erase the other — losing precisely the case this exists for: a game and
* Spotify at the same time.
*/
const bySource = new Map<string, Activity[]>();
export const useActivityStore = create<ActivityState>((set, get) => ({ export const useActivityStore = create<ActivityState>((set, get) => ({
userActivities: new Map(), userActivities: new Map(),
showActivity: true, showActivity: true,
@@ -75,8 +85,18 @@ export const useActivityStore = create<ActivityState>((set, get) => ({
}, 5000); }, 5000);
}, },
setSourceActivities: (source, activities) => {
if (activities.length === 0) bySource.delete(source);
else bySource.set(source, activities);
const merged = Array.from(bySource.values())
.flat()
.slice(0, ACTIVITY_LIMITS.MAX_ACTIVITIES_PER_USER);
get().pushActivities(merged);
},
reset: () => { reset: () => {
if (pushTimer) { clearTimeout(pushTimer); pushTimer = null; } if (pushTimer) { clearTimeout(pushTimer); pushTimer = null; }
bySource.clear();
set({ userActivities: new Map(), showActivity: true, myActivities: null }); set({ userActivities: new Map(), showActivity: true, myActivities: null });
}, },
})); }));