+2
-1
.gitignore
+2
-1
.gitignore
+58
-1
flake.lock
+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
+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
+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
+
'&': '&',
13
+
'<': '<',
14
+
'>': '>',
15
+
'"': '"',
16
+
"'": ''',
17
+
'/': '/',
18
+
'`': '`',
19
+
'=': '=',
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
+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
+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
+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
+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
wrangler.toml
···
1
+
compatibility_date = "2025-04-16"