Bluesky app fork with some witchin' additions 馃挮
witchsky.app
bluesky
fork
client
1import {AtUri} from '@atproto/api'
2import psl from 'psl'
3import TLDs from 'tlds'
4
5import {BSKY_SERVICE} from '#/lib/constants'
6import {isInvalidHandle} from '#/lib/strings/handles'
7import {startUriToStarterPackUri} from '#/lib/strings/starter-pack'
8import {logger} from '#/logger'
9
10export const BSKY_APP_HOST = 'https://witchsky.app'
11const BSKY_TRUSTED_HOSTS = [
12 'witchsky\\.app',
13 'witchsky\\.social',
14 'bsky\\.app',
15 'bsky\\.social',
16 'blueskyweb\\.xyz',
17 'blueskyweb\\.zendesk\\.com',
18 ...(__DEV__ ? ['localhost:19006', 'localhost:8100'] : []),
19]
20
21/*
22 * This will allow any BSKY_TRUSTED_HOSTS value by itself or with a subdomain.
23 * It will also allow relative paths like /profile as well as #.
24 */
25const TRUSTED_REGEX = new RegExp(
26 `^(http(s)?://(([\\w-]+\\.)?${BSKY_TRUSTED_HOSTS.join(
27 '|([\\w-]+\\.)?',
28 )})|/|#)`,
29)
30
31export function isValidDomain(str: string): boolean {
32 return !!TLDs.find(tld => {
33 let i = str.lastIndexOf(tld)
34 if (i === -1) {
35 return false
36 }
37 return str.charAt(i - 1) === '.' && i === str.length - tld.length
38 })
39}
40
41export function makeRecordUri(
42 didOrName: string,
43 collection: string,
44 rkey: string,
45) {
46 const urip = new AtUri('at://placeholder.placeholder/')
47 // @ts-expect-error TODO new-sdk-migration
48 urip.host = didOrName
49 urip.collection = collection
50 urip.rkey = rkey
51 return urip.toString()
52}
53
54export function toNiceDomain(url: string): string {
55 try {
56 const urlp = new URL(url)
57 if (`https://${urlp.host}` === BSKY_SERVICE) {
58 return 'Bluesky Social'
59 }
60 return urlp.host ? urlp.host : url
61 } catch (e) {
62 return url
63 }
64}
65
66export function toShortUrl(url: string): string {
67 try {
68 const urlp = new URL(url)
69 if (urlp.protocol !== 'http:' && urlp.protocol !== 'https:') {
70 return url
71 }
72 const path =
73 (urlp.pathname === '/' ? '' : urlp.pathname) + urlp.search + urlp.hash
74 if (path.length > 15) {
75 return urlp.host + path.slice(0, 13) + '...'
76 }
77 return urlp.host + path
78 } catch (e) {
79 return url
80 }
81}
82
83export function toShareUrl(url: string): string {
84 if (!url.startsWith('https')) {
85 const urlp = new URL('https://witchsky.app')
86 urlp.pathname = url
87 url = urlp.toString()
88 }
89 return url
90}
91
92// separate one for bluesky, im fully aware i could just have it in one function but im more worried about code conflicts from this
93export function toShareUrlBsky(url: string): string {
94 if (!url.startsWith('https')) {
95 const urlp = new URL('https://bsky.app')
96 urlp.pathname = url
97 url = urlp.toString()
98 }
99 return url
100}
101
102export function toBskyAppUrl(url: string): string {
103 return new URL(url, BSKY_APP_HOST).toString()
104}
105
106export function isBskyAppUrl(url: string): boolean {
107 return (
108 (url.startsWith('https://witchsky.app/') &&
109 !url.startsWith('https://witchsky.app/about')) ||
110 url.startsWith('https://bsky.app/') ||
111 (url.startsWith('https://deer.social/') &&
112 !url.startsWith('https://deer.social/about'))
113 )
114}
115
116export function isRelativeUrl(url: string): boolean {
117 return /^\/[^/]/.test(url)
118}
119
120export function isBskyRSSUrl(url: string): boolean {
121 return (
122 (isBskyAppUrl(url) || isRelativeUrl(url)) &&
123 /\/rss\/?$/.test(url)
124 )
125}
126
127export function isExternalUrl(url: string): boolean {
128 const external = !isBskyAppUrl(url) && url.startsWith('http')
129 const rss = isBskyRSSUrl(url)
130 return external || rss
131}
132
133export function isTrustedUrl(url: string): boolean {
134 return TRUSTED_REGEX.test(url)
135}
136
137export function isBskyPostUrl(url: string): boolean {
138 if (isBskyAppUrl(url)) {
139 try {
140 const urlp = new URL(url)
141 return /profile\/(?<name>[^/]+)\/post\/(?<rkey>[^/]+)/i.test(
142 urlp.pathname,
143 )
144 } catch {}
145 }
146 return false
147}
148
149export function isBskyCustomFeedUrl(url: string): boolean {
150 if (isBskyAppUrl(url)) {
151 try {
152 const urlp = new URL(url)
153 return /profile\/(?<name>[^/]+)\/feed\/(?<rkey>[^/]+)/i.test(
154 urlp.pathname,
155 )
156 } catch {}
157 }
158 return false
159}
160
161export function isBskyListUrl(url: string): boolean {
162 if (isBskyAppUrl(url)) {
163 try {
164 const urlp = new URL(url)
165 return /profile\/(?<name>[^/]+)\/lists\/(?<rkey>[^/]+)/i.test(
166 urlp.pathname,
167 )
168 } catch {
169 console.error('Unexpected error in isBskyListUrl()', url)
170 }
171 }
172 return false
173}
174
175export function isBskyStartUrl(url: string): boolean {
176 if (isBskyAppUrl(url)) {
177 try {
178 const urlp = new URL(url)
179 return /start\/(?<name>[^/]+)\/(?<rkey>[^/]+)/i.test(urlp.pathname)
180 } catch {
181 console.error('Unexpected error in isBskyStartUrl()', url)
182 }
183 }
184 return false
185}
186
187export function isBskyStarterPackUrl(url: string): boolean {
188 if (isBskyAppUrl(url)) {
189 try {
190 const urlp = new URL(url)
191 return /starter-pack\/(?<name>[^/]+)\/(?<rkey>[^/]+)/i.test(urlp.pathname)
192 } catch {
193 console.error('Unexpected error in isBskyStartUrl()', url)
194 }
195 }
196 return false
197}
198
199export function isBskyDownloadUrl(url: string): boolean {
200 if (isExternalUrl(url)) {
201 return false
202 }
203 return url === '/download' || url.startsWith('/download?')
204}
205
206export function convertBskyAppUrlIfNeeded(url: string): string {
207 if (isBskyAppUrl(url)) {
208 try {
209 const urlp = new URL(url)
210
211 if (isBskyStartUrl(url)) {
212 return startUriToStarterPackUri(urlp.pathname)
213 }
214
215 return urlp.pathname + urlp.search
216 } catch (e) {
217 console.error('Unexpected error in convertBskyAppUrlIfNeeded()', e)
218 }
219 } else if (isShortLink(url)) {
220 // We only want to do this on native, web handles the 301 for us
221 return shortLinkToHref(url)
222 }
223 return url
224}
225
226export function listUriToHref(url: string): string {
227 try {
228 const {hostname, rkey} = new AtUri(url)
229 return `/profile/${hostname}/lists/${rkey}`
230 } catch {
231 return ''
232 }
233}
234
235export function feedUriToHref(url: string): string {
236 try {
237 const {hostname, rkey} = new AtUri(url)
238 return `/profile/${hostname}/feed/${rkey}`
239 } catch {
240 return ''
241 }
242}
243
244export function postUriToRelativePath(
245 uri: string,
246 options?: {handle?: string},
247): string | undefined {
248 try {
249 const {hostname, rkey} = new AtUri(uri)
250 const handleOrDid =
251 options?.handle && !isInvalidHandle(options.handle)
252 ? options.handle
253 : hostname
254 return `/profile/${handleOrDid}/post/${rkey}`
255 } catch {
256 return undefined
257 }
258}
259
260/**
261 * Checks if the label in the post text matches the host of the link facet.
262 *
263 * Hosts are case-insensitive, so should be lowercase for comparison.
264 * @see https://www.rfc-editor.org/rfc/rfc3986#section-3.2.2
265 */
266export function linkRequiresWarning(uri: string, label: string) {
267 const labelDomain = labelToDomain(label)
268
269 // We should trust any relative URL or a # since we know it links to internal content
270 if (isRelativeUrl(uri) || uri === '#') {
271 return false
272 }
273
274 let urip
275 try {
276 urip = new URL(uri)
277 } catch {
278 return true
279 }
280
281 const host = urip.hostname.toLowerCase()
282 if (isTrustedUrl(uri)) {
283 // if this is a link to internal content, warn if it represents itself as a URL to another app
284 return !!labelDomain && labelDomain !== host && isPossiblyAUrl(labelDomain)
285 } else {
286 // if this is a link to external content, warn if the label doesnt match the target
287 if (!labelDomain) {
288 return true
289 }
290 return labelDomain !== host
291 }
292}
293
294/**
295 * Returns a lowercase domain hostname if the label is a valid URL.
296 *
297 * Hosts are case-insensitive, so should be lowercase for comparison.
298 * @see https://www.rfc-editor.org/rfc/rfc3986#section-3.2.2
299 */
300export function labelToDomain(label: string): string | undefined {
301 // any spaces just immediately consider the label a non-url
302 if (/\s/.test(label)) {
303 return undefined
304 }
305 try {
306 return new URL(label).hostname.toLowerCase()
307 } catch {}
308 try {
309 return new URL('https://' + label).hostname.toLowerCase()
310 } catch {}
311 return undefined
312}
313
314export function isPossiblyAUrl(str: string): boolean {
315 str = str.trim()
316 if (str.startsWith('http://')) {
317 return true
318 }
319 if (str.startsWith('https://')) {
320 return true
321 }
322 const [firstWord] = str.split(/[\s/]/)
323 return isValidDomain(firstWord)
324}
325
326export function splitApexDomain(hostname: string): [string, string] {
327 const hostnamep = psl.parse(hostname)
328 if (hostnamep.error || !hostnamep.listed || !hostnamep.domain) {
329 return ['', hostname]
330 }
331 return [
332 hostnamep.subdomain ? `${hostnamep.subdomain}.` : '',
333 hostnamep.domain,
334 ]
335}
336
337export function createBskyAppAbsoluteUrl(path: string): string {
338 const sanitizedPath = path.replace(BSKY_APP_HOST, '').replace(/^\/+/, '')
339 return `${BSKY_APP_HOST.replace(/\/$/, '')}/${sanitizedPath}`
340}
341
342export function createProxiedUrl(url: string): string {
343 let u
344 try {
345 u = new URL(url)
346 } catch {
347 return url
348 }
349
350 if (u?.protocol !== 'http:' && u?.protocol !== 'https:') {
351 return url
352 }
353
354 return `https://go.bsky.app/redirect?u=${encodeURIComponent(url)}`
355}
356
357export function isShortLink(url: string): boolean {
358 return url.startsWith('https://go.bsky.app/')
359}
360
361export function shortLinkToHref(url: string): string {
362 try {
363 const urlp = new URL(url)
364
365 // For now we only support starter packs, but in the future we should add additional paths to this check
366 const parts = urlp.pathname.split('/').filter(Boolean)
367 if (parts.length === 1) {
368 return `/starter-pack-short/${parts[0]}`
369 }
370 return url
371 } catch (e) {
372 logger.error('Failed to parse possible short link', {safeMessage: e})
373 return url
374 }
375}
376
377export function getHostnameFromUrl(url: string | URL): string | null {
378 let urlp
379 try {
380 urlp = new URL(url)
381 } catch (e) {
382 return null
383 }
384 return urlp.hostname
385}
386
387export function getServiceAuthAudFromUrl(url: string | URL): string | null {
388 const hostname = getHostnameFromUrl(url)
389 if (!hostname) {
390 return null
391 }
392 return `did:web:${hostname}`
393}
394
395// passes URL.parse, and has a TLD etc
396export function definitelyUrl(maybeUrl: string) {
397 try {
398 if (maybeUrl.endsWith('.')) return null
399
400 // Prepend 'https://' if the input doesn't start with a protocol
401 if (!maybeUrl.startsWith('https://') && !maybeUrl.startsWith('http://')) {
402 maybeUrl = 'https://' + maybeUrl
403 }
404
405 const url = new URL(maybeUrl)
406
407 // Extract the hostname and split it into labels
408 const hostname = url.hostname
409 const labels = hostname.split('.')
410
411 // Ensure there are at least two labels (e.g., 'example' and 'com')
412 if (labels.length < 2) return null
413
414 const tld = labels[labels.length - 1]
415
416 // Check that the TLD is at least two characters long and contains only letters
417 if (!/^[a-z]{2,}$/i.test(tld)) return null
418
419 return url.toString()
420 } catch {
421 return null
422 }
423}