about summary refs log tree commit diff
path: root/src/lib/strings
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib/strings')
-rw-r--r--src/lib/strings/display-names.ts3
-rw-r--r--src/lib/strings/embed-player.ts41
-rw-r--r--src/lib/strings/handles.ts29
-rw-r--r--src/lib/strings/helpers.ts21
-rw-r--r--src/lib/strings/time.ts2
-rw-r--r--src/lib/strings/url-helpers.ts70
6 files changed, 128 insertions, 38 deletions
diff --git a/src/lib/strings/display-names.ts b/src/lib/strings/display-names.ts
index 75383dd4f..e0f23fa2c 100644
--- a/src/lib/strings/display-names.ts
+++ b/src/lib/strings/display-names.ts
@@ -1,5 +1,4 @@
 import {ModerationUI} from '@atproto/api'
-import {describeModerationCause} from '../moderation'
 
 // \u2705 = ✅
 // \u2713 = ✓
@@ -14,7 +13,7 @@ export function sanitizeDisplayName(
   moderation?: ModerationUI,
 ): string {
   if (moderation?.blur) {
-    return `⚠${describeModerationCause(moderation.cause, 'account').name}`
+    return ''
   }
   if (typeof str === 'string') {
     return str.replace(CHECK_MARKS_RE, '').replace(CONTROL_CHARS_RE, '').trim()
diff --git a/src/lib/strings/embed-player.ts b/src/lib/strings/embed-player.ts
index 21a575b91..ee7328478 100644
--- a/src/lib/strings/embed-player.ts
+++ b/src/lib/strings/embed-player.ts
@@ -2,6 +2,15 @@ import {Dimensions} from 'react-native'
 import {isWeb} from 'platform/detection'
 const {height: SCREEN_HEIGHT} = Dimensions.get('window')
 
+const IFRAME_HOST = isWeb
+  ? // @ts-ignore only for web
+    window.location.host === 'localhost:8100'
+    ? 'http://localhost:8100'
+    : 'https://bsky.app'
+  : __DEV__ && !process.env.JEST_WORKER_ID
+  ? 'http://localhost:8100'
+  : 'https://bsky.app'
+
 export const embedPlayerSources = [
   'youtube',
   'youtubeShorts',
@@ -74,7 +83,7 @@ export function parseEmbedPlayerFromUrl(
       return {
         type: 'youtube_video',
         source: 'youtube',
-        playerUri: `https://bsky.app/iframe/youtube.html?videoId=${videoId}&start=${seek}`,
+        playerUri: `${IFRAME_HOST}/iframe/youtube.html?videoId=${videoId}&start=${seek}`,
       }
     }
   }
@@ -93,7 +102,7 @@ export function parseEmbedPlayerFromUrl(
         type: page === 'shorts' ? 'youtube_short' : 'youtube_video',
         source: page === 'shorts' ? 'youtubeShorts' : 'youtube',
         hideDetails: page === 'shorts' ? true : undefined,
-        playerUri: `https://bsky.app/iframe/youtube.html?videoId=${videoId}&start=${seek}`,
+        playerUri: `${IFRAME_HOST}/iframe/youtube.html?videoId=${videoId}&start=${seek}`,
       }
     }
   }
@@ -343,45 +352,45 @@ export function parseEmbedPlayerFromUrl(
   }
 }
 
