diff --git a/src/lib/atproto.test.ts b/src/lib/atproto.test.ts index af7f748..d362a6b 100644 --- a/src/lib/atproto.test.ts +++ b/src/lib/atproto.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { blobUrl, buildAtUri, normalizeAtUri, parseAtUri } from './atproto' +import { blobUrl, bskyProfileUrl, buildAtUri, normalizeAtUri, parseAtUri } from './atproto' afterEach(() => { vi.unstubAllGlobals() @@ -50,6 +50,26 @@ describe('parseAtUri', () => { }) }) +describe('bskyProfileUrl', () => { + it('builds a profile URL from a handle', () => { + expect(bskyProfileUrl('alice.example.com')).toBe( + 'https://bsky.app/profile/alice.example.com', + ) + }) + + it('accepts a DID', () => { + expect(bskyProfileUrl('did:plc:abc123')).toBe( + 'https://bsky.app/profile/did:plc:abc123', + ) + }) + + it('does not double a leading "@"', () => { + expect(bskyProfileUrl('@alice.example.com')).toBe( + 'https://bsky.app/profile/alice.example.com', + ) + }) +}) + describe('normalizeAtUri', () => { it('passes DID-authority uris through without any network call', async () => { const fetchSpy = vi.fn() diff --git a/src/lib/atproto.ts b/src/lib/atproto.ts index fd9f6f4..50f4e55 100644 --- a/src/lib/atproto.ts +++ b/src/lib/atproto.ts @@ -13,6 +13,14 @@ export function buildAtUri(did: string, collection: string, rkey: string): strin return `at://${did}/${collection}/${rkey}` } +/** + * bsky.app profile URL for a handle or DID (bsky.app accepts either). + * Tolerates a leading "@" on stored handles. + */ +export function bskyProfileUrl(handleOrDid: string): string { + return `https://bsky.app/profile/${handleOrDid.replace(/^@/, '')}` +} + const handleCache = new Map() export async function resolveHandleToDid(handle: string): Promise { diff --git a/src/popup/popup.css b/src/popup/popup.css index 9c98493..35bbdb0 100644 --- a/src/popup/popup.css +++ b/src/popup/popup.css @@ -82,6 +82,14 @@ body { text-overflow: ellipsis; white-space: nowrap; } +.pub-handle a { + color: inherit; + text-decoration: none; +} +.pub-handle a:hover { + color: var(--fg); + text-decoration: underline; +} .badge { margin-left: auto; color: var(--good); font-weight: 700; } .pub-desc { margin: 8px 0 0; diff --git a/src/popup/popup.ts b/src/popup/popup.ts index 15fa033..6c05b5e 100644 --- a/src/popup/popup.ts +++ b/src/popup/popup.ts @@ -1,3 +1,4 @@ +import { bskyProfileUrl } from '../lib/atproto' import { getStoredSession, restoreAgent, signOut } from '../lib/oauth' import { DEFAULT_READER_ID, READERS, docUrl, pubUrl } from '../lib/readers' import type { Msg, PageState, PubInfo, SessionInfo } from '../lib/types' @@ -75,7 +76,7 @@ function render() { renderPubIcon(pub) $('pub-name').textContent = pub.record.name - $('pub-handle').textContent = pub.handle ? `@${pub.handle}` : pub.did + renderByline(pub.handle ?? pub.did) $('verified').hidden = !pub.verified $('pub-desc').textContent = pub.record.description ?? '' $('pub-desc').hidden = !pub.record.description @@ -120,6 +121,19 @@ function renderPubIcon(pub: PubInfo) { fallback.hidden = true } +/** Author credit: "by @handle", linked to the bsky.app profile. */ +function renderByline(handleOrDid: string) { + const el = $('pub-handle') + el.textContent = 'by ' + const bare = handleOrDid.replace(/^@/, '') + const link = document.createElement('a') + link.href = bskyProfileUrl(bare) + link.target = '_blank' + link.rel = 'noopener noreferrer' + link.textContent = bare.startsWith('did:') ? bare : `@${bare}` + el.append(link) +} + function renderSubscribe() { const btn = $('subscribe') const rkey = state?.subscriptionRkey