Privacy policy and terms
CI / check (push) Canceled after 0s

/privacy and /terms describe what the app actually collects and shows
(public profiles and listening, 50 recent plays, encrypted service tokens,
stripped profile pictures, Android reader scope, song lookups that carry no
identity, 14-day backups, 13+), linked from a web footer, the sign-up
forms and mobile Settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
This commit is contained in:
2026-10-06 13:07:54 -04:00
co-authored by Claude Opus 5.5
parent 85e1643c29
commit 50f00f922b
7 changed files with 270 additions and 2 deletions
+6 -1
View File
@@ -1,7 +1,7 @@
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import { router } from 'expo-router'; import { router } from 'expo-router';
import { useState } from 'react'; import { useState } from 'react';
import { Alert, Platform, StyleSheet, View } from 'react-native'; import { Alert, Linking, Platform, StyleSheet, View } from 'react-native';
import useSWR from 'swr'; import useSWR from 'swr';
import { import {
SERVICE_LABELS, SERVICE_LABELS,
@@ -19,6 +19,7 @@ import { ServiceName } from '@/components/ServiceName';
import { SharingControls } from '@/components/SharingControls'; import { SharingControls } from '@/components/SharingControls';
import { Body, Button, Card, Chip, Field, Heading, Row, Title } from '@/components/ui'; import { Body, Button, Card, Chip, Field, Heading, Row, Title } from '@/components/ui';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { WEB_URL } from '@/lib/config';
import { showError } from '@/lib/errors'; import { showError } from '@/lib/errors';
import { AppleMusicPermissionError, appleMusicAvailable, connectAppleMusic } from '@/lib/appleMusic'; import { AppleMusicPermissionError, appleMusicAvailable, connectAppleMusic } from '@/lib/appleMusic';
import { SignInError, openSpotifyLinkPage, signInWithService } from '@/lib/connect'; import { SignInError, openSpotifyLinkPage, signInWithService } from '@/lib/connect';
@@ -470,6 +471,10 @@ function Account() {
<Button small title="Change email" onPress={() => router.push('/change-email')} /> <Button small title="Change email" onPress={() => router.push('/change-email')} />
<Button small title="Change password" onPress={() => router.push('/change-password')} /> <Button small title="Change password" onPress={() => router.push('/change-password')} />
</Row> </Row>
<Row>
<Button small title="Privacy policy" onPress={() => Linking.openURL(`${WEB_URL}/privacy`)} />
<Button small title="Terms" onPress={() => Linking.openURL(`${WEB_URL}/terms`)} />
</Row>
<Button small title="Blocked accounts" onPress={() => router.push('/blocked')} style={{ alignSelf: 'flex-start' }} /> <Button small title="Blocked accounts" onPress={() => router.push('/blocked')} style={{ alignSelf: 'flex-start' }} />
<Row> <Row>
<Button small title="Log out" onPress={signOut} /> <Button small title="Log out" onPress={signOut} />
+17 -1
View File
@@ -1,8 +1,9 @@
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import { Link } from 'expo-router'; import { Link } from 'expo-router';
import { useState } from 'react'; import { useState } from 'react';
import { KeyboardAvoidingView, Platform, StyleSheet, View } from 'react-native'; import { KeyboardAvoidingView, Linking, Platform, StyleSheet, Text, View } from 'react-native';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { WEB_URL } from '@/lib/config';
import { useSession } from '@/lib/session'; import { useSession } from '@/lib/session';
import { colors, space } from '@/lib/theme'; import { colors, space } from '@/lib/theme';
import { Screen } from './Screen'; import { Screen } from './Screen';
@@ -70,6 +71,19 @@ export function AuthForm({ mode }: { mode: 'login' | 'register' }) {
/> />
{error && <Body style={{ color: colors.danger }}>{error}</Body>} {error && <Body style={{ color: colors.danger }}>{error}</Body>}
<Button variant="primary" title={mode === 'login' ? 'Log in' : 'Create account'} loading={busy} onPress={submit} /> <Button variant="primary" title={mode === 'login' ? 'Log in' : 'Create account'} loading={busy} onPress={submit} />
{mode === 'register' && (
<Text style={styles.legal}>
By creating an account you agree to the{' '}
<Text style={styles.legalLink} onPress={() => Linking.openURL(`${WEB_URL}/terms`)}>
terms
</Text>{' '}
and{' '}
<Text style={styles.legalLink} onPress={() => Linking.openURL(`${WEB_URL}/privacy`)}>
privacy policy
</Text>
. You must be 13 or older. Your profile and what you play are public.
</Text>
)}
{mode === 'login' && ( {mode === 'login' && (
<Link href="/forgot-password" style={styles.link}> <Link href="/forgot-password" style={styles.link}>
Forgot your password? Forgot your password?
@@ -92,5 +106,7 @@ export function AuthForm({ mode }: { mode: 'login' | 'register' }) {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
content: { flexGrow: 1, justifyContent: 'center' }, content: { flexGrow: 1, justifyContent: 'center' },
link: { color: colors.muted, textAlign: 'center', paddingVertical: space.sm }, link: { color: colors.muted, textAlign: 'center', paddingVertical: space.sm },
legal: { color: colors.muted, fontSize: 12, textAlign: 'center', lineHeight: 17 },
legalLink: { color: colors.text, textDecorationLine: 'underline' },
logo: { width: 76, height: 76, borderRadius: 20, marginBottom: space.sm }, logo: { width: 76, height: 76, borderRadius: 20, marginBottom: space.sm },
}); });
+7
View File
@@ -92,6 +92,13 @@ input:focus, textarea:focus, select:focus {
.chip-small.danger { background: rgba(251, 113, 133, 0.18); color: var(--danger); } .chip-small.danger { background: rgba(251, 113, 133, 0.18); color: var(--danger); }
.banner { max-width: 680px; margin: 0 auto 0.5rem; padding: 0.6rem 1rem; border-radius: 0.8rem; background: rgba(123, 47, 247, 0.16); border: 1px solid rgba(123, 47, 247, 0.35); font-size: 0.9rem; } .banner { max-width: 680px; margin: 0 auto 0.5rem; padding: 0.6rem 1rem; border-radius: 0.8rem; background: rgba(123, 47, 247, 0.16); border: 1px solid rgba(123, 47, 247, 0.35); font-size: 0.9rem; }
.banner .link-button { font-size: inherit; color: var(--text); text-decoration: underline; } .banner .link-button { font-size: inherit; color: var(--text); text-decoration: underline; }
.footer { max-width: 680px; margin: 0 auto; padding: 0 1rem 2.5rem; display: flex; gap: 1.25rem; justify-content: center; font-size: 0.85rem; }
.footer a { color: var(--muted); text-decoration: none; }
.footer a:hover { color: var(--text); }
.small { font-size: 0.82rem; }
.legal { line-height: 1.6; }
.legal h2 { margin-top: 1.5rem; }
.legal ul { padding-left: 1.2rem; display: flex; flex-direction: column; gap: 0.45rem; }
.avatar-picker { display: flex; gap: 1rem; align-items: center; } .avatar-picker { display: flex; gap: 1rem; align-items: center; }
.avatar-picker-actions { display: flex; flex-direction: column; gap: 0.4rem; } .avatar-picker-actions { display: flex; flex-direction: column; gap: 0.4rem; }
.grow { flex: 1; min-width: 0; } .grow { flex: 1; min-width: 0; }
+6
View File
@@ -1,5 +1,6 @@
import type { Metadata, Viewport } from 'next'; import type { Metadata, Viewport } from 'next';
import { Inter, Space_Grotesk } from 'next/font/google'; import { Inter, Space_Grotesk } from 'next/font/google';
import Link from 'next/link';
import { Nav } from '@/components/Nav'; import { Nav } from '@/components/Nav';
import { Providers } from '@/components/Providers'; import { Providers } from '@/components/Providers';
import './globals.css'; import './globals.css';
@@ -28,6 +29,11 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<Providers> <Providers>
<Nav /> <Nav />
<main className="container">{children}</main> <main className="container">{children}</main>
<footer className="footer">
<Link href="/privacy">Privacy</Link>
<Link href="/terms">Terms</Link>
<a href="mailto:app@musicshare.binarygnome.com">Contact</a>
</footer>
</Providers> </Providers>
</body> </body>
</html> </html>
+137
View File
@@ -0,0 +1,137 @@
import type { Metadata } from 'next';
import Link from 'next/link';
export const metadata: Metadata = { title: 'Privacy policy · musicshare' };
const UPDATED = 'October 6, 2026';
const CONTACT = 'app@musicshare.binarygnome.com';
export default function PrivacyPage() {
return (
<article className="card legal">
<h1>Privacy policy</h1>
<p className="muted">Last updated {UPDATED}</p>
<p>
musicshare shows your friends what you&apos;re listening to. This page explains what we collect to do that, who can
see it, and how to delete it. In short: we only collect what the app needs, we don&apos;t show ads, and we never
sell your data.
</p>
<h2>What we collect</h2>
<ul>
<li>
<strong>Your account:</strong> username, email address, display name, bio, and profile picture if you add one.
Your password is stored only as a one-way hash. Profile pictures are resized and stripped of hidden data like
camera details and location before they&apos;re stored.
</li>
<li>
<strong>What you listen to:</strong> the song you&apos;re playing now (title, artist, album, artwork, where in the
song you are, and which app or service it&apos;s from) and your last 50 plays. Older plays are deleted
automatically.
</li>
<li>
<strong>Services you connect:</strong> to read what you&apos;re playing we keep a sign-in token for each service
(Plex, Last.fm, Apple Music, Jellyfin) or just your username (ListenBrainz). Tokens are encrypted. We never see
or store your Plex, Last.fm or Apple password; your Jellyfin password is used once to sign in and not kept.
</li>
<li>
<strong>The Android app&apos;s &ldquo;This phone&rdquo; sharing:</strong> if you turn it on, Android calls it
&ldquo;notification access&rdquo;, but the app only reads the song, artist and album from music apps you allow.
It doesn&apos;t read or store your notifications, messages or anything from other apps.
</li>
<li>
<strong>People:</strong> who you follow, who you&apos;ve pinned, who you&apos;ve blocked, and reports you send.
</li>
<li>
<strong>Technical data:</strong> sign-in sessions (stored as hashes), and your IP address, which is used briefly
to prevent abuse (like limiting sign-in attempts) and appears in server logs that are kept for a short time.
</li>
</ul>
<p>We don&apos;t use advertising, analytics or tracking tools, and we don&apos;t use cookies except the one that keeps you signed in.</p>
<h2>Who can see what</h2>
<ul>
<li>
<strong>Your profile is public.</strong> Your username, display name, picture, bio, featured song, what you&apos;re
playing and your recent plays can be seen by anyone who has your profile link or finds you in search, including
people who aren&apos;t signed in.
</li>
<li>Your followers see what you play in their feed, live.</li>
<li>
Your email address is never shown to other people. It&apos;s used to sign in, to send you confirmation and
password reset emails, and to contact you about your account.
</li>
<li>
People you block can&apos;t find you or see your profile, and you stop following each other. They aren&apos;t
told.
</li>
<li>
You can <strong>pause sharing</strong> at any time in Settings. While paused, nothing new you play is shared or
stored.
</li>
</ul>
<h2>Who we share it with</h2>
<p>We don&apos;t sell or rent your data. Some information goes to other services so the app can work:</p>
<ul>
<li>
<strong>Services you connect</strong> (Plex, Last.fm, ListenBrainz, Apple Music, your Jellyfin server): we ask
them what you&apos;re playing using the access you gave us.
</li>
<li>
<strong>Song matching:</strong> to build &ldquo;open in your app&rdquo; links we look up song titles and artists
with Deezer, Apple&apos;s iTunes search, Spotify&apos;s catalog and Odesli (song.link). These lookups contain only
the song, never who listened to it.
</li>
<li>
<strong>Our own servers:</strong> the app, database and email run on servers we manage ourselves.
</li>
<li>If the law requires it, we may have to share information with authorities.</li>
</ul>
<p>
Data from Last.fm is shown with a link back to the listener&apos;s Last.fm profile, as Last.fm requires. Each
connected service has its own privacy policy for the data it holds.
</p>
<h2>How long we keep it</h2>
<ul>
<li>Recent plays: the latest 50. Older ones are deleted as new ones come in.</li>
<li>
Everything else: until you delete it or your account. Deleting your account (Settings &rarr; Delete account)
removes your profile, picture, plays, follows and connected services right away. Database backups are kept for
14 days, so deleted data is fully gone after that.
</li>
<li>Password reset and email confirmation links expire within a day and are deleted once used.</li>
</ul>
<h2>Your choices and rights</h2>
<ul>
<li>Edit your profile, change your email or password, disconnect services and delete your account in Settings.</li>
<li>Pause sharing, block people, and remove followers whenever you like.</li>
<li>
You can ask us for a copy of your data, or to correct or delete it, by emailing{' '}
<a href={`mailto:${CONTACT}`}>{CONTACT}</a>. Depending on where you live (for example the EU, UK or
California) you may have further rights, and you can complain to your local data protection authority.
</li>
</ul>
<h2>Children</h2>
<p>musicshare isn&apos;t meant for children under 13, and we don&apos;t knowingly collect data from them. If you think a child under 13 has an account, email us and we&apos;ll delete it.</p>
<h2>Security</h2>
<p>
We use encrypted connections (HTTPS), hash passwords and session tokens, and encrypt service tokens. No system is
perfectly secure, so if something goes wrong that affects your data, we&apos;ll tell you.
</p>
<h2>Changes</h2>
<p>If we change this policy in a way that matters, we&apos;ll update the date above and let you know in the app or by email.</p>
<h2>Contact</h2>
<p>
Questions or requests: <a href={`mailto:${CONTACT}`}>{CONTACT}</a>. See also our <Link href="/terms">terms</Link>.
</p>
</article>
);
}
+91
View File
@@ -0,0 +1,91 @@
import type { Metadata } from 'next';
import Link from 'next/link';
export const metadata: Metadata = { title: 'Terms of use · musicshare' };
const UPDATED = 'October 6, 2026';
const CONTACT = 'app@musicshare.binarygnome.com';
export default function TermsPage() {
return (
<article className="card legal">
<h1>Terms of use</h1>
<p className="muted">Last updated {UPDATED}</p>
<p>
By creating an account or using musicshare (the website and apps), you agree to these terms. Please also read our{' '}
<Link href="/privacy">privacy policy</Link>, which explains what we collect and who can see it.
</p>
<h2>Your account</h2>
<ul>
<li>You need to be at least 13 years old to use musicshare.</li>
<li>Give a real email address you can access, and keep your password to yourself. You&apos;re responsible for what happens on your account.</li>
<li>One person per account. Don&apos;t pretend to be someone else.</li>
</ul>
<h2>What you share</h2>
<ul>
<li>
What you post (your profile picture, display name, bio and comments on featured songs) stays yours. You give us
permission to store and show it in musicshare so the app can work, and that ends when you delete it or your
account.
</li>
<li>
Your profile and listening activity are public, as described in the privacy policy. You can pause sharing at any
time.
</li>
<li>Only connect accounts that belong to you.</li>
</ul>
<h2>Be decent</h2>
<p>Don&apos;t use musicshare to:</p>
<ul>
<li>harass, threaten or bully anyone, or post hateful, sexual or violent content;</li>
<li>impersonate people, spam, or create fake accounts;</li>
<li>post things you don&apos;t have the right to share;</li>
<li>break the law, or break the terms of the music services you connect;</li>
<li>scrape, overload, or try to break into musicshare or other people&apos;s accounts.</li>
</ul>
<p>
You can block and report people from their profile. We may remove content, or suspend or delete accounts, that
break these rules.
</p>
<h2>Music services</h2>
<p>
musicshare isn&apos;t affiliated with or endorsed by Spotify, Apple, Last.fm, ListenBrainz, Plex, Jellyfin,
Deezer, YouTube or TIDAL. Their names belong to them. We show what you play and link to songs; we don&apos;t host
or stream music. Your use of those services is governed by their own terms.
</p>
<h2>Free, with optional donations</h2>
<p>
musicshare is free. Donations are voluntary, don&apos;t unlock anything, and aren&apos;t refundable. If we ever
offer paid features, we&apos;ll explain the price and terms before you pay.
</p>
<h2>No guarantees</h2>
<p>
We work to keep musicshare running well, but it&apos;s provided &ldquo;as is&rdquo;: it may have bugs, go down, or
change, and features can be added or removed. Connected services can also change or stop working with us. To the
extent the law allows, we aren&apos;t liable for indirect losses, or for more than you&apos;ve paid us in the last
12 months. Nothing here limits rights you have under consumer law that can&apos;t be waived.
</p>
<h2>Leaving</h2>
<p>
You can delete your account at any time in Settings. We may suspend or close accounts that break these terms, and
we&apos;ll tell you if we decide to shut the service down.
</p>
<h2>Changes</h2>
<p>If we change these terms in a way that matters, we&apos;ll update the date above and let you know. Continuing to use musicshare after that means you accept the new terms.</p>
<h2>Contact</h2>
<p>
<a href={`mailto:${CONTACT}`}>{CONTACT}</a>
</p>
</article>
);
}
+6
View File
@@ -57,6 +57,12 @@ export function AuthForm({ mode }: { mode: 'login' | 'register' }) {
<button className="btn btn-primary" disabled={busy}> <button className="btn btn-primary" disabled={busy}>
{mode === 'login' ? 'Log in' : 'Sign up'} {mode === 'login' ? 'Log in' : 'Sign up'}
</button> </button>
{mode === 'register' && (
<p className="muted small">
By signing up you agree to the <Link href="/terms">terms</Link> and <Link href="/privacy">privacy policy</Link>. You
must be 13 or older. Your profile and what you play are public.
</p>
)}
{mode === 'login' && ( {mode === 'login' && (
<p className="muted"> <p className="muted">
<Link href="/forgot-password">Forgot your password?</Link> <Link href="/forgot-password">Forgot your password?</Link>