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
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:
@@ -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,
|
||||
"tag": "0010_broken_blazing_skull",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "6",
|
||||
"when": 1788190305853,
|
||||
"tag": "0011_lethal_bruce_banner",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -76,6 +76,10 @@ export const config = {
|
||||
sourceCodeUrl,
|
||||
commit,
|
||||
|
||||
spotify: {
|
||||
clientId: envOptional('SPOTIFY_CLIENT_ID'),
|
||||
clientSecret: envOptional('SPOTIFY_CLIENT_SECRET'),
|
||||
},
|
||||
livekit: {
|
||||
url: envOptional('LIVEKIT_URL'),
|
||||
apiKey: envOptional('LIVEKIT_API_KEY'),
|
||||
|
||||
@@ -549,3 +549,20 @@ export const inviteRedemptions = sqliteTable('invite_redemptions', {
|
||||
inviteIdx: index('idx_invite_redemptions_invite_id').on(table.inviteId),
|
||||
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(),
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import { uploadRoutes } from './routes/uploads.js';
|
||||
import { filesRoutes } from './routes/files.js';
|
||||
import { dmRoutes } from './routes/dm.js';
|
||||
import { livekitRoutes } from './routes/livekit.js';
|
||||
import { spotifyRoutes } from './routes/spotify.js';
|
||||
import { socialRoutes } from './routes/social.js';
|
||||
import { settingsRoutes } from './routes/settings.js';
|
||||
import { utilRoutes } from './routes/utils.js';
|
||||
@@ -126,6 +127,7 @@ async function main(): Promise<void> {
|
||||
await app.register(filesRoutes);
|
||||
await app.register(dmRoutes);
|
||||
await app.register(livekitRoutes);
|
||||
await app.register(spotifyRoutes);
|
||||
await app.register(socialRoutes);
|
||||
await app.register(settingsRoutes);
|
||||
await app.register(utilRoutes);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -71,6 +71,7 @@ import type {
|
||||
AttachProofResponse,
|
||||
ReattachRequest,
|
||||
ReattachResponse,
|
||||
Activity,
|
||||
} from '@backspace/shared';
|
||||
import { getApiForOrigin, getOwnerInstanceForDm } from '../utils/crossStoreResolvers';
|
||||
|
||||
@@ -281,6 +282,13 @@ export class BackspaceApiClient {
|
||||
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: {
|
||||
initiatePeering: (data: { remoteOrigin: string }) => Promise<{ peer: FederationPeer; verified?: boolean }>;
|
||||
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 = {
|
||||
trending: (limit = 30, pos?: string) => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
@@ -22,6 +22,7 @@ import { UserProfileModal } from '../modals/UserProfileModal';
|
||||
import { IncomingCallModal } from '../voice/IncomingCallModal';
|
||||
import { PictureInPicture } from '../voice/PictureInPicture';
|
||||
import { SoundController } from '../voice/SoundController';
|
||||
import { useSpotifyActivity } from '../../hooks/useSpotifyActivity';
|
||||
import { GlobalAudioRenderer } from '../voice/GlobalAudioRenderer';
|
||||
import { NotificationController } from '../NotificationController';
|
||||
import { UserProfilePopout } from '../ui/UserProfilePopout';
|
||||
@@ -215,6 +216,7 @@ export function AppLayout() {
|
||||
const showBootSkeleton = useDelayedLoading(isLoading);
|
||||
const setCurrentSpace = useSpaceStore((s) => s.setCurrentSpace);
|
||||
const loadSpaceDetail = useSpaceStore((s) => s.loadSpaceDetail);
|
||||
useSpotifyActivity();
|
||||
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
|
||||
const loadMessages = useChatStore((s) => s.loadMessages);
|
||||
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() {
|
||||
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 (
|
||||
<div className="space-y-5">
|
||||
<h2 className="text-lg font-semibold text-txt-primary mb-6">Connections</h2>
|
||||
<ConnectedInstances />
|
||||
<div className="max-w-2xl">
|
||||
<h2 className="text-lg font-semibold text-txt-primary mb-6">{t('connections.title')}</h2>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ import { useAuthStore } from '../../../stores/authStore';
|
||||
import { useActivityStore } from '../../../stores/activityStore';
|
||||
import { api } from '../../../api/client';
|
||||
import { Toggle } from '../../ui/Toggle';
|
||||
import { useT } from '../../../i18n';
|
||||
|
||||
export function PrivacyPanel() {
|
||||
const t = useT();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const setUser = useAuthStore((s) => s.setUser);
|
||||
const showActivity = useActivityStore((s) => s.showActivity);
|
||||
@@ -31,7 +33,7 @@ export function PrivacyPanel() {
|
||||
|
||||
return (
|
||||
<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 className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
|
||||
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="flex items-center justify-between py-1">
|
||||
<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">
|
||||
When enabled, your profile appears in Discover People. Others can always add you by exact username.
|
||||
{t('privacy.discoverable.description')}
|
||||
</div>
|
||||
</div>
|
||||
<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="flex items-center justify-between py-1">
|
||||
<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">
|
||||
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>
|
||||
<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]);
|
||||
}
|
||||
@@ -33,6 +33,25 @@ export const en = {
|
||||
'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 — 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.aboutMe': 'About Me',
|
||||
'profile.memberSince': 'Member Since',
|
||||
|
||||
@@ -32,6 +32,25 @@ export const ptBR: Partial<Dictionary> = {
|
||||
'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.',
|
||||
|
||||
// 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
|
||||
'profile.aboutMe': 'Sobre mim',
|
||||
'profile.memberSince': 'Membro desde',
|
||||
|
||||
@@ -10,16 +10,16 @@ export function initActivityBridge(): void {
|
||||
// Subscribe to future activity changes from main process
|
||||
unsubscribe = window.backspace.onActivityDetected((activity) => {
|
||||
if (activity) {
|
||||
useActivityStore.getState().pushActivities([activity as Activity]);
|
||||
useActivityStore.getState().setSourceActivities('desktop', [activity as Activity]);
|
||||
} else {
|
||||
useActivityStore.getState().pushActivities([]);
|
||||
useActivityStore.getState().setSourceActivities('desktop', []);
|
||||
}
|
||||
});
|
||||
|
||||
// Request current state (handles instance-switch: game was already running)
|
||||
window.backspace.getCurrentActivity?.().then((activity: unknown) => {
|
||||
if (activity) {
|
||||
useActivityStore.getState().pushActivities([activity as Activity]);
|
||||
useActivityStore.getState().setSourceActivities('desktop', [activity as Activity]);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { create } from 'zustand';
|
||||
import type { Activity } from '@backspace/shared';
|
||||
import { ACTIVITY_LIMITS } from '@backspace/shared/src/activities.js';
|
||||
import { wsSendAll } from '../hooks/useWebSocket';
|
||||
|
||||
let pushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -14,9 +15,18 @@ interface ActivityState {
|
||||
initActivities: (activityMap: Record<string, Activity[]>) => void;
|
||||
setShowActivity: (show: boolean) => void;
|
||||
pushActivities: (activities: Activity[]) => void;
|
||||
setSourceActivities: (source: string, activities: Activity[]) => 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) => ({
|
||||
userActivities: new Map(),
|
||||
showActivity: true,
|
||||
@@ -75,8 +85,18 @@ export const useActivityStore = create<ActivityState>((set, get) => ({
|
||||
}, 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: () => {
|
||||
if (pushTimer) { clearTimeout(pushTimer); pushTimer = null; }
|
||||
bySource.clear();
|
||||
set({ userActivities: new Map(), showActivity: true, myActivities: null });
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user