Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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