Compare commits
8
Commits
v1.1.0
..
4402171f46
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4402171f46 | ||
|
|
31aed8dec4 | ||
|
|
b705bfcce4 | ||
|
|
82ff5336aa | ||
|
|
4da8487ef9 | ||
|
|
5cbde5e7f9 | ||
|
|
804493aee0 | ||
|
|
d64e810866 |
@@ -94,13 +94,22 @@ jobs:
|
|||||||
auth="Authorization: token $TOKEN"
|
auth="Authorization: token $TOKEN"
|
||||||
if [ "$REF_TYPE" = tag ]; then
|
if [ "$REF_TYPE" = tag ]; then
|
||||||
# Reuse the release if this tag was built before.
|
# Reuse the release if this tag was built before.
|
||||||
id=$(curl -fsS -H "$auth" "$api/releases/tags/$REF_NAME" 2>/dev/null | node -p 'JSON.parse(require("fs").readFileSync(0)).id' || true)
|
id=$(curl -fsS -H "$auth" "$api/releases/tags/$REF_NAME" 2>/dev/null | node -p 'JSON.parse(require("fs").readFileSync(0, "utf8") || "{}").id ?? ""' || true)
|
||||||
if [ -z "$id" ]; then
|
if [ -z "$id" ]; then
|
||||||
body=$(node -e 'console.log(JSON.stringify({ tag_name: process.argv[1], name: process.argv[1], body: "Android builds. musicshare-*-foss.apk has no closed-source code (no Google ML Kit)." }))' "$REF_NAME")
|
# The changelog first (the app shows the text above "---" as what's new), then a note for this page.
|
||||||
|
body=$(node -e '
|
||||||
|
const fs = require("fs");
|
||||||
|
const code = require("../apps/mobile/app.json").expo.android.versionCode;
|
||||||
|
const file = `../fastlane/metadata/android/en-US/changelogs/${code}.txt`;
|
||||||
|
const notes = fs.existsSync(file) ? fs.readFileSync(file, "utf8").trim() : "";
|
||||||
|
const note = "musicshare-*-foss.apk has no closed-source code (no Google ML Kit); it does not update itself.";
|
||||||
|
const tag = process.argv[1];
|
||||||
|
console.log(JSON.stringify({ tag_name: tag, name: tag, body: notes ? `${notes}\n\n---\n${note}` : `---\n${note}` }));
|
||||||
|
' "$REF_NAME")
|
||||||
id=$(curl -fsS -X POST -H "$auth" -H 'Content-Type: application/json' -d "$body" "$api/releases" | node -p 'JSON.parse(require("fs").readFileSync(0)).id')
|
id=$(curl -fsS -X POST -H "$auth" -H 'Content-Type: application/json' -d "$body" "$api/releases" | node -p 'JSON.parse(require("fs").readFileSync(0)).id')
|
||||||
fi
|
fi
|
||||||
for f in *.apk SHA256SUMS; do
|
for f in *.apk SHA256SUMS; do
|
||||||
old=$(curl -fsS -H "$auth" "$api/releases/$id/assets" | node -p 'JSON.parse(require("fs").readFileSync(0)).find(a => a.name === process.argv[1])?.id ?? ""' "$f")
|
old=$(curl -fsS -H "$auth" "$api/releases/$id/assets" | node -p 'JSON.parse(require("fs").readFileSync(0, "utf8") || "[]").find(a => a.name === process.argv[1])?.id ?? ""' "$f")
|
||||||
[ -n "$old" ] && curl -fsS -X DELETE -H "$auth" "$api/releases/$id/assets/$old"
|
[ -n "$old" ] && curl -fsS -X DELETE -H "$auth" "$api/releases/$id/assets/$old"
|
||||||
curl -fsS -X POST -H "$auth" -F "attachment=@$f" "$api/releases/$id/assets?name=$f" > /dev/null
|
curl -fsS -X POST -H "$auth" -F "attachment=@$f" "$api/releases/$id/assets?name=$f" > /dev/null
|
||||||
echo "Attached $f"
|
echo "Attached $f"
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
CREATE TABLE "comments" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"listen_id" uuid NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"body" varchar(500) NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "notifications" ADD COLUMN "comment_id" uuid;--> statement-breakpoint
|
||||||
|
ALTER TABLE "reports" ADD COLUMN "comment_id" uuid;--> statement-breakpoint
|
||||||
|
ALTER TABLE "reports" ADD COLUMN "comment_body" varchar(500);--> statement-breakpoint
|
||||||
|
ALTER TABLE "comments" ADD CONSTRAINT "comments_listen_id_listens_id_fk" FOREIGN KEY ("listen_id") REFERENCES "public"."listens"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "comments" ADD CONSTRAINT "comments_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "comments_listen_created_idx" ON "comments" USING btree ("listen_id","created_at");--> statement-breakpoint
|
||||||
|
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_comment_id_comments_id_fk" FOREIGN KEY ("comment_id") REFERENCES "public"."comments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "reports" ADD CONSTRAINT "reports_comment_id_comments_id_fk" FOREIGN KEY ("comment_id") REFERENCES "public"."comments"("id") ON DELETE set null ON UPDATE no action;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -120,6 +120,13 @@
|
|||||||
"when": 1792028149643,
|
"when": 1792028149643,
|
||||||
"tag": "0016_notifications",
|
"tag": "0016_notifications",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 17,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1792084480262,
|
||||||
|
"tag": "0017_comments",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -11,6 +11,8 @@ import { loadUser } from './lib/auth';
|
|||||||
import { HttpError } from './lib/http';
|
import { HttpError } from './lib/http';
|
||||||
import { redis } from './lib/redis';
|
import { redis } from './lib/redis';
|
||||||
import { adminRoutes } from './routes/admin';
|
import { adminRoutes } from './routes/admin';
|
||||||
|
import { appUpdateRoutes } from './routes/appUpdates';
|
||||||
|
import { commentRoutes } from './routes/comments';
|
||||||
import { accountRoutes } from './routes/account';
|
import { accountRoutes } from './routes/account';
|
||||||
import { authRoutes } from './routes/auth';
|
import { authRoutes } from './routes/auth';
|
||||||
import { avatarRoutes } from './routes/avatars';
|
import { avatarRoutes } from './routes/avatars';
|
||||||
@@ -77,10 +79,12 @@ export async function buildApp({ logger = true, rateLimits = true }: { logger?:
|
|||||||
await app.register(adminRoutes);
|
await app.register(adminRoutes);
|
||||||
await app.register(trackRoutes);
|
await app.register(trackRoutes);
|
||||||
await app.register(reactionRoutes);
|
await app.register(reactionRoutes);
|
||||||
|
await app.register(commentRoutes);
|
||||||
await app.register(notificationRoutes);
|
await app.register(notificationRoutes);
|
||||||
await app.register(connectionRoutes);
|
await app.register(connectionRoutes);
|
||||||
await app.register(deviceRoutes);
|
await app.register(deviceRoutes);
|
||||||
await app.register(realtimeRoutes);
|
await app.register(realtimeRoutes);
|
||||||
|
await app.register(appUpdateRoutes);
|
||||||
// registered as its own plugin so its permissive body parsers stay scoped to webhook routes
|
// registered as its own plugin so its permissive body parsers stay scoped to webhook routes
|
||||||
await app.register(webhookRoutes);
|
await app.register(webhookRoutes);
|
||||||
|
|
||||||
|
|||||||
@@ -105,6 +105,9 @@ export const reports = pgTable(
|
|||||||
.references(() => users.id, { onDelete: 'cascade' }),
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
reason: text('reason').$type<ReportReason>().notNull(),
|
reason: text('reason').$type<ReportReason>().notNull(),
|
||||||
note: varchar('note', { length: 500 }),
|
note: varchar('note', { length: 500 }),
|
||||||
|
/** When a comment was reported: which, and what it said then (kept if the comment is deleted). */
|
||||||
|
commentId: uuid('comment_id').references(() => comments.id, { onDelete: 'set null' }),
|
||||||
|
commentBody: varchar('comment_body', { length: 500 }),
|
||||||
status: text('status').$type<'open' | 'resolved'>().notNull().default('open'),
|
status: text('status').$type<'open' | 'resolved'>().notNull().default('open'),
|
||||||
createdAt: createdAt(),
|
createdAt: createdAt(),
|
||||||
},
|
},
|
||||||
@@ -262,6 +265,23 @@ export const reactions = pgTable(
|
|||||||
(t) => [primaryKey({ columns: [t.listenId, t.userId] })],
|
(t) => [primaryKey({ columns: [t.listenId, t.userId] })],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Comments on a play. They go with the play (plays with comments are kept when history is trimmed). */
|
||||||
|
export const comments = pgTable(
|
||||||
|
'comments',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
listenId: uuid('listen_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => listens.id, { onDelete: 'cascade' }),
|
||||||
|
userId: uuid('user_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
body: varchar('body', { length: 500 }).notNull(),
|
||||||
|
createdAt: createdAt(),
|
||||||
|
},
|
||||||
|
(t) => [index('comments_listen_created_idx').on(t.listenId, t.createdAt)],
|
||||||
|
);
|
||||||
|
|
||||||
/** Email confirmation links (sign-up and email changes). Only a hash of the token is stored. */
|
/** Email confirmation links (sign-up and email changes). Only a hash of the token is stored. */
|
||||||
export const emailVerifications = pgTable(
|
export const emailVerifications = pgTable(
|
||||||
'email_verifications',
|
'email_verifications',
|
||||||
@@ -306,7 +326,7 @@ export const lyrics = pgTable('lyrics', {
|
|||||||
fetchedAt: timestamp('fetched_at', { withTimezone: true }).notNull().defaultNow(),
|
fetchedAt: timestamp('fetched_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Things that happened to a user: a reaction, a new follower, a song sent to them. */
|
/** Things that happened to a user: a reaction or comment, a new follower, a song sent to them. */
|
||||||
export const notifications = pgTable(
|
export const notifications = pgTable(
|
||||||
'notifications',
|
'notifications',
|
||||||
{
|
{
|
||||||
@@ -319,10 +339,12 @@ export const notifications = pgTable(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id, { onDelete: 'cascade' }),
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
trackId: uuid('track_id').references(() => tracks.id, { onDelete: 'cascade' }),
|
trackId: uuid('track_id').references(() => tracks.id, { onDelete: 'cascade' }),
|
||||||
/** The play a reaction was on (gone once the play is trimmed from history). */
|
/** The play a reaction or comment was on (gone once the play is trimmed from history). */
|
||||||
listenId: uuid('listen_id').references(() => listens.id, { onDelete: 'set null' }),
|
listenId: uuid('listen_id').references(() => listens.id, { onDelete: 'set null' }),
|
||||||
|
/** The comment (or reply); deleting the comment takes its notifications with it. */
|
||||||
|
commentId: uuid('comment_id').references(() => comments.id, { onDelete: 'cascade' }),
|
||||||
emoji: varchar('emoji', { length: 32 }),
|
emoji: varchar('emoji', { length: 32 }),
|
||||||
/** A note sent with a song. */
|
/** A note sent with a song, or the start of a comment. */
|
||||||
message: varchar('message', { length: 280 }),
|
message: varchar('message', { length: 280 }),
|
||||||
readAt: timestamp('read_at', { withTimezone: true }),
|
readAt: timestamp('read_at', { withTimezone: true }),
|
||||||
createdAt: createdAt(),
|
createdAt: createdAt(),
|
||||||
|
|||||||
@@ -34,6 +34,13 @@ const schema = z.object({
|
|||||||
.string()
|
.string()
|
||||||
.default('https://lrclib.net')
|
.default('https://lrclib.net')
|
||||||
.transform((v) => v.replace(/\/+$/, '')),
|
.transform((v) => v.replace(/\/+$/, '')),
|
||||||
|
/**
|
||||||
|
* App updates: the Gitea/Forgejo API URL of the latest Android release, e.g.
|
||||||
|
* https://gitea.example.com/api/v1/repos/owner/musicshare/releases/latest. Empty turns update checks off.
|
||||||
|
* The token (read access to the repo) is only needed when the repo is private; downloads go through us.
|
||||||
|
*/
|
||||||
|
ANDROID_RELEASES_URL: z.string().default(''),
|
||||||
|
ANDROID_RELEASES_TOKEN: optional,
|
||||||
/** Apple Music (MusicKit): Team ID, MusicKit key ID, and the key's .p8 contents (newlines may be written as \n). */
|
/** Apple Music (MusicKit): Team ID, MusicKit key ID, and the key's .p8 contents (newlines may be written as \n). */
|
||||||
APPLE_TEAM_ID: optional,
|
APPLE_TEAM_ID: optional,
|
||||||
APPLE_MUSICKIT_KEY_ID: optional,
|
APPLE_MUSICKIT_KEY_ID: optional,
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { env } from '../env';
|
|||||||
import { HttpError } from './http';
|
import { HttpError } from './http';
|
||||||
|
|
||||||
export const AVATAR_SIZE = 512;
|
export const AVATAR_SIZE = 512;
|
||||||
export const AVATAR_MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
|
/** Full-size photos from 50-200 MP phone cameras can be well over 10 MB (the app shrinks them first since 1.3.0). */
|
||||||
|
export const AVATAR_MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Uploaded avatars are stored as a path (`/avatars/<id>?v=<version>`) so they keep
|
* Uploaded avatars are stored as a path (`/avatars/<id>?v=<version>`) so they keep
|
||||||
@@ -16,14 +17,16 @@ export const publicAvatarUrl = (stored: string | null): string | null =>
|
|||||||
* Square-crops, resizes and re-encodes to WebP. Re-encoding also drops EXIF
|
* Square-crops, resizes and re-encodes to WebP. Re-encoding also drops EXIF
|
||||||
* (phone photos carry GPS location) and anything that isn't image data.
|
* (phone photos carry GPS location) and anything that isn't image data.
|
||||||
*/
|
*/
|
||||||
export async function processAvatar(input: Buffer): Promise<Buffer> {
|
export async function processAvatar(input: Buffer, log?: (err: unknown) => void): Promise<Buffer> {
|
||||||
try {
|
try {
|
||||||
return await sharp(input, { limitInputPixels: 50_000_000, animated: false })
|
// sharp's default pixel limit (~268 MP) fits a 200 MP camera; JPEGs are shrunk while decoding, so it stays cheap.
|
||||||
|
return await sharp(input, { animated: false })
|
||||||
.rotate() // apply the EXIF orientation before it's stripped
|
.rotate() // apply the EXIF orientation before it's stripped
|
||||||
.resize(AVATAR_SIZE, AVATAR_SIZE, { fit: 'cover', position: 'attention' })
|
.resize(AVATAR_SIZE, AVATAR_SIZE, { fit: 'cover', position: 'attention' })
|
||||||
.webp({ quality: 82 })
|
.webp({ quality: 82 })
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
log?.(err);
|
||||||
throw new HttpError(400, "That file isn't an image we can read. Try a JPEG, PNG, WebP or HEIC.");
|
throw new HttpError(400, "That file isn't an image we can read. Try a JPEG, PNG, WebP or HEIC.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export async function adminRoutes(app: FastifyInstance) {
|
|||||||
createdAt: report.createdAt.toISOString(),
|
createdAt: report.createdAt.toISOString(),
|
||||||
reporter: by ? toUserSummary(by) : null,
|
reporter: by ? toUserSummary(by) : null,
|
||||||
reported: { ...toUserSummary(who), suspended: Boolean(who.suspendedAt) },
|
reported: { ...toUserSummary(who), suspended: Boolean(who.suspendedAt) },
|
||||||
|
comment: report.commentBody ? { id: report.commentId, body: report.commentBody } : null,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Readable } from 'node:stream';
|
||||||
|
import type { ReadableStream as WebReadableStream } from 'node:stream/web';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import type { AppUpdateDTO } from '@musicshare/shared';
|
||||||
|
import { HttpError } from '../lib/http';
|
||||||
|
import { fetchFromReleaseHost, latestAndroidRelease } from '../services/appUpdates';
|
||||||
|
|
||||||
|
/** The Android app checks here for a newer version of itself and downloads it through us. */
|
||||||
|
export async function appUpdateRoutes(app: FastifyInstance) {
|
||||||
|
app.get(
|
||||||
|
'/app/android/latest',
|
||||||
|
{ config: { rateLimit: { max: 30, timeWindow: '1 minute' } } },
|
||||||
|
async (req, reply): Promise<AppUpdateDTO | null> => {
|
||||||
|
try {
|
||||||
|
const release = await latestAndroidRelease();
|
||||||
|
return reply.send(release?.update ?? null);
|
||||||
|
} catch (err) {
|
||||||
|
req.log.warn(err, 'release lookup failed');
|
||||||
|
throw new HttpError(503, "Couldn't check for updates right now");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Only the latest release's APK, so this can't be used to fetch anything else from the release host. */
|
||||||
|
app.get<{ Params: { name: string } }>(
|
||||||
|
'/app/android/apk/:name',
|
||||||
|
{ config: { rateLimit: { max: 10, timeWindow: '1 hour' } } },
|
||||||
|
async (req, reply) => {
|
||||||
|
const release = await latestAndroidRelease().catch(() => null);
|
||||||
|
if (!release || req.params.name !== release.apkName) throw new HttpError(404, 'Not found');
|
||||||
|
const res = await fetchFromReleaseHost(release.assetUrl, { signal: AbortSignal.timeout(10 * 60_000) });
|
||||||
|
if (!res.ok || !res.body) {
|
||||||
|
req.log.warn({ status: res.status }, 'APK download from the release host failed');
|
||||||
|
throw new HttpError(502, "Couldn't download the update right now");
|
||||||
|
}
|
||||||
|
reply
|
||||||
|
.header('content-type', 'application/vnd.android.package-archive')
|
||||||
|
.header('content-disposition', `attachment; filename="${release.apkName}"`)
|
||||||
|
.header('cache-control', 'no-store');
|
||||||
|
const length = res.headers.get('content-length');
|
||||||
|
if (length) reply.header('content-length', length);
|
||||||
|
return reply.send(Readable.fromWeb(res.body as WebReadableStream));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,9 +14,11 @@ export async function avatarRoutes(app: FastifyInstance) {
|
|||||||
const file = await req.file({ limits: { fileSize: AVATAR_MAX_UPLOAD_BYTES, files: 1 } }).catch(() => undefined);
|
const file = await req.file({ limits: { fileSize: AVATAR_MAX_UPLOAD_BYTES, files: 1 } }).catch(() => undefined);
|
||||||
if (!file) throw new HttpError(400, 'Choose a picture to upload');
|
if (!file) throw new HttpError(400, 'Choose a picture to upload');
|
||||||
const input = await file.toBuffer().catch(() => {
|
const input = await file.toBuffer().catch(() => {
|
||||||
throw new HttpError(413, 'That picture is too big (10 MB max)');
|
throw new HttpError(413, `That picture is too big (${AVATAR_MAX_UPLOAD_BYTES / 1024 / 1024} MB max)`);
|
||||||
});
|
});
|
||||||
const image = await processAvatar(input);
|
const image = await processAvatar(input, (err) =>
|
||||||
|
req.log.warn({ err, bytes: input.length, mimetype: file.mimetype, filename: file.filename }, 'avatar not readable'),
|
||||||
|
);
|
||||||
|
|
||||||
const values = { image, contentType: 'image/webp', updatedAt: new Date() };
|
const values = { image, contentType: 'image/webp', updatedAt: new Date() };
|
||||||
await db
|
await db
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { and, asc, eq, inArray, isNull, ne, or } from 'drizzle-orm';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { commentSchema, reportSchema, type CommentDTO, type CommentThreadDTO } from '@musicshare/shared';
|
||||||
|
import { db, schema } from '../db';
|
||||||
|
import type { User } from '../db/schema';
|
||||||
|
import { requireUser } from '../lib/auth';
|
||||||
|
import { HttpError } from '../lib/http';
|
||||||
|
import { blockStatus, hiddenUserIds } from '../services/blocks';
|
||||||
|
import { notify } from '../services/notifications';
|
||||||
|
import { getReactions, publishNowPlaying, toUserSummary } from '../services/nowPlaying';
|
||||||
|
import { getTrackDTOs } from '../services/tracks';
|
||||||
|
|
||||||
|
const params = z.object({ id: z.uuid() });
|
||||||
|
|
||||||
|
/** Who may write on a play: the person who played it, and the people they follow. */
|
||||||
|
async function canCommentOn(viewerId: string, ownerId: string) {
|
||||||
|
if (viewerId === ownerId) return true;
|
||||||
|
const [row] = await db
|
||||||
|
.select({ one: schema.follows.followerId })
|
||||||
|
.from(schema.follows)
|
||||||
|
.where(and(eq(schema.follows.followerId, ownerId), eq(schema.follows.followeeId, viewerId)));
|
||||||
|
return Boolean(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The play and its owner, if the viewer may see it: the owner isn't suspended, and no block either way. */
|
||||||
|
async function visiblePlay(viewerId: string, listenId: string) {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ listen: schema.listens, owner: schema.users })
|
||||||
|
.from(schema.listens)
|
||||||
|
.innerJoin(schema.users, eq(schema.users.id, schema.listens.userId))
|
||||||
|
.where(eq(schema.listens.id, listenId));
|
||||||
|
if (!row || row.owner.suspendedAt) throw new HttpError(404, 'That play is gone');
|
||||||
|
const { viewerBlocked, blockedByThem } = await blockStatus(viewerId, row.owner.id);
|
||||||
|
if (viewerBlocked || blockedByThem) throw new HttpError(404, 'That play is gone');
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toCommentDTO = (c: typeof schema.comments.$inferSelect, author: User, viewer: User, ownerId: string): CommentDTO => ({
|
||||||
|
id: c.id,
|
||||||
|
user: toUserSummary(author),
|
||||||
|
body: c.body,
|
||||||
|
createdAt: c.createdAt.toISOString(),
|
||||||
|
canDelete: c.userId === viewer.id || ownerId === viewer.id || viewer.role === 'admin',
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function commentRoutes(app: FastifyInstance) {
|
||||||
|
app.get('/listens/:id/comments', async (req): Promise<CommentThreadDTO> => {
|
||||||
|
const viewer = requireUser(req);
|
||||||
|
const { id } = params.parse(req.params);
|
||||||
|
const { listen, owner } = await visiblePlay(viewer.id, id);
|
||||||
|
const hidden = await hiddenUserIds(viewer.id);
|
||||||
|
const rows = await db
|
||||||
|
.select({ comment: schema.comments, author: schema.users })
|
||||||
|
.from(schema.comments)
|
||||||
|
.innerJoin(schema.users, eq(schema.users.id, schema.comments.userId))
|
||||||
|
.where(and(eq(schema.comments.listenId, id), isNull(schema.users.suspendedAt)))
|
||||||
|
.orderBy(asc(schema.comments.createdAt));
|
||||||
|
const [tracks, reactions, canComment] = await Promise.all([
|
||||||
|
getTrackDTOs([listen.trackId]),
|
||||||
|
getReactions([id]),
|
||||||
|
canCommentOn(viewer.id, owner.id),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
listenId: id,
|
||||||
|
owner: toUserSummary(owner),
|
||||||
|
track: tracks.get(listen.trackId)!,
|
||||||
|
playedAt: listen.startedAt.toISOString(),
|
||||||
|
reactions: reactions.get(id) ?? [],
|
||||||
|
comments: rows.filter((r) => !hidden.has(r.author.id)).map((r) => toCommentDTO(r.comment, r.author, viewer, owner.id)),
|
||||||
|
canComment,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
'/listens/:id/comments',
|
||||||
|
{ config: { rateLimit: { max: 20, timeWindow: '1 minute' } } },
|
||||||
|
async (req): Promise<CommentDTO> => {
|
||||||
|
const viewer = requireUser(req);
|
||||||
|
const { id } = params.parse(req.params);
|
||||||
|
const { body } = commentSchema.parse(req.body);
|
||||||
|
const { listen, owner } = await visiblePlay(viewer.id, id);
|
||||||
|
if (!(await canCommentOn(viewer.id, owner.id))) {
|
||||||
|
throw new HttpError(403, `Only people @${owner.username} follows can comment on their songs`);
|
||||||
|
}
|
||||||
|
const [comment] = await db.insert(schema.comments).values({ listenId: id, userId: viewer.id, body }).returning();
|
||||||
|
|
||||||
|
const excerpt = body.length > 280 ? `${body.slice(0, 279)}…` : body;
|
||||||
|
const about = { actorId: viewer.id, listenId: id, trackId: listen.trackId, commentId: comment!.id, message: excerpt };
|
||||||
|
await notify({ ...about, userId: owner.id, type: 'comment' });
|
||||||
|
// Everyone else who has commented here hears about replies (unless either side has blocked the other).
|
||||||
|
const others = await db
|
||||||
|
.selectDistinct({ userId: schema.comments.userId })
|
||||||
|
.from(schema.comments)
|
||||||
|
.where(and(eq(schema.comments.listenId, id), ne(schema.comments.userId, viewer.id), ne(schema.comments.userId, owner.id)));
|
||||||
|
const hidden = await hiddenUserIds(viewer.id);
|
||||||
|
for (const { userId } of others) {
|
||||||
|
if (!hidden.has(userId)) await notify({ ...about, userId, type: 'reply' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await publishNowPlaying(owner.id); // the comment count on the feed
|
||||||
|
return toCommentDTO(comment!, viewer, viewer, owner.id);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
async function findComment(commentId: string) {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ comment: schema.comments, ownerId: schema.listens.userId })
|
||||||
|
.from(schema.comments)
|
||||||
|
.innerJoin(schema.listens, eq(schema.listens.id, schema.comments.listenId))
|
||||||
|
.where(eq(schema.comments.id, commentId));
|
||||||
|
if (!row) throw new HttpError(404, 'That comment is gone');
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The author, the play's owner, or an admin. Its notifications go with it. */
|
||||||
|
app.delete('/comments/:id', async (req) => {
|
||||||
|
const viewer = requireUser(req);
|
||||||
|
const { id } = params.parse(req.params);
|
||||||
|
const { comment, ownerId } = await findComment(id);
|
||||||
|
if (comment.userId !== viewer.id && ownerId !== viewer.id && viewer.role !== 'admin') {
|
||||||
|
throw new HttpError(403, "You can't delete that comment");
|
||||||
|
}
|
||||||
|
await db.delete(schema.comments).where(eq(schema.comments.id, id));
|
||||||
|
await publishNowPlaying(ownerId);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
'/comments/:id/report',
|
||||||
|
{ config: { rateLimit: { max: 10, timeWindow: '1 hour' } } },
|
||||||
|
async (req) => {
|
||||||
|
const viewer = requireUser(req);
|
||||||
|
const { id } = params.parse(req.params);
|
||||||
|
const { comment } = await findComment(id);
|
||||||
|
await visiblePlay(viewer.id, comment.listenId);
|
||||||
|
if (comment.userId === viewer.id) throw new HttpError(400, "You can't report your own comment");
|
||||||
|
const input = reportSchema.parse(req.body);
|
||||||
|
await db.insert(schema.reports).values({
|
||||||
|
reporterId: viewer.id,
|
||||||
|
reportedId: comment.userId,
|
||||||
|
reason: input.reason,
|
||||||
|
note: input.note || null,
|
||||||
|
commentId: comment.id,
|
||||||
|
commentBody: comment.body,
|
||||||
|
});
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** When someone's blocked, their comments on each other's plays go too. */
|
||||||
|
export async function removeCommentsBetween(a: string, b: string) {
|
||||||
|
const playsOf = (userId: string) => db.select({ id: schema.listens.id }).from(schema.listens).where(eq(schema.listens.userId, userId));
|
||||||
|
await db
|
||||||
|
.delete(schema.comments)
|
||||||
|
.where(
|
||||||
|
or(
|
||||||
|
and(eq(schema.comments.userId, a), inArray(schema.comments.listenId, playsOf(b))),
|
||||||
|
and(eq(schema.comments.userId, b), inArray(schema.comments.listenId, playsOf(a))),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { HttpError } from '../lib/http';
|
|||||||
import { FOLLOWS_CHANGED_CHANNEL, redis } from '../lib/redis';
|
import { FOLLOWS_CHANGED_CHANNEL, redis } from '../lib/redis';
|
||||||
import { removeNotificationsBetween } from '../services/notifications';
|
import { removeNotificationsBetween } from '../services/notifications';
|
||||||
import { toUserSummary } from '../services/nowPlaying';
|
import { toUserSummary } from '../services/nowPlaying';
|
||||||
|
import { removeCommentsBetween } from './comments';
|
||||||
import { removeReactionsBetween } from './reactions';
|
import { removeReactionsBetween } from './reactions';
|
||||||
|
|
||||||
async function findUser(username: string) {
|
async function findUser(username: string) {
|
||||||
@@ -49,6 +50,7 @@ export async function safetyRoutes(app: FastifyInstance) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
await removeReactionsBetween(me.id, them.id);
|
await removeReactionsBetween(me.id, them.id);
|
||||||
|
await removeCommentsBetween(me.id, them.id);
|
||||||
await removeNotificationsBetween(me.id, them.id);
|
await removeNotificationsBetween(me.id, them.id);
|
||||||
await followsChanged(me.id, them.id);
|
await followsChanged(me.id, them.id);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import type { AppUpdateDTO } from '@musicshare/shared';
|
||||||
|
import { parseVersion } from '@musicshare/shared';
|
||||||
|
import { env } from '../env';
|
||||||
|
|
||||||
|
/** The newest release, plus where its APK really lives (served through GET /app/android/apk/:name). */
|
||||||
|
export interface AndroidRelease {
|
||||||
|
update: AppUpdateDTO;
|
||||||
|
apkName: string;
|
||||||
|
assetUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GiteaAsset {
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
browser_download_url: string;
|
||||||
|
}
|
||||||
|
interface GiteaRelease {
|
||||||
|
tag_name: string;
|
||||||
|
body?: string | null;
|
||||||
|
draft?: boolean;
|
||||||
|
prerelease?: boolean;
|
||||||
|
published_at?: string;
|
||||||
|
created_at: string;
|
||||||
|
assets?: GiteaAsset[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const CACHE_MS = 10 * 60_000;
|
||||||
|
const FAILURE_CACHE_MS = 60_000;
|
||||||
|
let cached: { at: number; ttl: number; release: AndroidRelease | null } | null = null;
|
||||||
|
|
||||||
|
export const resetAndroidReleaseCache = () => {
|
||||||
|
cached = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Credentials for the release host. Gitea takes an access token as the basic-auth username with this
|
||||||
|
* password, which works for both its API and the release download links.
|
||||||
|
*/
|
||||||
|
function releaseHeaders(url: string): Record<string, string> {
|
||||||
|
const token = env.ANDROID_RELEASES_TOKEN;
|
||||||
|
// Only ever send the token to the host it's for (asset links could point elsewhere).
|
||||||
|
if (!token || new URL(url).origin !== new URL(env.ANDROID_RELEASES_URL).origin) return {};
|
||||||
|
return { authorization: `Basic ${Buffer.from(`${token}:x-oauth-basic`).toString('base64')}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchFromReleaseHost(url: string, init: RequestInit = {}) {
|
||||||
|
return fetch(url, { ...init, headers: { ...releaseHeaders(url), ...(init.headers as Record<string, string>) } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The release notes shown in the app: the text before a "---" line (what follows is for the releases page). */
|
||||||
|
function notesFrom(body: string | null | undefined): string | null {
|
||||||
|
const notes = (body ?? '').split(/^\s*---\s*$/m)[0]!.trim();
|
||||||
|
return notes || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(): Promise<AndroidRelease | null> {
|
||||||
|
const res = await fetchFromReleaseHost(env.ANDROID_RELEASES_URL, {
|
||||||
|
headers: { accept: 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
});
|
||||||
|
if (res.status === 404) return null; // no releases yet
|
||||||
|
if (!res.ok) throw new Error(`release lookup failed: ${res.status}`);
|
||||||
|
const release = (await res.json()) as GiteaRelease;
|
||||||
|
if (release.draft || release.prerelease) return null;
|
||||||
|
|
||||||
|
const version = release.tag_name.replace(/^v/, '');
|
||||||
|
if (!parseVersion(version)) return null;
|
||||||
|
const apkName = `musicshare-${version}.apk`;
|
||||||
|
const apk = release.assets?.find((a) => a.name === apkName);
|
||||||
|
const sums = release.assets?.find((a) => a.name === 'SHA256SUMS');
|
||||||
|
// Without a checksum the phone can't check the download, so don't offer it.
|
||||||
|
if (!apk || !sums) return null;
|
||||||
|
|
||||||
|
const sumsRes = await fetchFromReleaseHost(sums.browser_download_url, { signal: AbortSignal.timeout(10_000) });
|
||||||
|
if (!sumsRes.ok) throw new Error(`SHA256SUMS download failed: ${sumsRes.status}`);
|
||||||
|
const sha256 = (await sumsRes.text())
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim().split(/\s+\*?/))
|
||||||
|
.find(([, name]) => name === apkName)?.[0]
|
||||||
|
?.toLowerCase();
|
||||||
|
if (!sha256 || !/^[0-9a-f]{64}$/.test(sha256)) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
apkName,
|
||||||
|
assetUrl: apk.browser_download_url,
|
||||||
|
update: {
|
||||||
|
version,
|
||||||
|
apkUrl: `${env.API_PUBLIC_URL.replace(/\/+$/, '')}/app/android/apk/${encodeURIComponent(apkName)}`,
|
||||||
|
sha256,
|
||||||
|
size: apk.size,
|
||||||
|
notes: notesFrom(release.body),
|
||||||
|
publishedAt: release.published_at ?? release.created_at,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The newest Android release (cached for a few minutes), or null when there is none or updates are off. */
|
||||||
|
export async function latestAndroidRelease(): Promise<AndroidRelease | null> {
|
||||||
|
if (!env.ANDROID_RELEASES_URL) return null;
|
||||||
|
if (cached && Date.now() - cached.at < cached.ttl) return cached.release;
|
||||||
|
try {
|
||||||
|
const release = await load();
|
||||||
|
cached = { at: Date.now(), ttl: CACHE_MS, release };
|
||||||
|
return release;
|
||||||
|
} catch (err) {
|
||||||
|
// Keep serving the last good answer for a bit rather than hammering the release host.
|
||||||
|
cached = { at: Date.now(), ttl: FAILURE_CACHE_MS, release: cached?.release ?? null };
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,10 +65,8 @@ async function lookup(track: { title: string; artist: string; album: string | nu
|
|||||||
if (track.durationMs) {
|
if (track.durationMs) {
|
||||||
const params = new URLSearchParams({ track_name: track.title, artist_name: artist, duration: String(Math.round(track.durationMs / 1000)) });
|
const params = new URLSearchParams({ track_name: track.title, artist_name: artist, duration: String(Math.round(track.durationMs / 1000)) });
|
||||||
if (track.album) params.set('album_name', track.album);
|
if (track.album) params.set('album_name', track.album);
|
||||||
const exact = await fetchJson<LrclibRecord>(`${env.LRCLIB_URL}/api/get?${params}`, { headers }).catch((e: unknown) => {
|
// Not found, or LRCLIB's exact lookup is overloaded (it rate-limits /api/get separately): try the search.
|
||||||
if (e instanceof HttpError && e.status === 404) return null;
|
const exact = await fetchJson<LrclibRecord>(`${env.LRCLIB_URL}/api/get?${params}`, { headers }).catch(() => null);
|
||||||
throw e;
|
|
||||||
});
|
|
||||||
if (exact) return exact;
|
if (exact) return exact;
|
||||||
}
|
}
|
||||||
const params = new URLSearchParams({ track_name: track.title, artist_name: artist });
|
const params = new URLSearchParams({ track_name: track.title, artist_name: artist });
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export async function notify(input: {
|
|||||||
listenId?: string | null;
|
listenId?: string | null;
|
||||||
emoji?: string | null;
|
emoji?: string | null;
|
||||||
message?: string | null;
|
message?: string | null;
|
||||||
|
commentId?: string | null;
|
||||||
}): Promise<string | null> {
|
}): Promise<string | null> {
|
||||||
if (input.userId === input.actorId) return null;
|
if (input.userId === input.actorId) return null;
|
||||||
const n = schema.notifications;
|
const n = schema.notifications;
|
||||||
@@ -91,6 +92,7 @@ export async function toNotificationDTOs(rows: Row[]): Promise<NotificationDTO[]
|
|||||||
type: r.type,
|
type: r.type,
|
||||||
actor,
|
actor,
|
||||||
track: r.trackId ? (tracks.get(r.trackId) ?? null) : null,
|
track: r.trackId ? (tracks.get(r.trackId) ?? null) : null,
|
||||||
|
listenId: r.listenId,
|
||||||
emoji: r.emoji,
|
emoji: r.emoji,
|
||||||
message: r.message,
|
message: r.message,
|
||||||
read: Boolean(r.readAt),
|
read: Boolean(r.readAt),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { and, desc, eq, inArray, isNull, lt } from 'drizzle-orm';
|
import { and, count, desc, eq, inArray, isNull, lt, notExists } from 'drizzle-orm';
|
||||||
import type { NowPlayingDTO, NowPlayingProvider, ReactionDTO, RecentPlayDTO, UserSummaryDTO } from '@musicshare/shared';
|
import type { NowPlayingDTO, NowPlayingProvider, ReactionDTO, RecentPlayDTO, UserSummaryDTO } from '@musicshare/shared';
|
||||||
import { db, schema } from '../db';
|
import { db, schema } from '../db';
|
||||||
import type { User } from '../db/schema';
|
import type { User } from '../db/schema';
|
||||||
@@ -40,6 +40,18 @@ export async function getReactions(listenIds: string[]): Promise<Map<string, Rea
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How many comments each play has (from people who aren't suspended). */
|
||||||
|
export async function getCommentCounts(listenIds: string[]): Promise<Map<string, number>> {
|
||||||
|
if (listenIds.length === 0) return new Map();
|
||||||
|
const rows = await db
|
||||||
|
.select({ listenId: schema.comments.listenId, n: count() })
|
||||||
|
.from(schema.comments)
|
||||||
|
.innerJoin(schema.users, eq(schema.users.id, schema.comments.userId))
|
||||||
|
.where(and(inArray(schema.comments.listenId, listenIds), isNull(schema.users.suspendedAt)))
|
||||||
|
.groupBy(schema.comments.listenId);
|
||||||
|
return new Map(rows.map((r) => [r.listenId, r.n]));
|
||||||
|
}
|
||||||
|
|
||||||
/** Phones report changes and a heartbeat every few minutes; if they go quiet (app killed, no signal), stop showing "listening now". */
|
/** Phones report changes and a heartbeat every few minutes; if they go quiet (app killed, no signal), stop showing "listening now". */
|
||||||
export const DEVICE_STALE_MS = 9 * 60_000;
|
export const DEVICE_STALE_MS = 9 * 60_000;
|
||||||
const PUSH_SOURCES = new Set<NowPlayingProvider>(['android_media_session']);
|
const PUSH_SOURCES = new Set<NowPlayingProvider>(['android_media_session']);
|
||||||
@@ -63,7 +75,8 @@ export async function getNowPlaying(userIds: string[]): Promise<NowPlayingDTO[]>
|
|||||||
const l = listenIds.get(np.userId);
|
const l = listenIds.get(np.userId);
|
||||||
return l && l.trackId === np.trackId ? l.id : null;
|
return l && l.trackId === np.trackId ? l.id : null;
|
||||||
};
|
};
|
||||||
const reactions = await getReactions(rows.map((r) => listenFor(r.np)).filter((id): id is string => Boolean(id)));
|
const shownListens = rows.map((r) => listenFor(r.np)).filter((id): id is string => Boolean(id));
|
||||||
|
const [reactions, commentCounts] = await Promise.all([getReactions(shownListens), getCommentCounts(shownListens)]);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
return rows
|
return rows
|
||||||
.map(({ np, user }) => ({ np, user, track: tracks.get(np.trackId)! }))
|
.map(({ np, user }) => ({ np, user, track: tracks.get(np.trackId)! }))
|
||||||
@@ -78,6 +91,7 @@ export async function getNowPlaying(userIds: string[]): Promise<NowPlayingDTO[]>
|
|||||||
updatedAt: np.updatedAt.toISOString(),
|
updatedAt: np.updatedAt.toISOString(),
|
||||||
listenId: listenFor(np),
|
listenId: listenFor(np),
|
||||||
reactions: reactions.get(listenFor(np) ?? '') ?? [],
|
reactions: reactions.get(listenFor(np) ?? '') ?? [],
|
||||||
|
commentCount: commentCounts.get(listenFor(np) ?? '') ?? 0,
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => Number(b.isPlaying) - Number(a.isPlaying) || b.updatedAt.localeCompare(a.updatedAt));
|
.sort((a, b) => Number(b.isPlaying) - Number(a.isPlaying) || b.updatedAt.localeCompare(a.updatedAt));
|
||||||
}
|
}
|
||||||
@@ -108,7 +122,11 @@ async function recordListen(userId: string, trackId: string, source: NowPlayingP
|
|||||||
.offset(LISTEN_HISTORY_LIMIT - 1)
|
.offset(LISTEN_HISTORY_LIMIT - 1)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (cutoff) {
|
if (cutoff) {
|
||||||
await db.delete(schema.listens).where(and(eq(schema.listens.userId, userId), lt(schema.listens.startedAt, cutoff.startedAt)));
|
// Plays with comments stay, so conversations don't vanish as new songs come in.
|
||||||
|
const commented = db.select({ one: schema.comments.id }).from(schema.comments).where(eq(schema.comments.listenId, schema.listens.id));
|
||||||
|
await db
|
||||||
|
.delete(schema.listens)
|
||||||
|
.where(and(eq(schema.listens.userId, userId), lt(schema.listens.startedAt, cutoff.startedAt), notExists(commented)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,13 +140,19 @@ export async function getRecentPlays(userId: string, limit = 10): Promise<Recent
|
|||||||
// Don't repeat the track that's playing right now.
|
// Don't repeat the track that's playing right now.
|
||||||
const [current] = await db.select().from(schema.nowPlaying).where(eq(schema.nowPlaying.userId, userId));
|
const [current] = await db.select().from(schema.nowPlaying).where(eq(schema.nowPlaying.userId, userId));
|
||||||
const plays = current?.isPlaying && rows[0]?.trackId === current.trackId ? rows.slice(1) : rows.slice(0, limit);
|
const plays = current?.isPlaying && rows[0]?.trackId === current.trackId ? rows.slice(1) : rows.slice(0, limit);
|
||||||
const [tracks, reactions] = await Promise.all([getTrackDTOs(plays.map((p) => p.trackId)), getReactions(plays.map((p) => p.id))]);
|
const ids = plays.map((p) => p.id);
|
||||||
|
const [tracks, reactions, commentCounts] = await Promise.all([
|
||||||
|
getTrackDTOs(plays.map((p) => p.trackId)),
|
||||||
|
getReactions(ids),
|
||||||
|
getCommentCounts(ids),
|
||||||
|
]);
|
||||||
return plays.map((p) => ({
|
return plays.map((p) => ({
|
||||||
listenId: p.id,
|
listenId: p.id,
|
||||||
track: tracks.get(p.trackId)!,
|
track: tracks.get(p.trackId)!,
|
||||||
source: p.source,
|
source: p.source,
|
||||||
playedAt: p.startedAt.toISOString(),
|
playedAt: p.startedAt.toISOString(),
|
||||||
reactions: reactions.get(p.id) ?? [],
|
reactions: reactions.get(p.id) ?? [],
|
||||||
|
commentCount: commentCounts.get(p.id) ?? 0,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { resetAndroidReleaseCache } from '../src/services/appUpdates';
|
||||||
|
import { json, routeFetch, useTestApp } from './helpers';
|
||||||
|
|
||||||
|
const app = useTestApp();
|
||||||
|
beforeEach(resetAndroidReleaseCache);
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
const SHA = 'ab'.repeat(32);
|
||||||
|
const release = (over: object = {}) => ({
|
||||||
|
tag_name: 'v1.2.0',
|
||||||
|
body: 'Lyrics and notifications.\n\n---\nmusicshare-*-foss.apk has no closed-source code.',
|
||||||
|
draft: false,
|
||||||
|
prerelease: false,
|
||||||
|
published_at: '2026-10-15T15:33:31Z',
|
||||||
|
created_at: '2026-10-15T15:33:00Z',
|
||||||
|
assets: [
|
||||||
|
{ name: 'musicshare-1.2.0.apk', size: 77_000_000, browser_download_url: 'https://git.test/me/musicshare/releases/download/v1.2.0/musicshare-1.2.0.apk' },
|
||||||
|
{ name: 'musicshare-1.2.0-foss.apk', size: 67_000_000, browser_download_url: 'https://git.test/me/musicshare/releases/download/v1.2.0/musicshare-1.2.0-foss.apk' },
|
||||||
|
{ name: 'SHA256SUMS', size: 200, browser_download_url: 'https://git.test/me/musicshare/releases/download/v1.2.0/SHA256SUMS' },
|
||||||
|
],
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
const sums = `${'cd'.repeat(32)} musicshare-1.2.0-foss.apk\n${SHA} musicshare-1.2.0.apk\n`;
|
||||||
|
|
||||||
|
describe('app updates', () => {
|
||||||
|
it('reports the latest release with its checksum and notes, asking the release host only once', async () => {
|
||||||
|
const auth: (string | null)[] = [];
|
||||||
|
const seen = (init?: RequestInit) => auth.push(new Headers(init?.headers).get('authorization'));
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
routeFetch([
|
||||||
|
[/releases\/latest$/, (_url, init) => (seen(init), json(release()))],
|
||||||
|
[/SHA256SUMS$/, (_url, init) => (seen(init), new Response(sums))],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const res = await app().inject({ url: '/app/android/latest' });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json()).toEqual({
|
||||||
|
version: '1.2.0',
|
||||||
|
apkUrl: 'https://api.test/app/android/apk/musicshare-1.2.0.apk',
|
||||||
|
sha256: SHA,
|
||||||
|
size: 77_000_000,
|
||||||
|
notes: 'Lyrics and notifications.',
|
||||||
|
publishedAt: '2026-10-15T15:33:31Z',
|
||||||
|
});
|
||||||
|
expect(auth).toEqual(Array(2).fill(`Basic ${Buffer.from('test-release-token:x-oauth-basic').toString('base64')}`));
|
||||||
|
|
||||||
|
await app().inject({ url: '/app/android/latest' });
|
||||||
|
expect(auth).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers nothing for drafts, pre-releases or releases without a checksum', async () => {
|
||||||
|
for (const over of [{ prerelease: true }, { draft: true }, { assets: release().assets.slice(0, 2) }]) {
|
||||||
|
resetAndroidReleaseCache();
|
||||||
|
vi.stubGlobal('fetch', routeFetch([[/releases\/latest$/, () => json(release(over))]]));
|
||||||
|
expect((await app().inject({ url: '/app/android/latest' })).json()).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('streams only the latest APK, never sending the token to another host', async () => {
|
||||||
|
let apkAuth: string | null = 'unset';
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
routeFetch([
|
||||||
|
[
|
||||||
|
/releases\/latest$/,
|
||||||
|
() =>
|
||||||
|
json(
|
||||||
|
release({
|
||||||
|
assets: [
|
||||||
|
{ name: 'musicshare-1.2.0.apk', size: 4, browser_download_url: 'https://cdn.example/musicshare-1.2.0.apk' },
|
||||||
|
{ name: 'SHA256SUMS', size: 200, browser_download_url: 'https://git.test/SHA256SUMS' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[/SHA256SUMS$/, () => new Response(sums)],
|
||||||
|
[/cdn\.example/, (_url, init) => ((apkAuth = new Headers(init?.headers).get('authorization')), new Response('APK!'))],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect((await app().inject({ url: '/app/android/apk/musicshare-1.1.0.apk' })).statusCode).toBe(404);
|
||||||
|
const res = await app().inject({ url: '/app/android/apk/musicshare-1.2.0.apk' });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.headers['content-type']).toBe('application/vnd.android.package-archive');
|
||||||
|
expect(res.body).toBe('APK!');
|
||||||
|
expect(apkAuth).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says 503 when the release host is down', async () => {
|
||||||
|
vi.stubGlobal('fetch', routeFetch([[/releases\/latest$/, () => json({ message: 'oops' }, 500)]]));
|
||||||
|
expect((await app().inject({ url: '/app/android/latest' })).statusCode).toBe(503);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,6 +22,14 @@ const photo = () =>
|
|||||||
.toBuffer();
|
.toBuffer();
|
||||||
|
|
||||||
describe('avatars', () => {
|
describe('avatars', () => {
|
||||||
|
it('takes a full-size photo from a high-resolution phone camera (64 MP)', async () => {
|
||||||
|
const { token } = await createUser(app(), 'hires');
|
||||||
|
const big = await sharp({ create: { width: 8000, height: 8000, channels: 3, background: '#203040' } }).jpeg().toBuffer();
|
||||||
|
const { payload, headers } = multipart(big);
|
||||||
|
const res = await app().inject({ method: 'POST', url: '/me/avatar', payload, headers: { ...headers, ...bearer(token) } });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
it('resizes, re-encodes and serves an uploaded picture', async () => {
|
it('resizes, re-encodes and serves an uploaded picture', async () => {
|
||||||
const { token, user } = await createUser(app(), 'alice');
|
const { token, user } = await createUser(app(), 'alice');
|
||||||
const { payload, headers } = multipart(await photo());
|
const { payload, headers } = multipart(await photo());
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { applyPollResult, LISTEN_HISTORY_LIMIT } from '../src/services/nowPlaying';
|
||||||
|
import { bearer, createUser, follow, useTestApp } from './helpers';
|
||||||
|
|
||||||
|
const app = useTestApp();
|
||||||
|
const song = (title: string) => ({ track: { title, artist: 'Massive Attack' }, isPlaying: true, progressMs: null });
|
||||||
|
|
||||||
|
/** Alice plays a song. Bob follows her and she follows him back; Carol follows her, but she doesn't follow Carol. */
|
||||||
|
async function setup() {
|
||||||
|
const alice = await createUser(app(), 'alice');
|
||||||
|
const bob = await createUser(app(), 'bob');
|
||||||
|
const carol = await createUser(app(), 'carol');
|
||||||
|
await follow(app(), bob.token, 'alice');
|
||||||
|
await follow(app(), alice.token, 'bob');
|
||||||
|
await follow(app(), carol.token, 'alice');
|
||||||
|
await applyPollResult(alice.user.id, 'lastfm', song('Teardrop'));
|
||||||
|
const feed = (await app().inject({ url: '/feed', headers: bearer(bob.token) })).json();
|
||||||
|
return { alice, bob, carol, listenId: feed[0].listenId as string };
|
||||||
|
}
|
||||||
|
const comment = (token: string, listenId: string, body: string) =>
|
||||||
|
app().inject({ method: 'POST', url: `/listens/${listenId}/comments`, headers: bearer(token), payload: { body } });
|
||||||
|
const thread = (token: string, listenId: string) => app().inject({ url: `/listens/${listenId}/comments`, headers: bearer(token) });
|
||||||
|
/** Comment and reply notifications (setup's follows make some too). */
|
||||||
|
const notifications = async (token: string) =>
|
||||||
|
((await app().inject({ url: '/notifications', headers: bearer(token) })).json().notifications as { type: string; message: string }[]).filter(
|
||||||
|
(n) => n.type === 'comment' || n.type === 'reply',
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('comments', () => {
|
||||||
|
it('lets the player and people they follow comment, oldest first, with a count on the feed', async () => {
|
||||||
|
const { alice, bob, carol, listenId } = await setup();
|
||||||
|
expect((await comment(bob.token, listenId, ' what a tune ')).json()).toMatchObject({ body: 'what a tune', canDelete: true });
|
||||||
|
await comment(alice.token, listenId, 'right?');
|
||||||
|
|
||||||
|
const res = await comment(carol.token, listenId, 'hi');
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
expect(res.json().error).toBe('Only people @alice follows can comment on their songs');
|
||||||
|
|
||||||
|
const forCarol = (await thread(carol.token, listenId)).json();
|
||||||
|
expect(forCarol).toMatchObject({ canComment: false, owner: { username: 'alice' }, track: { title: 'Teardrop' } });
|
||||||
|
expect(forCarol.comments.map((c: { body: string; canDelete: boolean }) => [c.body, c.canDelete])).toEqual([
|
||||||
|
['what a tune', false],
|
||||||
|
['right?', false],
|
||||||
|
]);
|
||||||
|
expect((await thread(alice.token, listenId)).json().comments.every((c: { canDelete: boolean }) => c.canDelete)).toBe(true);
|
||||||
|
|
||||||
|
const feed = (await app().inject({ url: '/feed', headers: bearer(carol.token) })).json();
|
||||||
|
expect(feed[0].commentCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty and overlong comments', async () => {
|
||||||
|
const { bob, listenId } = await setup();
|
||||||
|
expect((await comment(bob.token, listenId, ' ')).statusCode).toBe(400);
|
||||||
|
expect((await comment(bob.token, listenId, 'x'.repeat(501))).statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('notifies the player, and earlier commenters about replies', async () => {
|
||||||
|
const { alice, bob, listenId } = await setup();
|
||||||
|
const dave = await createUser(app(), 'dave');
|
||||||
|
await follow(app(), alice.token, 'dave');
|
||||||
|
|
||||||
|
await comment(bob.token, listenId, 'what a tune');
|
||||||
|
await comment(dave.token, listenId, 'agreed');
|
||||||
|
await comment(alice.token, listenId, 'thanks both');
|
||||||
|
|
||||||
|
expect((await notifications(alice.token)).map((n) => [n.type, n.message])).toEqual([
|
||||||
|
['comment', 'agreed'],
|
||||||
|
['comment', 'what a tune'],
|
||||||
|
]);
|
||||||
|
expect((await notifications(bob.token)).map((n) => [n.type, n.message])).toEqual([
|
||||||
|
['reply', 'thanks both'],
|
||||||
|
['reply', 'agreed'],
|
||||||
|
]);
|
||||||
|
const [latest] = (await app().inject({ url: '/notifications', headers: bearer(bob.token) })).json().notifications;
|
||||||
|
expect(latest).toMatchObject({ type: 'reply', listenId });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can be deleted by the author, the player or an admin (taking its notifications along), not anyone else', async () => {
|
||||||
|
const { alice, bob, carol, listenId } = await setup();
|
||||||
|
const { id } = (await comment(bob.token, listenId, 'first')).json();
|
||||||
|
const del = (token: string) => app().inject({ method: 'DELETE', url: `/comments/${id}`, headers: bearer(token) });
|
||||||
|
expect((await del(carol.token)).statusCode).toBe(403);
|
||||||
|
expect(await notifications(alice.token)).toHaveLength(1);
|
||||||
|
expect((await del(alice.token)).statusCode).toBe(200);
|
||||||
|
expect((await thread(bob.token, listenId)).json().comments).toEqual([]);
|
||||||
|
expect(await notifications(alice.token)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can be reported, keeping what it said for the admins', async () => {
|
||||||
|
const { bob, carol, listenId } = await setup();
|
||||||
|
const { id } = (await comment(bob.token, listenId, 'rude words')).json();
|
||||||
|
const report = (token: string) =>
|
||||||
|
app().inject({ method: 'POST', url: `/comments/${id}/report`, headers: bearer(token), payload: { reason: 'harassment' } });
|
||||||
|
expect((await report(bob.token)).statusCode).toBe(400);
|
||||||
|
expect((await report(carol.token)).statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('goes away with a block, both ways', async () => {
|
||||||
|
const { alice, bob, listenId } = await setup();
|
||||||
|
await comment(bob.token, listenId, 'hello');
|
||||||
|
await app().inject({ method: 'POST', url: '/users/bob/block', headers: bearer(alice.token), payload: {} });
|
||||||
|
expect((await thread(bob.token, listenId)).statusCode).toBe(404);
|
||||||
|
expect((await thread(alice.token, listenId)).json().comments).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps plays with comments when old history is trimmed', async () => {
|
||||||
|
const { alice, bob, listenId } = await setup();
|
||||||
|
await comment(bob.token, listenId, 'keep this');
|
||||||
|
for (let i = 0; i < LISTEN_HISTORY_LIMIT + 2; i++) {
|
||||||
|
await applyPollResult(alice.user.id, 'lastfm', song(`Song ${i}`));
|
||||||
|
}
|
||||||
|
const res = await thread(bob.token, listenId);
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().comments).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -55,6 +55,20 @@ describe('lyrics', () => {
|
|||||||
expect(body.plain).toBe('the one');
|
expect(body.plain).toBe('the one');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses the search when LRCLIB's exact lookup is rate-limited", async () => {
|
||||||
|
const { id } = await upsertTrack({ title: 'Midnight City', artist: 'M83', durationMs: 241_000 });
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
routeFetch([
|
||||||
|
[/\/api\/get\?/, () => json({ message: 'slow down' }, 429)],
|
||||||
|
[/\/api\/search\?/, () => json([record()])],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const res = await lyrics(id);
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().synced).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
it('returns null when nothing matches, and 503 when LRCLIB is down', async () => {
|
it('returns null when nothing matches, and 503 when LRCLIB is down', async () => {
|
||||||
const { id } = await upsertTrack({ title: 'Unknown', artist: 'Nobody', durationMs: 100_000 });
|
const { id } = await upsertTrack({ title: 'Unknown', artist: 'Nobody', durationMs: 100_000 });
|
||||||
vi.stubGlobal('fetch', routeFetch([[/lrclib/, () => json({ message: 'down' }, 500)]]));
|
vi.stubGlobal('fetch', routeFetch([[/lrclib/, () => json({ message: 'down' }, 500)]]));
|
||||||
|
|||||||
@@ -76,8 +76,8 @@ describe('notifications', () => {
|
|||||||
|
|
||||||
it('saves notification settings', async () => {
|
it('saves notification settings', async () => {
|
||||||
const { alice } = await setup();
|
const { alice } = await setup();
|
||||||
const me = (await app().inject({ method: 'PATCH', url: '/me', headers: bearer(alice.token), payload: { notifyPrefs: { follow: false } } })).json();
|
const me = (await app().inject({ method: 'PATCH', url: '/me', headers: bearer(alice.token), payload: { notifyPrefs: { follow: false, reply: false } } })).json();
|
||||||
expect(me.notifyPrefs).toEqual({ reaction: true, follow: false, share: true });
|
expect(me.notifyPrefs).toEqual({ reaction: true, comment: true, reply: false, follow: false, share: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ const testEnv = {
|
|||||||
SPOTIFY_CLIENT_ID: 'test-spotify-id',
|
SPOTIFY_CLIENT_ID: 'test-spotify-id',
|
||||||
SPOTIFY_CLIENT_SECRET: 'test-spotify-secret',
|
SPOTIFY_CLIENT_SECRET: 'test-spotify-secret',
|
||||||
ODESLI_API_KEY: '',
|
ODESLI_API_KEY: '',
|
||||||
|
ANDROID_RELEASES_URL: 'https://git.test/api/v1/repos/me/musicshare/releases/latest',
|
||||||
|
ANDROID_RELEASES_TOKEN: 'test-release-token',
|
||||||
// Never send real email from tests (apps/api/.env may hold real SMTP settings).
|
// Never send real email from tests (apps/api/.env may hold real SMTP settings).
|
||||||
SMTP_HOST: '',
|
SMTP_HOST: '',
|
||||||
APPLE_TEAM_ID: 'TEAMID1234',
|
APPLE_TEAM_ID: 'TEAMID1234',
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ export default ({ config }: ConfigContext): ExpoConfig => {
|
|||||||
return {
|
return {
|
||||||
...(config as ExpoConfig),
|
...(config as ExpoConfig),
|
||||||
extra: { ...config.extra, foss },
|
extra: { ...config.extra, foss },
|
||||||
plugins: [...plugins, ['expo-build-properties', { android: { usesCleartextTraffic: localHttp } }] satisfies Plugin],
|
plugins: [
|
||||||
|
...plugins,
|
||||||
|
['expo-build-properties', { android: { usesCleartextTraffic: localHttp } }] satisfies Plugin,
|
||||||
|
// F-Droid delivers updates itself; only our own builds install them (modules/updater).
|
||||||
|
...(foss ? ['./plugins/withoutSelfUpdate'] : []),
|
||||||
|
],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "musicshare",
|
"name": "musicshare",
|
||||||
"slug": "musicshare",
|
"slug": "musicshare",
|
||||||
"version": "1.1.0",
|
"version": "1.2.0",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "musicshare",
|
"scheme": "musicshare",
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
},
|
},
|
||||||
"predictiveBackGestureEnabled": false,
|
"predictiveBackGestureEnabled": false,
|
||||||
"package": "tech.chrisedwards.musicshare",
|
"package": "tech.chrisedwards.musicshare",
|
||||||
"versionCode": 2
|
"versionCode": 3
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"expo-router",
|
"expo-router",
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
plugins {
|
||||||
|
id 'com.android.library'
|
||||||
|
id 'expo-module-gradle-plugin'
|
||||||
|
}
|
||||||
|
|
||||||
|
group = 'tech.chrisedwards.musicshare.updater'
|
||||||
|
version = '0.1.0'
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace "tech.chrisedwards.musicshare.updater"
|
||||||
|
defaultConfig {
|
||||||
|
versionCode 1
|
||||||
|
versionName "0.1.0"
|
||||||
|
}
|
||||||
|
lintOptions {
|
||||||
|
abortOnError false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- Installing our own updates (downloaded from our server, checked against the release's SHA-256).
|
||||||
|
The F-Droid build removes both: F-Droid delivers its updates itself (plugins/withoutSelfUpdate.js). -->
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
<!-- Android 12+: lets updates after the first one install without a confirmation screen. -->
|
||||||
|
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
|
||||||
|
|
||||||
|
<application>
|
||||||
|
<receiver android:name=".InstallResultReceiver" android:exported="false" />
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
package tech.chrisedwards.musicshare.updater
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageInstaller
|
||||||
|
import android.os.Build
|
||||||
|
|
||||||
|
/** Where the system reports how an install session went. */
|
||||||
|
class InstallResultReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
|
||||||
|
when (intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)) {
|
||||||
|
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
|
||||||
|
if (intent.getBooleanExtra(EXTRA_INTERACTIVE, false)) {
|
||||||
|
val confirm = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION") intent.getParcelableExtra(Intent.EXTRA_INTENT)
|
||||||
|
}
|
||||||
|
if (confirm != null) {
|
||||||
|
context.startActivity(confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||||
|
UpdaterBus.emit("waiting", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A quiet attempt (app in the background): drop it; the app offers the update next time it's open.
|
||||||
|
val sessionId = intent.getIntExtra(PackageInstaller.EXTRA_SESSION_ID, -1)
|
||||||
|
if (sessionId != -1) runCatching { context.packageManager.packageInstaller.abandonSession(sessionId) }
|
||||||
|
UpdaterBus.emit("needs_user", null)
|
||||||
|
}
|
||||||
|
PackageInstaller.STATUS_SUCCESS -> UpdaterBus.emit("installed", null)
|
||||||
|
PackageInstaller.STATUS_FAILURE_ABORTED -> UpdaterBus.emit("cancelled", message)
|
||||||
|
else -> UpdaterBus.emit("failed", message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val EXTRA_INTERACTIVE = "tech.chrisedwards.musicshare.updater.INTERACTIVE"
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
package tech.chrisedwards.musicshare.updater
|
||||||
|
|
||||||
|
/** Hands install results from the receiver (no JS) to the module while the app is open. */
|
||||||
|
internal object UpdaterBus {
|
||||||
|
interface Listener {
|
||||||
|
fun onInstallStatus(status: String, message: String?)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Volatile var listener: Listener? = null
|
||||||
|
|
||||||
|
fun emit(status: String, message: String?) {
|
||||||
|
listener?.onInstallStatus(status, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
+139
@@ -0,0 +1,139 @@
|
|||||||
|
package tech.chrisedwards.musicshare.updater
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageInstaller
|
||||||
|
import android.os.Build
|
||||||
|
import expo.modules.kotlin.exception.CodedException
|
||||||
|
import expo.modules.kotlin.exception.Exceptions
|
||||||
|
import expo.modules.kotlin.modules.Module
|
||||||
|
import expo.modules.kotlin.modules.ModuleDefinition
|
||||||
|
import java.io.File
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
|
class UpdaterModule : Module() {
|
||||||
|
private val context: Context get() = appContext.reactContext ?: throw Exceptions.ReactContextLost()
|
||||||
|
|
||||||
|
private val busListener = object : UpdaterBus.Listener {
|
||||||
|
override fun onInstallStatus(status: String, message: String?) {
|
||||||
|
sendEvent("onInstallStatus", mapOf("status" to status, "message" to message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun definition() = ModuleDefinition {
|
||||||
|
Name("MusicshareUpdater")
|
||||||
|
|
||||||
|
Events("onProgress", "onInstallStatus")
|
||||||
|
|
||||||
|
OnStartObserving("onInstallStatus") { UpdaterBus.listener = busListener }
|
||||||
|
OnStopObserving("onInstallStatus") { if (UpdaterBus.listener === busListener) UpdaterBus.listener = null }
|
||||||
|
|
||||||
|
/** Delete downloaded updates (once installed, or no longer wanted). */
|
||||||
|
Function("clearDownloads") {
|
||||||
|
updatesDir().deleteRecursively()
|
||||||
|
}
|
||||||
|
|
||||||
|
Function("currentVersion") {
|
||||||
|
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs off the main thread (AsyncFunction), so plain blocking I/O is fine.
|
||||||
|
AsyncFunction("download") { url: String, sha256: String, fileName: String ->
|
||||||
|
if (!fileName.matches(Regex("[A-Za-z0-9._-]+\\.apk"))) throw UpdateException("Bad file name")
|
||||||
|
val dir = updatesDir().apply { mkdirs() }
|
||||||
|
val target = File(dir, fileName)
|
||||||
|
// Only the newest download is worth keeping.
|
||||||
|
dir.listFiles()?.filter { it.name != fileName }?.forEach { it.delete() }
|
||||||
|
if (target.exists() && sha256Of(target) == sha256.lowercase()) return@AsyncFunction target.absolutePath
|
||||||
|
|
||||||
|
val partial = File(dir, "$fileName.part")
|
||||||
|
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||||
|
connectTimeout = 15_000
|
||||||
|
readTimeout = 30_000
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (connection.responseCode != 200) throw UpdateException("Download failed (HTTP ${connection.responseCode})")
|
||||||
|
val total = connection.contentLengthLong
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
var received = 0L
|
||||||
|
var lastReported = 0L
|
||||||
|
connection.inputStream.use { input ->
|
||||||
|
partial.outputStream().use { output ->
|
||||||
|
val buffer = ByteArray(64 * 1024)
|
||||||
|
while (true) {
|
||||||
|
val n = input.read(buffer)
|
||||||
|
if (n < 0) break
|
||||||
|
output.write(buffer, 0, n)
|
||||||
|
digest.update(buffer, 0, n)
|
||||||
|
received += n
|
||||||
|
if (received - lastReported > 1_000_000) {
|
||||||
|
lastReported = received
|
||||||
|
sendEvent("onProgress", mapOf("received" to received.toDouble(), "total" to total.toDouble()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val actual = digest.digest().joinToString("") { "%02x".format(it) }
|
||||||
|
if (actual != sha256.lowercase()) {
|
||||||
|
partial.delete()
|
||||||
|
throw UpdateException("The download didn't match its checksum")
|
||||||
|
}
|
||||||
|
if (!partial.renameTo(target)) throw UpdateException("Couldn't save the download")
|
||||||
|
target.absolutePath
|
||||||
|
} finally {
|
||||||
|
connection.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("install") { path: String, interactive: Boolean ->
|
||||||
|
val apk = File(path)
|
||||||
|
if (!apk.exists() || apk.parentFile?.canonicalPath != updatesDir().canonicalPath) throw UpdateException("No downloaded update")
|
||||||
|
val installer = context.packageManager.packageInstaller
|
||||||
|
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply {
|
||||||
|
setAppPackageName(context.packageName)
|
||||||
|
setSize(apk.length())
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val sessionId = installer.createSession(params)
|
||||||
|
installer.openSession(sessionId).use { session ->
|
||||||
|
apk.inputStream().use { input ->
|
||||||
|
session.openWrite("base.apk", 0, apk.length()).use { output ->
|
||||||
|
input.copyTo(output)
|
||||||
|
session.fsync(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val intent = Intent(context, InstallResultReceiver::class.java)
|
||||||
|
.setPackage(context.packageName)
|
||||||
|
.putExtra(InstallResultReceiver.EXTRA_INTERACTIVE, interactive)
|
||||||
|
// Mutable: the system adds the result to it.
|
||||||
|
val flags = PendingIntent.FLAG_UPDATE_CURRENT or
|
||||||
|
(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0)
|
||||||
|
val pending = PendingIntent.getBroadcast(context, sessionId, intent, flags)
|
||||||
|
session.commit(pending.intentSender)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not cacheDir: Android empties caches when storage runs low, which could lose a download before it's installed.
|
||||||
|
private fun updatesDir() = File(context.noBackupFilesDir, "updates")
|
||||||
|
|
||||||
|
private fun sha256Of(file: File): String {
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
file.inputStream().use { input ->
|
||||||
|
val buffer = ByteArray(64 * 1024)
|
||||||
|
while (true) {
|
||||||
|
val n = input.read(buffer)
|
||||||
|
if (n < 0) break
|
||||||
|
digest.update(buffer, 0, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return digest.digest().joinToString("") { "%02x".format(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class UpdateException(message: String) : CodedException(message)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"platforms": ["android"],
|
||||||
|
"android": {
|
||||||
|
"modules": ["tech.chrisedwards.musicshare.updater.UpdaterModule"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// Android-only native module; the default export is null where it is unavailable.
|
||||||
|
export { default } from './src/UpdaterModule';
|
||||||
|
export type { InstallStatus } from './src/UpdaterModule';
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { NativeModule, requireOptionalNativeModule } from 'expo';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the system did with an install: `installed` (rarely seen: updating ourselves ends this process),
|
||||||
|
* `needs_user` (a quiet install wasn't allowed; it has to be started from the app), `waiting` (the system's
|
||||||
|
* confirmation screen is showing), `cancelled`, or `failed`.
|
||||||
|
*/
|
||||||
|
export type InstallStatus = 'installed' | 'needs_user' | 'waiting' | 'cancelled' | 'failed';
|
||||||
|
|
||||||
|
type Events = {
|
||||||
|
onProgress: (event: { received: number; total: number }) => void;
|
||||||
|
onInstallStatus: (event: { status: InstallStatus; message: string | null }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
declare class MusicshareUpdaterModule extends NativeModule<Events> {
|
||||||
|
/** Delete downloaded updates (once installed, or no longer wanted). */
|
||||||
|
clearDownloads(): void;
|
||||||
|
/** This build's versionName, e.g. "1.1.0". */
|
||||||
|
currentVersion(): string;
|
||||||
|
/** Download to the app's cache and check the SHA-256; resolves with the file's path. Reuses an earlier complete download. */
|
||||||
|
download(url: string, sha256: string, fileName: string): Promise<string>;
|
||||||
|
/**
|
||||||
|
* Hand the APK to the system installer. `interactive` shows the system's confirmation when it's needed;
|
||||||
|
* otherwise only a quiet install is tried. The outcome arrives as onInstallStatus.
|
||||||
|
*/
|
||||||
|
install(path: string, interactive: boolean): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Android only; null elsewhere and in Expo Go. */
|
||||||
|
export default requireOptionalNativeModule<MusicshareUpdaterModule>('MusicshareUpdater');
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@musicshare/mobile",
|
"name": "@musicshare/mobile",
|
||||||
"main": "expo-router/entry",
|
"main": "expo-router/entry",
|
||||||
"version": "1.1.0",
|
"version": "1.2.0",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo-google-fonts/space-grotesk": "^0.4.1",
|
"@expo-google-fonts/space-grotesk": "^0.4.1",
|
||||||
@@ -12,10 +12,12 @@
|
|||||||
"expo-camera": "~57.0.6",
|
"expo-camera": "~57.0.6",
|
||||||
"expo-constants": "~57.0.20",
|
"expo-constants": "~57.0.20",
|
||||||
"expo-device": "~57.0.2",
|
"expo-device": "~57.0.2",
|
||||||
|
"expo-file-system": "~57.0.7",
|
||||||
"expo-font": "~57.0.4",
|
"expo-font": "~57.0.4",
|
||||||
"expo-glass-effect": "~57.0.4",
|
"expo-glass-effect": "~57.0.4",
|
||||||
"expo-haptics": "~57.0.3",
|
"expo-haptics": "~57.0.3",
|
||||||
"expo-image": "~57.0.5",
|
"expo-image": "~57.0.5",
|
||||||
|
"expo-image-manipulator": "~57.0.21",
|
||||||
"expo-image-picker": "~57.0.20",
|
"expo-image-picker": "~57.0.20",
|
||||||
"expo-linear-gradient": "~57.0.2",
|
"expo-linear-gradient": "~57.0.2",
|
||||||
"expo-linking": "~57.0.11",
|
"expo-linking": "~57.0.11",
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// F-Droid builds (MUSICSHARE_FOSS=1) get their updates from F-Droid, so they mustn't be able to
|
||||||
|
// install packages themselves: remove the permissions the updater module (modules/updater) adds.
|
||||||
|
const { AndroidConfig, withAndroidManifest } = require('expo/config-plugins');
|
||||||
|
|
||||||
|
const PERMISSIONS = ['android.permission.REQUEST_INSTALL_PACKAGES', 'android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION'];
|
||||||
|
|
||||||
|
module.exports = function withoutSelfUpdate(config) {
|
||||||
|
return withAndroidManifest(config, (cfg) => {
|
||||||
|
const manifest = AndroidConfig.Manifest.ensureToolsAvailable(cfg.modResults).manifest;
|
||||||
|
const existing = manifest['uses-permission'] ?? [];
|
||||||
|
manifest['uses-permission'] = [
|
||||||
|
...existing.filter((p) => !PERMISSIONS.includes(p.$['android:name'])),
|
||||||
|
...PERMISSIONS.map((name) => ({ $: { 'android:name': name, 'tools:node': 'remove' } })),
|
||||||
|
];
|
||||||
|
return cfg;
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
|
import { Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||||
import Animated, { FadeInDown, LinearTransition } from 'react-native-reanimated';
|
import Animated, { FadeIn } from 'react-native-reanimated';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import useSWR, { useSWRConfig } from 'swr';
|
import useSWR, { useSWRConfig } from 'swr';
|
||||||
import { sortFeed, type ConnectionDTO, type FeedItemDTO, type FollowListDTO, type ProfileDTO } from '@musicshare/shared';
|
import { sortFeed, type ConnectionDTO, type FeedItemDTO, type FollowListDTO, type ProfileDTO } from '@musicshare/shared';
|
||||||
@@ -10,6 +10,7 @@ import { ActivityBell } from '@/components/ActivityBell';
|
|||||||
import { Avatar } from '@/components/Avatar';
|
import { Avatar } from '@/components/Avatar';
|
||||||
import { NowPlayingCard } from '@/components/NowPlayingCard';
|
import { NowPlayingCard } from '@/components/NowPlayingCard';
|
||||||
import { RelinkReminder } from '@/components/RelinkReminder';
|
import { RelinkReminder } from '@/components/RelinkReminder';
|
||||||
|
import { UpdateBanner } from '@/components/UpdateBanner';
|
||||||
import { SharingControls } from '@/components/SharingControls';
|
import { SharingControls } from '@/components/SharingControls';
|
||||||
import { Body, Button, Card, Title } from '@/components/ui';
|
import { Body, Button, Card, Title } from '@/components/ui';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
@@ -84,9 +85,9 @@ export default function FeedScreen() {
|
|||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.safe} edges={['top']}>
|
<SafeAreaView style={styles.safe} edges={['top']}>
|
||||||
<AmbientBackground />
|
<AmbientBackground />
|
||||||
{/* Cards rise in on load and glide to new positions when someone pins or starts playing. */}
|
{/* No itemLayoutAnimation: it leaves the cards behind when the header above them changes height
|
||||||
|
(e.g. your own card's lyrics loading), opening a gap under "Friends listening". */}
|
||||||
<Animated.FlatList
|
<Animated.FlatList
|
||||||
itemLayoutAnimation={LinearTransition.springify().damping(18)}
|
|
||||||
data={feed ?? []}
|
data={feed ?? []}
|
||||||
keyExtractor={(item) => item.user.id}
|
keyExtractor={(item) => item.user.id}
|
||||||
contentContainerStyle={styles.list}
|
contentContainerStyle={styles.list}
|
||||||
@@ -97,6 +98,7 @@ export default function FeedScreen() {
|
|||||||
<Text style={styles.brand}>musicshare</Text>
|
<Text style={styles.brand}>musicshare</Text>
|
||||||
<ActivityBell />
|
<ActivityBell />
|
||||||
</View>
|
</View>
|
||||||
|
<UpdateBanner />
|
||||||
<SharingControls compact />
|
<SharingControls compact />
|
||||||
<RelinkReminder />
|
<RelinkReminder />
|
||||||
{offerAppleMusic && (
|
{offerAppleMusic && (
|
||||||
@@ -160,8 +162,8 @@ export default function FeedScreen() {
|
|||||||
</View>
|
</View>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
renderItem={({ item, index }) => (
|
renderItem={({ item }) => (
|
||||||
<Animated.View entering={FadeInDown.delay(Math.min(index, 8) * 60).springify().damping(16)}>
|
<Animated.View entering={FadeIn.duration(180)}>
|
||||||
<NowPlayingCard
|
<NowPlayingCard
|
||||||
np={item}
|
np={item}
|
||||||
preferred={me?.preferredService}
|
preferred={me?.preferredService}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { File } from 'expo-file-system';
|
||||||
|
import { ImageManipulator, SaveFormat } from 'expo-image-manipulator';
|
||||||
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';
|
||||||
@@ -26,10 +28,11 @@ import { api } from '@/lib/api';
|
|||||||
import { WEB_URL } from '@/lib/config';
|
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, openSignupPage, openSpotifyLinkPage, signInWithService } from '@/lib/connect';
|
||||||
import { openReaderSettings, useReader } from '@/lib/nowPlayingReader';
|
import { openReaderSettings, useReader } from '@/lib/nowPlayingReader';
|
||||||
import { NTFY_URL, disablePush, enablePush, pushAvailable, usePushStatus } from '@/lib/push';
|
import { NTFY_URL, disablePush, enablePush, pushAvailable, usePushStatus } from '@/lib/push';
|
||||||
import { useSession } from '@/lib/session';
|
import { useSession } from '@/lib/session';
|
||||||
|
import { checkForUpdate, currentVersion, installUpdate, updaterAvailable, useUpdateState } from '@/lib/updates';
|
||||||
import { colors, radius, space } from '@/lib/theme';
|
import { colors, radius, space } from '@/lib/theme';
|
||||||
|
|
||||||
export default function SettingsScreen() {
|
export default function SettingsScreen() {
|
||||||
@@ -44,6 +47,7 @@ export default function SettingsScreen() {
|
|||||||
<ThisPhone />
|
<ThisPhone />
|
||||||
<Connections />
|
<Connections />
|
||||||
<FeaturedPicker />
|
<FeaturedPicker />
|
||||||
|
<AppVersion />
|
||||||
<Account />
|
<Account />
|
||||||
</Screen>
|
</Screen>
|
||||||
);
|
);
|
||||||
@@ -101,10 +105,18 @@ function AvatarPicker() {
|
|||||||
});
|
});
|
||||||
const asset = result.canceled ? null : result.assets[0];
|
const asset = result.canceled ? null : result.assets[0];
|
||||||
if (!asset) return;
|
if (!asset) return;
|
||||||
|
run(async () => {
|
||||||
|
// Shrink first: photos from 50-200 MP cameras are huge, and the server keeps 512 px anyway.
|
||||||
|
const side = Math.min(1024, asset.width || 1024);
|
||||||
|
const image = await (await ImageManipulator.manipulate(asset.uri).resize({ width: side }).renderAsync()).saveAsync({
|
||||||
|
compress: 0.85,
|
||||||
|
format: SaveFormat.JPEG,
|
||||||
|
});
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
// React Native's FormData takes a file as { uri, name, type }.
|
// Expo's fetch needs a real file object (it rejects React Native's { uri, name, type } parts).
|
||||||
form.append('file', { uri: asset.uri, name: asset.fileName ?? 'avatar.jpg', type: asset.mimeType ?? 'image/jpeg' } as unknown as Blob);
|
form.append('file', new File(image.uri) as unknown as Blob);
|
||||||
run(() => api.uploadAvatar(form));
|
return api.uploadAvatar(form);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const remove = () =>
|
const remove = () =>
|
||||||
@@ -289,6 +301,7 @@ function Connections() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const linkSpotifyOnLastfm = () => openSpotifyLinkPage('lastfm').catch((e) => showError(e));
|
const linkSpotifyOnLastfm = () => openSpotifyLinkPage('lastfm').catch((e) => showError(e));
|
||||||
|
const linkSpotifyOnListenBrainz = () => openSpotifyLinkPage('listenbrainz').catch((e) => showError(e));
|
||||||
const spotifyLinked = byProvider.has('lastfm') || byProvider.has('listenbrainz');
|
const spotifyLinked = byProvider.has('lastfm') || byProvider.has('listenbrainz');
|
||||||
|
|
||||||
const disconnect = (provider: NowPlayingProvider) =>
|
const disconnect = (provider: NowPlayingProvider) =>
|
||||||
@@ -382,8 +395,10 @@ function Connections() {
|
|||||||
<Button small variant="primary" title="Sign in" onPress={() => connectOAuth('lastfm')} />
|
<Button small variant="primary" title="Sign in" onPress={() => connectOAuth('lastfm')} />
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
{byProvider.has('lastfm') && (
|
{byProvider.has('lastfm') ? (
|
||||||
<Button small title="Link Spotify to Last.fm" onPress={linkSpotifyOnLastfm} style={{ alignSelf: 'flex-start' }} />
|
<Button small title="Link Spotify to Last.fm" onPress={linkSpotifyOnLastfm} style={{ alignSelf: 'flex-start' }} />
|
||||||
|
) : (
|
||||||
|
<Button small title="No account? Create one" onPress={() => openSignupPage('lastfm')} style={{ alignSelf: 'flex-start' }} />
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -393,6 +408,7 @@ function Connections() {
|
|||||||
active={byProvider.has('listenbrainz')}
|
active={byProvider.has('listenbrainz')}
|
||||||
onConnect={(username) => run(() => api.connectUsername('listenbrainz', username))}
|
onConnect={(username) => run(() => api.connectUsername('listenbrainz', username))}
|
||||||
onDisconnect={() => disconnect('listenbrainz')}
|
onDisconnect={() => disconnect('listenbrainz')}
|
||||||
|
onLinkSpotify={linkSpotifyOnListenBrainz}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<JellyfinConnection
|
<JellyfinConnection
|
||||||
@@ -417,12 +433,14 @@ function UsernameConnection({
|
|||||||
active,
|
active,
|
||||||
onConnect,
|
onConnect,
|
||||||
onDisconnect,
|
onDisconnect,
|
||||||
|
onLinkSpotify,
|
||||||
}: {
|
}: {
|
||||||
provider: 'lastfm' | 'listenbrainz';
|
provider: 'lastfm' | 'listenbrainz';
|
||||||
status: React.ReactNode;
|
status: React.ReactNode;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
onConnect: (username: string) => Promise<void>;
|
onConnect: (username: string) => Promise<void>;
|
||||||
onDisconnect: () => void;
|
onDisconnect: () => void;
|
||||||
|
onLinkSpotify: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -454,6 +472,18 @@ function UsernameConnection({
|
|||||||
/>
|
/>
|
||||||
</Row>
|
</Row>
|
||||||
)}
|
)}
|
||||||
|
{active ? (
|
||||||
|
<Button small title={`Link Spotify to ${SERVICE_LABELS[provider]}`} onPress={onLinkSpotify} style={{ alignSelf: 'flex-start' }} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button small title="No account? Create one" onPress={() => openSignupPage(provider)} style={{ alignSelf: 'flex-start' }} />
|
||||||
|
{provider === 'listenbrainz' && (
|
||||||
|
<Body muted style={styles.small}>
|
||||||
|
ListenBrainz uses MusicBrainz accounts: create one there, then sign in to ListenBrainz once.
|
||||||
|
</Body>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -514,6 +544,58 @@ function JellyfinConnection({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** This build's version, and checking for a newer one (our own APKs; F-Droid updates its builds itself). */
|
||||||
|
function AppVersion() {
|
||||||
|
const state = useUpdateState();
|
||||||
|
if (!updaterAvailable) return null;
|
||||||
|
const status = (() => {
|
||||||
|
switch (state.status) {
|
||||||
|
case 'checking':
|
||||||
|
return 'Checking for updates…';
|
||||||
|
case 'upToDate':
|
||||||
|
return 'Up to date.';
|
||||||
|
case 'downloading':
|
||||||
|
return `Downloading ${state.update.version}… ${Math.round(state.progress * 100)}%`;
|
||||||
|
case 'ready':
|
||||||
|
return `Version ${state.update.version} is ready to install.`;
|
||||||
|
case 'installing':
|
||||||
|
return `Installing ${state.update.version}…`;
|
||||||
|
case 'error':
|
||||||
|
return state.message;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Heading>App version {currentVersion()}</Heading>
|
||||||
|
{status && (
|
||||||
|
<Body muted style={styles.small}>
|
||||||
|
{status}
|
||||||
|
</Body>
|
||||||
|
)}
|
||||||
|
{state.status === 'ready' || state.status === 'installing' ? (
|
||||||
|
<Button
|
||||||
|
small
|
||||||
|
variant="primary"
|
||||||
|
title="Install update"
|
||||||
|
disabled={state.status === 'installing'}
|
||||||
|
onPress={() => installUpdate(true)}
|
||||||
|
style={{ alignSelf: 'flex-start' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
small
|
||||||
|
title="Check for updates"
|
||||||
|
loading={state.status === 'checking' || state.status === 'downloading'}
|
||||||
|
onPress={() => checkForUpdate({ force: true })}
|
||||||
|
style={{ alignSelf: 'flex-start' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Account() {
|
function Account() {
|
||||||
const { me, setMe, signOut } = useSession();
|
const { me, setMe, signOut } = useSession();
|
||||||
const resend = async () => {
|
const resend = async () => {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { appleMusicAvailable, useAppleMusicLive } from '@/lib/appleMusic';
|
|||||||
import { syncDevice } from '@/lib/nowPlayingReader';
|
import { syncDevice } from '@/lib/nowPlayingReader';
|
||||||
import { syncPush } from '@/lib/push';
|
import { syncPush } from '@/lib/push';
|
||||||
import { SessionProvider, useSession } from '@/lib/session';
|
import { SessionProvider, useSession } from '@/lib/session';
|
||||||
|
import { startUpdater } from '@/lib/updates';
|
||||||
import { colors, space } from '@/lib/theme';
|
import { colors, space } from '@/lib/theme';
|
||||||
|
|
||||||
SplashScreen.preventAutoHideAsync();
|
SplashScreen.preventAutoHideAsync();
|
||||||
@@ -43,6 +44,11 @@ function AppNavigator() {
|
|||||||
if (ready) SplashScreen.hideAsync();
|
if (ready) SplashScreen.hideAsync();
|
||||||
}, [ready]);
|
}, [ready]);
|
||||||
|
|
||||||
|
// Look for a newer version of the app (our own APKs only; see lib/updates.ts).
|
||||||
|
useEffect(() => {
|
||||||
|
if (ready) startUpdater();
|
||||||
|
}, [ready]);
|
||||||
|
|
||||||
// Keep the Android listener pointed at this account (no-op elsewhere).
|
// Keep the Android listener pointed at this account (no-op elsewhere).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (signedIn) syncDevice().catch(() => {});
|
if (signedIn) syncDevice().catch(() => {});
|
||||||
@@ -103,6 +109,7 @@ function AppNavigator() {
|
|||||||
<Stack.Screen name="scan" options={{ title: 'Scan a code', presentation: 'modal' }} />
|
<Stack.Screen name="scan" options={{ title: 'Scan a code', presentation: 'modal' }} />
|
||||||
<Stack.Screen name="notifications" options={{ title: 'Notifications' }} />
|
<Stack.Screen name="notifications" options={{ title: 'Notifications' }} />
|
||||||
<Stack.Screen name="t/[id]" options={{ title: '' }} />
|
<Stack.Screen name="t/[id]" options={{ title: '' }} />
|
||||||
|
<Stack.Screen name="play/[id]" options={{ title: 'Comments' }} />
|
||||||
<Stack.Screen name="send/[trackId]" options={{ title: 'Send to friends', presentation: 'modal' }} />
|
<Stack.Screen name="send/[trackId]" options={{ title: 'Send to friends', presentation: 'modal' }} />
|
||||||
</Stack.Protected>
|
</Stack.Protected>
|
||||||
<Stack.Protected guard={!signedIn}>
|
<Stack.Protected guard={!signedIn}>
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { Stack, router, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { ActivityIndicator, Alert, FlatList, KeyboardAvoidingView, Platform, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import { COMMENT_MAX_LENGTH, timeAgo, type CommentDTO, type CommentThreadDTO } from '@musicshare/shared';
|
||||||
|
import { AmbientBackground } from '@/components/AmbientBackground';
|
||||||
|
import { Avatar } from '@/components/Avatar';
|
||||||
|
import { Reactions } from '@/components/Reactions';
|
||||||
|
import { TrackCard } from '@/components/TrackCard';
|
||||||
|
import { Body, Card } from '@/components/ui';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { showError } from '@/lib/errors';
|
||||||
|
import { tapHaptic } from '@/lib/haptics';
|
||||||
|
import { useSession } from '@/lib/session';
|
||||||
|
import { colors, radius, space } from '@/lib/theme';
|
||||||
|
|
||||||
|
/** One play and its comments: where the 💬 chip and comment notifications lead. */
|
||||||
|
export default function PlayScreen() {
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const { me } = useSession();
|
||||||
|
// Polled while open so other people's comments show up without refreshing.
|
||||||
|
const { data, error, mutate } = useSWR<CommentThreadDTO>(id ? `/listens/${id}/comments` : null, { refreshInterval: 5000 });
|
||||||
|
const [draft, setDraft] = useState('');
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
|
||||||
|
const send = async () => {
|
||||||
|
const body = draft.trim();
|
||||||
|
if (!body || !id) return;
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
const comment = await api.addComment(id, { body });
|
||||||
|
tapHaptic();
|
||||||
|
setDraft('');
|
||||||
|
await mutate((t) => (t ? { ...t, comments: [...t.comments, comment] } : t), { revalidate: false });
|
||||||
|
} catch (err) {
|
||||||
|
showError(err, "Couldn't post that");
|
||||||
|
}
|
||||||
|
setSending(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = (c: CommentDTO) =>
|
||||||
|
Alert.alert('Delete this comment?', undefined, [
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Delete',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
await api.deleteComment(c.id);
|
||||||
|
await mutate((t) => (t ? { ...t, comments: t.comments.filter((x) => x.id !== c.id) } : t), { revalidate: false });
|
||||||
|
} catch (err) {
|
||||||
|
showError(err, "Couldn't delete it");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const options = (c: CommentDTO) => {
|
||||||
|
const mine = c.user.id === me?.id;
|
||||||
|
Alert.alert(c.user.displayName ?? c.user.username, undefined, [
|
||||||
|
...(c.canDelete ? [{ text: 'Delete', style: 'destructive' as const, onPress: () => remove(c) }] : []),
|
||||||
|
...(!mine ? [{ text: 'Report', onPress: () => router.push(`/report/${c.user.username}?comment=${c.id}`) }] : []),
|
||||||
|
{ text: 'Cancel', style: 'cancel' as const },
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<View style={styles.center}>
|
||||||
|
<Stack.Screen options={{ title: '' }} />
|
||||||
|
<Body muted>That play is gone.</Body>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<View style={styles.center}>
|
||||||
|
<ActivityIndicator color={colors.muted} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const owner = data.owner.displayName ?? data.owner.username;
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.safe} edges={['bottom']}>
|
||||||
|
<Stack.Screen options={{ title: 'Comments' }} />
|
||||||
|
<AmbientBackground />
|
||||||
|
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : undefined} keyboardVerticalOffset={90}>
|
||||||
|
<FlatList
|
||||||
|
data={data.comments}
|
||||||
|
keyExtractor={(c) => c.id}
|
||||||
|
contentContainerStyle={styles.list}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
ListHeaderComponent={
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Pressable onPress={() => router.push(`/u/${data.owner.username}`)} style={styles.playedBy}>
|
||||||
|
<Avatar user={data.owner} size={28} />
|
||||||
|
<Text style={styles.muted}>
|
||||||
|
<Text style={styles.name}>{owner}</Text> played this · {timeAgo(data.playedAt)}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<TrackCard track={data.track} preferred={me?.preferredService} />
|
||||||
|
<Reactions listenId={data.listenId} reactions={data.reactions} ownerId={data.owner.id} style={{ marginTop: 0 }} />
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
ListEmptyComponent={<Body muted>{data.canComment ? 'No comments yet. Say something!' : 'No comments yet.'}</Body>}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<Pressable onLongPress={() => options(item)} delayLongPress={300} style={styles.comment}>
|
||||||
|
<Pressable onPress={() => router.push(`/u/${item.user.username}`)} hitSlop={4}>
|
||||||
|
<Avatar user={item.user} size={34} />
|
||||||
|
</Pressable>
|
||||||
|
<View style={{ flex: 1, minWidth: 0, gap: 2 }}>
|
||||||
|
<Text style={styles.muted}>
|
||||||
|
<Text style={styles.name}>{item.user.displayName ?? item.user.username}</Text> · {timeAgo(item.createdAt)}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.body} selectable>
|
||||||
|
{item.body}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Pressable onPress={() => options(item)} hitSlop={10} accessibilityLabel="Comment options">
|
||||||
|
<Text style={styles.more}>⋯</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{data.canComment ? (
|
||||||
|
<View style={styles.composer}>
|
||||||
|
<TextInput
|
||||||
|
value={draft}
|
||||||
|
onChangeText={setDraft}
|
||||||
|
placeholder={data.owner.id === me?.id ? 'Comment on your song' : `Comment on ${owner}'s song`}
|
||||||
|
placeholderTextColor={colors.muted}
|
||||||
|
multiline
|
||||||
|
maxLength={COMMENT_MAX_LENGTH}
|
||||||
|
style={styles.input}
|
||||||
|
/>
|
||||||
|
<Pressable
|
||||||
|
onPress={send}
|
||||||
|
disabled={!draft.trim() || sending}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Post comment"
|
||||||
|
style={[styles.send, (!draft.trim() || sending) && { opacity: 0.4 }]}>
|
||||||
|
{sending ? <ActivityIndicator color="#fff" size="small" /> : <Text style={styles.sendText}>Post</Text>}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Card style={styles.closed}>
|
||||||
|
<Body muted style={{ fontSize: 13 }}>
|
||||||
|
Only people {owner} follows can comment on their songs.
|
||||||
|
</Body>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
safe: { flex: 1, backgroundColor: colors.bg },
|
||||||
|
center: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.bg },
|
||||||
|
list: { padding: space.lg, gap: space.md, paddingBottom: space.xl },
|
||||||
|
header: { gap: space.md, marginBottom: space.sm },
|
||||||
|
playedBy: { flexDirection: 'row', alignItems: 'center', gap: space.sm },
|
||||||
|
comment: { flexDirection: 'row', gap: space.md, alignItems: 'flex-start' },
|
||||||
|
name: { color: colors.text, fontWeight: '700' },
|
||||||
|
muted: { color: colors.muted, fontSize: 13 },
|
||||||
|
body: { color: colors.text, fontSize: 15, lineHeight: 21 },
|
||||||
|
more: { color: colors.muted, fontSize: 18, paddingHorizontal: space.xs },
|
||||||
|
composer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
gap: space.sm,
|
||||||
|
paddingHorizontal: space.lg,
|
||||||
|
paddingVertical: space.sm,
|
||||||
|
borderTopWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderTopColor: colors.border,
|
||||||
|
backgroundColor: colors.bg,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
flex: 1,
|
||||||
|
maxHeight: 120,
|
||||||
|
color: colors.text,
|
||||||
|
fontSize: 15,
|
||||||
|
paddingHorizontal: space.md,
|
||||||
|
paddingVertical: space.sm,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
},
|
||||||
|
send: { paddingHorizontal: space.md, paddingVertical: space.sm + 2, borderRadius: radius.pill, backgroundColor: colors.accent },
|
||||||
|
sendText: { color: '#fff', fontWeight: '700' },
|
||||||
|
closed: { margin: space.lg, marginTop: 0 },
|
||||||
|
});
|
||||||
@@ -8,7 +8,8 @@ import { api } from '@/lib/api';
|
|||||||
import { showError } from '@/lib/errors';
|
import { showError } from '@/lib/errors';
|
||||||
|
|
||||||
export default function ReportScreen() {
|
export default function ReportScreen() {
|
||||||
const { username } = useLocalSearchParams<{ username: string }>();
|
// ?comment=<id> reports one comment of theirs rather than their account.
|
||||||
|
const { username, comment } = useLocalSearchParams<{ username: string; comment?: string }>();
|
||||||
const [reason, setReason] = useState<ReportReason | null>(null);
|
const [reason, setReason] = useState<ReportReason | null>(null);
|
||||||
const [note, setNote] = useState('');
|
const [note, setNote] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -18,7 +19,8 @@ export default function ReportScreen() {
|
|||||||
if (!reason) return;
|
if (!reason) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await api.report(username, { reason, note: note.trim() || null });
|
const input = { reason, note: note.trim() || null };
|
||||||
|
await (comment ? api.reportComment(comment, input) : api.report(username, input));
|
||||||
setSent(true);
|
setSent(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(err, "Couldn't send the report");
|
showError(err, "Couldn't send the report");
|
||||||
@@ -38,7 +40,7 @@ export default function ReportScreen() {
|
|||||||
return (
|
return (
|
||||||
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
|
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
|
||||||
<Screen>
|
<Screen>
|
||||||
<Body muted>What's wrong with @{username}'s account?</Body>
|
<Body muted>{comment ? `What's wrong with @${username}'s comment?` : `What's wrong with @${username}'s account?`}</Body>
|
||||||
<Row>
|
<Row>
|
||||||
{REPORT_REASONS.map((r) => (
|
{REPORT_REASONS.map((r) => (
|
||||||
<Chip key={r} label={REPORT_REASON_LABELS[r]} selected={reason === r} onPress={() => setReason(r)} />
|
<Chip key={r} label={REPORT_REASON_LABELS[r]} selected={reason === r} onPress={() => setReason(r)} />
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useEffect, useRef, useState } from 'react';
|
|||||||
import { Linking, Pressable, StyleSheet, Text, View } from 'react-native';
|
import { Linking, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
import Animated, {
|
import Animated, {
|
||||||
FadeIn,
|
FadeIn,
|
||||||
FadeInDown,
|
|
||||||
FadeOut,
|
FadeOut,
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
@@ -98,11 +97,11 @@ export function NowPlayingCard({
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Animated.View key={np.track.id} entering={FadeInDown.duration(380).springify()} exiting={FadeOut.duration(200)}>
|
<Animated.View key={np.track.id} entering={FadeIn.duration(250)} exiting={FadeOut.duration(200)}>
|
||||||
<TrackRow track={np.track} preferred={preferred} expanded={expanded} onToggle={toggle} />
|
<TrackRow track={np.track} preferred={preferred} expanded={expanded} onToggle={toggle} />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
<ProgressBar np={np} compact={!expanded} />
|
<ProgressBar np={np} compact={!expanded} />
|
||||||
<Reactions listenId={np.listenId} reactions={np.reactions} ownerId={np.user.id} />
|
<Reactions listenId={np.listenId} reactions={np.reactions} ownerId={np.user.id} commentCount={np.commentCount ?? 0} />
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<Animated.View entering={FadeIn.duration(200)} exiting={FadeOut.duration(120)} style={styles.details}>
|
<Animated.View entering={FadeIn.duration(200)} exiting={FadeOut.duration(120)} style={styles.details}>
|
||||||
<ServiceLinks track={np.track} preferred={preferred} />
|
<ServiceLinks track={np.track} preferred={preferred} />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { FlatList, Modal, Pressable, StyleSheet, Text, TextInput, View, type ViewStyle } from 'react-native';
|
import { FlatList, Modal, Pressable, StyleSheet, Text, TextInput, View, type ViewStyle } from 'react-native';
|
||||||
import Animated, { FadeIn, FadeOut, ZoomIn } from 'react-native-reanimated';
|
import { router } from 'expo-router';
|
||||||
|
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
|
||||||
import { EMOJI_PICKER_GROUPS, QUICK_REACTIONS, searchEmoji, type ReactionDTO } from '@musicshare/shared';
|
import { EMOJI_PICKER_GROUPS, QUICK_REACTIONS, searchEmoji, type ReactionDTO } from '@musicshare/shared';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { showError } from '@/lib/errors';
|
import { showError } from '@/lib/errors';
|
||||||
@@ -11,16 +12,19 @@ import { colors, radius, space } from '@/lib/theme';
|
|||||||
/**
|
/**
|
||||||
* Emoji reactions on one play. Tap an emoji to add yours (or take it back); "☺+"
|
* Emoji reactions on one play. Tap an emoji to add yours (or take it back); "☺+"
|
||||||
* opens the quick six, and "+" the full picker. One reaction per person.
|
* opens the quick six, and "+" the full picker. One reaction per person.
|
||||||
|
* With `commentCount`, a 💬 chip opens the play's comments.
|
||||||
*/
|
*/
|
||||||
export function Reactions({
|
export function Reactions({
|
||||||
listenId,
|
listenId,
|
||||||
reactions,
|
reactions,
|
||||||
ownerId,
|
ownerId,
|
||||||
|
commentCount,
|
||||||
style,
|
style,
|
||||||
}: {
|
}: {
|
||||||
listenId: string | null;
|
listenId: string | null;
|
||||||
reactions: ReactionDTO[];
|
reactions: ReactionDTO[];
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
|
commentCount?: number;
|
||||||
style?: ViewStyle;
|
style?: ViewStyle;
|
||||||
}) {
|
}) {
|
||||||
const { me } = useSession();
|
const { me } = useSession();
|
||||||
@@ -31,7 +35,8 @@ export function Reactions({
|
|||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
|
||||||
const canReact = Boolean(me && listenId && me.id !== ownerId);
|
const canReact = Boolean(me && listenId && me.id !== ownerId);
|
||||||
if (!listenId || (!canReact && shown.length === 0)) return null;
|
const showComments = commentCount !== undefined && Boolean(me);
|
||||||
|
if (!listenId || (!canReact && shown.length === 0 && !showComments)) return null;
|
||||||
const mine = me ? shown.find((r) => r.users.some((u) => u.id === me.id))?.emoji : undefined;
|
const mine = me ? shown.find((r) => r.users.some((u) => u.id === me.id))?.emoji : undefined;
|
||||||
|
|
||||||
const choose = async (emoji: string) => {
|
const choose = async (emoji: string) => {
|
||||||
@@ -50,7 +55,7 @@ export function Reactions({
|
|||||||
<View style={[styles.wrap, style]}>
|
<View style={[styles.wrap, style]}>
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
{shown.map((r) => (
|
{shown.map((r) => (
|
||||||
<Animated.View key={r.emoji} entering={ZoomIn.springify()}>
|
<Animated.View key={r.emoji} entering={FadeIn.duration(150)}>
|
||||||
<Pressable
|
<Pressable
|
||||||
disabled={!canReact}
|
disabled={!canReact}
|
||||||
onPress={() => choose(r.emoji)}
|
onPress={() => choose(r.emoji)}
|
||||||
@@ -73,6 +78,17 @@ export function Reactions({
|
|||||||
<Text style={[styles.count, { color: colors.muted }]}>+</Text>
|
<Text style={[styles.count, { color: colors.muted }]}>+</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
|
{showComments && (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => router.push(`/play/${listenId}`)}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={commentCount ? `${commentCount} comment${commentCount === 1 ? '' : 's'}` : 'Comments'}
|
||||||
|
hitSlop={6}
|
||||||
|
style={({ pressed }) => [styles.chip, pressed && { transform: [{ scale: 0.9 }] }]}>
|
||||||
|
<Text style={styles.emoji}>💬</Text>
|
||||||
|
{commentCount ? <Text style={styles.count}>{commentCount}</Text> : null}
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
{quickOpen && (
|
{quickOpen && (
|
||||||
<Animated.View entering={FadeIn.duration(150)} exiting={FadeOut.duration(120)} style={styles.quick}>
|
<Animated.View entering={FadeIn.duration(150)} exiting={FadeOut.duration(120)} style={styles.quick}>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function RecentPlays({
|
|||||||
</View>
|
</View>
|
||||||
<Text style={styles.time}>{timeAgo(p.playedAt)}</Text>
|
<Text style={styles.time}>{timeAgo(p.playedAt)}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Reactions listenId={p.listenId} reactions={p.reactions} ownerId={ownerId} style={styles.reactions} />
|
<Reactions listenId={p.listenId} reactions={p.reactions} ownerId={ownerId} commentCount={p.commentCount ?? 0} style={styles.reactions} />
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
{credits.map((s) => (
|
{credits.map((s) => (
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { StyleSheet } from 'react-native';
|
||||||
|
import { installUpdate, updaterAvailable, useUpdateState } from '@/lib/updates';
|
||||||
|
import { Body, Button, Card, Heading } from './ui';
|
||||||
|
|
||||||
|
/** "Update ready" on the feed, once a newer version has downloaded (lib/updates.ts). */
|
||||||
|
export function UpdateBanner() {
|
||||||
|
const state = useUpdateState();
|
||||||
|
if (!updaterAvailable || (state.status !== 'ready' && state.status !== 'installing')) return null;
|
||||||
|
return (
|
||||||
|
<Card highlighted>
|
||||||
|
<Heading>musicshare {state.update.version} is ready</Heading>
|
||||||
|
{state.update.notes ? (
|
||||||
|
<Body muted style={styles.notes} numberOfLines={4}>
|
||||||
|
{state.update.notes}
|
||||||
|
</Body>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
small
|
||||||
|
variant="primary"
|
||||||
|
title={state.status === 'installing' ? 'Installing…' : 'Install update'}
|
||||||
|
disabled={state.status === 'installing'}
|
||||||
|
onPress={() => installUpdate(true)}
|
||||||
|
style={styles.button}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
notes: { fontSize: 13 },
|
||||||
|
button: { alignSelf: 'flex-start' },
|
||||||
|
});
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Linking } from 'react-native';
|
|
||||||
import { mutate } from 'swr';
|
import { mutate } from 'swr';
|
||||||
import { SERVICE_LABELS } from '@musicshare/shared';
|
import { SERVICE_LABELS } from '@musicshare/shared';
|
||||||
import { SignInError, signInWithService } from '@/lib/connect';
|
import { SignInError, openSignupPage, signInWithService } from '@/lib/connect';
|
||||||
import { Button } from '../ui';
|
import { Button } from '../ui';
|
||||||
import { ErrorText, SetupActions, SetupHeader, type SetupProps } from './common';
|
import { ErrorText, SetupActions, SetupHeader, type SetupProps } from './common';
|
||||||
|
|
||||||
@@ -57,7 +56,7 @@ export function ServiceSignInSetup({
|
|||||||
/>
|
/>
|
||||||
<ErrorText>{error}</ErrorText>
|
<ErrorText>{error}</ErrorText>
|
||||||
<SetupActions primary="Sign in with Last.fm" loading={busy} onPrimary={run} onSkip={onDone} skipLabel={skipLabel} />
|
<SetupActions primary="Sign in with Last.fm" loading={busy} onPrimary={run} onSkip={onDone} skipLabel={skipLabel} />
|
||||||
<Button small title="New to Last.fm? Create an account" onPress={() => Linking.openURL('https://www.last.fm/join')} />
|
<Button small title="New to Last.fm? Create an account" onPress={() => openSignupPage('lastfm')} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Linking, Pressable, StyleSheet, Text, View } from 'react-native';
|
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
import useSWR, { mutate } from 'swr';
|
import useSWR, { mutate } from 'swr';
|
||||||
import type { ConnectionDTO } from '@musicshare/shared';
|
import type { ConnectionDTO } from '@musicshare/shared';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { SignInError, openSpotifyLinkPage, signInWithService } from '@/lib/connect';
|
import { SignInError, openSignupPage, openSpotifyLinkPage, signInWithService } from '@/lib/connect';
|
||||||
import { successHaptic } from '@/lib/haptics';
|
import { successHaptic } from '@/lib/haptics';
|
||||||
import { colors, radius, space } from '@/lib/theme';
|
import { colors, radius, space } from '@/lib/theme';
|
||||||
import { Body, Button, Field } from '../ui';
|
import { Body, Button, Field } from '../ui';
|
||||||
@@ -108,7 +108,7 @@ export function SpotifySetup({ onDone, skipLabel }: SetupProps) {
|
|||||||
onPress={() => setVia('listenbrainz')}
|
onPress={() => setVia('listenbrainz')}
|
||||||
title="ListenBrainz"
|
title="ListenBrainz"
|
||||||
badge="Open source"
|
badge="Open source"
|
||||||
detail="Free and run by a nonprofit. Enter your ListenBrainz username."
|
detail="Free and run by a nonprofit. Enter your ListenBrainz username, then approve Spotify on ListenBrainz."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{via === 'listenbrainz' && (
|
{via === 'listenbrainz' && (
|
||||||
@@ -123,9 +123,16 @@ export function SpotifySetup({ onDone, skipLabel }: SetupProps) {
|
|||||||
)}
|
)}
|
||||||
<Button title={skipLabel ?? 'Not now'} onPress={onDone} />
|
<Button title={skipLabel ?? 'Not now'} onPress={onDone} />
|
||||||
</View>
|
</View>
|
||||||
<Body muted style={styles.small} onPress={() => Linking.openURL(via === 'listenbrainz' ? 'https://listenbrainz.org/login/' : 'https://www.last.fm/join')}>
|
{via && (
|
||||||
No account yet? Create one free on {via === 'listenbrainz' ? 'ListenBrainz' : 'Last.fm'} first.
|
<View style={{ gap: space.xs }}>
|
||||||
|
<Button small title={`No account? Create one on ${via === 'listenbrainz' ? 'MusicBrainz' : 'Last.fm'}`} onPress={() => openSignupPage(via)} />
|
||||||
|
{via === 'listenbrainz' && (
|
||||||
|
<Body muted style={styles.small}>
|
||||||
|
ListenBrainz uses MusicBrainz accounts. After creating one, sign in to ListenBrainz once, then come back.
|
||||||
</Body>
|
</Body>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as Linking from 'expo-linking';
|
import * as Linking from 'expo-linking';
|
||||||
import * as WebBrowser from 'expo-web-browser';
|
import * as WebBrowser from 'expo-web-browser';
|
||||||
import { SPOTIFY_LINK_PAGES } from '@musicshare/shared';
|
import { SIGNUP_PAGES, SPOTIFY_LINK_PAGES } from '@musicshare/shared';
|
||||||
import { api } from './api';
|
import { api } from './api';
|
||||||
|
|
||||||
export type SignInResult = 'connected' | 'cancelled';
|
export type SignInResult = 'connected' | 'cancelled';
|
||||||
@@ -25,3 +25,6 @@ export async function signInWithService(provider: 'plex' | 'lastfm'): Promise<Si
|
|||||||
/** Where users approve "Spotify Scrobbling" (Last.fm) or the Spotify link (ListenBrainz). */
|
/** Where users approve "Spotify Scrobbling" (Last.fm) or the Spotify link (ListenBrainz). */
|
||||||
export const openSpotifyLinkPage = (on: keyof typeof SPOTIFY_LINK_PAGES = 'lastfm') =>
|
export const openSpotifyLinkPage = (on: keyof typeof SPOTIFY_LINK_PAGES = 'lastfm') =>
|
||||||
WebBrowser.openBrowserAsync(SPOTIFY_LINK_PAGES[on]);
|
WebBrowser.openBrowserAsync(SPOTIFY_LINK_PAGES[on]);
|
||||||
|
|
||||||
|
/** Their sign-up page, in the in-app browser (ListenBrainz accounts start on MusicBrainz). */
|
||||||
|
export const openSignupPage = (service: keyof typeof SIGNUP_PAGES) => WebBrowser.openBrowserAsync(SIGNUP_PAGES[service]);
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { useSyncExternalStore } from 'react';
|
||||||
|
import { AppState } from 'react-native';
|
||||||
|
import { isNewerVersion, type AppUpdateDTO } from '@musicshare/shared';
|
||||||
|
import Updater, { type InstallStatus } from '@/modules/updater';
|
||||||
|
import { api } from './api';
|
||||||
|
import { FOSS_BUILD } from './build';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-updating for the APKs we publish (not F-Droid builds, which F-Droid updates, nor dev builds).
|
||||||
|
*
|
||||||
|
* When the app opens (at most every few hours) it asks the server for the newest release and, if
|
||||||
|
* it's newer, downloads it in the background and checks its SHA-256. Then:
|
||||||
|
* - leaving the app tries a quiet install, once per version. Android 12+ allows that once this app has
|
||||||
|
* installed an update itself (and "install unknown apps" is allowed for it); the new version runs next
|
||||||
|
* time it's opened. On phones with Google Play, Play Protect may still ask to scan a new version.
|
||||||
|
* - otherwise the feed shows "Update ready", and tapping it opens Android's install screen.
|
||||||
|
*/
|
||||||
|
export const updaterAvailable = Updater !== null && !FOSS_BUILD && !__DEV__;
|
||||||
|
|
||||||
|
export type UpdateState =
|
||||||
|
| { status: 'idle' | 'checking' | 'upToDate' }
|
||||||
|
| { status: 'downloading'; update: AppUpdateDTO; progress: number }
|
||||||
|
| { status: 'ready' | 'installing'; update: AppUpdateDTO; path: string }
|
||||||
|
| { status: 'error'; message: string; update?: AppUpdateDTO };
|
||||||
|
|
||||||
|
const CHECK_EVERY_MS = 6 * 3600_000;
|
||||||
|
|
||||||
|
let state: UpdateState = { status: 'idle' };
|
||||||
|
let lastCheck = 0;
|
||||||
|
/** Versions we've already tried to install on the way out; after that, only when asked (no repeat prompts). */
|
||||||
|
const autoTried = new Set<string>();
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
function set(next: UpdateState) {
|
||||||
|
// Visible with `adb logcat -s ReactNativeJS`, for checking updates on a real phone.
|
||||||
|
if (next.status !== state.status) console.log(`[updates] ${next.status}${'message' in next ? `: ${next.message}` : ''}`);
|
||||||
|
state = next;
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
|
||||||
|
export const currentVersion = () => Updater?.currentVersion() ?? null;
|
||||||
|
|
||||||
|
/** Look for an update and download it. `force` skips the every-few-hours limit (the Settings button). */
|
||||||
|
export async function checkForUpdate({ force = false } = {}): Promise<void> {
|
||||||
|
if (!Updater || !updaterAvailable) return;
|
||||||
|
if (['checking', 'downloading', 'installing'].includes(state.status)) return;
|
||||||
|
if (state.status === 'ready') return; // already have it
|
||||||
|
if (!force && Date.now() - lastCheck < CHECK_EVERY_MS) return;
|
||||||
|
lastCheck = Date.now();
|
||||||
|
|
||||||
|
set({ status: 'checking' });
|
||||||
|
let update: AppUpdateDTO | null;
|
||||||
|
try {
|
||||||
|
update = await api.androidUpdate();
|
||||||
|
} catch {
|
||||||
|
set({ status: 'error', message: "Couldn't check for updates." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!update || !isNewerVersion(update.version, Updater.currentVersion())) {
|
||||||
|
Updater.clearDownloads(); // e.g. the update we downloaded has since been installed
|
||||||
|
set({ status: 'upToDate' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ status: 'downloading', update, progress: 0 });
|
||||||
|
const progress = Updater.addListener('onProgress', ({ received, total }) => {
|
||||||
|
if (state.status === 'downloading') set({ ...state, progress: total > 0 ? received / total : 0 });
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const path = await Updater.download(update.apkUrl, update.sha256, `musicshare-${update.version}.apk`);
|
||||||
|
set({ status: 'ready', update, path });
|
||||||
|
} catch (err) {
|
||||||
|
set({ status: 'error', message: err instanceof Error ? err.message : "Couldn't download the update.", update });
|
||||||
|
} finally {
|
||||||
|
progress.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Install the downloaded update. `interactive` shows Android's confirmation screen if it needs one. */
|
||||||
|
export async function installUpdate(interactive: boolean): Promise<void> {
|
||||||
|
if (!Updater || state.status !== 'ready') return;
|
||||||
|
const ready = state;
|
||||||
|
set({ ...ready, status: 'installing' });
|
||||||
|
try {
|
||||||
|
await Updater.install(ready.path, interactive);
|
||||||
|
} catch (err) {
|
||||||
|
set({ status: 'error', message: err instanceof Error ? err.message : "Couldn't install the update.", update: ready.update });
|
||||||
|
lastCheck = 0; // e.g. the download went missing: fetch it again next time
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInstallStatus(status: InstallStatus, message: string | null) {
|
||||||
|
if (state.status !== 'installing') return;
|
||||||
|
const { update, path } = state;
|
||||||
|
switch (status) {
|
||||||
|
case 'waiting':
|
||||||
|
return; // Android's screen is up; its answer comes next
|
||||||
|
case 'needs_user':
|
||||||
|
case 'cancelled':
|
||||||
|
return set({ status: 'ready', update, path });
|
||||||
|
case 'installed':
|
||||||
|
return set({ status: 'upToDate' });
|
||||||
|
case 'failed':
|
||||||
|
return set({ status: 'error', message: message ?? "Couldn't install the update.", update });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let started = false;
|
||||||
|
|
||||||
|
/** Call once at startup: checks now, again when the app comes back, and tries a quiet install on the way out. */
|
||||||
|
export function startUpdater() {
|
||||||
|
if (started || !Updater || !updaterAvailable) return;
|
||||||
|
started = true;
|
||||||
|
Updater.addListener('onInstallStatus', ({ status, message }) => onInstallStatus(status, message));
|
||||||
|
AppState.addEventListener('change', (appState) => {
|
||||||
|
if (appState === 'active') checkForUpdate().catch(() => {});
|
||||||
|
else if (appState === 'background' && state.status === 'ready' && !autoTried.has(state.update.version)) {
|
||||||
|
autoTried.add(state.update.version);
|
||||||
|
installUpdate(false).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
checkForUpdate().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateState(): UpdateState {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
(listener) => {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
},
|
||||||
|
() => state,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -71,6 +71,11 @@ export default function AdminPage() {
|
|||||||
<strong>{REPORT_REASON_LABELS[r.reason as ReportReason] ?? r.reason}</strong>
|
<strong>{REPORT_REASON_LABELS[r.reason as ReportReason] ?? r.reason}</strong>
|
||||||
{r.reporter && <span className="muted"> · reported by @{r.reporter.username}</span>}
|
{r.reporter && <span className="muted"> · reported by @{r.reporter.username}</span>}
|
||||||
</p>
|
</p>
|
||||||
|
{r.comment && (
|
||||||
|
<p className="muted small">
|
||||||
|
Their comment{r.comment.id ? '' : ' (since deleted)'}: <q>{r.comment.body}</q>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{r.note && <blockquote>{r.note}</blockquote>}
|
{r.note && <blockquote>{r.note}</blockquote>}
|
||||||
<div className="row">
|
<div className="row">
|
||||||
{r.status === 'open' && (
|
{r.status === 'open' && (
|
||||||
|
|||||||
@@ -130,6 +130,24 @@ input:focus, textarea:focus, select:focus {
|
|||||||
.reaction-emoji { font-size: 1.05rem; line-height: 1.3; }
|
.reaction-emoji { font-size: 1.05rem; line-height: 1.3; }
|
||||||
.reaction-count { font-size: 0.78rem; font-variant-numeric: tabular-nums; }
|
.reaction-count { font-size: 0.78rem; font-variant-numeric: tabular-nums; }
|
||||||
.reaction-add { color: var(--muted); font-size: 1rem; }
|
.reaction-add { color: var(--muted); font-size: 1rem; }
|
||||||
|
a.reaction { text-decoration: none; }
|
||||||
|
|
||||||
|
/* Comments on a play (/play/[id]) */
|
||||||
|
.played-by { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; }
|
||||||
|
.played-by-user { display: inline-flex; align-items: center; gap: 0.4rem; color: var(--text); }
|
||||||
|
.comments { display: flex; flex-direction: column; gap: 0.9rem; }
|
||||||
|
.comments h2 { margin: 0; }
|
||||||
|
.comment { display: flex; gap: 0.75rem; align-items: flex-start; }
|
||||||
|
.comment-main { flex: 1; min-width: 0; }
|
||||||
|
.comment-main a { color: var(--text); }
|
||||||
|
.comment-body { margin: 0.15rem 0 0; white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.45; }
|
||||||
|
.comment-actions { display: flex; gap: 0.6rem; opacity: 0.6; }
|
||||||
|
.comment:hover .comment-actions, .comment:focus-within .comment-actions { opacity: 1; }
|
||||||
|
.link-button { background: none; border: 0; padding: 0; font: inherit; font-size: 0.8rem; color: var(--muted); cursor: pointer; }
|
||||||
|
.link-button:hover { color: var(--text); text-decoration: underline; }
|
||||||
|
.comment-form { display: flex; gap: 0.6rem; align-items: flex-end; }
|
||||||
|
.comment-form textarea { flex: 1; resize: vertical; min-height: 2.6rem; }
|
||||||
|
@media (max-width: 520px) { .comment-actions { opacity: 1; } }
|
||||||
.reaction-add span { font-size: 0.75rem; margin-left: -0.15rem; }
|
.reaction-add span { font-size: 0.75rem; margin-left: -0.15rem; }
|
||||||
.reaction-add-wrap { position: static; } /* the quick menu anchors to the row, so it never runs off the card */
|
.reaction-add-wrap { position: static; } /* the quick menu anchors to the row, so it never runs off the card */
|
||||||
.reaction-quick { position: absolute; bottom: calc(100% + 0.4rem); left: 0; z-index: 30; display: flex; align-items: center; gap: 0.15rem; padding: 0.3rem; border-radius: 999px; background: #1a1535; border: 1px solid rgba(255, 255, 255, 0.14); box-shadow: 0 14px 30px -10px rgba(0, 0, 0, 0.7); animation: pop 0.18s ease-out; white-space: nowrap; }
|
.reaction-quick { position: absolute; bottom: calc(100% + 0.4rem); left: 0; z-index: 30; display: flex; align-items: center; gap: 0.15rem; padding: 0.3rem; border-radius: 999px; background: #1a1535; border: 1px solid rgba(255, 255, 255, 0.14); box-shadow: 0 14px 30px -10px rgba(0, 0, 0, 0.7); animation: pop 0.18s ease-out; white-space: nowrap; }
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { notificationPath, notificationText, timeAgo, type NotificationListDTO }
|
|||||||
import { Avatar } from '@/components/Avatar';
|
import { Avatar } from '@/components/Avatar';
|
||||||
import { client, useMe } from '@/lib/api';
|
import { client, useMe } from '@/lib/api';
|
||||||
|
|
||||||
/** Reactions, new followers and songs sent to you, newest first. Opening it marks them read. */
|
/** Reactions, comments, new followers and songs sent to you, newest first. Opening it marks them read. */
|
||||||
export default function NotificationsPage() {
|
export default function NotificationsPage() {
|
||||||
const { me, loggedOut } = useMe();
|
const { me, loggedOut } = useMe();
|
||||||
const { mutate } = useSWRConfig();
|
const { mutate } = useSWRConfig();
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import { COMMENT_MAX_LENGTH, timeAgo, type CommentDTO, type CommentThreadDTO } from '@musicshare/shared';
|
||||||
|
import { Avatar } from '@/components/Avatar';
|
||||||
|
import { ReportForm } from '@/components/ProfileMenu';
|
||||||
|
import { Reactions } from '@/components/Reactions';
|
||||||
|
import { TrackCard } from '@/components/TrackCard';
|
||||||
|
import { client, fetcher, useMe } from '@/lib/api';
|
||||||
|
|
||||||
|
/** One play and its comments: where the 💬 link and comment notifications lead. */
|
||||||
|
export default function PlayPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const { me, loggedOut } = useMe();
|
||||||
|
// Polled while open so other people's comments show up without reloading.
|
||||||
|
const { data, error, mutate } = useSWR<CommentThreadDTO>(me ? `/listens/${id}/comments` : null, fetcher, { refreshInterval: 5000 });
|
||||||
|
const [draft, setDraft] = useState('');
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [problem, setProblem] = useState<string | null>(null);
|
||||||
|
const [reporting, setReporting] = useState<CommentDTO | null>(null);
|
||||||
|
|
||||||
|
if (loggedOut) {
|
||||||
|
return (
|
||||||
|
<p className="muted">
|
||||||
|
<Link href="/login">Log in</Link> to see the comments.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (error) return <p className="muted">That play is gone.</p>;
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
const owner = data.owner.displayName ?? data.owner.username;
|
||||||
|
|
||||||
|
const send = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const body = draft.trim();
|
||||||
|
if (!body) return;
|
||||||
|
setSending(true);
|
||||||
|
setProblem(null);
|
||||||
|
try {
|
||||||
|
const comment = await client.addComment(id, { body });
|
||||||
|
setDraft('');
|
||||||
|
await mutate((t) => (t ? { ...t, comments: [...t.comments, comment] } : t), { revalidate: false });
|
||||||
|
} catch (err) {
|
||||||
|
setProblem((err as Error).message);
|
||||||
|
}
|
||||||
|
setSending(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (c: CommentDTO) => {
|
||||||
|
if (!confirm('Delete this comment?')) return;
|
||||||
|
try {
|
||||||
|
await client.deleteComment(c.id);
|
||||||
|
await mutate((t) => (t ? { ...t, comments: t.comments.filter((x) => x.id !== c.id) } : t), { revalidate: false });
|
||||||
|
} catch (err) {
|
||||||
|
setProblem((err as Error).message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stack">
|
||||||
|
<p className="muted played-by">
|
||||||
|
<Link href={`/u/${data.owner.username}`} className="played-by-user">
|
||||||
|
<Avatar user={data.owner} size={28} />
|
||||||
|
<strong>{owner}</strong>
|
||||||
|
</Link>{' '}
|
||||||
|
played this · {timeAgo(data.playedAt)}
|
||||||
|
</p>
|
||||||
|
<section className="card">
|
||||||
|
<TrackCard track={data.track} preferred={me?.preferredService} />
|
||||||
|
<Reactions listenId={data.listenId} reactions={data.reactions} ownerId={data.owner.id} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card comments">
|
||||||
|
<h2>Comments</h2>
|
||||||
|
{data.comments.length === 0 && <p className="muted">{data.canComment ? 'No comments yet. Say something!' : 'No comments yet.'}</p>}
|
||||||
|
{data.comments.map((c) => (
|
||||||
|
<div key={c.id} className="comment">
|
||||||
|
<Link href={`/u/${c.user.username}`}>
|
||||||
|
<Avatar user={c.user} size={34} />
|
||||||
|
</Link>
|
||||||
|
<div className="comment-main">
|
||||||
|
<div className="muted small">
|
||||||
|
<Link href={`/u/${c.user.username}`}>
|
||||||
|
<strong>{c.user.displayName ?? c.user.username}</strong>
|
||||||
|
</Link>{' '}
|
||||||
|
· {timeAgo(c.createdAt)}
|
||||||
|
</div>
|
||||||
|
<p className="comment-body">{c.body}</p>
|
||||||
|
</div>
|
||||||
|
<div className="comment-actions">
|
||||||
|
{c.canDelete && (
|
||||||
|
<button type="button" className="link-button" onClick={() => remove(c)}>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{c.user.id !== me?.id && (
|
||||||
|
<button type="button" className="link-button" onClick={() => setReporting(c)}>
|
||||||
|
Report
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{data.canComment ? (
|
||||||
|
<form className="comment-form" onSubmit={send}>
|
||||||
|
<textarea
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
// Enter posts; Shift+Enter is a new line.
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.currentTarget.form?.requestSubmit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={data.owner.id === me?.id ? 'Comment on your song' : `Comment on ${owner}'s song`}
|
||||||
|
maxLength={COMMENT_MAX_LENGTH}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
<button className="btn btn-primary" disabled={!draft.trim() || sending}>
|
||||||
|
Post
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<p className="muted small">Only people {owner} follows can comment on their songs.</p>
|
||||||
|
)}
|
||||||
|
{problem && <p className="error">{problem}</p>}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{reporting && (
|
||||||
|
<ReportForm
|
||||||
|
username={reporting.user.username}
|
||||||
|
name={reporting.user.displayName ?? reporting.user.username}
|
||||||
|
commentId={reporting.id}
|
||||||
|
onClose={() => setReporting(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import Link from 'next/link';
|
|||||||
|
|
||||||
export const metadata: Metadata = { title: 'Privacy policy · musicshare' };
|
export const metadata: Metadata = { title: 'Privacy policy · musicshare' };
|
||||||
|
|
||||||
const UPDATED = 'October 14, 2026';
|
const UPDATED = 'October 15, 2026';
|
||||||
const CONTACT = 'app@musicshare.binarygnome.com';
|
const CONTACT = 'app@musicshare.binarygnome.com';
|
||||||
|
|
||||||
export default function PrivacyPage() {
|
export default function PrivacyPage() {
|
||||||
@@ -40,11 +40,16 @@ export default function PrivacyPage() {
|
|||||||
“notification access”, but the app only reads the song, artist and album from music apps you allow.
|
“notification access”, but the app only reads the song, artist and album from music apps you allow.
|
||||||
It doesn't read or store your notifications, messages or anything from other apps.
|
It doesn't read or store your notifications, messages or anything from other apps.
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Comments and reactions</strong> you leave on plays. Comments stay until you or the person who played the
|
||||||
|
song deletes them, or one of your accounts is deleted. Plays with comments are kept in their history so the
|
||||||
|
conversation isn't lost.
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>People:</strong> who you follow, who you've pinned, who you've blocked, and reports you send.
|
<strong>People:</strong> who you follow, who you've pinned, who you've blocked, and reports you send.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Notifications:</strong> reactions, new followers and songs (with any note) sent to you, and your
|
<strong>Notifications:</strong> reactions, comments and replies, new followers and songs (with any note) sent to you, and your
|
||||||
notification settings. If you turn on push notifications, we store the push address your browser or
|
notification settings. If you turn on push notifications, we store the push address your browser or
|
||||||
phone gives us and its encryption keys.
|
phone gives us and its encryption keys.
|
||||||
</li>
|
</li>
|
||||||
@@ -63,13 +68,17 @@ export default function PrivacyPage() {
|
|||||||
people who aren't signed in.
|
people who aren't signed in.
|
||||||
</li>
|
</li>
|
||||||
<li>Your followers see what you play in their feed, live.</li>
|
<li>Your followers see what you play in their feed, live.</li>
|
||||||
|
<li>
|
||||||
|
Comments on a play can be read by signed-in people who can see that play. Only you and the people you follow can
|
||||||
|
write them, and you can delete any comment on your plays.
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Your email address is never shown to other people. It's used to sign in, to send you confirmation and
|
Your email address is never shown to other people. It's used to sign in, to send you confirmation and
|
||||||
password reset emails, and to contact you about your account.
|
password reset emails, and to contact you about your account.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
People you block can't find you or see your profile, and you stop following each other. They aren't
|
People you block can't find you or see your profile, and you stop following each other. Their comments on
|
||||||
told.
|
your plays, and yours on theirs, are removed. They aren't told.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
You can <strong>pause sharing</strong> at any time in Settings. While paused, nothing new you play is shared or
|
You can <strong>pause sharing</strong> at any time in Settings. While paused, nothing new you play is shared or
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Suspense, useEffect, useRef, useState } from 'react';
|
|||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import {
|
import {
|
||||||
SERVICE_LABELS,
|
SERVICE_LABELS,
|
||||||
|
SIGNUP_PAGES,
|
||||||
SPOTIFY_LINK_PAGES,
|
SPOTIFY_LINK_PAGES,
|
||||||
STREAMING_SERVICES,
|
STREAMING_SERVICES,
|
||||||
isQuietScrobbler,
|
isQuietScrobbler,
|
||||||
@@ -317,14 +318,21 @@ function Connections() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{!byProvider.has(p) && (
|
||||||
|
<div className="muted small">
|
||||||
|
No account?{' '}
|
||||||
|
<a href={SIGNUP_PAGES[p]} target="_blank" rel="noreferrer">
|
||||||
|
Create one free on {p === 'lastfm' ? 'Last.fm' : 'MusicBrainz'}
|
||||||
|
</a>
|
||||||
|
{p === 'listenbrainz' ? ', then sign in to ListenBrainz once (it uses MusicBrainz accounts).' : '.'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{byProvider.has(p) ? (
|
{byProvider.has(p) ? (
|
||||||
<div className="row">
|
<div className="row">
|
||||||
{p === 'lastfm' && (
|
<a className="btn" href={SPOTIFY_LINK_PAGES[p]} target="_blank" rel="noreferrer">
|
||||||
<a className="btn" href="https://www.last.fm/settings/applications" target="_blank" rel="noreferrer">
|
|
||||||
Link Spotify
|
Link Spotify
|
||||||
</a>
|
</a>
|
||||||
)}
|
|
||||||
<button className="btn" onClick={() => disconnect(p)}>
|
<button className="btn" onClick={() => disconnect(p)}>
|
||||||
Disconnect
|
Disconnect
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export function NowPlayingCard({
|
|||||||
>
|
>
|
||||||
<TrackRow track={np.track} preferred={preferred} expanded={expanded} onToggle={() => setExpanded((v) => !v)} />
|
<TrackRow track={np.track} preferred={preferred} expanded={expanded} onToggle={() => setExpanded((v) => !v)} />
|
||||||
<ProgressBar np={np} compact={!expanded} />
|
<ProgressBar np={np} compact={!expanded} />
|
||||||
<Reactions listenId={np.listenId} reactions={np.reactions} ownerId={np.user.id} />
|
<Reactions listenId={np.listenId} reactions={np.reactions} ownerId={np.user.id} commentCount={np.commentCount ?? 0} />
|
||||||
<AnimatePresence initial={false}>
|
<AnimatePresence initial={false}>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|||||||
@@ -62,7 +62,8 @@ export function ProfileMenu({ profile, onChanged }: { profile: ProfileDTO; onCha
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReportForm({ username, name, onClose }: { username: string; name: string; onClose: () => void }) {
|
/** Report someone's account, or one of their comments (`commentId`). */
|
||||||
|
export function ReportForm({ username, name, commentId, onClose }: { username: string; name: string; commentId?: string; onClose: () => void }) {
|
||||||
const [reason, setReason] = useState<ReportReason | null>(null);
|
const [reason, setReason] = useState<ReportReason | null>(null);
|
||||||
const [note, setNote] = useState('');
|
const [note, setNote] = useState('');
|
||||||
const [sent, setSent] = useState(false);
|
const [sent, setSent] = useState(false);
|
||||||
@@ -72,7 +73,8 @@ function ReportForm({ username, name, onClose }: { username: string; name: strin
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!reason) return;
|
if (!reason) return;
|
||||||
try {
|
try {
|
||||||
await client.report(username, { reason, note: note.trim() || null });
|
const input = { reason, note: note.trim() || null };
|
||||||
|
await (commentId ? client.reportComment(commentId, input) : client.report(username, input));
|
||||||
setSent(true);
|
setSent(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError((err as Error).message);
|
setError((err as Error).message);
|
||||||
@@ -90,7 +92,7 @@ function ReportForm({ username, name, onClose }: { username: string; name: strin
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Modal onClose={onClose} title={`Report ${name}`}>
|
<Modal onClose={onClose} title={commentId ? `Report ${name}'s comment` : `Report ${name}`}>
|
||||||
<form className="form" onSubmit={submit}>
|
<form className="form" onSubmit={submit}>
|
||||||
<fieldset className="choices">
|
<fieldset className="choices">
|
||||||
<legend className="muted">What's wrong?</legend>
|
<legend className="muted">What's wrong?</legend>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { EMOJI_PICKER_GROUPS, QUICK_REACTIONS, searchEmoji, type ReactionDTO } from '@musicshare/shared';
|
import { EMOJI_PICKER_GROUPS, QUICK_REACTIONS, searchEmoji, type ReactionDTO } from '@musicshare/shared';
|
||||||
import { client, useMe } from '@/lib/api';
|
import { client, useMe } from '@/lib/api';
|
||||||
@@ -9,7 +10,18 @@ import { Modal } from './ProfileMenu';
|
|||||||
* Emoji reactions on one play. Tap an emoji to add yours (or take it back); "☺+"
|
* Emoji reactions on one play. Tap an emoji to add yours (or take it back); "☺+"
|
||||||
* offers the quick six, and "More" opens the full picker. One reaction per person.
|
* offers the quick six, and "More" opens the full picker. One reaction per person.
|
||||||
*/
|
*/
|
||||||
export function Reactions({ listenId, reactions, ownerId }: { listenId: string | null; reactions: ReactionDTO[]; ownerId: string }) {
|
export function Reactions({
|
||||||
|
listenId,
|
||||||
|
reactions,
|
||||||
|
ownerId,
|
||||||
|
commentCount,
|
||||||
|
}: {
|
||||||
|
listenId: string | null;
|
||||||
|
reactions: ReactionDTO[];
|
||||||
|
ownerId: string;
|
||||||
|
/** Shows a 💬 link to the play's comments. */
|
||||||
|
commentCount?: number;
|
||||||
|
}) {
|
||||||
const { me } = useMe();
|
const { me } = useMe();
|
||||||
// The server's answer to our last tap, until fresh props arrive (e.g. a realtime update).
|
// The server's answer to our last tap, until fresh props arrive (e.g. a realtime update).
|
||||||
const [local, setLocal] = useState<{ from: ReactionDTO[]; value: ReactionDTO[] } | null>(null);
|
const [local, setLocal] = useState<{ from: ReactionDTO[]; value: ReactionDTO[] } | null>(null);
|
||||||
@@ -27,7 +39,8 @@ export function Reactions({ listenId, reactions, ownerId }: { listenId: string |
|
|||||||
}, [quickOpen]);
|
}, [quickOpen]);
|
||||||
|
|
||||||
const canReact = Boolean(me && listenId && me.id !== ownerId);
|
const canReact = Boolean(me && listenId && me.id !== ownerId);
|
||||||
if (!listenId || (!canReact && shown.length === 0)) return null;
|
const showComments = commentCount !== undefined && Boolean(me);
|
||||||
|
if (!listenId || (!canReact && shown.length === 0 && !showComments)) return null;
|
||||||
const mine = me ? shown.find((r) => r.users.some((u) => u.id === me.id))?.emoji : undefined;
|
const mine = me ? shown.find((r) => r.users.some((u) => u.id === me.id))?.emoji : undefined;
|
||||||
|
|
||||||
const choose = async (emoji: string) => {
|
const choose = async (emoji: string) => {
|
||||||
@@ -77,6 +90,16 @@ export function Reactions({ listenId, reactions, ownerId }: { listenId: string |
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{showComments && (
|
||||||
|
<Link
|
||||||
|
href={`/play/${listenId}`}
|
||||||
|
className="reaction"
|
||||||
|
aria-label={commentCount ? `${commentCount} comment${commentCount === 1 ? '' : 's'}` : 'Comments'}
|
||||||
|
>
|
||||||
|
<span className="reaction-emoji">💬</span>
|
||||||
|
{commentCount ? <span className="reaction-count">{commentCount}</span> : null}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
{error && <span className="error">{error}</span>}
|
{error && <span className="error">{error}</span>}
|
||||||
{pickerOpen && <EmojiPicker onPick={choose} onClose={() => setPickerOpen(false)} />}
|
{pickerOpen && <EmojiPicker onPick={choose} onClose={() => setPickerOpen(false)} />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function RecentPlays({
|
|||||||
) : (
|
) : (
|
||||||
<div className="recent-row">{row}</div>
|
<div className="recent-row">{row}</div>
|
||||||
)}
|
)}
|
||||||
<Reactions listenId={p.listenId} reactions={p.reactions} ownerId={ownerId} />
|
<Reactions listenId={p.listenId} reactions={p.reactions} ownerId={ownerId} commentCount={p.commentCount ?? 0} />
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -43,3 +43,8 @@ VAPID_PUBLIC_KEY=
|
|||||||
VAPID_PRIVATE_KEY=
|
VAPID_PRIVATE_KEY=
|
||||||
# Lyrics: LRCLIB (or your own copy of it). Empty turns lyrics off.
|
# Lyrics: LRCLIB (or your own copy of it). Empty turns lyrics off.
|
||||||
LRCLIB_URL=https://lrclib.net
|
LRCLIB_URL=https://lrclib.net
|
||||||
|
|
||||||
|
# App updates: the Android app checks the latest Gitea release through the API and updates itself.
|
||||||
|
# Empty turns it off. The token needs read access to the repo, and only if the repo is private.
|
||||||
|
ANDROID_RELEASES_URL=https://gitea.chrisedwards.tech/api/v1/repos/chris/musicshare/releases/latest
|
||||||
|
ANDROID_RELEASES_TOKEN=
|
||||||
|
|||||||
@@ -70,6 +70,32 @@ that release's files.
|
|||||||
**Test builds:** go to **Actions > Android > Run workflow** and pick a branch. The APKs go to the
|
**Test builds:** go to **Actions > Android > Run workflow** and pick a branch. The APKs go to the
|
||||||
repo's **Packages** as `musicshare-android`, versioned `<branch>-<commit>`, instead of to a release.
|
repo's **Packages** as `musicshare-android`, versioned `<branch>-<commit>`, instead of to a release.
|
||||||
|
|
||||||
|
## App updates
|
||||||
|
|
||||||
|
The normal APK updates itself from these releases; the F-Droid build doesn't (F-Droid updates it).
|
||||||
|
|
||||||
|
1. The app asks our server (`GET /app/android/latest`) when it opens, at most every 6 hours, or when you
|
||||||
|
tap **Settings > Check for updates**.
|
||||||
|
2. The server reads the latest release from Gitea and returns its version, the APK's SHA-256 (from
|
||||||
|
`SHA256SUMS`) and the release notes. Drafts and pre-releases are skipped.
|
||||||
|
3. If it's newer, the app downloads the APK through our server and checks the SHA-256.
|
||||||
|
4. The next time you leave the app, it tries a quiet install (once per version). Android 12+ allows that
|
||||||
|
after the app has installed one update itself. Otherwise the feed shows **Update ready**, and tapping it
|
||||||
|
opens Android's install screen; the first time, Android asks you to allow musicshare to install apps.
|
||||||
|
On phones with Google Play, Play Protect may ask to scan each new version ("App scan recommended")
|
||||||
|
because the app isn't from the Play Store. Choosing **Scan app** lets the update finish.
|
||||||
|
|
||||||
|
Set these on the API server (`deploy/production.env`):
|
||||||
|
|
||||||
|
```ini
|
||||||
|
ANDROID_RELEASES_URL=https://gitea.chrisedwards.tech/api/v1/repos/chris/musicshare/releases/latest
|
||||||
|
# Only if the repo is private: a token with read:repository (User settings > Applications).
|
||||||
|
ANDROID_RELEASES_TOKEN=
|
||||||
|
```
|
||||||
|
|
||||||
|
The release notes come from `fastlane/metadata/android/en-US/changelogs/<versionCode>.txt`: the workflow
|
||||||
|
puts that text above a `---` line in the release, and the app shows only that part.
|
||||||
|
|
||||||
## Building by hand
|
## Building by hand
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
The app now updates itself: new versions download in the background and install when you leave the app, or from the banner on the feed. Song cards fade in instead of bouncing. Create a Last.fm or ListenBrainz account and link Spotify to either one straight from Settings.
|
||||||
Generated
+15
-1
@@ -809,7 +809,7 @@
|
|||||||
},
|
},
|
||||||
"apps/mobile": {
|
"apps/mobile": {
|
||||||
"name": "@musicshare/mobile",
|
"name": "@musicshare/mobile",
|
||||||
"version": "1.1.0",
|
"version": "1.2.0",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo-google-fonts/space-grotesk": "^0.4.1",
|
"@expo-google-fonts/space-grotesk": "^0.4.1",
|
||||||
@@ -820,10 +820,12 @@
|
|||||||
"expo-camera": "~57.0.6",
|
"expo-camera": "~57.0.6",
|
||||||
"expo-constants": "~57.0.20",
|
"expo-constants": "~57.0.20",
|
||||||
"expo-device": "~57.0.2",
|
"expo-device": "~57.0.2",
|
||||||
|
"expo-file-system": "~57.0.7",
|
||||||
"expo-font": "~57.0.4",
|
"expo-font": "~57.0.4",
|
||||||
"expo-glass-effect": "~57.0.4",
|
"expo-glass-effect": "~57.0.4",
|
||||||
"expo-haptics": "~57.0.3",
|
"expo-haptics": "~57.0.3",
|
||||||
"expo-image": "~57.0.5",
|
"expo-image": "~57.0.5",
|
||||||
|
"expo-image-manipulator": "~57.0.21",
|
||||||
"expo-image-picker": "~57.0.20",
|
"expo-image-picker": "~57.0.20",
|
||||||
"expo-linear-gradient": "~57.0.2",
|
"expo-linear-gradient": "~57.0.2",
|
||||||
"expo-linking": "~57.0.11",
|
"expo-linking": "~57.0.11",
|
||||||
@@ -11288,6 +11290,18 @@
|
|||||||
"expo": "*"
|
"expo": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-image-manipulator": {
|
||||||
|
"version": "57.0.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-image-manipulator/-/expo-image-manipulator-57.0.21.tgz",
|
||||||
|
"integrity": "sha512-NZeNvZxBNf/PK97Df5zORmtqRR/LovkA+lHdRLm1IJFiVKjs4xr+Ib6/jWhDxn04VOX3TJswCQyu4SCxkgMe4Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"expo-image-loader": "~57.0.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-image-picker": {
|
"node_modules/expo-image-picker": {
|
||||||
"version": "57.0.20",
|
"version": "57.0.20",
|
||||||
"resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-57.0.20.tgz",
|
"resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-57.0.20.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type {
|
import type {
|
||||||
AdminReportDTO,
|
AdminReportDTO,
|
||||||
AdminStatsDTO,
|
AdminStatsDTO,
|
||||||
|
CommentDTO,
|
||||||
|
CommentThreadDTO,
|
||||||
ConnectionDTO,
|
ConnectionDTO,
|
||||||
FollowListDTO,
|
FollowListDTO,
|
||||||
ReactionDTO,
|
ReactionDTO,
|
||||||
@@ -13,9 +15,11 @@ import type {
|
|||||||
import type { DeviceDTO } from './devices';
|
import type { DeviceDTO } from './devices';
|
||||||
import type { LyricsDTO } from './lyrics';
|
import type { LyricsDTO } from './lyrics';
|
||||||
import type { NotificationListDTO } from './notifications';
|
import type { NotificationListDTO } from './notifications';
|
||||||
|
import type { AppUpdateDTO } from './updates';
|
||||||
import type {
|
import type {
|
||||||
ChangeEmailInput,
|
ChangeEmailInput,
|
||||||
ChangePasswordInput,
|
ChangePasswordInput,
|
||||||
|
CommentInput,
|
||||||
LoginInput,
|
LoginInput,
|
||||||
RegisterInput,
|
RegisterInput,
|
||||||
ReportReason,
|
ReportReason,
|
||||||
@@ -135,6 +139,14 @@ export function createApiClient({ baseUrl, getToken, credentials }: ApiClientOpt
|
|||||||
react: (listenId: string, emoji: string) => request<ReactionDTO[]>(`/listens/${q(listenId)}/reaction`, { method: 'PUT', body: { emoji } }),
|
react: (listenId: string, emoji: string) => request<ReactionDTO[]>(`/listens/${q(listenId)}/reaction`, { method: 'PUT', body: { emoji } }),
|
||||||
unreact: (listenId: string) => request<ReactionDTO[]>(`/listens/${q(listenId)}/reaction`, { method: 'DELETE' }),
|
unreact: (listenId: string) => request<ReactionDTO[]>(`/listens/${q(listenId)}/reaction`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
/** A play with its comments. */
|
||||||
|
comments: (listenId: string) => request<CommentThreadDTO>(`/listens/${q(listenId)}/comments`),
|
||||||
|
addComment: (listenId: string, input: CommentInput) =>
|
||||||
|
request<CommentDTO>(`/listens/${q(listenId)}/comments`, { method: 'POST', body: input }),
|
||||||
|
deleteComment: (id: string) => request<{ ok: true }>(`/comments/${q(id)}`, { method: 'DELETE' }),
|
||||||
|
reportComment: (id: string, input: { reason: ReportReason; note?: string | null }) =>
|
||||||
|
request<{ ok: true }>(`/comments/${q(id)}/report`, { body: input }),
|
||||||
|
|
||||||
searchTracks: (query: string) => request<TrackSearchResultDTO[]>(`/tracks/search?q=${q(query)}`),
|
searchTracks: (query: string) => request<TrackSearchResultDTO[]>(`/tracks/search?q=${q(query)}`),
|
||||||
/** Null when no lyrics were found for the song. */
|
/** Null when no lyrics were found for the song. */
|
||||||
lyrics: (trackId: string) => request<LyricsDTO | null>(`/tracks/${q(trackId)}/lyrics`),
|
lyrics: (trackId: string) => request<LyricsDTO | null>(`/tracks/${q(trackId)}/lyrics`),
|
||||||
@@ -160,6 +172,9 @@ export function createApiClient({ baseUrl, getToken, credentials }: ApiClientOpt
|
|||||||
request<ConnectionDTO>('/connections/jellyfin', { body: input }),
|
request<ConnectionDTO>('/connections/jellyfin', { body: input }),
|
||||||
disconnect: (provider: string) => request(`/connections/${q(provider)}`, { method: 'DELETE' }),
|
disconnect: (provider: string) => request(`/connections/${q(provider)}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
/** The newest Android release, or null when the server doesn't offer updates. */
|
||||||
|
androidUpdate: () => request<AppUpdateDTO | null>('/app/android/latest'),
|
||||||
|
|
||||||
devices: () => request<DeviceDTO[]>('/me/devices'),
|
devices: () => request<DeviceDTO[]>('/me/devices'),
|
||||||
/** The token is returned once; it can only report playback for this user. */
|
/** The token is returned once; it can only report playback for this user. */
|
||||||
registerDevice: (input: { platform: 'android' | 'ios'; name: string }) =>
|
registerDevice: (input: { platform: 'android' | 'ios'; name: string }) =>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ function item(username: string, opts: { pinnedAt?: string; isPlaying?: boolean;
|
|||||||
updatedAt: opts.updatedAt ?? '2026-01-01T00:00:00.000Z',
|
updatedAt: opts.updatedAt ?? '2026-01-01T00:00:00.000Z',
|
||||||
listenId: null,
|
listenId: null,
|
||||||
reactions: [],
|
reactions: [],
|
||||||
|
commentCount: 0,
|
||||||
pinnedAt: opts.pinnedAt ?? null,
|
pinnedAt: opts.pinnedAt ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,9 +57,10 @@ export interface NowPlayingDTO {
|
|||||||
sourceUrl: string | null;
|
sourceUrl: string | null;
|
||||||
progressMs: number | null;
|
progressMs: number | null;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
/** The play this is, for reactions (null for very old rows). */
|
/** The play this is, for reactions and comments (null for very old rows). */
|
||||||
listenId: string | null;
|
listenId: string | null;
|
||||||
reactions: ReactionDTO[];
|
reactions: ReactionDTO[];
|
||||||
|
commentCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A feed entry. pinnedAt is set when the viewer pinned this friend. */
|
/** A feed entry. pinnedAt is set when the viewer pinned this friend. */
|
||||||
@@ -90,6 +91,29 @@ export interface RecentPlayDTO {
|
|||||||
source: NowPlayingProvider;
|
source: NowPlayingProvider;
|
||||||
playedAt: string;
|
playedAt: string;
|
||||||
reactions: ReactionDTO[];
|
reactions: ReactionDTO[];
|
||||||
|
commentCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A comment on a play. */
|
||||||
|
export interface CommentDTO {
|
||||||
|
id: string;
|
||||||
|
user: UserSummaryDTO;
|
||||||
|
body: string;
|
||||||
|
createdAt: string;
|
||||||
|
/** The viewer wrote it, owns the play, or is an admin. */
|
||||||
|
canDelete: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A play and its comments, oldest first (GET /listens/:id/comments). */
|
||||||
|
export interface CommentThreadDTO {
|
||||||
|
listenId: string;
|
||||||
|
owner: UserSummaryDTO;
|
||||||
|
track: TrackDTO;
|
||||||
|
playedAt: string;
|
||||||
|
reactions: ReactionDTO[];
|
||||||
|
comments: CommentDTO[];
|
||||||
|
/** False when the viewer can read but not write (only the player and people they follow can comment). */
|
||||||
|
canComment: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProfileDTO {
|
export interface ProfileDTO {
|
||||||
@@ -119,6 +143,8 @@ export interface AdminReportDTO {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
reporter: UserSummaryDTO | null;
|
reporter: UserSummaryDTO | null;
|
||||||
reported: UserSummaryDTO & { suspended: boolean };
|
reported: UserSummaryDTO & { suspended: boolean };
|
||||||
|
/** When a comment was reported: what it said (kept even if it's since been deleted). */
|
||||||
|
comment: { id: string | null; body: string } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminStatsDTO {
|
export interface AdminStatsDTO {
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ export * from './progress';
|
|||||||
export * from './reactions';
|
export * from './reactions';
|
||||||
export * from './lyrics';
|
export * from './lyrics';
|
||||||
export * from './notifications';
|
export * from './notifications';
|
||||||
|
export * from './updates';
|
||||||
|
|||||||
@@ -17,11 +17,22 @@ describe('notifications', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('links songs to the song and follows to the person', () => {
|
it('links songs to the song and follows to the person', () => {
|
||||||
expect(notificationPath({ type: 'share', actor, track })).toBe('/t/t1');
|
expect(notificationPath({ type: 'share', actor, track, listenId: null })).toBe('/t/t1');
|
||||||
expect(notificationPath({ type: 'follow', actor, track: null })).toBe('/u/bob');
|
expect(notificationPath({ type: 'follow', actor, track: null, listenId: null })).toBe('/u/bob');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the play for comments, replies and reactions', () => {
|
||||||
|
expect(notificationPath({ type: 'comment', actor, track, listenId: 'l1' })).toBe('/play/l1');
|
||||||
|
expect(notificationPath({ type: 'reaction', actor, track, listenId: 'l1' })).toBe('/play/l1');
|
||||||
|
// The play was trimmed from history: fall back to the song.
|
||||||
|
expect(notificationPath({ type: 'reaction', actor, track, listenId: null })).toBe('/t/t1');
|
||||||
|
expect(notificationText({ type: 'reply', actor, track, emoji: null, message: 'same' })).toEqual({
|
||||||
|
title: 'Bob replied on Teardrop by Massive Attack',
|
||||||
|
body: 'same',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('treats missing settings as on', () => {
|
it('treats missing settings as on', () => {
|
||||||
expect(notificationPrefs({ follow: false })).toEqual({ reaction: true, follow: false, share: true });
|
expect(notificationPrefs({ follow: false })).toEqual({ reaction: true, comment: true, reply: true, follow: false, share: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { TrackDTO, UserSummaryDTO } from './dto';
|
import type { TrackDTO, UserSummaryDTO } from './dto';
|
||||||
|
|
||||||
export const NOTIFICATION_TYPES = ['reaction', 'follow', 'share'] as const;
|
export const NOTIFICATION_TYPES = ['reaction', 'comment', 'reply', 'follow', 'share'] as const;
|
||||||
export type NotificationType = (typeof NOTIFICATION_TYPES)[number];
|
export type NotificationType = (typeof NOTIFICATION_TYPES)[number];
|
||||||
|
|
||||||
/** Which notifications get pushed to your phone/browser. */
|
/** Which notifications get pushed to your phone/browser. */
|
||||||
@@ -8,6 +8,8 @@ export type NotificationPrefs = Record<NotificationType, boolean>;
|
|||||||
|
|
||||||
export const NOTIFICATION_PREF_LABELS: Record<NotificationType, string> = {
|
export const NOTIFICATION_PREF_LABELS: Record<NotificationType, string> = {
|
||||||
reaction: 'Reactions to your songs',
|
reaction: 'Reactions to your songs',
|
||||||
|
comment: 'Comments on your songs',
|
||||||
|
reply: 'Replies where you commented',
|
||||||
follow: 'New followers',
|
follow: 'New followers',
|
||||||
share: 'Songs sent to you',
|
share: 'Songs sent to you',
|
||||||
};
|
};
|
||||||
@@ -15,6 +17,8 @@ export const NOTIFICATION_PREF_LABELS: Record<NotificationType, string> = {
|
|||||||
/** Missing settings mean on. */
|
/** Missing settings mean on. */
|
||||||
export const notificationPrefs = (stored: Partial<NotificationPrefs> | null | undefined): NotificationPrefs => ({
|
export const notificationPrefs = (stored: Partial<NotificationPrefs> | null | undefined): NotificationPrefs => ({
|
||||||
reaction: stored?.reaction ?? true,
|
reaction: stored?.reaction ?? true,
|
||||||
|
comment: stored?.comment ?? true,
|
||||||
|
reply: stored?.reply ?? true,
|
||||||
follow: stored?.follow ?? true,
|
follow: stored?.follow ?? true,
|
||||||
share: stored?.share ?? true,
|
share: stored?.share ?? true,
|
||||||
});
|
});
|
||||||
@@ -24,6 +28,8 @@ export interface NotificationDTO {
|
|||||||
type: NotificationType;
|
type: NotificationType;
|
||||||
actor: UserSummaryDTO;
|
actor: UserSummaryDTO;
|
||||||
track: TrackDTO | null;
|
track: TrackDTO | null;
|
||||||
|
/** The play a reaction or comment was on, while it exists. */
|
||||||
|
listenId: string | null;
|
||||||
emoji: string | null;
|
emoji: string | null;
|
||||||
message: string | null;
|
message: string | null;
|
||||||
read: boolean;
|
read: boolean;
|
||||||
@@ -47,6 +53,10 @@ export function notificationText(n: Pick<NotificationDTO, 'type' | 'actor' | 'tr
|
|||||||
switch (n.type) {
|
switch (n.type) {
|
||||||
case 'reaction':
|
case 'reaction':
|
||||||
return { title: `${who} reacted ${n.emoji ?? ''}`.trim(), body: `to ${song}` };
|
return { title: `${who} reacted ${n.emoji ?? ''}`.trim(), body: `to ${song}` };
|
||||||
|
case 'comment':
|
||||||
|
return { title: `${who} commented on ${song}`, body: n.message ?? '' };
|
||||||
|
case 'reply':
|
||||||
|
return { title: `${who} replied on ${song}`, body: n.message ?? '' };
|
||||||
case 'follow':
|
case 'follow':
|
||||||
return { title: `${who} followed you`, body: 'Follow back to see what they play.' };
|
return { title: `${who} followed you`, body: 'Follow back to see what they play.' };
|
||||||
case 'share':
|
case 'share':
|
||||||
@@ -55,7 +65,8 @@ export function notificationText(n: Pick<NotificationDTO, 'type' | 'actor' | 'tr
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Where tapping it goes, as an app/web path. */
|
/** Where tapping it goes, as an app/web path. */
|
||||||
export function notificationPath(n: Pick<NotificationDTO, 'type' | 'actor' | 'track'>): string {
|
export function notificationPath(n: Pick<NotificationDTO, 'type' | 'actor' | 'track' | 'listenId'>): string {
|
||||||
|
if ((n.type === 'comment' || n.type === 'reply' || n.type === 'reaction') && n.listenId) return `/play/${n.listenId}`;
|
||||||
if ((n.type === 'share' || n.type === 'reaction') && n.track) return `/t/${n.track.id}`;
|
if ((n.type === 'share' || n.type === 'reaction') && n.track) return `/t/${n.track.id}`;
|
||||||
return `/u/${n.actor.username}`;
|
return `/u/${n.actor.username}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export const updateMeSchema = z.object({
|
|||||||
.nullable()
|
.nullable()
|
||||||
.optional(),
|
.optional(),
|
||||||
notifyPrefs: z
|
notifyPrefs: z
|
||||||
.object({ reaction: z.boolean(), follow: z.boolean(), share: z.boolean() })
|
.object({ reaction: z.boolean(), comment: z.boolean(), reply: z.boolean(), follow: z.boolean(), share: z.boolean() })
|
||||||
.partial()
|
.partial()
|
||||||
.optional(),
|
.optional(),
|
||||||
/** Mark onboarding as finished. */
|
/** Mark onboarding as finished. */
|
||||||
@@ -112,6 +112,12 @@ export const REPORT_REASON_LABELS: Record<ReportReason, string> = {
|
|||||||
impersonation: 'Pretending to be someone else',
|
impersonation: 'Pretending to be someone else',
|
||||||
other: 'Something else',
|
other: 'Something else',
|
||||||
};
|
};
|
||||||
|
export const COMMENT_MAX_LENGTH = 500;
|
||||||
|
export const commentSchema = z.object({
|
||||||
|
body: z.string().trim().min(1, 'Write something first').max(COMMENT_MAX_LENGTH, `Keep it under ${COMMENT_MAX_LENGTH} characters`),
|
||||||
|
});
|
||||||
|
export type CommentInput = z.infer<typeof commentSchema>;
|
||||||
|
|
||||||
export const reportSchema = z.object({
|
export const reportSchema = z.object({
|
||||||
reason: z.enum(REPORT_REASONS),
|
reason: z.enum(REPORT_REASONS),
|
||||||
note: z.string().trim().max(500).nullish(),
|
note: z.string().trim().max(500).nullish(),
|
||||||
|
|||||||
@@ -103,6 +103,15 @@ export const SPOTIFY_LINK_PAGES = {
|
|||||||
listenbrainz: 'https://listenbrainz.org/settings/music-services/details/',
|
listenbrainz: 'https://listenbrainz.org/settings/music-services/details/',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where to make a free account (neither lets other apps create one). ListenBrainz accounts are
|
||||||
|
* MusicBrainz accounts: register there, then sign in to ListenBrainz once to start it.
|
||||||
|
*/
|
||||||
|
export const SIGNUP_PAGES = {
|
||||||
|
lastfm: 'https://www.last.fm/join',
|
||||||
|
listenbrainz: 'https://musicbrainz.org/register',
|
||||||
|
} as const;
|
||||||
|
|
||||||
const QUIET_AFTER_MS = 7 * 86_400_000;
|
const QUIET_AFTER_MS = 7 * 86_400_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { isNewerVersion, parseVersion } from './updates';
|
||||||
|
|
||||||
|
describe('parseVersion', () => {
|
||||||
|
it('reads dotted versions, with or without a leading v', () => {
|
||||||
|
expect(parseVersion('1.10.0')).toEqual([1, 10, 0]);
|
||||||
|
expect(parseVersion('v2.0')).toEqual([2, 0]);
|
||||||
|
expect(parseVersion('1.2.0-beta')).toBeNull();
|
||||||
|
expect(parseVersion('')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isNewerVersion', () => {
|
||||||
|
it('compares numerically, part by part', () => {
|
||||||
|
expect(isNewerVersion('1.10.0', '1.9.3')).toBe(true);
|
||||||
|
expect(isNewerVersion('1.1.0', '1.1.0')).toBe(false);
|
||||||
|
expect(isNewerVersion('1.1', '1.1.0')).toBe(false);
|
||||||
|
expect(isNewerVersion('1.1.1', '1.1')).toBe(true);
|
||||||
|
expect(isNewerVersion('1.0.9', '1.1.0')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never treats something unparseable as newer', () => {
|
||||||
|
expect(isNewerVersion('nightly', '1.0.0')).toBe(false);
|
||||||
|
expect(isNewerVersion('2.0.0', 'unknown')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/** The newest Android release, as the server reports it (GET /app/android/latest). */
|
||||||
|
export interface AppUpdateDTO {
|
||||||
|
/** e.g. "1.2.0" (the release tag without its "v"). */
|
||||||
|
version: string;
|
||||||
|
/** Where to download the APK (through our server, so the release host can stay private). */
|
||||||
|
apkUrl: string;
|
||||||
|
/** Hex SHA-256 of the APK; the phone checks it before installing. */
|
||||||
|
sha256: string;
|
||||||
|
size: number;
|
||||||
|
/** What's new, from the release notes. */
|
||||||
|
notes: string | null;
|
||||||
|
publishedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "1.10.0" → [1, 10, 0]; null for anything that isn't a plain dotted version (an optional leading "v" is fine). */
|
||||||
|
export function parseVersion(version: string): number[] | null {
|
||||||
|
const m = /^v?(\d+(?:\.\d+)*)$/.exec(version.trim());
|
||||||
|
return m ? m[1]!.split('.').map(Number) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when `candidate` is a later version than `current`. Unparseable versions are never newer. */
|
||||||
|
export function isNewerVersion(candidate: string, current: string): boolean {
|
||||||
|
const a = parseVersion(candidate);
|
||||||
|
const b = parseVersion(current);
|
||||||
|
if (!a || !b) return false;
|
||||||
|
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
||||||
|
const diff = (a[i] ?? 0) - (b[i] ?? 0);
|
||||||
|
if (diff !== 0) return diff > 0;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user