Bluesky app fork with some witchin' additions 💫

add opengraph (#38)

authored by Aviva and committed by GitHub 6118729c 618d3b35

Changed files
+524 -62
functions
profile
[handleOrDID]
post
web
+2 -1
.gitignore
··· 119 119 # ogcard assets 120 120 bskyogcard/src/assets/fonts/noto-* 121 121 122 - # direnv 122 + # deer 123 123 .direnv 124 + .wrangler
+58 -1
flake.lock
··· 41 41 "type": "github" 42 42 } 43 43 }, 44 + "flake-parts": { 45 + "inputs": { 46 + "nixpkgs-lib": [ 47 + "wrangler-flake", 48 + "nixpkgs" 49 + ] 50 + }, 51 + "locked": { 52 + "lastModified": 1743550720, 53 + "narHash": "sha256-hIshGgKZCgWh6AYJpJmRgFdR3WUbkY04o82X05xqQiY=", 54 + "owner": "hercules-ci", 55 + "repo": "flake-parts", 56 + "rev": "c621e8422220273271f52058f618c94e405bb0f5", 57 + "type": "github" 58 + }, 59 + "original": { 60 + "owner": "hercules-ci", 61 + "repo": "flake-parts", 62 + "type": "github" 63 + } 64 + }, 44 65 "flake-utils": { 45 66 "inputs": { 46 67 "systems": "systems" ··· 109 130 "type": "github" 110 131 } 111 132 }, 133 + "nixpkgs_3": { 134 + "locked": { 135 + "lastModified": 1744904290, 136 + "narHash": "sha256-ewg0m4mGwl3iO4aN73yZoT8lCgEHtapP3d/trfUE6To=", 137 + "owner": "nixos", 138 + "repo": "nixpkgs", 139 + "rev": "e03df76c3a8ac2119f45ff18c9b994513dbb7a4c", 140 + "type": "github" 141 + }, 142 + "original": { 143 + "owner": "nixos", 144 + "repo": "nixpkgs", 145 + "rev": "e03df76c3a8ac2119f45ff18c9b994513dbb7a4c", 146 + "type": "github" 147 + } 148 + }, 112 149 "root": { 113 150 "inputs": { 114 151 "android-nixpkgs": "android-nixpkgs", 115 152 "flake-utils": "flake-utils_2", 116 - "nixpkgs": "nixpkgs_2" 153 + "nixpkgs": "nixpkgs_2", 154 + "wrangler-flake": "wrangler-flake" 117 155 } 118 156 }, 119 157 "systems": { ··· 143 181 "original": { 144 182 "owner": "nix-systems", 145 183 "repo": "default", 184 + "type": "github" 185 + } 186 + }, 187 + "wrangler-flake": { 188 + "inputs": { 189 + "flake-parts": "flake-parts", 190 + "nixpkgs": "nixpkgs_3" 191 + }, 192 + "locked": { 193 + "lastModified": 1745836852, 194 + "narHash": "sha256-4rlqhVU89ypXQTWpJchMdocHNSZBVTUehiNWnAy0zJ0=", 195 + "owner": "ryand56", 196 + "repo": "wrangler", 197 + "rev": "070db974683ef1f8e95dadef549f223381ee8544", 198 + "type": "github" 199 + }, 200 + "original": { 201 + "owner": "ryand56", 202 + "repo": "wrangler", 146 203 "type": "github" 147 204 } 148 205 }
+6
flake.nix
··· 3 3 nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 4 4 flake-utils.url = "github:numtide/flake-utils"; 5 5 android-nixpkgs.url = "github:tadfisher/android-nixpkgs"; 6 + wrangler-flake.url = "github:ryand56/wrangler"; 6 7 }; 7 8 8 9 outputs = 9 10 { 10 11 nixpkgs, 11 12 flake-utils, 13 + wrangler-flake, 12 14 android-nixpkgs, 13 15 ... 14 16 }: ··· 78 80 typescript 79 81 typescript-language-server 80 82 83 + go 84 + gopls 85 + 86 + wrangler-flake.packages.${system}.wrangler 81 87 ]; 82 88 83 89 shellHook = ''
+157
functions/profile/[handleOrDID].ts
··· 1 + import {AtpAgent} from '@atproto/api' 2 + 3 + import {type AnyProfileView} from '#/types/bsky/profile' 4 + 5 + type PResp = Awaited<ReturnType<AtpAgent['getProfile']>> 6 + 7 + // based on https://github.com/Janpot/escape-html-template-tag/blob/master/src/index.ts 8 + 9 + const ENTITIES: { 10 + [key: string]: string 11 + } = { 12 + '&': '&amp;', 13 + '<': '&lt;', 14 + '>': '&gt;', 15 + '"': '&quot;', 16 + "'": '&#39;', 17 + '/': '&#x2F;', 18 + '`': '&#x60;', 19 + '=': '&#x3D;', 20 + } 21 + 22 + const ENT_REGEX = new RegExp(Object.keys(ENTITIES).join('|'), 'g') 23 + 24 + function escapehtml(unsafe: Sub): string { 25 + if (Array.isArray(unsafe)) { 26 + return unsafe.map(escapehtml).join('') 27 + } 28 + if (unsafe instanceof HtmlSafeString) { 29 + return unsafe.toString() 30 + } 31 + return String(unsafe).replace(ENT_REGEX, char => ENTITIES[char]) 32 + } 33 + 34 + type Sub = HtmlSafeString | string | (HtmlSafeString | string)[] 35 + 36 + export class HtmlSafeString { 37 + private _parts: readonly string[] 38 + private _subs: readonly Sub[] 39 + constructor(parts: readonly string[], subs: readonly Sub[]) { 40 + this._parts = parts 41 + this._subs = subs 42 + } 43 + 44 + toString(): string { 45 + let result = this._parts[0] 46 + for (let i = 1; i < this._parts.length; i++) { 47 + result += escapehtml(this._subs[i - 1]) + this._parts[i] 48 + } 49 + return result 50 + } 51 + } 52 + 53 + export function html(parts: TemplateStringsArray, ...subs: Sub[]) { 54 + return new HtmlSafeString(parts, subs) 55 + } 56 + 57 + export const renderHandleString = (profile: AnyProfileView) => 58 + profile.displayName 59 + ? `${profile.displayName} (@${profile.handle})` 60 + : `@${profile.handle}` 61 + 62 + class HeadHandler { 63 + profile: PResp 64 + url: string 65 + constructor(profile: PResp, url: string) { 66 + this.profile = profile 67 + this.url = url 68 + } 69 + async element(element) { 70 + const view = this.profile.data 71 + 72 + const description = view.description 73 + ? html` 74 + <meta name="description" content="${view.description}" /> 75 + <meta property="og:description" content="${view.description}" /> 76 + ` 77 + : '' 78 + const img = view.banner 79 + ? html` 80 + <meta property="og:image" content="${view.banner}" /> 81 + <meta name="twitter:card" content="summary_large_image" /> 82 + ` 83 + : view.avatar 84 + ? html`<meta name="twitter:card" content="summary" />` 85 + : '' 86 + element.append( 87 + html` 88 + <meta property="og:site_name" content="deer.social" /> 89 + <meta property="og:type" content="profile" /> 90 + <meta property="profile:username" content="${view.handle}" /> 91 + <meta property="og:url" content="${this.url}" /> 92 + <meta property="og:title" content="${renderHandleString(view)}" /> 93 + ${description} ${img} 94 + <meta name="twitter:label1" content="Account DID" /> 95 + <meta name="twitter:value1" content="${view.did}" /> 96 + <link 97 + rel="alternate" 98 + href="at://${view.did}/app.bsky.actor.profile/self" /> 99 + `, 100 + {html: true}, 101 + ) 102 + } 103 + } 104 + 105 + class TitleHandler { 106 + profile: PResp 107 + constructor(profile: PResp) { 108 + this.profile = profile 109 + } 110 + async element(element) { 111 + element.setInnerContent(renderHandleString(this.profile.data)) 112 + } 113 + } 114 + 115 + class NoscriptHandler { 116 + profile: PResp 117 + constructor(profile: PResp) { 118 + this.profile = profile 119 + } 120 + async element(element) { 121 + const view = this.profile.data 122 + 123 + element.append( 124 + html` 125 + <div id="bsky_profile_summary"> 126 + <h3>Profile</h3> 127 + <p id="bsky_display_name">${view.displayName ?? ''}</p> 128 + <p id="bsky_handle">${view.handle}</p> 129 + <p id="bsky_did">${view.did}</p> 130 + <p id="bsky_profile_description">${view.description ?? ''}</p> 131 + </div> 132 + `, 133 + {html: true}, 134 + ) 135 + } 136 + } 137 + 138 + export async function onRequest(context) { 139 + const agent = new AtpAgent({service: 'https://public.api.bsky.app/'}) 140 + const {request, env} = context 141 + const origin = new URL(request.url).origin 142 + 143 + const base = env.ASSETS.fetch(new URL('/', origin)) 144 + try { 145 + const profile = await agent.getProfile({ 146 + actor: context.params.handleOrDID, 147 + }) 148 + return new HTMLRewriter() 149 + .on(`head`, new HeadHandler(profile, request.url)) 150 + .on(`title`, new TitleHandler(profile)) 151 + .on(`noscript`, new NoscriptHandler(profile)) 152 + .transform(await base) 153 + } catch (e) { 154 + console.error(e) 155 + return await base 156 + } 157 + }
+227
functions/profile/[handleOrDID]/post/[rkey].ts
··· 1 + import { 2 + AppBskyEmbedExternal, 3 + AppBskyEmbedImages, 4 + AppBskyEmbedRecord, 5 + AppBskyEmbedRecordWithMedia, 6 + AppBskyFeedDefs, 7 + AtpAgent, 8 + type Facet, 9 + RichText, 10 + } from '@atproto/api' 11 + import {isViewRecord} from '@atproto/api/dist/client/types/app/bsky/embed/record' 12 + import {isThreadViewPost} from '@atproto/api/dist/client/types/app/bsky/feed/defs' 13 + 14 + import {html, renderHandleString} from '../../[handleOrDID].ts' 15 + 16 + type Thread = AppBskyFeedDefs.ThreadViewPost 17 + 18 + export function expandPostTextRich( 19 + postView: AppBskyFeedDefs.ThreadViewPost, 20 + ): string { 21 + if ( 22 + !postView.post || 23 + AppBskyFeedDefs.isNotFoundPost(postView) || 24 + AppBskyFeedDefs.isBlockedPost(postView) 25 + ) { 26 + return '' 27 + } 28 + 29 + const post = postView.post 30 + const record = post.record 31 + const embed = post.embed 32 + const originalText = typeof record?.text === 'string' ? record.text : '' 33 + const facets = record?.facets as [Facet] | undefined 34 + 35 + let expandedText = originalText 36 + 37 + // Use RichText to process facets if they exist 38 + if (originalText && facets && facets.length > 0) { 39 + try { 40 + const rt = new RichText({text: originalText, facets}) 41 + const modifiedSegmentsText: string[] = [] 42 + 43 + for (const segment of rt.segments()) { 44 + const link = segment.link 45 + if ( 46 + link && 47 + segment.text.endsWith('...') && 48 + link.uri.includes(segment.text.slice(0, -3)) 49 + ) { 50 + // Replace shortened text with full URI 51 + modifiedSegmentsText.push(link.uri) 52 + } else { 53 + // Keep original segment text 54 + modifiedSegmentsText.push(segment.text) 55 + } 56 + } 57 + expandedText = modifiedSegmentsText.join('') 58 + } catch (error) { 59 + console.error('Error processing RichText segments:', error) 60 + // Fallback to original text on error 61 + expandedText = originalText 62 + } 63 + } 64 + 65 + // Append external link URL if present and not already in text 66 + if (AppBskyEmbedExternal.isView(embed) && embed.external?.uri) { 67 + const externalUri = embed.external.uri 68 + if (!expandedText.includes(externalUri)) { 69 + expandedText = expandedText 70 + ? `${expandedText}\n${externalUri}` 71 + : externalUri 72 + } 73 + } 74 + 75 + // Append placeholder for quote posts or other record embeds 76 + if ( 77 + AppBskyEmbedRecord.isView(embed) || 78 + AppBskyEmbedRecordWithMedia.isView(embed) 79 + ) { 80 + // no idea why this is needed lol 81 + const record = embed.record.record ?? embed.record 82 + if (isViewRecord(record)) { 83 + const quote = `↘️ quoting ${renderHandleString(record.author)}:\n\n${ 84 + record.value.text 85 + }` 86 + expandedText = expandedText ? `${expandedText}\n\n${quote}` : quote 87 + } else { 88 + const placeholder = '[quote/embed]' 89 + if (!expandedText.includes(placeholder)) { 90 + expandedText = expandedText 91 + ? `${expandedText}\n\n${placeholder}` 92 + : placeholder 93 + } 94 + } 95 + } 96 + 97 + // prepend reply header 98 + if (isThreadViewPost(postView.parent)) { 99 + const header = `↩️ reply to ${renderHandleString( 100 + postView.parent.post.author, 101 + )}:` 102 + expandedText = expandedText ? `${header}\n\n${expandedText}` : header 103 + } 104 + 105 + return expandedText 106 + } 107 + 108 + class HeadHandler { 109 + thread: Thread 110 + url: string 111 + postTextString: string 112 + constructor(thread: Thread, url: string, postTextString: string) { 113 + this.thread = thread 114 + this.url = url 115 + this.postTextString = postTextString 116 + } 117 + async element(element) { 118 + const author = this.thread.post.author 119 + 120 + const postText = 121 + this.postTextString.length > 0 122 + ? html` 123 + <meta name="description" content="${this.postTextString}" /> 124 + <meta property="og:description" content="${this.postTextString}" /> 125 + ` 126 + : '' 127 + 128 + const embed = this.thread.post.embed 129 + 130 + const embedElems = !embed 131 + ? '' 132 + : AppBskyEmbedImages.isView(embed) 133 + ? html`${embed.images.map( 134 + i => html`<meta property="og:image" content="${i.thumb}" />`, 135 + )} 136 + <meta name="twitter:card" content="summary_large_image" /> ` 137 + : // TODO: in the future, embed videos 138 + 'thumbnail' in embed && embed.thumbnail 139 + ? html` 140 + <meta property="og:image" content="${embed.thumbnail}" /> 141 + <meta name="twitter:card" content="summary_large_image" /> 142 + ` 143 + : html`<meta name="twitter:card" content="summary" />` 144 + 145 + element.append( 146 + html` 147 + <meta property="og:site_name" content="deer.social" /> 148 + <meta property="og:type" content="article" /> 149 + <meta property="profile:username" content="${author.handle}" /> 150 + <meta property="og:url" content="${this.url}" /> 151 + <meta property="og:title" content="${renderHandleString(author)}" /> 152 + ${postText} ${embedElems} 153 + <meta name="twitter:label1" content="Account DID" /> 154 + <meta name="twitter:value1" content="${author.did}" /> 155 + <meta 156 + name="article:published_time" 157 + content="${this.thread.post.indexedAt}" /> 158 + `, 159 + {html: true}, 160 + ) 161 + } 162 + } 163 + 164 + class TitleHandler { 165 + thread: Thread 166 + constructor(thread: Thread) { 167 + this.thread = thread 168 + } 169 + async element(element) { 170 + element.setInnerContent(renderHandleString(this.thread.post.author)) 171 + } 172 + } 173 + 174 + class NoscriptHandler { 175 + thread: Thread 176 + postTextString: string 177 + constructor(thread: Thread, postTextString: string) { 178 + this.thread = thread 179 + this.postTextString = postTextString 180 + } 181 + async element(element) { 182 + element.append( 183 + html` 184 + <div id="bsky_post_summary"> 185 + <h3>Post</h3> 186 + <p id="bsky_display_name"> 187 + ${this.thread.post.author.displayName ?? ''} 188 + </p> 189 + <p id="bsky_handle">${this.thread.post.author.handle}</p> 190 + <p id="bsky_did">${this.thread.post.author.did}</p> 191 + <p id="bsky_post_text">${this.postTextString}</p> 192 + <p id="bsky_post_indexedat">${this.thread.post.indexedAt}</p> 193 + </div> 194 + `, 195 + {html: true}, 196 + ) 197 + } 198 + } 199 + 200 + export async function onRequest(context) { 201 + const agent = new AtpAgent({service: 'https://public.api.bsky.app/'}) 202 + const {request, env} = context 203 + const origin = new URL(request.url).origin 204 + const {handleOrDID, rkey}: {handleOrDID: string; rkey: string} = 205 + context.params 206 + 207 + const base = env.ASSETS.fetch(new URL('/', origin)) 208 + try { 209 + const {data} = await agent.getPostThread({ 210 + uri: `at://${handleOrDID}/app.bsky.feed.post/${rkey}`, 211 + depth: 1, 212 + parentHeight: 1, 213 + }) 214 + if (!AppBskyFeedDefs.isThreadViewPost(data.thread)) { 215 + throw new Error('Expected a ThreadViewPost') 216 + } 217 + const postTextString = expandPostTextRich(data.thread) 218 + return new HTMLRewriter() 219 + .on(`head`, new HeadHandler(data.thread, request.url, postTextString)) 220 + .on(`title`, new TitleHandler(data.thread)) 221 + .on(`noscript`, new NoscriptHandler(data.thread, postTextString)) 222 + .transform(await base) 223 + } catch (e) { 224 + console.error(e) 225 + return await base 226 + } 227 + }
+54
justfile
··· 1 + export PATH := "./node_modules/.bin:" + env_var('PATH') 2 + 3 + # lots of just -> yarn, but this lets us chain yarn command deps 4 + 5 + [group('dist')] 6 + dist-build-web: intl build-web 7 + 8 + [group('dist')] 9 + dist-build-android-sideload: intl build-android-sideload 10 + 11 + [group('build')] 12 + intl: 13 + yarn intl:build 14 + 15 + [group('build')] 16 + prebuild-android: 17 + expo prebuild -p android 18 + 19 + [group('build')] 20 + build-web: && postbuild-web 21 + yarn build-web 22 + 23 + [group('build')] 24 + build-android-sideload: prebuild-android 25 + eas build --local --platform android --profile sideload-android 26 + 27 + [group('build')] 28 + postbuild-web: 29 + # build system outputs some srcs and hrefs like src="static/" 30 + # need to rewrite to be src="/static/" to handle non root pages 31 + sed -i 's/\(src\|href\)="static/\1="\/static/g' web-build/index.html 32 + 33 + # we need to copy the static iframe html to support youtube embeds 34 + cp -r bskyweb/static/iframe/ web-build/iframe 35 + 36 + # copy our static pages over! 37 + cp -r deer-static-about web-build/about 38 + 39 + [group('dev')] 40 + dev-android-setup: prebuild-android 41 + yarn android 42 + 43 + [group('dev')] 44 + dev-web: 45 + yarn web 46 + 47 + [group('dev')] 48 + dev-web-functions: build-web 49 + wrangler pages dev ./web-build 50 + 51 + [group('lint')] 52 + typecheck: 53 + yarn typecheck 54 +
+4 -11
pages_build.sh
··· 1 1 #!/usr/bin/env bash 2 2 3 - yarn intl:build 4 - yarn build-web 3 + mkdir ./bin 4 + curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to ./bin --tag 1.40.0 5 + export PATH="$PATH:$(pwd)/.bin" 5 6 6 - # build system outputs some srcs and hrefs like src="static/" 7 - # need to rewrite to be src="/static/" to handle non root pages 8 - sed -i 's/\(src\|href\)="static/\1="\/static/g' web-build/index.html 9 - 10 - # we need to copy the static iframe html to support youtube embeds 11 - cp -r bskyweb/static/iframe/ web-build/iframe 12 - 13 - # copy our static pages over! 14 - cp -r deer-static-about web-build/about 7 + ./bin/just dist-build-web
+15 -49
web/index.html
··· 2 2 <html> 3 3 <head> 4 4 <meta charset="utf-8"> 5 - <meta name="theme-color"> 5 + <meta name="theme-color" content="#4b9b6c"> 6 6 <!-- 7 7 This viewport works for phones with notches. 8 8 It's optimized for gestures by disabling global zoom. ··· 92 92 </head> 93 93 94 94 <body> 95 - <!-- 96 - A generic no script element with a reload button and a message. 97 - Feel free to customize this however you'd like. 98 - --> 99 - <noscript> 100 - <form 101 - action="" 102 - style=" 103 - background-color: #fff; 104 - position: fixed; 105 - top: 0; 106 - left: 0; 107 - right: 0; 108 - bottom: 0; 109 - z-index: 9999; 110 - " 111 - > 112 - <div 113 - style=" 114 - font-size: 18px; 115 - font-family: Helvetica, sans-serif; 116 - line-height: 24px; 117 - margin: 10%; 118 - width: 80%; 119 - " 120 - > 121 - <p lang="en">Oh no! It looks like JavaScript is not enabled in your browser.</p> 122 - <p lang="en" style="margin: 20px 0;"> 123 - <button 124 - type="submit" 125 - style=" 126 - background-color: #4630eb; 127 - border-radius: 100px; 128 - border: none; 129 - box-shadow: none; 130 - color: #fff; 131 - cursor: pointer; 132 - font-weight: bold; 133 - line-height: 20px; 134 - padding: 6px 16px; 135 - " 136 - > 137 - Reload 138 - </button> 139 - </p> 140 - </div> 141 - </form> 142 - </noscript> 95 + <noscript style=" 96 + background-color: #fff; 97 + position: fixed; 98 + top: 0; 99 + left: 0; 100 + right: 0; 101 + bottom: 0; 102 + z-index: 9999; 103 + margin: 1em; 104 + "> 105 + <h1 lang="en">JavaScript Required</h1> 106 + <p lang="en">This is a heavily interactive web application, and JavaScript is required. Simple HTML interfaces are possible, but that is not what this is. 107 + <p lang="en">Learn more about Bluesky at <a href="https://bsky.social">bsky.social</a> and <a href="https://atproto.com">atproto.com</a>, or this fork at <a href="https://github.com/a-viv-a/deer-social">github.com/a-viv-a/deer.social</a>. 108 + </noscript> 143 109 144 110 <!-- The root element for your Expo app. --> 145 111 <div id="root">
+1
wrangler.toml
··· 1 + compatibility_date = "2025-04-16"