Reactions to your songs, new followers, and songs sent to you are now notifications: a bell on the feed (app and website) with an unread count that updates live, and a list that marks them read. "Send ✉" on a song sends it to people who follow you, with an optional note. Settings picks which kinds to be notified about. Push delivery uses no third-party accounts: - Android: UnifiedPush through a distributor app (ntfy), pointed at our own ntfy server (docker-compose.ntfy.yml). Works the same in the F-Droid build; no Firebase. - Browsers: standard Web Push from the existing service worker. Both use Web Push encryption with a VAPID key pair the server makes and keeps in the database. Push addresses are checked like any other user-supplied URL. Setup is in docs/PUSH.md; the privacy page covers the new data. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
170 lines
5.0 KiB
TypeScript
170 lines
5.0 KiB
TypeScript
import type { Entitlements, Plan } from './plans';
|
|
import type { NotificationPrefs } from './notifications';
|
|
import type { NowPlayingProvider, StreamingService } from './services';
|
|
|
|
export interface TrackDTO {
|
|
id: string;
|
|
title: string;
|
|
artist: string;
|
|
album: string | null;
|
|
artworkUrl: string | null;
|
|
durationMs: number | null;
|
|
isrc: string | null;
|
|
links: Partial<Record<StreamingService, string>>;
|
|
}
|
|
|
|
export interface UserSummaryDTO {
|
|
id: string;
|
|
username: string;
|
|
displayName: string | null;
|
|
avatarUrl: string | null;
|
|
}
|
|
|
|
export interface MeDTO extends UserSummaryDTO {
|
|
email: string;
|
|
emailVerified: boolean;
|
|
/** A new address waiting to be confirmed (the link was sent there). */
|
|
pendingEmail: string | null;
|
|
bio: string | null;
|
|
preferredService: StreamingService | null;
|
|
/** The services they use; null until chosen, meaning all of them. */
|
|
services: StreamingService[] | null;
|
|
notifyPrefs: NotificationPrefs;
|
|
role: 'user' | 'admin';
|
|
/** When onboarding was finished; null means show it. */
|
|
onboardedAt: string | null;
|
|
/** ISO time sharing resumes, or null when sharing. Far future means "until turned back on". */
|
|
sharingPausedUntil: string | null;
|
|
plan: Plan;
|
|
entitlements: Entitlements;
|
|
}
|
|
|
|
/** One emoji on a play, and who left it (newest first). */
|
|
export interface ReactionDTO {
|
|
emoji: string;
|
|
count: number;
|
|
users: UserSummaryDTO[];
|
|
}
|
|
|
|
export interface NowPlayingDTO {
|
|
user: UserSummaryDTO;
|
|
track: TrackDTO;
|
|
isPlaying: boolean;
|
|
source: NowPlayingProvider;
|
|
/** The app it's playing in, when a phone reported it (e.g. "Spotify"). */
|
|
sourceApp: string | null;
|
|
/** Link to the source for attribution, e.g. the user's Last.fm profile (required by Last.fm's terms). */
|
|
sourceUrl: string | null;
|
|
progressMs: number | null;
|
|
updatedAt: string;
|
|
/** The play this is, for reactions (null for very old rows). */
|
|
listenId: string | null;
|
|
reactions: ReactionDTO[];
|
|
}
|
|
|
|
/** A feed entry. pinnedAt is set when the viewer pinned this friend. */
|
|
export interface FeedItemDTO extends NowPlayingDTO {
|
|
pinnedAt: string | null;
|
|
}
|
|
|
|
/** Pinned first (oldest pin on top, so order is stable), then live listeners, then most recent. */
|
|
export function sortFeed<T extends FeedItemDTO>(items: T[]): T[] {
|
|
return [...items].sort(
|
|
(a, b) =>
|
|
Number(Boolean(b.pinnedAt)) - Number(Boolean(a.pinnedAt)) ||
|
|
(a.pinnedAt && b.pinnedAt ? a.pinnedAt.localeCompare(b.pinnedAt) : 0) ||
|
|
Number(b.isPlaying) - Number(a.isPlaying) ||
|
|
b.updatedAt.localeCompare(a.updatedAt),
|
|
);
|
|
}
|
|
|
|
export interface FeaturedSongDTO {
|
|
track: TrackDTO;
|
|
comment: string | null;
|
|
updatedAt: string;
|
|
}
|
|
|
|
export interface RecentPlayDTO {
|
|
listenId: string;
|
|
track: TrackDTO;
|
|
source: NowPlayingProvider;
|
|
playedAt: string;
|
|
reactions: ReactionDTO[];
|
|
}
|
|
|
|
export interface ProfileDTO {
|
|
user: UserSummaryDTO & { bio: string | null };
|
|
followerCount: number;
|
|
followingCount: number;
|
|
isFollowing: boolean;
|
|
isPinned: boolean;
|
|
isSelf: boolean;
|
|
/** They follow the viewer. */
|
|
followsYou: boolean;
|
|
/** The viewer has blocked them (activity is hidden; they can be unblocked). */
|
|
isBlocked: boolean;
|
|
featured: FeaturedSongDTO | null;
|
|
nowPlaying: NowPlayingDTO | null;
|
|
/** Most recent first, not including the track playing right now. */
|
|
recent: RecentPlayDTO[];
|
|
/** Profile links for sources whose data appears here (Last.fm requires attribution). */
|
|
attribution: Partial<Record<'lastfm' | 'listenbrainz', string>>;
|
|
}
|
|
|
|
export interface AdminReportDTO {
|
|
id: string;
|
|
reason: string;
|
|
note: string | null;
|
|
status: 'open' | 'resolved';
|
|
createdAt: string;
|
|
reporter: UserSummaryDTO | null;
|
|
reported: UserSummaryDTO & { suspended: boolean };
|
|
}
|
|
|
|
export interface AdminStatsDTO {
|
|
users: number;
|
|
listeningNow: number;
|
|
openReports: number;
|
|
suspended: number;
|
|
}
|
|
|
|
/** A row in a followers/following list. */
|
|
export interface FollowListItemDTO extends UserSummaryDTO {
|
|
/** Whether the viewer follows this person (for Follow/Following buttons). */
|
|
isFollowing: boolean;
|
|
}
|
|
|
|
export interface FollowListDTO {
|
|
users: FollowListItemDTO[];
|
|
/** Pass back as ?cursor= for the next page; null when there are no more. */
|
|
nextCursor: string | null;
|
|
}
|
|
|
|
export interface ConnectionDTO {
|
|
provider: NowPlayingProvider;
|
|
externalUserId: string;
|
|
displayName: string | null;
|
|
status: 'active' | 'error';
|
|
lastError: string | null;
|
|
lastPolledAt: string | null;
|
|
/** Last time this connection saw something playing. */
|
|
lastActivityAt: string | null;
|
|
/** Secret URL the user can paste into Plex/Jellyfin for instant updates. */
|
|
webhookUrl: string | null;
|
|
}
|
|
|
|
export interface TrackSearchResultDTO {
|
|
deezerId: string;
|
|
title: string;
|
|
artist: string;
|
|
album: string | null;
|
|
artworkUrl: string | null;
|
|
durationMs: number | null;
|
|
}
|
|
|
|
/** Messages pushed over the realtime websocket. */
|
|
export type RealtimeMessage =
|
|
| { type: 'now_playing'; data: NowPlayingDTO }
|
|
/** Something new in the viewer's notifications. */
|
|
| { type: 'notification'; data: { unread: number } };
|