-export function getPlayerHeight({
+export function getPlayerAspect({
   type,
-  width,
   hasThumb,
+  width,
 }: {
   type: EmbedPlayerParams['type']
-  width: number
   hasThumb: boolean
-}) {
-  if (!hasThumb) return (width / 16) * 9
+  width: number
+}): {aspectRatio?: number; height?: number} {
+  if (!hasThumb) return {aspectRatio: 16 / 9}
 
   switch (type) {
     case 'youtube_video':
     case 'twitch_video':
     case 'vimeo_video':
-      return (width / 16) * 9
+      return {aspectRatio: 16 / 9}
     case 'youtube_short':
       if (SCREEN_HEIGHT < 600) {
-        return ((width / 9) * 16) / 1.75
+        return {aspectRatio: (9 / 16) * 1.75}
       } else {
-        return ((width / 9) * 16) / 1.5
+        return {aspectRatio: (9 / 16) * 1.5}
       }
     case 'spotify_album':
     case 'apple_music_album':
     case 'apple_music_playlist':
     case 'spotify_playlist':
     case 'soundcloud_set':
-      return 380
+      return {height: 380}
     case 'spotify_song':
       if (width <= 300) {
-        return 155
+        return {height: 155}
       }
-      return 232
+      return {height: 232}
     case 'soundcloud_track':
-      return 165
+      return {height: 165}
     case 'apple_music_song':
-      return 150
+      return {height: 150}
     default:
-      return width
+      return {aspectRatio: 16 / 9}
   }
 }
 
diff --git a/src/lib/strings/handles.ts b/src/lib/strings/handles.ts
index 6ce462435..a18fef453 100644
--- a/src/lib/strings/handles.ts
+++ b/src/lib/strings/handles.ts
@@ -1,3 +1,8 @@
+// Regex from the go implementation
+// https://github.com/bluesky-social/indigo/blob/main/atproto/syntax/handle.go#L10
+const VALIDATE_REGEX =
+  /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/
+
 export function makeValidHandle(str: string): string {
   if (str.length > 20) {
     str = str.slice(0, 20)
@@ -19,3 +24,27 @@ export function isInvalidHandle(handle: string): boolean {
 export function sanitizeHandle(handle: string, prefix = ''): string {
   return isInvalidHandle(handle) ? '⚠Invalid Handle' : `${prefix}${handle}`
 }
+
+export interface IsValidHandle {
+  handleChars: boolean
+  frontLength: boolean
+  totalLength: boolean
+  overall: boolean
+}
+
+// More checks from https://github.com/bluesky-social/atproto/blob/main/packages/pds/src/handle/index.ts#L72
+export function validateHandle(str: string, userDomain: string): IsValidHandle {
+  const fullHandle = createFullHandle(str, userDomain)
+
+  const results = {
+    handleChars:
+      !str || (VALIDATE_REGEX.test(fullHandle) && !str.includes('.')),
+    frontLength: str.length >= 3,
+    totalLength: fullHandle.length <= 253,
+  }
+
+  return {
+    ...results,
+    overall: !Object.values(results).includes(false),
+  }
+}
diff --git a/src/lib/strings/helpers.ts b/src/lib/strings/helpers.ts
index e2abe9019..de4562d2c 100644
--- a/src/lib/strings/helpers.ts
+++ b/src/lib/strings/helpers.ts
@@ -8,10 +8,27 @@ export function pluralize(n: number, base: string, plural?: string): string {
   return base + 's'
 }
 
-export function enforceLen(str: string, len: number, ellipsis = false): string {
+export function enforceLen(
+  str: string,
+  len: number,
+  ellipsis = false,
+  mode: 'end' | 'middle' = 'end',
+): string {
   str = str || ''
   if (str.length > len) {
-    return str.slice(0, len) + (ellipsis ? '...' : '')
+    if (ellipsis) {
+      if (mode === 'end') {
+        return str.slice(0, len) + '…'
+      } else if (mode === 'middle') {
+        const half = Math.floor(len / 2)
+        return str.slice(0, half) + '…' + str.slice(-half)
+      } else {
+        // fallback
+        return str.slice(0, len)
+      }
+    } else {
+      return str.slice(0, len)
+    }
   }
   return str
 }
diff --git a/src/lib/strings/time.ts b/src/lib/strings/time.ts
index 05a60e94b..3e162af1a 100644
--- a/src/lib/strings/time.ts
+++ b/src/lib/strings/time.ts
@@ -23,7 +23,7 @@ export function ago(date: number | string | Date): string {
   } else if (diffSeconds < DAY) {
     return `${Math.floor(diffSeconds / HOUR)}h`
   } else if (diffSeconds < MONTH) {
-    return `${Math.floor(diffSeconds / DAY)}d`
+    return `${Math.round(diffSeconds / DAY)}d`
   } else if (diffSeconds < YEAR) {
     return `${Math.floor(diffSeconds / MONTH)}mo`
   } else {
diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts
index 8a71718c8..70a2b7069 100644
--- a/src/lib/strings/url-helpers.ts
+++ b/src/lib/strings/url-helpers.ts
@@ -1,8 +1,27 @@
 import {AtUri} from '@atproto/api'
-import {PROD_SERVICE} from 'lib/constants'
+import {BSKY_SERVICE} from 'lib/constants'
 import TLDs from 'tlds'
 import psl from 'psl'
 
+export const BSKY_APP_HOST = 'https://bsky.app'
+const BSKY_TRUSTED_HOSTS = [
+  'bsky.app',
+  'bsky.social',
+  'blueskyweb.xyz',
+  'blueskyweb.zendesk.com',
+  ...(__DEV__ ? ['localhost:19006', 'localhost:8100'] : []),
+]
+
+/*
+ * This will allow any BSKY_TRUSTED_HOSTS value by itself or with a subdomain.
+ * It will also allow relative paths like /profile as well as #.
+ */
+const TRUSTED_REGEX = new RegExp(
+  `^(http(s)?://(([\\w-]+\\.)?${BSKY_TRUSTED_HOSTS.join(
+    '|([\\w-]+\\.)?',
+  )})|/|#)`,
+)
+
 export function isValidDomain(str: string): boolean {
   return !!TLDs.find(tld => {
     let i = str.lastIndexOf(tld)
@@ -28,7 +47,7 @@ export function makeRecordUri(
 export function toNiceDomain(url: string): string {
   try {
     const urlp = new URL(url)
-    if (`https://${urlp.host}` === PROD_SERVICE) {
+    if (`https://${urlp.host}` === BSKY_SERVICE) {
       return 'Bluesky Social'
     }
     return urlp.host ? urlp.host : url
@@ -67,8 +86,25 @@ export function isBskyAppUrl(url: string): boolean {
   return url.startsWith('https://bsky.app/')
 }
 
+export function isRelativeUrl(url: string): boolean {
+  return /^\/[^/]/.test(url)
+}
+
+export function isBskyRSSUrl(url: string): boolean {
+  return (
+    (url.startsWith('https://bsky.app/') || isRelativeUrl(url)) &&
+    /\/rss\/?$/.test(url)
+  )
+}
+
 export function isExternalUrl(url: string): boolean {
-  return !isBskyAppUrl(url) && url.startsWith('http')
+  const external = !isBskyAppUrl(url) && url.startsWith('http')
+  const rss = isBskyRSSUrl(url)
+  return external || rss
+}
+
+export function isTrustedUrl(url: string): boolean {
+  return TRUSTED_REGEX.test(url)
 }
 
 export function isBskyPostUrl(url: string): boolean {
@@ -148,6 +184,11 @@ export function feedUriToHref(url: string): string {
 export function linkRequiresWarning(uri: string, label: string) {
   const labelDomain = labelToDomain(label)
 
+  // We should trust any relative URL or a # since we know it links to internal content
+  if (isRelativeUrl(uri) || uri === '#') {
+    return false
+  }
+
   let urip
   try {
     urip = new URL(uri)
@@ -156,21 +197,11 @@ export function linkRequiresWarning(uri: string, label: string) {
   }
 
   const host = urip.hostname.toLowerCase()
-
-  if (host === 'bsky.app') {
-    // if this is a link to internal content,
-    // warn if it represents itself as a URL to another app
-    if (
-      labelDomain &&
-      labelDomain !== 'bsky.app' &&
-      isPossiblyAUrl(labelDomain)
-    ) {
-      return true
-    }
-    return false
+  if (isTrustedUrl(uri)) {
+    // if this is a link to internal content, warn if it represents itself as a URL to another app
+    return !!labelDomain && labelDomain !== host && isPossiblyAUrl(labelDomain)
   } else {
-    // if this is a link to external content,
-    // warn if the label doesnt match the target
+    // if this is a link to external content, warn if the label doesnt match the target
     if (!labelDomain) {
       return true
     }
@@ -220,3 +251,8 @@ export function splitApexDomain(hostname: string): [string, string] {
     hostnamep.domain,
   ]
 }
+
+export function createBskyAppAbsoluteUrl(path: string): string {
+  const sanitizedPath = path.replace(BSKY_APP_HOST, '').replace(/^\/+/, '')
+  return `${BSKY_APP_HOST.replace(/\/$/, '')}/${sanitizedPath}`
+}