Merge widget Workshop identity fix into mobile

This commit is contained in:
Thomas Camlong committed 2026-08-10 15:15:36 +02:00
commit efd3dae0db
19 files changed
+114 -362

No files matched your search

+17 -81
View File
@@ -71,12 +71,7 @@ export interface WorkshopUserRecord extends WorkshopBaseRecord {
email: string;
emailVisibility: boolean;
verified: boolean;
username: string;
displayName: string;
avatarUrl: string;
avatar: string;
githubUsername: string;
githubProfileUrl: string;
isAdmin: boolean;
created: string;
updated: string;
@@ -100,11 +95,7 @@ export interface WorkshopSubmissionRecord extends WorkshopBaseRecord {
export interface WorkshopListingRecord extends Omit<WorkshopSubmissionRecord, "content" | "screenshots"> {
screenshots: string[] | string;
authorName: string;
authorAvatar: string;
authorAvatarUrl: string;
authorGithubUsername: string;
authorGithubProfileUrl: string;
score: number;
upvotes: number;
downvotes: number;
@@ -176,7 +167,10 @@ function filterListings(items: WorkshopSubmissionSummary[], options: WorkshopLis
if (options.type && options.type !== "all" && item.type !== options.type) return false;
if (options.author && item.author !== options.author) return false;
if (options.includeOutdated === false && item.outdated) return false;
if (search && !`${item.title}\n${item.description}\n${item.authorName}`.toLocaleLowerCase().includes(search))
if (
search &&
!`${item.title}\n${item.description}\n${item.authorGithubUsername}`.toLocaleLowerCase().includes(search)
)
return false;
return true;
}),
@@ -196,7 +190,7 @@ function listingFilter(pocketBase: TypedWorkshopPocketBase, options: WorkshopLis
if (options.type && options.type !== "all") filters.push(pocketBase.filter("type = {:type}", { type: options.type }));
if (options.search?.trim()) {
filters.push(
pocketBase.filter("(title ~ {:search} || description ~ {:search} || authorName ~ {:search})", {
pocketBase.filter("(title ~ {:search} || description ~ {:search} || authorGithubUsername ~ {:search})", {
search: options.search.trim(),
}),
);
@@ -209,10 +203,7 @@ function listingFilter(pocketBase: TypedWorkshopPocketBase, options: WorkshopLis
const isViewCompatibilityError = (error: unknown) =>
error instanceof ClientResponseError && (error.status === 400 || error.status === 404);
const enrichAuthor = (
row: unknown,
resolveAvatarUrl?: (record: Record<string, unknown>, user: Record<string, unknown>) => string,
) => {
const enrichAuthor = (row: unknown) => {
if (typeof row !== "object" || row === null || Array.isArray(row)) return row;
const record = row as Record<string, unknown>;
const expand = record.expand;
@@ -222,27 +213,15 @@ const enrichAuthor = (
: undefined;
const user =
typeof author === "object" && author !== null && !Array.isArray(author) ? (author as Record<string, unknown>) : {};
const githubUsername = record.authorGithubUsername || user.githubUsername || user.username;
return {
...record,
authorName: record.authorName || user.displayName || user.username,
authorAvatarUrl: resolveAvatarUrl?.(record, user) || record.authorAvatarUrl || user.avatarUrl,
authorGithubUsername: githubUsername,
authorGithubProfileUrl:
record.authorGithubProfileUrl ||
user.githubProfileUrl ||
(typeof githubUsername === "string" && githubUsername
? `https://github.com/${encodeURIComponent(githubUsername)}`
: undefined),
authorGithubUsername: record.authorGithubUsername || user.githubUsername,
};
};
const parseListings = (
rows: unknown[],
resolveAvatarUrl?: (record: Record<string, unknown>, user: Record<string, unknown>) => string,
) =>
const parseListings = (rows: unknown[]) =>
rows.flatMap((row) => {
const result = workshopSubmissionSummarySchema.safeParse(enrichAuthor(row, resolveAvatarUrl));
const result = workshopSubmissionSummarySchema.safeParse(enrichAuthor(row));
return result.success ? [result.data] : [];
});
@@ -284,33 +263,12 @@ export class WorkshopBackend {
this.pocketBase.autoCancellation(false);
}
private userAvatarUrl(record: Record<string, unknown>) {
const avatar = typeof record.avatar === "string" ? record.avatar : "";
if (avatar) return this.pocketBase.files.getURL(record, avatar);
return typeof record.avatarUrl === "string" ? record.avatarUrl : "";
}
private listingAuthorAvatarUrl(record: Record<string, unknown>, user: Record<string, unknown>) {
const expandedAvatarUrl = this.userAvatarUrl(user);
if (expandedAvatarUrl) return expandedAvatarUrl;
const author = typeof record.author === "string" ? record.author : "";
const avatar = typeof record.authorAvatar === "string" ? record.authorAvatar : "";
if (!author || !avatar) return "";
return `${this.baseUrl}/api/files/users/${encodeURIComponent(author)}/${encodeURIComponent(avatar)}`;
}
public get currentUser(): WorkshopUser | null {
const record = this.pocketBase.authStore.record;
if (!record) return null;
return workshopUserSchema.parse({
id: record.id,
displayName: record.displayName || record.name || record.username || "Community member",
avatarUrl: this.userAvatarUrl(record),
githubUsername: record.githubUsername || "",
githubProfileUrl:
record.githubProfileUrl ||
(record.githubUsername ? `https://github.com/${encodeURIComponent(record.githubUsername)}` : ""),
isAdmin: record.isAdmin === true,
});
}
@@ -337,7 +295,6 @@ export class WorkshopBackend {
try {
const auth = await this.pocketBase.collection("users").authWithOAuth2({
provider: "github",
createData: { displayName: "GitHub user" },
});
this.pocketBase.authStore.save(auth.token, auth.record);
return this.currentUser;
@@ -362,7 +319,7 @@ export class WorkshopBackend {
});
return {
...result,
items: parseListings(result.items, (record, user) => this.listingAuthorAvatarUrl(record, user)),
items: parseListings(result.items),
};
} catch (error) {
if (!isViewCompatibilityError(error)) throw error;
@@ -389,7 +346,7 @@ export class WorkshopBackend {
sort: listingSort(options.sort),
signal: requestSignal(options.signal),
});
return parseListings(rows, (record, user) => this.listingAuthorAvatarUrl(record, user));
return parseListings(rows);
} catch (error) {
if (!isViewCompatibilityError(error)) throw error;
return filterListings(await this.loadListingsFallback(options.signal), options);
@@ -409,7 +366,7 @@ export class WorkshopBackend {
return this.pocketBase.collection("submissions").getFullList({ ...options, expand: "author" });
throw error;
});
return parseListings(rows, (record, user) => this.listingAuthorAvatarUrl(record, user));
return parseListings(rows);
}
public async get(id: string, signal?: AbortSignal): Promise<WorkshopSubmissionDetail> {
@@ -425,10 +382,7 @@ export class WorkshopBackend {
throw error;
});
return workshopSubmissionDetailSchema.parse(
enrichAuthor(
{ ...submission, ...listing, expand: submission.expand, content: submission.content },
(record, user) => this.listingAuthorAvatarUrl(record, user),
),
enrichAuthor({ ...submission, ...listing, expand: submission.expand, content: submission.content }),
);
} catch (error) {
throw workshopError(error, "Submission not found");
@@ -548,7 +502,7 @@ export class WorkshopBackend {
return items.map((item) =>
workshopReportSchema.parse({
...item,
reporterName: item.expand?.reporter?.displayName,
reporterGithubUsername: item.expand?.reporter?.githubUsername,
submissionTitle: item.expand?.submission?.title,
}),
);
@@ -585,13 +539,7 @@ export class WorkshopBackend {
return rows.map((row) =>
workshopCommentSchema.parse({
...row,
authorName: row.expand?.author?.displayName,
authorAvatarUrl: row.expand?.author ? this.userAvatarUrl(row.expand.author) : undefined,
authorGithubProfileUrl:
row.expand?.author?.githubProfileUrl ||
(row.expand?.author?.githubUsername
? `https://github.com/${encodeURIComponent(row.expand.author.githubUsername)}`
: undefined),
authorGithubUsername: row.expand?.author?.githubUsername,
}),
);
} catch (error) {
@@ -606,13 +554,7 @@ export class WorkshopBackend {
.create({ submission, author: this.currentUser?.id ?? "", content: content.trim() }, { expand: "author" });
return workshopCommentSchema.parse({
...row,
authorName: row.expand?.author?.displayName,
authorAvatarUrl: row.expand?.author ? this.userAvatarUrl(row.expand.author) : undefined,
authorGithubProfileUrl:
row.expand?.author?.githubProfileUrl ||
(row.expand?.author?.githubUsername
? `https://github.com/${encodeURIComponent(row.expand.author.githubUsername)}`
: undefined),
authorGithubUsername: row.expand?.author?.githubUsername,
});
} catch (error) {
throw workshopError(error, "Failed to post comment");
@@ -626,13 +568,7 @@ export class WorkshopBackend {
.update(id, { content: content.trim() }, { expand: "author" });
return workshopCommentSchema.parse({
...row,
authorName: row.expand?.author?.displayName,
authorAvatarUrl: row.expand?.author ? this.userAvatarUrl(row.expand.author) : undefined,
authorGithubProfileUrl:
row.expand?.author?.githubProfileUrl ||
(row.expand?.author?.githubUsername
? `https://github.com/${encodeURIComponent(row.expand.author.githubUsername)}`
: undefined),
authorGithubUsername: row.expand?.author?.githubUsername,
});
} catch (error) {
throw workshopError(error, "Failed to update comment");
+4 -8
View File
@@ -70,9 +70,7 @@ describe("WorkshopBackend", () => {
token: "token",
record: {
id: "user-id",
displayName: "octocat",
githubUsername: "octocat",
githubProfileUrl: "https://github.com/octocat",
},
meta: { username: "octocat" },
});
@@ -80,14 +78,11 @@ describe("WorkshopBackend", () => {
const client = new WorkshopBackend("https://workshop.example.com");
const user = await client.signInWithGitHub();
expect(mocks.authWithOAuth2).toHaveBeenCalledWith({
provider: "github",
createData: { displayName: "GitHub user" },
});
expect(mocks.authWithOAuth2).toHaveBeenCalledWith({ provider: "github" });
expect(mocks.authWithOAuth2.mock.calls[0]?.[0]).not.toHaveProperty("requestKey");
expect(mocks.authWithOAuth2.mock.calls[0]?.[0]).not.toHaveProperty("urlCallback");
expect(mocks.update).not.toHaveBeenCalled();
expect(user).toMatchObject({ displayName: "octocat", githubUsername: "octocat" });
expect(user).toMatchObject({ githubUsername: "octocat" });
});
test("exposes the typed submission collection service", () => {
@@ -219,7 +214,8 @@ describe("WorkshopBackend", () => {
1,
12,
expect.objectContaining({
filter: "(title ~ 'active streams' || description ~ 'active streams' || authorName ~ 'active streams')",
filter:
"(title ~ 'active streams' || description ~ 'active streams' || authorGithubUsername ~ 'active streams')",
}),
);
});
+8 -1
View File
@@ -3,6 +3,8 @@ import { describe, expect, test } from "vitest";
import { CUSTOM_WIDGET_STARTER } from "@homarr/custom-widgets/core";
import {
githubAvatarUrl,
githubProfileUrl,
normalizeHttpUrl,
resolveHomarrUrlConfig,
validateWorkshopWidget,
@@ -11,6 +13,11 @@ import {
} from "./schema";
describe("Workshop URL configuration", () => {
test("derives GitHub identity URLs from the stored username", () => {
expect(githubProfileUrl("octocat")).toBe("https://github.com/octocat");
expect(githubAvatarUrl("octocat")).toBe("https://github.com/octocat.png");
});
test("normalizes the URL contract and derives Workshop defaults", () => {
expect(
resolveHomarrUrlConfig({
@@ -98,7 +105,7 @@ describe("Workshop widget validation", () => {
expect(result.type).toBe("customWidget");
expect(result.screenshots).toEqual([]);
expect(result.authorName).toBe("Community member");
expect(result.authorGithubUsername).toBe("");
expect(result.upvotes).toBe(0);
});
+7 -10
View File
@@ -16,6 +16,11 @@ export const MAX_WORKSHOP_SCREENSHOT_BYTES = 5 * 1024 * 1024;
export const WORKSHOP_REQUEST_TIMEOUT_MS = 8_000;
export const WORKSHOP_SCREENSHOT_MIME_TYPES = ["image/png", "image/jpeg", "image/webp"] as const;
export const githubProfileUrl = (username: string) =>
username ? `https://github.com/${encodeURIComponent(username)}` : "";
export const githubAvatarUrl = (username: string) =>
username ? `https://github.com/${encodeURIComponent(username)}.png` : "";
export interface HomarrUrlConfig {
homarrWebsiteUrl: string;
workshopApiUrl: string;
@@ -85,10 +90,7 @@ const workshopFileListSchema = z
export const workshopUserSchema = z.object({
id: z.string(),
displayName: z.string(),
avatarUrl: z.string().default(""),
githubUsername: z.string().default(""),
githubProfileUrl: z.string().default(""),
isAdmin: z.boolean().default(false),
});
export type WorkshopUser = z.infer<typeof workshopUserSchema>;
@@ -111,10 +113,7 @@ const workshopSubmissionBaseObjectSchema = z.object({
widgetSchema: z.string(),
screenshots: workshopFileListSchema,
author: z.string(),
authorName: z.string().default("Community member"),
authorAvatarUrl: z.string().default(""),
authorGithubUsername: z.string().default(""),
authorGithubProfileUrl: z.string().default(""),
score: z.number().int().default(0),
upvotes: z.number().int().default(0),
downvotes: z.number().int().default(0),
@@ -174,7 +173,7 @@ export const workshopReportSchema = z.object({
id: z.string(),
submission: z.string(),
reporter: z.string(),
reporterName: z.string().default("Community member"),
reporterGithubUsername: z.string().default(""),
submissionTitle: z.string().default("Deleted submission"),
category: workshopReportCategorySchema,
explanation: z.string(),
@@ -191,9 +190,7 @@ export const workshopCommentSchema = z.object({
content: z.string().min(1).max(2_000),
created: z.string(),
updated: z.string(),
authorName: z.string().default("Community member"),
authorAvatarUrl: z.string().default(""),
authorGithubProfileUrl: z.string().default(""),
authorGithubUsername: z.string().default(""),
});
export type WorkshopComment = z.infer<typeof workshopCommentSchema>;