diff --git a/src/components/common/UserCard.vue b/src/components/common/UserCard.vue index f3b7db2..86b7491 100644 --- a/src/components/common/UserCard.vue +++ b/src/components/common/UserCard.vue @@ -3,7 +3,8 @@
-
+ +
{{ initials(user.handle) }}
@@ -79,6 +80,13 @@ function formatCount(n: number): string { overflow: hidden; } +.avatar-image { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + .avatar-fallback { width: 100%; height: 100%; diff --git a/src/features/home/HomePage.vue b/src/features/home/HomePage.vue index 31355fd..b02fd01 100644 --- a/src/features/home/HomePage.vue +++ b/src/features/home/HomePage.vue @@ -15,7 +15,7 @@

Profile Browser

-

Jump straight to a Tangled profile or browse that handle's repos.

+

Jump straight to a Tangled profile or repo.

Enter an AT Protocol handle, then open the profile directly or resolve the user's Personal Data Server and browse their public repositories here. diff --git a/src/features/profile/UserProfilePage.vue b/src/features/profile/UserProfilePage.vue index f7b5e8e..5b4e687 100644 --- a/src/features/profile/UserProfilePage.vue +++ b/src/features/profile/UserProfilePage.vue @@ -24,7 +24,8 @@ diff --git a/src/services/tangled/endpoints.ts b/src/services/tangled/endpoints.ts index f81852d..a77cf19 100644 --- a/src/services/tangled/endpoints.ts +++ b/src/services/tangled/endpoints.ts @@ -41,6 +41,7 @@ import { throwOnXrpcError } from "@/services/atproto/client.js"; import { MalformedResponseError, NotFoundError } from "@/core/errors/tangled.js"; type KnotParams = Record>; +type BlueskyProfileResponse = { did: string; handle: string; displayName?: string; avatar?: string }; function encodeKnotQueryParam(key: string, value: string | number | boolean): string { const encodedValue = encodeURIComponent(String(value)); @@ -193,6 +194,19 @@ export async function fetchActorProfile( return getRecord(pds, did, "sh.tangled.actor.profile", "self"); } +export async function fetchBlueskyProfile(actor: string): Promise { + const url = new URL("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile"); + url.searchParams.set("actor", actor); + + const res = await fetch(url.toString()); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string; message?: string }; + throwOnXrpcError(res.status, body.error ?? "Unknown", body.message); + } + + return res.json() as Promise; +} + /** * Fetch a repo record by its PDS record key. * This is distinct from the repo's `name`, which is the identifier used by diff --git a/src/services/tangled/normalizers.ts b/src/services/tangled/normalizers.ts index a715b0f..ae753a5 100644 --- a/src/services/tangled/normalizers.ts +++ b/src/services/tangled/normalizers.ts @@ -102,12 +102,20 @@ export function normalizeLogText(raw: string): CommitEntry[] { if (text.startsWith("{")) { try { - return text - .split("\n") - .filter(Boolean) - .map((line) => normalizeCommitObject(JSON.parse(line) as Record)); + const parsed = JSON.parse(text) as Record; + const commits = getCommitObjects(parsed); + if (commits.length) { + return commits.map((item) => normalizeCommitObject(item)); + } } catch { - console.warn("Failed to parse log as newline-delimited JSON, falling back to other formats"); + try { + return text + .split("\n") + .filter(Boolean) + .map((line) => normalizeCommitObject(JSON.parse(line) as Record)); + } catch { + console.warn("Failed to parse log as JSON, falling back to other formats"); + } } } @@ -117,10 +125,29 @@ export function normalizeLogText(raw: string): CommitEntry[] { .map((line) => ({ hash: "", message: line, when: "" })); } +function getCommitObjects(obj: Record): Array> { + const collections = [obj.commits, obj.log, obj.entries]; + + for (const value of collections) { + if (Array.isArray(value)) { + return value.filter((item): item is Record => typeof item === "object" && item !== null); + } + } + + if ("hash" in obj || "sha" in obj || "id" in obj || "message" in obj || "subject" in obj) { + return [obj]; + } + + return []; +} + function normalizeCommitObject(obj: Record): CommitEntry { + const hash = normalizeCommitHash(obj.hash ?? obj.sha ?? obj.id) ?? ""; + const shortHash = normalizeCommitHash(obj.shortHash) || (hash ? hash.slice(0, 7) : undefined); + return { - hash: String(obj.hash ?? obj.sha ?? obj.id ?? ""), - shortHash: obj.shortHash != null ? String(obj.shortHash) : undefined, + hash, + shortHash, message: String(obj.message ?? obj.subject ?? ""), when: String(obj.when ?? obj.date ?? obj.timestamp ?? ""), authorName: obj.author @@ -136,6 +163,21 @@ function normalizeCommitObject(obj: Record): CommitEntry { }; } +function normalizeCommitHash(value: unknown): string | undefined { + if (value == null) return undefined; + if (typeof value === "string") return value || undefined; + + if (value instanceof Uint8Array) { + return Array.from(value, (byte) => byte.toString(16).padStart(2, "0")).join(""); + } + + if (Array.isArray(value) && value.every((entry) => Number.isInteger(entry) && entry >= 0 && entry <= 255)) { + return value.map((byte) => Number(byte).toString(16).padStart(2, "0")).join(""); + } + + return String(value) || undefined; +} + export type BranchEntry = { name: string; isDefault?: boolean }; export function normalizeBranchesText(raw: string, defaultBranch?: string): BranchEntry[] { @@ -374,15 +416,19 @@ export function normalizeActorProfile( did: string, handle: string, displayName?: string, + avatar?: string, ): UserSummary & { location?: string; pronouns?: string; links?: string[]; pinnedRepos?: string[] } { + const links = record.links?.map((link) => link.trim()).filter(Boolean); + return { did, handle, displayName, + avatar, bio: record.description, location: record.location, pronouns: record.pronouns, - links: record.links, + links, pinnedRepos: record.pinnedRepositories, }; } diff --git a/src/services/tangled/queries.ts b/src/services/tangled/queries.ts index 6ac18bb..de7fd24 100644 --- a/src/services/tangled/queries.ts +++ b/src/services/tangled/queries.ts @@ -29,6 +29,7 @@ import { fetchRepoDiff, fetchRepoCompare, fetchActorProfile, + fetchBlueskyProfile, fetchRepoRecordByName, fetchIssueRecord, fetchPullRecord, @@ -78,6 +79,20 @@ function isEnabled(required: boolean, enabled?: MaybeRef): boolean { return required && (enabled === undefined || !!toValue(enabled)); } +async function resolveBlueskyProfile( + record: Awaited>["value"], + did: string, +): Promise<{ displayName?: string; avatar?: string }> { + if (!record.bluesky) return {}; + + try { + const profile = await fetchBlueskyProfile(did); + return { displayName: profile.displayName, avatar: profile.avatar }; + } catch { + return {}; + } +} + /** Resolved identity: DID + PDS hostname for an AT Protocol handle. */ export type Identity = { did: string; pds: string }; @@ -305,7 +320,14 @@ export function useActorProfile( queryKey: computed(() => ["actorProfile", normalizedPds.value, normalizedDid.value]), queryFn: async () => { const { value } = await fetchActorProfile(normalizedPds.value, normalizedDid.value); - return normalizeActorProfile(value, toValue(did), toValue(handle), toValue(displayName)); + const bluesky = await resolveBlueskyProfile(value, normalizedDid.value); + return normalizeActorProfile( + value, + toValue(did), + toValue(handle), + bluesky.displayName ?? toValue(displayName), + bluesky.avatar, + ); }, enabled: computed(() => isEnabled(hasText(normalizedPds) && hasText(normalizedDid), options.enabled)), staleTime: 10 * MIN, @@ -483,8 +505,9 @@ export function useUserFollowing( try { const { value } = await fetchActorProfile(subject.pds, subject.did); + const bluesky = await resolveBlueskyProfile(value, subject.did); return { - ...normalizeActorProfile(value, subject.did, subject.handle), + ...normalizeActorProfile(value, subject.did, subject.handle, bluesky.displayName, bluesky.avatar), followAtUri: follow.atUri, followedAt: follow.createdAt, }; diff --git a/tests/unit/tangled-normalizers.spec.ts b/tests/unit/tangled-normalizers.spec.ts index 18df9a5..b39d0c2 100644 --- a/tests/unit/tangled-normalizers.spec.ts +++ b/tests/unit/tangled-normalizers.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { buildKnotUrl } from "@/services/tangled/endpoints.js"; -import { buildIssueCommentThread, normalizeRepoRecord, normalizeTree } from "@/services/tangled/normalizers.js"; +import { buildIssueCommentThread, normalizeLogText, normalizeRepoRecord, normalizeTree } from "@/services/tangled/normalizers.js"; import { getAtUriRkey, parseAtUri } from "@/services/tangled/uris.js"; import type { IssueComment } from "@/domain/models/comment.js"; @@ -69,6 +69,54 @@ describe("AT URI helpers", () => { ["vendor/lib", "submodule"], ]); }); + + it("parses wrapped commit arrays from repo log payloads", () => { + const commits = normalizeLogText( + JSON.stringify({ + commits: [ + { + hash: "60074765a75ecb6a763dcf82252ef4365187af21", + shortHash: "6007476", + message: "feat: persist sidebar state between reloads", + when: "2026-03-21T14:59:03Z", + author: { name: "Owais Jamil", email: "desertthunder.dev@gmail.com" }, + }, + ], + }), + ); + + expect(commits).toEqual([ + { + hash: "60074765a75ecb6a763dcf82252ef4365187af21", + shortHash: "6007476", + message: "feat: persist sidebar state between reloads", + when: "2026-03-21T14:59:03Z", + authorName: "Owais Jamil", + authorEmail: "desertthunder.dev@gmail.com", + }, + ]); + }); + + it("hex-encodes byte-array commit hashes", () => { + const commits = normalizeLogText( + JSON.stringify({ + commits: [ + { + hash: [219, 149, 244, 86, 116, 134, 146, 69, 98, 104, 59, 177, 138, 231, 236, 43, 189, 85, 234, 95], + message: "docs: update site", + when: "2026-03-21T15:08:58Z", + }, + ], + }), + ); + + expect(commits[0]).toMatchObject({ + hash: "db95f4567486924562683bb18ae7ec2bbd55ea5f", + shortHash: "db95f45", + message: "docs: update site", + when: "2026-03-21T15:08:58Z", + }); + }); }); describe("buildIssueCommentThread", () => { diff --git a/vite.config.ts b/vite.config.ts index 44f15a4..1f3a26e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -9,5 +9,5 @@ import { defineConfig } from "vite"; export default defineConfig({ plugins: [vue(), legacy()], resolve: { alias: { "@": path.resolve(__dirname, "./src") } }, - test: { globals: true, environment: "jsdom" }, + test: { globals: true, environment: "jsdom", watch: false, ui: false }, });