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/errors.ts23
-rw-r--r--src/lib/strings/handles.ts13
-rw-r--r--src/lib/strings/helpers.ts17
-rw-r--r--src/lib/strings/mention-manip.ts37
-rw-r--r--src/lib/strings/rich-text-detection.ts107
-rw-r--r--src/lib/strings/rich-text-sanitize.ts32
-rw-r--r--src/lib/strings/rich-text.ts216
-rw-r--r--src/lib/strings/time.ts29
-rw-r--r--src/lib/strings/url-helpers.ts108
9 files changed, 582 insertions, 0 deletions
diff --git a/src/lib/strings/errors.ts b/src/lib/strings/errors.ts
new file mode 100644
index 000000000..0efcad335
--- /dev/null
+++ b/src/lib/strings/errors.ts
@@ -0,0 +1,23 @@
+export function cleanError(str: any): string {
+  if (!str) {
+    return ''
+  }
+  if (typeof str !== 'string') {
+    str = str.toString()
+  }
+  if (isNetworkError(str)) {
+    return 'Unable to connect. Please check your internet connection and try again.'
+  }
+  if (str.includes('Upstream Failure')) {
+    return 'The server appears to be experiencing issues. Please try again in a few moments.'
+  }
+  if (str.startsWith('Error: ')) {
+    return str.slice('Error: '.length)
+  }
+  return str
+}
+
+export function isNetworkError(e: unknown) {
+  const str = String(e)
+  return str.includes('Abort') || str.includes('Network request failed')
+}
diff --git a/src/lib/strings/handles.ts b/src/lib/strings/handles.ts
new file mode 100644
index 000000000..3409a0312
--- /dev/null
+++ b/src/lib/strings/handles.ts
@@ -0,0 +1,13 @@
+export function makeValidHandle(str: string): string {
+  if (str.length > 20) {
+    str = str.slice(0, 20)
+  }
+  str = str.toLowerCase()
+  return str.replace(/^[^a-z]+/g, '').replace(/[^a-z0-9-]/g, '')
+}
+
+export function createFullHandle(name: string, domain: string): string {
+  name = (name || '').replace(/[.]+$/, '')
+  domain = (domain || '').replace(/^[.]+/, '')
+  return `${name}.${domain}`
+}
diff --git a/src/lib/strings/helpers.ts b/src/lib/strings/helpers.ts
new file mode 100644
index 000000000..183d53e31
--- /dev/null
+++ b/src/lib/strings/helpers.ts
@@ -0,0 +1,17 @@
+export function pluralize(n: number, base: string, plural?: string): string {
+  if (n === 1) {
+    return base
+  }
+  if (plural) {
+    return plural
+  }
+  return base + 's'
+}
+
+export function enforceLen(str: string, len: number, ellipsis = false): string {
+  str = str || ''
+  if (str.length > len) {
+    return str.slice(0, len) + (ellipsis ? '...' : '')
+  }
+  return str
+}
diff --git a/src/lib/strings/mention-manip.ts b/src/lib/strings/mention-manip.ts
new file mode 100644
index 000000000..1f7cbe434
--- /dev/null
+++ b/src/lib/strings/mention-manip.ts
@@ -0,0 +1,37 @@
+interface FoundMention {
+  value: string
+  index: number
+}
+
+export function getMentionAt(
+  text: string,
+  cursorPos: number,
+): FoundMention | undefined {
+  let re = /(^|\s)@([a-z0-9.]*)/gi
+  let match
+  while ((match = re.exec(text))) {
+    const spaceOffset = match[1].length
+    const index = match.index + spaceOffset
+    if (
+      cursorPos >= index &&
+      cursorPos <= index + match[0].length - spaceOffset
+    ) {
+      return {value: match[2], index}
+    }
+  }
+  return undefined
+}
+
+export function insertMentionAt(
+  text: string,
+  cursorPos: number,
+  mention: string,
+) {
+  const target = getMentionAt(text, cursorPos)
+  if (target) {
+    return `${text.slice(0, target.index)}@${mention} ${text.slice(
+      target.index + target.value.length + 1, // add 1 to include the "@"
+    )}`
+  }
+  return text
+}
diff --git a/src/lib/strings/rich-text-detection.ts b/src/lib/strings/rich-text-detection.ts
new file mode 100644
index 000000000..386ed48e1
--- /dev/null
+++ b/src/lib/strings/rich-text-detection.ts
@@ -0,0 +1,107 @@
+import {AppBskyFeedPost} from '@atproto/api'
+type Entity = AppBskyFeedPost.Entity
+import {isValidDomain} from './url-helpers'
+
+export function extractEntities(
+  text: string,
+  knownHandles?: Set<string>,
+): Entity[] | undefined {
+  let match
+  let ents: Entity[] = []
+  {
+    // mentions
+    const re = /(^|\s|\()(@)([a-zA-Z0-9.-]+)(\b)/g
+    while ((match = re.exec(text))) {
+      if (knownHandles && !knownHandles.has(match[3])) {
+        continue // not a known handle
+      } else if (!match[3].includes('.')) {
+        continue // probably not a handle
+      }
+      const start = text.indexOf(match[3], match.index) - 1
+      ents.push({
+        type: 'mention',
+        value: match[3],
+        index: {start, end: start + match[3].length + 1},
+      })
+    }
+  }
+  {
+    // links
+    const re =
+      /(^|\s|\()((https?:\/\/[\S]+)|((?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/gim
+    while ((match = re.exec(text))) {
+      let value = match[2]
+      if (!value.startsWith('http')) {
+        const domain = match.groups?.domain
+        if (!domain || !isValidDomain(domain)) {
+          continue
+        }
+        value = `https://${value}`
+      }
+      const start = text.indexOf(match[2], match.index)
+      const index = {start, end: start + match[2].length}
+      // strip ending puncuation
+      if (/[.,;!?]$/.test(value)) {
+        value = value.slice(0, -1)
+        index.end--
+      }
+      if (/[)]$/.test(value) && !value.includes('(')) {
+        value = value.slice(0, -1)
+        index.end--
+      }
+      ents.push({
+        type: 'link',
+        value,
+        index,
+      })
+    }
+  }
+  return ents.length > 0 ? ents : undefined
+}
+
+interface DetectedLink {
+  link: string
+}
+type DetectedLinkable = string | DetectedLink
+export function detectLinkables(text: string): DetectedLinkable[] {
+  const re =
+    /((^|\s|\()@[a-z0-9.-]*)|((^|\s|\()https?:\/\/[\S]+)|((^|\s|\()(?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*)/gi
+  const segments = []
+  let match
+  let start = 0
+  while ((match = re.exec(text))) {
+    let matchIndex = match.index
+    let matchValue = match[0]
+
+    if (match.groups?.domain && !isValidDomain(match.groups?.domain)) {
+      continue
+    }
+
+    if (/\s|\(/.test(matchValue)) {
+      // HACK
+      // skip the starting space
+      // we have to do this because RN doesnt support negative lookaheads
+      // -prf
+      matchIndex++
+      matchValue = matchValue.slice(1)
+    }
+
+    // strip ending puncuation
+    if (/[.,;!?]$/.test(matchValue)) {
+      matchValue = matchValue.slice(0, -1)
+    }
+    if (/[)]$/.test(matchValue) && !matchValue.includes('(')) {
+      matchValue = matchValue.slice(0, -1)
+    }
+
+    if (start !== matchIndex) {
+      segments.push(text.slice(start, matchIndex))
+    }
+    segments.push({link: matchValue})
+    start = matchIndex + matchValue.length
+  }
+  if (start < text.length) {
+    segments.push(text.slice(start))
+  }
+  return segments
+}
diff --git a/src/lib/strings/rich-text-sanitize.ts b/src/lib/strings/rich-text-sanitize.ts
new file mode 100644
index 000000000..0b5895707
--- /dev/null
+++ b/src/lib/strings/rich-text-sanitize.ts
@@ -0,0 +1,32 @@
+import {RichText} from './rich-text'
+
+const EXCESS_SPACE_RE = /[\r\n]([\u00AD\u2060\u200D\u200C\u200B\s]*[\r\n]){2,}/
+const REPLACEMENT_STR = '\n\n'
+
+export function removeExcessNewlines(richText: RichText): RichText {
+  return clean(richText, EXCESS_SPACE_RE, REPLACEMENT_STR)
+}
+
+// TODO: check on whether this works correctly with multi-byte codepoints
+export function clean(
+  richText: RichText,
+  targetRegexp: RegExp,
+  replacementString: string,
+): RichText {
+  richText = richText.clone()
+
+  let match = richText.text.match(targetRegexp)
+  while (match && typeof match.index !== 'undefined') {
+    const oldText = richText.text
+    const removeStartIndex = match.index
+    const removeEndIndex = removeStartIndex + match[0].length
+    richText.delete(removeStartIndex, removeEndIndex)
+    if (richText.text === oldText) {
+      break // sanity check
+    }
+    richText.insert(removeStartIndex, replacementString)
+    match = richText.text.match(targetRegexp)
+  }
+
+  return richText
+}
diff --git a/src/lib/strings/rich-text.ts b/src/lib/strings/rich-text.ts
new file mode 100644
index 000000000..1df2144e0
--- /dev/null
+++ b/src/lib/strings/rich-text.ts
@@ -0,0 +1,216 @@
+/*
+= Rich Text Manipulation
+
+When we sanitize rich text, we have to update the entity indices as the
+text is modified. This can be modeled as inserts() and deletes() of the
+rich text string. The possible scenarios are outlined below, along with
+their expected behaviors.
+
+NOTE: Slices are start inclusive, end exclusive
+
+== richTextInsert()
+
+Target string:
+
+   0 1 2 3 4 5 6 7 8 910   // string indices
+   h e l l o   w o r l d   // string value
+       ^-------^           // target slice {start: 2, end: 7}
+
+Scenarios:
+
+A: ^                       // insert "test" at 0
+B:        ^                // insert "test" at 4
+C:                 ^       // insert "test" at 8
+
+A = before           -> move both by num added
+B = inner            -> move end by num added
+C = after            -> noop
+
+Results:
+
+A: 0 1 2 3 4 5 6 7 8 910   // string indices
+   t e s t h e l l o   w   // string value
+               ^-------^   // target slice {start: 6, end: 11}
+
+B: 0 1 2 3 4 5 6 7 8 910   // string indices
+   h e l l t e s t o   w   // string value
+       ^---------------^   // target slice {start: 2, end: 11}
+
+C: 0 1 2 3 4 5 6 7 8 910   // string indices
+   h e l l o   w o t e s   // string value
+       ^-------^           // target slice {start: 2, end: 7}
+
+== richTextDelete()
+
+Target string:
+
+   0 1 2 3 4 5 6 7 8 910   // string indices
+   h e l l o   w o r l d   // string value
+       ^-------^           // target slice {start: 2, end: 7}
+
+Scenarios:
+
+A: ^---------------^       // remove slice {start: 0, end: 9}
+B:               ^-----^   // remove slice {start: 7, end: 11}
+C:         ^-----------^   // remove slice {start: 4, end: 11}
+D:       ^-^               // remove slice {start: 3, end: 5}
+E:   ^-----^               // remove slice {start: 1, end: 5}
+F: ^-^                     // remove slice {start: 0, end: 2}
+
+A = entirely outer   -> delete slice
+B = entirely after   -> noop
+C = partially after  -> move end to remove-start
+D = entirely inner   -> move end by num removed
+E = partially before -> move start to remove-start index, move end by num removed
+F = entirely before  -> move both by num removed
+
+Results:
+
+A: 0 1 2 3 4 5 6 7 8 910   // string indices
+   l d                     // string value
+                           // target slice (deleted)
+
+B: 0 1 2 3 4 5 6 7 8 910   // string indices
+   h e l l o   w           // string value
+       ^-------^           // target slice {start: 2, end: 7}
+
+C: 0 1 2 3 4 5 6 7 8 910   // string indices
+   h e l l                 // string value
+       ^-^                 // target slice {start: 2, end: 4}
+
+D: 0 1 2 3 4 5 6 7 8 910   // string indices
+   h e l   w o r l d       // string value
+       ^---^               // target slice {start: 2, end: 5}
+
+E: 0 1 2 3 4 5 6 7 8 910   // string indices
+   h   w o r l d           // string value
+     ^-^                   // target slice {start: 1, end: 3}
+
+F: 0 1 2 3 4 5 6 7 8 910   // string indices
+   l l o   w o r l d       // string value
+   ^-------^               // target slice {start: 0, end: 5}
+ */
+
+import cloneDeep from 'lodash.clonedeep'
+import {AppBskyFeedPost} from '@atproto/api'
+import {removeExcessNewlines} from './rich-text-sanitize'
+
+export type Entity = AppBskyFeedPost.Entity
+export interface RichTextOpts {
+  cleanNewlines?: boolean
+}
+
+export class RichText {
+  constructor(
+    public text: string,
+    public entities?: Entity[],
+    opts?: RichTextOpts,
+  ) {
+    if (opts?.cleanNewlines) {
+      removeExcessNewlines(this).copyInto(this)
+    }
+  }
+
+  clone() {
+    return new RichText(this.text, cloneDeep(this.entities))
+  }
+
+  copyInto(target: RichText) {
+    target.text = this.text
+    target.entities = cloneDeep(this.entities)
+  }
+
+  insert(insertIndex: number, insertText: string) {
+    this.text =
+      this.text.slice(0, insertIndex) +
+      insertText +
+      this.text.slice(insertIndex)
+
+    if (!this.entities?.length) {
+      return this
+    }
+
+    const numCharsAdded = insertText.length
+    for (const ent of this.entities) {
+      // see comment at top of file for labels of each scenario
+      // scenario A (before)
+      if (insertIndex <= ent.index.start) {
+        // move both by num added
+        ent.index.start += numCharsAdded
+        ent.index.end += numCharsAdded
+      }
+      // scenario B (inner)
+      else if (insertIndex >= ent.index.start && insertIndex < ent.index.end) {
+        // move end by num added
+        ent.index.end += numCharsAdded
+      }
+      // scenario C (after)
+      // noop
+    }
+    return this
+  }
+
+  delete(removeStartIndex: number, removeEndIndex: number) {
+    this.text =
+      this.text.slice(0, removeStartIndex) + this.text.slice(removeEndIndex)
+
+    if (!this.entities?.length) {
+      return this
+    }
+
+    const numCharsRemoved = removeEndIndex - removeStartIndex
+    for (const ent of this.entities) {
+      // see comment at top of file for labels of each scenario
+      // scenario A (entirely outer)
+      if (
+        removeStartIndex <= ent.index.start &&
+        removeEndIndex >= ent.index.end
+      ) {
+        // delete slice (will get removed in final pass)
+        ent.index.start = 0
+        ent.index.end = 0
+      }
+      // scenario B (entirely after)
+      else if (removeStartIndex > ent.index.end) {
+        // noop
+      }
+      // scenario C (partially after)
+      else if (
+        removeStartIndex > ent.index.start &&
+        removeStartIndex <= ent.index.end &&
+        removeEndIndex > ent.index.end
+      ) {
+        // move end to remove start
+        ent.index.end = removeStartIndex
+      }
+      // scenario D (entirely inner)
+      else if (
+        removeStartIndex >= ent.index.start &&
+        removeEndIndex <= ent.index.end
+      ) {
+        // move end by num removed
+        ent.index.end -= numCharsRemoved
+      }
+      // scenario E (partially before)
+      else if (
+        removeStartIndex < ent.index.start &&
+        removeEndIndex >= ent.index.start &&
+        removeEndIndex <= ent.index.end
+      ) {
+        // move start to remove-start index, move end by num removed
+        ent.index.start = removeStartIndex
+        ent.index.end -= numCharsRemoved
+      }
+      // scenario F (entirely before)
+      else if (removeEndIndex < ent.index.start) {
+        // move both by num removed
+        ent.index.start -= numCharsRemoved
+        ent.index.end -= numCharsRemoved
+      }
+    }
+
+    // filter out any entities that were made irrelevant
+    this.entities = this.entities.filter(ent => ent.index.start < ent.index.end)
+    return this
+  }
+}
diff --git a/src/lib/strings/time.ts b/src/lib/strings/time.ts
new file mode 100644
index 000000000..4f62eeba9
--- /dev/null
+++ b/src/lib/strings/time.ts
@@ -0,0 +1,29 @@
+const MINUTE = 60
+const HOUR = MINUTE * 60
+const DAY = HOUR * 24
+const MONTH = DAY * 30
+const YEAR = DAY * 365
+export function ago(date: number | string | Date): string {
+  let ts: number
+  if (typeof date === 'string') {
+    ts = Number(new Date(date))
+  } else if (date instanceof Date) {
+    ts = Number(date)
+  } else {
+    ts = date
+  }
+  const diffSeconds = Math.floor((Date.now() - ts) / 1e3)
+  if (diffSeconds < MINUTE) {
+    return `${diffSeconds}s`
+  } else if (diffSeconds < HOUR) {
+    return `${Math.floor(diffSeconds / MINUTE)}m`
+  } else if (diffSeconds < DAY) {
+    return `${Math.floor(diffSeconds / HOUR)}h`
+  } else if (diffSeconds < MONTH) {
+    return `${Math.floor(diffSeconds / DAY)}d`
+  } else if (diffSeconds < YEAR) {
+    return `${Math.floor(diffSeconds / MONTH)}mo`
+  } else {
+    return new Date(ts).toLocaleDateString()
+  }
+}
diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts
new file mode 100644
index 000000000..a149f49c3
--- /dev/null
+++ b/src/lib/strings/url-helpers.ts
@@ -0,0 +1,108 @@
+import {AtUri} from '../../third-party/uri'
+import {PROD_SERVICE} from 'state/index'
+import TLDs from 'tlds'
+
+export function isValidDomain(str: string): boolean {
+  return !!TLDs.find(tld => {
+    let i = str.lastIndexOf(tld)
+    if (i === -1) {
+      return false
+    }
+    return str.charAt(i - 1) === '.' && i === str.length - tld.length
+  })
+}
+
+export function makeRecordUri(
+  didOrName: string,
+  collection: string,
+  rkey: string,
+) {
+  const urip = new AtUri('at://host/')
+  urip.host = didOrName
+  urip.collection = collection
+  urip.rkey = rkey
+  return urip.toString()
+}
+
+export function toNiceDomain(url: string): string {
+  try {
+    const urlp = new URL(url)
+    if (`https://${urlp.host}` === PROD_SERVICE) {
+      return 'Bluesky Social'
+    }
+    return urlp.host
+  } catch (e) {
+    return url
+  }
+}
+
+export function toShortUrl(url: string): string {
+  try {
+    const urlp = new URL(url)
+    const shortened =
+      urlp.host +
+      (urlp.pathname === '/' ? '' : urlp.pathname) +
+      urlp.search +
+      urlp.hash
+    if (shortened.length > 30) {
+      return shortened.slice(0, 27) + '...'
+    }
+    return shortened
+  } catch (e) {
+    return url
+  }
+}
+
+export function toShareUrl(url: string): string {
+  if (!url.startsWith('https')) {
+    const urlp = new URL('https://bsky.app')
+    urlp.pathname = url
+    url = urlp.toString()
+  }
+  return url
+}
+
+export function isBskyAppUrl(url: string): boolean {
+  return url.startsWith('https://bsky.app/')
+}
+
+export function convertBskyAppUrlIfNeeded(url: string): string {
+  if (isBskyAppUrl(url)) {
+    try {
+      const urlp = new URL(url)
+      return urlp.pathname
+    } catch (e) {
+      console.error('Unexpected error in convertBskyAppUrlIfNeeded()', e)
+    }
+  }
+  return url
+}
+
+export function getYoutubeVideoId(link: string): string | undefined {
+  let url
+  try {
+    url = new URL(link)
+  } catch (e) {
+    return undefined
+  }
+
+  if (
+    url.hostname !== 'www.youtube.com' &&
+    url.hostname !== 'youtube.com' &&
+    url.hostname !== 'youtu.be'
+  ) {
+    return undefined
+  }
+  if (url.hostname === 'youtu.be') {
+    const videoId = url.pathname.split('/')[1]
+    if (!videoId) {
+      return undefined
+    }
+    return videoId
+  }
+  const videoId = url.searchParams.get('v') as string
+  if (!videoId) {
+    return undefined
+  }
+  return videoId
+}