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

Configure Feed

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

at cope-settings-sync 423 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) 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}