diff --git a/.gitignore b/.gitignore index 11b57871e..9c9aa6995 100644 --- a/.gitignore +++ b/.gitignore @@ -119,5 +119,6 @@ src/locale/locales/**/*.js # ogcard assets bskyogcard/src/assets/fonts/noto-* -# direnv +# deer .direnv +.wrangler diff --git a/flake.lock b/flake.lock index 261180af0..6f7c3c04a 100644 --- a/flake.lock +++ b/flake.lock @@ -41,6 +41,27 @@ "type": "github" } }, + "flake-parts": { + "inputs": { + "nixpkgs-lib": [ + "wrangler-flake", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1743550720, + "narHash": "sha256-hIshGgKZCgWh6AYJpJmRgFdR3WUbkY04o82X05xqQiY=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "c621e8422220273271f52058f618c94e405bb0f5", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, "flake-utils": { "inputs": { "systems": "systems" @@ -109,11 +130,28 @@ "type": "github" } }, + "nixpkgs_3": { + "locked": { + "lastModified": 1744904290, + "narHash": "sha256-ewg0m4mGwl3iO4aN73yZoT8lCgEHtapP3d/trfUE6To=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "e03df76c3a8ac2119f45ff18c9b994513dbb7a4c", + "type": "github" + }, + "original": { + "owner": "nixos", + "repo": "nixpkgs", + "rev": "e03df76c3a8ac2119f45ff18c9b994513dbb7a4c", + "type": "github" + } + }, "root": { "inputs": { "android-nixpkgs": "android-nixpkgs", "flake-utils": "flake-utils_2", - "nixpkgs": "nixpkgs_2" + "nixpkgs": "nixpkgs_2", + "wrangler-flake": "wrangler-flake" } }, "systems": { @@ -145,6 +183,25 @@ "repo": "default", "type": "github" } + }, + "wrangler-flake": { + "inputs": { + "flake-parts": "flake-parts", + "nixpkgs": "nixpkgs_3" + }, + "locked": { + "lastModified": 1745836852, + "narHash": "sha256-4rlqhVU89ypXQTWpJchMdocHNSZBVTUehiNWnAy0zJ0=", + "owner": "ryand56", + "repo": "wrangler", + "rev": "070db974683ef1f8e95dadef549f223381ee8544", + "type": "github" + }, + "original": { + "owner": "ryand56", + "repo": "wrangler", + "type": "github" + } } }, "root": "root", diff --git a/flake.nix b/flake.nix index 56bc48b07..9801e72d3 100644 --- a/flake.nix +++ b/flake.nix @@ -3,12 +3,14 @@ nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; flake-utils.url = "github:numtide/flake-utils"; android-nixpkgs.url = "github:tadfisher/android-nixpkgs"; + wrangler-flake.url = "github:ryand56/wrangler"; }; outputs = { nixpkgs, flake-utils, + wrangler-flake, android-nixpkgs, ... }: @@ -78,6 +80,10 @@ typescript typescript-language-server + go + gopls + + wrangler-flake.packages.${system}.wrangler ]; shellHook = '' diff --git a/functions/profile/[handleOrDID].ts b/functions/profile/[handleOrDID].ts new file mode 100644 index 000000000..aad768258 --- /dev/null +++ b/functions/profile/[handleOrDID].ts @@ -0,0 +1,157 @@ +import {AtpAgent} from '@atproto/api' + +import {type AnyProfileView} from '#/types/bsky/profile' + +type PResp = Awaited> + +// based on https://github.com/Janpot/escape-html-template-tag/blob/master/src/index.ts + +const ENTITIES: { + [key: string]: string +} = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + '/': '/', + '`': '`', + '=': '=', +} + +const ENT_REGEX = new RegExp(Object.keys(ENTITIES).join('|'), 'g') + +function escapehtml(unsafe: Sub): string { + if (Array.isArray(unsafe)) { + return unsafe.map(escapehtml).join('') + } + if (unsafe instanceof HtmlSafeString) { + return unsafe.toString() + } + return String(unsafe).replace(ENT_REGEX, char => ENTITIES[char]) +} + +type Sub = HtmlSafeString | string | (HtmlSafeString | string)[] + +export class HtmlSafeString { + private _parts: readonly string[] + private _subs: readonly Sub[] + constructor(parts: readonly string[], subs: readonly Sub[]) { + this._parts = parts + this._subs = subs + } + + toString(): string { + let result = this._parts[0] + for (let i = 1; i < this._parts.length; i++) { + result += escapehtml(this._subs[i - 1]) + this._parts[i] + } + return result + } +} + +export function html(parts: TemplateStringsArray, ...subs: Sub[]) { + return new HtmlSafeString(parts, subs) +} + +export const renderHandleString = (profile: AnyProfileView) => + profile.displayName + ? `${profile.displayName} (@${profile.handle})` + : `@${profile.handle}` + +class HeadHandler { + profile: PResp + url: string + constructor(profile: PResp, url: string) { + this.profile = profile + this.url = url + } + async element(element) { + const view = this.profile.data + + const description = view.description + ? html` + + + ` + : '' + const img = view.banner + ? html` + + + ` + : view.avatar + ? html`` + : '' + element.append( + html` + + + + + + ${description} ${img} + + + + `, + {html: true}, + ) + } +} + +class TitleHandler { + profile: PResp + constructor(profile: PResp) { + this.profile = profile + } + async element(element) { + element.setInnerContent(renderHandleString(this.profile.data)) + } +} + +class NoscriptHandler { + profile: PResp + constructor(profile: PResp) { + this.profile = profile + } + async element(element) { + const view = this.profile.data + + element.append( + html` +
+

