about summary refs log tree commit diff
path: root/src/lib
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/api/feed-manip.ts6
-rw-r--r--src/lib/api/hack-add-deleted-embed.ts24
-rw-r--r--src/lib/api/index.ts38
-rw-r--r--src/lib/icons.tsx38
-rw-r--r--src/lib/moderation.ts107
-rw-r--r--src/lib/sentry.ts5
-rw-r--r--src/lib/strings/display-names.ts11
-rw-r--r--src/lib/strings/handles.ts2
-rw-r--r--src/lib/strings/rich-text-manip.ts34
-rw-r--r--src/lib/strings/url-helpers.ts15
10 files changed, 264 insertions, 16 deletions
diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts
index f4bf6cdff..472289b40 100644
--- a/src/lib/api/feed-manip.ts
+++ b/src/lib/api/feed-manip.ts
@@ -17,6 +17,12 @@ export class FeedViewPostsSlice {
 
   constructor(public items: FeedViewPost[] = []) {}
 
+  get _reactKey() {
+    return `slice-${this.items[0].post.uri}-${
+      this.items[0].reason?.indexedAt || this.items[0].post.indexedAt
+    }`
+  }
+
   get uri() {
     if (this.isFlattenedReply) {
       return this.items[1].post.uri
diff --git a/src/lib/api/hack-add-deleted-embed.ts b/src/lib/api/hack-add-deleted-embed.ts
new file mode 100644
index 000000000..59aad21a2
--- /dev/null
+++ b/src/lib/api/hack-add-deleted-embed.ts
@@ -0,0 +1,24 @@
+import {
+  AppBskyFeedDefs,
+  AppBskyFeedPost,
+  ComAtprotoRepoStrongRef,
+} from '@atproto/api'
+
+/**
+ * HACK
+ * The server doesnt seem to be correctly giving the notFound view yet
+ * so I'm adding it manually for now
+ * -prf
+ */
+export function hackAddDeletedEmbed(post: AppBskyFeedDefs.PostView) {
+  const record = post.record as AppBskyFeedPost.Record
+  if (record.embed?.$type === 'app.bsky.embed.record' && !post.embed) {
+    post.embed = {
+      $type: 'app.bsky.embed.record#view',
+      record: {
+        $type: 'app.bsky.embed.record#viewNotFound',
+        uri: (record.embed.record as ComAtprotoRepoStrongRef.Main).uri,
+      },
+    }
+  }
+}
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts
index 458ef7baa..4ecd32046 100644
--- a/src/lib/api/index.ts
+++ b/src/lib/api/index.ts
@@ -4,6 +4,7 @@ import {
   AppBskyEmbedRecord,
   AppBskyEmbedRecordWithMedia,
   AppBskyRichtextFacet,
+  ComAtprotoLabelDefs,
   ComAtprotoRepoUploadBlob,
   RichText,
 } from '@atproto/api'
@@ -13,6 +14,7 @@ import {isNetworkError} from 'lib/strings/errors'
 import {LinkMeta} from '../link-meta/link-meta'
 import {isWeb} from 'platform/detection'
 import {ImageModel} from 'state/models/media/image'
+import {shortenLinks} from 'lib/strings/rich-text-manip'
 
 export interface ExternalEmbedDraft {
   uri: string
@@ -29,10 +31,24 @@ export async function resolveName(store: RootStoreModel, didOrHandle: string) {
   if (didOrHandle.startsWith('did:')) {
     return didOrHandle
   }
-  const res = await store.agent.resolveHandle({
-    handle: didOrHandle,
-  })
-  return res.data.did
+
+  // we run the resolution always to ensure freshness
+  const promise = store.agent
+    .resolveHandle({
+      handle: didOrHandle,
+    })
+    .then(res => {
+      store.handleResolutions.cache.set(didOrHandle, res.data.did)
+      return res.data.did
+    })
+
+  // but we can return immediately if it's cached
+  const cached = store.handleResolutions.cache.get(didOrHandle)
+  if (cached) {
+    return cached
+  }
+
+  return promise
 }
 
 export async function uploadBlob(
@@ -63,6 +79,7 @@ interface PostOpts {
   }
   extLink?: ExternalEmbedDraft
   images?: ImageModel[]
+  labels?: string[]
   knownHandles?: Set<string>
   onStateChange?: (state: string) => void
   langs?: string[]
@@ -76,7 +93,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
     | AppBskyEmbedRecordWithMedia.Main
     | undefined
   let reply
-  const rt = new RichText(
+  let rt = new RichText(
     {text: opts.rawText.trim()},
     {
       cleanNewlines: true,
@@ -85,6 +102,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
 
   opts.onStateChange?.('Processing...')
   await rt.detectFacets(store.agent)
+  rt = shortenLinks(rt)
 
   // filter out any mention facets that didn't map to a user
   rt.facets = rt.facets?.filter(facet => {
@@ -220,6 +238,15 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
     }
   }
 
+  // set labels
+  let labels: ComAtprotoLabelDefs.SelfLabels | undefined
+  if (opts.labels?.length) {
+    labels = {
+      $type: 'com.atproto.label.defs#selfLabels',
+      values: opts.labels.map(val => ({val})),
+    }
+  }
+
   // add top 3 languages from user preferences if langs is provided
   let langs = opts.langs
   if (opts.langs) {
@@ -234,6 +261,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
       reply,
       embed,
       langs,
+      labels,
     })
   } catch (e: any) {
     console.error(`Failed to create post: ${e.toString()}`)
diff --git a/src/lib/icons.tsx b/src/lib/icons.tsx
index 4ea3b4d65..233f8a473 100644
--- a/src/lib/icons.tsx
+++ b/src/lib/icons.tsx
@@ -957,3 +957,41 @@ export function SatelliteDishIcon({
     </Svg>
   )
 }
+
+// Copyright (c) 2020 Refactoring UI Inc.
+// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE
+export function ShieldExclamation({
+  style,
+  size,
+  strokeWidth = 1.5,
+}: {
+  style?: StyleProp<TextStyle>
+  size?: string | number
+  strokeWidth?: number
+}) {
+  let color = 'currentColor'
+  if (
+    style &&
+    typeof style === 'object' &&
+    'color' in style &&
+    typeof style.color === 'string'
+  ) {
+    color = style.color
+  }
+  return (
+    <Svg
+      width={size}
+      height={size}
+      fill="none"
+      viewBox="0 0 24 24"
+      strokeWidth={strokeWidth || 1.5}
+      stroke={color}
+      style={style}>
+      <Path
+        strokeLinecap="round"
+        strokeLinejoin="round"
+        d="M12 9v3.75m0-10.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.75c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.57-.598-3.75h-.152c-3.196 0-6.1-1.249-8.25-3.286zm0 13.036h.008v.008H12v-.008z"
+      />
+    </Svg>
+  )
+}
diff --git a/src/lib/moderation.ts b/src/lib/moderation.ts
new file mode 100644
index 000000000..aadee0e74
--- /dev/null
+++ b/src/lib/moderation.ts
@@ -0,0 +1,107 @@
+import {ModerationCause, ProfileModeration} from '@atproto/api'
+
+export interface ModerationCauseDescription {
+  name: string
+  description: string
+}
+
+export function describeModerationCause(
+  cause: ModerationCause | undefined,
+  context: 'account' | 'content',
+): ModerationCauseDescription {
+  if (!cause) {
+    return {
+      name: 'Content Warning',
+      description:
+        'Moderator has chosen to set a general warning on the content.',
+    }
+  }
+  if (cause.type === 'blocking') {
+    return {
+      name: 'User Blocked',
+      description: 'You have blocked this user. You cannot view their content.',
+    }
+  }
+  if (cause.type === 'blocked-by') {
+    return {
+      name: 'User Blocking You',
+      description: 'This user has blocked you. You cannot view their content.',
+    }
+  }
+  if (cause.type === 'block-other') {
+    return {
+      name: 'Content Not Available',
+      description:
+        'This content is not available because one of the users involved has blocked the other.',
+    }
+  }
+  if (cause.type === 'muted') {
+    if (cause.source.type === 'list') {
+      return {
+        name:
+          context === 'account'
+            ? `Muted by "${cause.source.list.name}"`
+            : `Post by muted user ("${cause.source.list.name}")`,
+        description: 'You have muted this user',
+      }
+    } else {
+      return {
+        name: context === 'account' ? 'Muted User' : 'Post by muted user',
+        description: 'You have muted this user',
+      }
+    }
+  }
+  return cause.labelDef.strings[context].en
+}
+
+export function getProfileModerationCauses(
+  moderation: ProfileModeration,
+): ModerationCause[] {
+  /*
+  Gather everything on profile and account that blurs or alerts
+  */
+  return [
+    moderation.decisions.profile.cause,
+    ...moderation.decisions.profile.additionalCauses,
+    moderation.decisions.account.cause,
+    ...moderation.decisions.account.additionalCauses,
+  ].filter(cause => {
+    if (!cause) {
+      return false
+    }
+    if (cause?.type === 'label') {
+      if (
+        cause.labelDef.onwarn === 'blur' ||
+        cause.labelDef.onwarn === 'alert'
+      ) {
+        return true
+      } else {
+        return false
+      }
+    }
+    return true
+  }) as ModerationCause[]
+}
+
+export function isCauseALabelOnUri(
+  cause: ModerationCause | undefined,
+  uri: string,
+): boolean {
+  if (cause?.type !== 'label') {
+    return false
+  }
+  return cause.label.uri === uri
+}
+
+export function getModerationCauseKey(cause: ModerationCause): string {
+  const source =
+    cause.source.type === 'labeler'
+      ? cause.source.labeler.did
+      : cause.source.type === 'list'
+      ? cause.source.list.uri
+      : 'user'
+  if (cause.type === 'label') {
+    return `label:${cause.label.val}:${source}`
+  }
+  return `${cause.type}:${source}`
+}
diff --git a/src/lib/sentry.ts b/src/lib/sentry.ts
index c5d1d3eb6..5448415ff 100644
--- a/src/lib/sentry.ts
+++ b/src/lib/sentry.ts
@@ -15,6 +15,11 @@ Sentry.init({
   environment: __DEV__ ? 'development' : 'production', // Set the environment
   enableAutoPerformanceTracking: true, // Enable auto performance tracking
   tracesSampleRate: 0.5, // Set tracesSampleRate to 1.0 to capture 100% of transactions for performance monitoring. // TODO: this might be too much in production
+  _experiments: {
+    // The sampling rate for profiling is relative to TracesSampleRate.
+    // In this case, we'll capture profiles for 50% of transactions.
+    profilesSampleRate: 0.5,
+  },
   integrations: isNative
     ? [
         new Sentry.Native.ReactNativeTracing({
diff --git a/src/lib/strings/display-names.ts b/src/lib/strings/display-names.ts
index b98153732..29b7c3b50 100644
--- a/src/lib/strings/display-names.ts
+++ b/src/lib/strings/display-names.ts
@@ -1,10 +1,19 @@
+import {ModerationUI} from '@atproto/api'
+import {describeModerationCause} from '../moderation'
+
 // \u2705 = ✅
 // \u2713 = ✓
 // \u2714 = ✔
 // \u2611 = ☑
 const CHECK_MARKS_RE = /[\u2705\u2713\u2714\u2611]/gu
 
-export function sanitizeDisplayName(str: string): string {
+export function sanitizeDisplayName(
+  str: string,
+  moderation?: ModerationUI,
+): string {
+  if (moderation?.blur) {
+    return `⚠${describeModerationCause(moderation.cause, 'account').name}`
+  }
   if (typeof str === 'string') {
     return str.replace(CHECK_MARKS_RE, '').trim()
   }
diff --git a/src/lib/strings/handles.ts b/src/lib/strings/handles.ts
index 3c01d9345..6ce462435 100644
--- a/src/lib/strings/handles.ts
+++ b/src/lib/strings/handles.ts
@@ -3,7 +3,7 @@ export function makeValidHandle(str: string): string {
     str = str.slice(0, 20)
   }
   str = str.toLowerCase()
-  return str.replace(/^[^a-z]+/g, '').replace(/[^a-z0-9-]/g, '')
+  return str.replace(/^[^a-z0-9]+/g, '').replace(/[^a-z0-9-]/g, '')
 }
 
 export function createFullHandle(name: string, domain: string): string {
diff --git a/src/lib/strings/rich-text-manip.ts b/src/lib/strings/rich-text-manip.ts
new file mode 100644
index 000000000..d9cd8c071
--- /dev/null
+++ b/src/lib/strings/rich-text-manip.ts
@@ -0,0 +1,34 @@
+import {RichText, UnicodeString} from '@atproto/api'
+import {toShortUrl} from './url-helpers'
+
+export function shortenLinks(rt: RichText): RichText {
+  if (!rt.facets?.length) {
+    return rt
+  }
+  rt = rt.clone()
+  // enumerate the link facets
+  if (rt.facets) {
+    for (const facet of rt.facets) {
+      const isLink = !!facet.features.find(
+        f => f.$type === 'app.bsky.richtext.facet#link',
+      )
+      if (!isLink) {
+        continue
+      }
+
+      // extract and shorten the URL
+      const {byteStart, byteEnd} = facet.index
+      const url = rt.unicodeText.slice(byteStart, byteEnd)
+      const shortened = new UnicodeString(toShortUrl(url))
+
+      // insert the shorten URL
+      rt.insert(byteStart, shortened.utf16)
+      // update the facet to cover the new shortened URL
+      facet.index.byteStart = byteStart
+      facet.index.byteEnd = byteStart + shortened.length
+      // remove the old URL
+      rt.delete(byteStart + shortened.length, byteEnd + shortened.length)
+    }
+  }
+  return rt
+}
diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts
index 105c631bf..b509aad01 100644
--- a/src/lib/strings/url-helpers.ts
+++ b/src/lib/strings/url-helpers.ts
@@ -30,7 +30,7 @@ export function toNiceDomain(url: string): string {
     if (`https://${urlp.host}` === PROD_SERVICE) {
       return 'Bluesky Social'
     }
-    return urlp.host
+    return urlp.host ? urlp.host : url
   } catch (e) {
     return url
   }
@@ -42,15 +42,12 @@ export function toShortUrl(url: string): string {
     if (urlp.protocol !== 'http:' && urlp.protocol !== 'https:') {
       return url
     }
-    const shortened =
-      urlp.host +
-      (urlp.pathname === '/' ? '' : urlp.pathname) +
-      urlp.search +
-      urlp.hash
-    if (shortened.length > 30) {
-      return shortened.slice(0, 27) + '...'
+    const path =
+      (urlp.pathname === '/' ? '' : urlp.pathname) + urlp.search + urlp.hash
+    if (path.length > 15) {
+      return urlp.host + path.slice(0, 13) + '...'
     }
-    return shortened ? shortened : url
+    return urlp.host + path
   } catch (e) {
     return url
   }