Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
119
fork

Configure Feed

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

at a876aae44ea07494ebea9727350aa060b81f317b 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}