Profile

+

${view.displayName ?? ''}

+

${view.handle}

+

${view.did}

+

${view.description ?? ''}

+
+ `, + {html: true}, + ) + } +} + +export async function onRequest(context) { + const agent = new AtpAgent({service: 'https://public.api.bsky.app/'}) + const {request, env} = context + const origin = new URL(request.url).origin + + const base = env.ASSETS.fetch(new URL('/', origin)) + try { + const profile = await agent.getProfile({ + actor: context.params.handleOrDID, + }) + return new HTMLRewriter() + .on(`head`, new HeadHandler(profile, request.url)) + .on(`title`, new TitleHandler(profile)) + .on(`noscript`, new NoscriptHandler(profile)) + .transform(await base) + } catch (e) { + console.error(e) + return await base + } +} diff --git a/functions/profile/[handleOrDID]/post/[rkey].ts b/functions/profile/[handleOrDID]/post/[rkey].ts new file mode 100644 index 000000000..b9c2f7192 --- /dev/null +++ b/functions/profile/[handleOrDID]/post/[rkey].ts @@ -0,0 +1,227 @@ +import { + AppBskyEmbedExternal, + AppBskyEmbedImages, + AppBskyEmbedRecord, + AppBskyEmbedRecordWithMedia, + AppBskyFeedDefs, + AtpAgent, + type Facet, + RichText, +} from '@atproto/api' +import {isViewRecord} from '@atproto/api/dist/client/types/app/bsky/embed/record' +import {isThreadViewPost} from '@atproto/api/dist/client/types/app/bsky/feed/defs' + +import {html, renderHandleString} from '../../[handleOrDID].ts' + +type Thread = AppBskyFeedDefs.ThreadViewPost + +export function expandPostTextRich( + postView: AppBskyFeedDefs.ThreadViewPost, +): string { + if ( + !postView.post || + AppBskyFeedDefs.isNotFoundPost(postView) || + AppBskyFeedDefs.isBlockedPost(postView) + ) { + return '' + } + + const post = postView.post + const record = post.record + const embed = post.embed + const originalText = typeof record?.text === 'string' ? record.text : '' + const facets = record?.facets as [Facet] | undefined + + let expandedText = originalText + + // Use RichText to process facets if they exist + if (originalText && facets && facets.length > 0) { + try { + const rt = new RichText({text: originalText, facets}) + const modifiedSegmentsText: string[] = [] + + for (const segment of rt.segments()) { + const link = segment.link + if ( + link && + segment.text.endsWith('...') && + link.uri.includes(segment.text.slice(0, -3)) + ) { + // Replace shortened text with full URI + modifiedSegmentsText.push(link.uri) + } else { + // Keep original segment text + modifiedSegmentsText.push(segment.text) + } + } + expandedText = modifiedSegmentsText.join('') + } catch (error) { + console.error('Error processing RichText segments:', error) + // Fallback to original text on error + expandedText = originalText + } + } + + // Append external link URL if present and not already in text + if (AppBskyEmbedExternal.isView(embed) && embed.external?.uri) { + const externalUri = embed.external.uri + if (!expandedText.includes(externalUri)) { + expandedText = expandedText + ? `${expandedText}\n${externalUri}` + : externalUri + } + } + + // Append placeholder for quote posts or other record embeds + if ( + AppBskyEmbedRecord.isView(embed) || + AppBskyEmbedRecordWithMedia.isView(embed) + ) { + // no idea why this is needed lol + const record = embed.record.record ?? embed.record + if (isViewRecord(record)) { + const quote = `↘️ quoting ${renderHandleString(record.author)}:\n\n${ + record.value.text + }` + expandedText = expandedText ? `${expandedText}\n\n${quote}` : quote + } else { + const placeholder = '[quote/embed]' + if (!expandedText.includes(placeholder)) { + expandedText = expandedText + ? `${expandedText}\n\n${placeholder}` + : placeholder + } + } + } + + // prepend reply header + if (isThreadViewPost(postView.parent)) { + const header = `↩️ reply to ${renderHandleString( + postView.parent.post.author, + )}:` + expandedText = expandedText ? `${header}\n\n${expandedText}` : header + } + + return expandedText +} + +class HeadHandler { + thread: Thread + url: string + postTextString: string + constructor(thread: Thread, url: string, postTextString: string) { + this.thread = thread + this.url = url + this.postTextString = postTextString + } + async element(element) { + const author = this.thread.post.author + + const postText = + this.postTextString.length > 0 + ? html` + + + ` + : '' + + const embed = this.thread.post.embed + + const embedElems = !embed + ? '' + : AppBskyEmbedImages.isView(embed) + ? html`${embed.images.map( + i => html``, + )} + ` + : // TODO: in the future, embed videos + 'thumbnail' in embed && embed.thumbnail + ? html` + + + ` + : html`` + + element.append( + html` + + + + + + ${postText} ${embedElems} + + + + `, + {html: true}, + ) + } +} + +class TitleHandler { + thread: Thread + constructor(thread: Thread) { + this.thread = thread + } + async element(element) { + element.setInnerContent(renderHandleString(this.thread.post.author)) + } +} + +class NoscriptHandler { + thread: Thread + postTextString: string + constructor(thread: Thread, postTextString: string) { + this.thread = thread + this.postTextString = postTextString + } + async element(element) { + element.append( + html` +
+

Post

+

+ ${this.thread.post.author.displayName ?? ''} +

+

${this.thread.post.author.handle}

+

${this.thread.post.author.did}

+

${this.postTextString}

+

${this.thread.post.indexedAt}

+
+ `, + {html: true}, + ) + } +} + +export async function onRequest(context) { + const agent = new AtpAgent({service: 'https://public.api.bsky.app/'}) + const {request, env} = context + const origin = new URL(request.url).origin + const {handleOrDID, rkey}: {handleOrDID: string; rkey: string} = + context.params + + const base = env.ASSETS.fetch(new URL('/', origin)) + try { + const {data} = await agent.getPostThread({ + uri: `at://${handleOrDID}/app.bsky.feed.post/${rkey}`, + depth: 1, + parentHeight: 1, + }) + if (!AppBskyFeedDefs.isThreadViewPost(data.thread)) { + throw new Error('Expected a ThreadViewPost') + } + const postTextString = expandPostTextRich(data.thread) + return new HTMLRewriter() + .on(`head`, new HeadHandler(data.thread, request.url, postTextString)) + .on(`title`, new TitleHandler(data.thread)) + .on(`noscript`, new NoscriptHandler(data.thread, postTextString)) + .transform(await base) + } catch (e) { + console.error(e) + return await base + } +} diff --git a/justfile b/justfile new file mode 100644 index 000000000..64b7fd4eb --- /dev/null +++ b/justfile @@ -0,0 +1,54 @@ +export PATH := "./node_modules/.bin:" + env_var('PATH') + +# lots of just -> yarn, but this lets us chain yarn command deps + +[group('dist')] +dist-build-web: intl build-web + +[group('dist')] +dist-build-android-sideload: intl build-android-sideload + +[group('build')] +intl: + yarn intl:build + +[group('build')] +prebuild-android: + expo prebuild -p android + +[group('build')] +build-web: && postbuild-web + yarn build-web + +[group('build')] +build-android-sideload: prebuild-android + eas build --local --platform android --profile sideload-android + +[group('build')] +postbuild-web: + # build system outputs some srcs and hrefs like src="static/" + # need to rewrite to be src="/static/" to handle non root pages + sed -i 's/\(src\|href\)="static/\1="\/static/g' web-build/index.html + + # we need to copy the static iframe html to support youtube embeds + cp -r bskyweb/static/iframe/ web-build/iframe + + # copy our static pages over! + cp -r deer-static-about web-build/about + +[group('dev')] +dev-android-setup: prebuild-android + yarn android + +[group('dev')] +dev-web: + yarn web + +[group('dev')] +dev-web-functions: build-web + wrangler pages dev ./web-build + +[group('lint')] +typecheck: + yarn typecheck + diff --git a/pages_build.sh b/pages_build.sh index d724fa40d..2a56f2249 100755 --- a/pages_build.sh +++ b/pages_build.sh @@ -1,14 +1,7 @@ #!/usr/bin/env bash -yarn intl:build -yarn build-web +mkdir ./bin +curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to ./bin --tag 1.40.0 +export PATH="$PATH:$(pwd)/.bin" -# build system outputs some srcs and hrefs like src="static/" -# need to rewrite to be src="/static/" to handle non root pages -sed -i 's/\(src\|href\)="static/\1="\/static/g' web-build/index.html - -# we need to copy the static iframe html to support youtube embeds -cp -r bskyweb/static/iframe/ web-build/iframe - -# copy our static pages over! -cp -r deer-static-about web-build/about +./bin/just dist-build-web diff --git a/web/index.html b/web/index.html index e23994088..d65041773 100644 --- a/web/index.html +++ b/web/index.html @@ -2,7 +2,7 @@ - + - +