about summary refs log tree commit diff
path: root/bskyembed/src
diff options
context:
space:
mode:
Diffstat (limited to 'bskyembed/src')
-rw-r--r--bskyembed/src/components/container.tsx2
-rw-r--r--bskyembed/src/components/embed.tsx40
-rw-r--r--bskyembed/src/components/post.tsx50
-rw-r--r--bskyembed/src/components/verification-check.tsx56
-rw-r--r--bskyembed/src/screens/landing.tsx13
-rw-r--r--bskyembed/src/screens/post.tsx2
-rw-r--r--bskyembed/src/types/bsky/index.ts49
-rw-r--r--bskyembed/src/types/bsky/profile.ts10
-rw-r--r--bskyembed/src/util/nice-date.ts11
-rw-r--r--bskyembed/src/util/parse-embed.ts152
-rw-r--r--bskyembed/src/util/pretty-number.ts9
-rw-r--r--bskyembed/src/util/rkey.ts6
-rw-r--r--bskyembed/src/util/verification-state.ts31
-rw-r--r--bskyembed/src/utils.ts28
14 files changed, 403 insertions, 56 deletions
diff --git a/bskyembed/src/components/container.tsx b/bskyembed/src/components/container.tsx
index 8e142a25b..bafc497ae 100644
--- a/bskyembed/src/components/container.tsx
+++ b/bskyembed/src/components/container.tsx
@@ -49,7 +49,7 @@ export function Container({
         }
       }}>
       {href && <Link href={href} />}
-      <div className="flex-1 px-4 pt-3 pb-2.5">{children}</div>
+      <div className="flex-1 px-4 pt-3 pb-2.5 max-w-full">{children}</div>
     </div>
   )
 }
diff --git a/bskyembed/src/components/embed.tsx b/bskyembed/src/components/embed.tsx
index 428782b64..52618a89d 100644
--- a/bskyembed/src/components/embed.tsx
+++ b/bskyembed/src/components/embed.tsx
@@ -17,8 +17,11 @@ import infoIcon from '../../assets/circleInfo_stroke2_corner0_rounded.svg'
 import playIcon from '../../assets/play_filled_corner2_rounded.svg'
 import starterPackIcon from '../../assets/starterPack.svg'
 import {CONTENT_LABELS, labelsToInfo} from '../labels'
-import {getRkey} from '../utils'
+import * as bsky from '../types/bsky'
+import {getRkey} from '../util/rkey'
+import {getVerificationState} from '../util/verification-state'
 import {Link} from './link'
+import {VerificationCheck} from './verification-check'
 
 export function Embed({
   content,
@@ -75,23 +78,35 @@ export function Embed({
           CONTENT_LABELS.includes(label.val),
         )
 
+        const verification = getVerificationState({profile: record.author})
+
         return (
           <Link
             href={`/profile/${record.author.did}/post/${getRkey(record)}`}
             className="transition-colors hover:bg-neutral-100 dark:hover:bg-slate-700 border dark:border-slate-600 rounded-xl p-2 gap-1.5 w-full flex flex-col">
             <div className="flex gap-1.5 items-center">
-              <div className="w-4 h-4 overflow-hidden rounded-full bg-neutral-300 dark:bg-slate-700 shrink-0">
+              <div className="w-4 h-4 rounded-full bg-neutral-300 dark:bg-slate-700 shrink-0">
                 <img
+                  className="rounded-full"
                   src={record.author.avatar}
                   style={isAuthorLabeled ? {filter: 'blur(1.5px)'} : undefined}
                 />
               </div>
-              <p className="line-clamp-1 text-sm">
-                <span className="font-bold">{record.author.displayName}</span>
-                <span className="text-textLight dark:text-textDimmed ml-1">
+              <div className="flex flex-1 items-center shrink min-w-0 min-h-0">
+                <p className="block text-sm shrink-0 font-bold max-w-[70%] line-clamp-1">
+                  {record.author.displayName?.trim() || record.author.handle}
+                </p>
+                {verification.isVerified && (
+                  <VerificationCheck
+                    className="ml-[3px] mt-px shrink-0 self-center"
+                    verifier={verification.role === 'verifier'}
+                    size={12}
+                  />
+                )}
+                <p className="block line-clamp-1 text-sm text-textLight dark:text-textDimmed shrink-[10] ml-1">
                   @{record.author.handle}
-                </span>
-              </p>
+                </p>
+              </div>
             </div>
             {text && <p className="text-sm">{text}</p>}
             {record.embeds?.map(embed => (
@@ -404,7 +419,12 @@ function StarterPackEmbed({
 }: {
   content: AppBskyGraphDefs.StarterPackViewBasic
 }) {
-  if (!AppBskyGraphStarterpack.isRecord(content.record)) {
+  if (
+    !bsky.dangerousIsType<AppBskyGraphStarterpack.Record>(
+      content.record,
+      AppBskyGraphStarterpack.isRecord,
+    )
+  ) {
     return null
   }
 
@@ -443,7 +463,9 @@ function StarterPackEmbed({
 }
 
 // from #/lib/strings/starter-pack.ts
-function getStarterPackImage(starterPack: AppBskyGraphDefs.StarterPackView) {
+function getStarterPackImage(
+  starterPack: AppBskyGraphDefs.StarterPackViewBasic,
+) {
   const rkey = getRkey({uri: starterPack.uri})
   return `https://ogcard.cdn.bsky.app/start/${starterPack.creator.did}/${rkey}`
 }
diff --git a/bskyembed/src/components/post.tsx b/bskyembed/src/components/post.tsx
index 6ecac5796..d216ce0e5 100644
--- a/bskyembed/src/components/post.tsx
+++ b/bskyembed/src/components/post.tsx
@@ -11,10 +11,15 @@ import likeIcon from '../../assets/heart2_filled_stroke2_corner0_rounded.svg'
 import logo from '../../assets/logo.svg'
 import repostIcon from '../../assets/repost_stroke2_corner2_rounded.svg'
 import {CONTENT_LABELS} from '../labels'
-import {getRkey, niceDate, prettyNumber} from '../utils'
+import * as bsky from '../types/bsky'
+import {niceDate} from '../util/nice-date'
+import {prettyNumber} from '../util/pretty-number'
+import {getRkey} from '../util/rkey'
+import {getVerificationState} from '../util/verification-state'
 import {Container} from './container'
 import {Embed} from './embed'
 import {Link} from './link'
+import {VerificationCheck} from './verification-check'
 
 interface Props {
   thread: AppBskyFeedDefs.ThreadViewPost
@@ -28,16 +33,25 @@ export function Post({thread}: Props) {
   )
 
   let record: AppBskyFeedPost.Record | null = null
-  if (AppBskyFeedPost.isRecord(post.record)) {
+  if (
+    bsky.dangerousIsType<AppBskyFeedPost.Record>(
+      post.record,
+      AppBskyFeedPost.isRecord,
+    )
+  ) {
     record = post.record
   }
 
+  const verification = getVerificationState({profile: post.author})
+
   const href = `/profile/${post.author.did}/post/${getRkey(post)}`
   return (
     <Container href={href}>
       <div className="flex-1 flex-col flex gap-2" lang={record?.langs?.[0]}>
-        <div className="flex gap-2.5 items-center cursor-pointer">
-          <Link href={`/profile/${post.author.did}`} className="rounded-full">
+        <div className="flex gap-2.5 items-center cursor-pointer w-full max-w-full">
+          <Link
+            href={`/profile/${post.author.did}`}
+            className="rounded-full shrink-0">
             <div className="w-10 h-10 overflow-hidden rounded-full bg-neutral-300 dark:bg-slate-700 shrink-0">
               <img
                 src={post.author.avatar}
@@ -45,19 +59,27 @@ export function Post({thread}: Props) {
               />
             </div>
           </Link>
-          <div>
-            <Link
-              href={`/profile/${post.author.did}`}
-              className="font-bold text-[17px] leading-5 line-clamp-1 hover:underline underline-offset-2 decoration-2">
-              <p>{post.author.displayName}</p>
-            </Link>
+          <div className="flex flex-1 flex-col min-w-0">
+            <div className="flex flex-1 items-center">
+              <Link
+                href={`/profile/${post.author.did}`}
+                className="block font-bold text-[17px] leading-5 line-clamp-1 hover:underline underline-offset-2 text-ellipsis decoration-2">
+                {post.author.displayName?.trim() || post.author.handle}
+              </Link>
+              {verification.isVerified && (
+                <VerificationCheck
+                  className="pl-[3px] mt-px shrink-0"
+                  verifier={verification.role === 'verifier'}
+                  size={15}
+                />
+              )}
+            </div>
             <Link
               href={`/profile/${post.author.did}`}
-              className="text-[15px] text-textLight dark:text-textDimmed hover:underline line-clamp-1">
-              <p>@{post.author.handle}</p>
+              className="block text-[15px] text-textLight dark:text-textDimmed hover:underline line-clamp-1">
+              @{post.author.handle}
             </Link>
           </div>
-          <div className="flex-1" />
           <Link
             href={href}
             className="transition-transform hover:scale-110 shrink-0 self-start">
@@ -133,7 +155,7 @@ function PostContent({record}: {record: AppBskyFeedPost.Record | null}) {
         <Link
           key={counter}
           href={segment.link.uri}
-          className="text-blue-400 hover:underline"
+          className="text-blue-500 hover:underline"
           disableTracking={
             !segment.link.uri.startsWith('https://bsky.app') &&
             !segment.link.uri.startsWith('https://go.bsky.app')
diff --git a/bskyembed/src/components/verification-check.tsx b/bskyembed/src/components/verification-check.tsx
new file mode 100644
index 000000000..a9710ea2f
--- /dev/null
+++ b/bskyembed/src/components/verification-check.tsx
@@ -0,0 +1,56 @@
+import {h} from 'preact'
+
+type IconProps = {
+  size: number
+  className?: string
+}
+
+export function VerificationCheck({
+  verifier,
+  ...rest
+}: {verifier: boolean} & IconProps) {
+  return verifier ? <VerifierCheck {...rest} /> : <VerifiedCheck {...rest} />
+}
+
+export function VerifiedCheck({size, className}: IconProps) {
+  return (
+    <svg
+      fill="none"
+      viewBox="0 0 24 24"
+      width={size}
+      height={size}
+      className={className}>
+      <circle cx="12" cy="12" r="11.5" fill="hsl(211, 99%, 53%)" />
+      <path
+        fill="#fff"
+        fillRule="evenodd"
+        clipRule="evenodd"
+        d="M17.659 8.175a1.361 1.361 0 0 1 0 1.925l-6.224 6.223a1.361 1.361 0 0 1-1.925 0L6.4 13.212a1.361 1.361 0 0 1 1.925-1.925l2.149 2.148 5.26-5.26a1.361 1.361 0 0 1 1.925 0Z"
+      />
+    </svg>
+  )
+}
+
+export function VerifierCheck({size, className}: IconProps) {
+  return (
+    <svg
+      fill="none"
+      viewBox="0 0 24 24"
+      width={size}
+      height={size}
+      className={className}>
+      <path
+        fill="hsl(211, 99%, 53%)"
+        fillRule="evenodd"
+        clipRule="evenodd"
+        d="M8.792 1.615a4.154 4.154 0 0 1 6.416 0 4.154 4.154 0 0 0 3.146 1.515 4.154 4.154 0 0 1 4 5.017 4.154 4.154 0 0 0 .777 3.404 4.154 4.154 0 0 1-1.427 6.255 4.153 4.153 0 0 0-2.177 2.73 4.154 4.154 0 0 1-5.781 2.784 4.154 4.154 0 0 0-3.492 0 4.154 4.154 0 0 1-5.78-2.784 4.154 4.154 0 0 0-2.178-2.73A4.154 4.154 0 0 1 .87 11.551a4.154 4.154 0 0 0 .776-3.404A4.154 4.154 0 0 1 5.646 3.13a4.154 4.154 0 0 0 3.146-1.515Z"
+      />
+      <path
+        fill="#fff"
+        fillRule="evenodd"
+        clipRule="evenodd"
+        d="M17.861 8.26a1.438 1.438 0 0 1 0 2.033l-6.571 6.571a1.437 1.437 0 0 1-2.033 0L5.97 13.58a1.438 1.438 0 0 1 2.033-2.033l2.27 2.269 5.554-5.555a1.437 1.437 0 0 1 2.033 0Z"
+      />
+    </svg>
+  )
+}
diff --git a/bskyembed/src/screens/landing.tsx b/bskyembed/src/screens/landing.tsx
index 880b71337..bb650241f 100644
--- a/bskyembed/src/screens/landing.tsx
+++ b/bskyembed/src/screens/landing.tsx
@@ -14,9 +14,11 @@ import {
 import {Container} from '../components/container'
 import {Link} from '../components/link'
 import {Post} from '../components/post'
-import {niceDate} from '../utils'
+import * as bsky from '../types/bsky'
+import {niceDate} from '../util/nice-date'
 
-const DEFAULT_POST = 'https://bsky.app/profile/emilyliu.me/post/3jzn6g7ixgq2y'
+const DEFAULT_POST =
+  'https://bsky.app/profile/did:plc:vjug55kidv6sye7ykr5faxxn/post/3jzn6g7ixgq2y'
 const DEFAULT_URI =
   'at://did:plc:vjug55kidv6sye7ykr5faxxn/app.bsky.feed.post/3jzn6g7ixgq2y'
 
@@ -222,7 +224,12 @@ function Snippet({
   const snippet = useMemo(() => {
     const record = thread.post.record
 
-    if (!AppBskyFeedPost.isRecord(record)) {
+    if (
+      !bsky.dangerousIsType<AppBskyFeedPost.Record>(
+        record,
+        AppBskyFeedPost.isRecord,
+      )
+    ) {
       return ''
     }
 
diff --git a/bskyembed/src/screens/post.tsx b/bskyembed/src/screens/post.tsx
index 4cd72b69b..83914f66f 100644
--- a/bskyembed/src/screens/post.tsx
+++ b/bskyembed/src/screens/post.tsx
@@ -8,7 +8,7 @@ import {applyTheme, initSystemColorMode} from '../color-mode'
 import {Container} from '../components/container'
 import {Link} from '../components/link'
 import {Post} from '../components/post'
-import {getRkey} from '../utils'
+import {getRkey} from '../util/rkey'
 
 const root = document.getElementById('app')
 if (!root) throw new Error('No root element')
diff --git a/bskyembed/src/types/bsky/index.ts b/bskyembed/src/types/bsky/index.ts
new file mode 100644
index 000000000..c462cdf65
--- /dev/null
+++ b/bskyembed/src/types/bsky/index.ts
@@ -0,0 +1,49 @@
+import {ValidationResult} from '@atproto/lexicon'
+
+export * as profile from './profile'
+
+/**
+ * Fast type checking without full schema validation, for use with data we
+ * trust, or for non-critical path use cases. Why? Our SDK's `is*` identity
+ * utils do not assert the type of the entire object, only the `$type` string.
+ *
+ * For full validation of the object schema, use the `validate` export from
+ * this file.
+ *
+ * Usage:
+ * ```ts
+ * import * as bsky from '#/types/bsky'
+ *
+ * if (bsky.dangerousIsType<AppBskyFeedPost.Record>(item, AppBskyFeedPost.isRecord)) {
+ *   // `item` has type `$Typed<AppBskyFeedPost.Record>` here
+ * }
+ * ```
+ */
+export function dangerousIsType<R extends {$type?: string}>(
+  record: unknown,
+  identity: <V>(v: V) => v is V & {$type: NonNullable<R['$type']>},
+): record is R {
+  return identity(record)
+}
+
+/**
+ * Fully validates the object schema, which has a performance cost.
+ *
+ * For faster checks with data we trust, like that from our app view, use the
+ * `dangerousIsType` export from this same file.
+ *
+ * Usage:
+ * ```ts
+ * import * as bsky from '#/types/bsky'
+ *
+ * if (bsky.validate(item, AppBskyFeedPost.validateRecord)) {
+ *   // `item` has type `$Typed<AppBskyFeedPost.Record>` here
+ * }
+ * ```
+ */
+export function validate<R extends {$type?: string}>(
+  record: unknown,
+  validator: (v: unknown) => ValidationResult<R>,
+): record is R {
+  return validator(record).success
+}
diff --git a/bskyembed/src/types/bsky/profile.ts b/bskyembed/src/types/bsky/profile.ts
new file mode 100644
index 000000000..12c8146ae
--- /dev/null
+++ b/bskyembed/src/types/bsky/profile.ts
@@ -0,0 +1,10 @@
+import {type AppBskyActorDefs, type ChatBskyActorDefs} from '@atproto/api'
+
+/**
+ * Matches any profile view exported by our SDK
+ */
+export type AnyProfileView =
+  | AppBskyActorDefs.ProfileViewBasic
+  | AppBskyActorDefs.ProfileView
+  | AppBskyActorDefs.ProfileViewDetailed
+  | ChatBskyActorDefs.ProfileViewBasic
diff --git a/bskyembed/src/util/nice-date.ts b/bskyembed/src/util/nice-date.ts
new file mode 100644
index 000000000..016c97a69
--- /dev/null
+++ b/bskyembed/src/util/nice-date.ts
@@ -0,0 +1,11 @@
+export function niceDate(date: number | string | Date) {
+  const d = new Date(date)
+  return `${d.toLocaleDateString('en-us', {
+    year: 'numeric',
+    month: 'short',
+    day: 'numeric',
+  })} at ${d.toLocaleTimeString(undefined, {
+    hour: 'numeric',
+    minute: '2-digit',
+  })}`
+}
diff --git a/bskyembed/src/util/parse-embed.ts b/bskyembed/src/util/parse-embed.ts
new file mode 100644
index 000000000..97c3dc33e
--- /dev/null
+++ b/bskyembed/src/util/parse-embed.ts
@@ -0,0 +1,152 @@
+/**
+ * This file is a copy of what exists in the social-app
+ */
+
+import {
+  AppBskyEmbedExternal,
+  AppBskyEmbedImages,
+  AppBskyEmbedRecord,
+  AppBskyEmbedRecordWithMedia,
+  AppBskyEmbedVideo,
+  AppBskyFeedDefs,
+  AppBskyGraphDefs,
+  AppBskyLabelerDefs,
+} from '@atproto/api'
+
+export type Embed =
+  | {
+      type: 'post'
+      view: AppBskyEmbedRecord.ViewRecord
+    }
+  | {
+      type: 'post_not_found'
+      view: AppBskyEmbedRecord.ViewNotFound
+    }
+  | {
+      type: 'post_blocked'
+      view: AppBskyEmbedRecord.ViewBlocked
+    }
+  | {
+      type: 'post_detached'
+      view: AppBskyEmbedRecord.ViewDetached
+    }
+  | {
+      type: 'feed'
+      view: AppBskyFeedDefs.GeneratorView
+    }
+  | {
+      type: 'list'
+      view: AppBskyGraphDefs.ListView
+    }
+  | {
+      type: 'labeler'
+      view: AppBskyLabelerDefs.LabelerView
+    }
+  | {
+      type: 'starter_pack'
+      view: AppBskyGraphDefs.StarterPackViewBasic
+    }
+  | {
+      type: 'images'
+      view: AppBskyEmbedImages.View
+    }
+  | {
+      type: 'link'
+      view: AppBskyEmbedExternal.View
+    }
+  | {
+      type: 'video'
+      view: AppBskyEmbedVideo.View
+    }
+  | {
+      type: 'post_with_media'
+      view: Embed
+      media: Embed
+    }
+  | {
+      type: 'unknown'
+      view: null
+    }
+
+export type EmbedType<T extends Embed['type']> = Extract<Embed, {type: T}>
+
+export function parseEmbedRecordView({record}: AppBskyEmbedRecord.View): Embed {
+  if (AppBskyEmbedRecord.isViewRecord(record)) {
+    return {
+      type: 'post',
+      view: record,
+    }
+  } else if (AppBskyEmbedRecord.isViewNotFound(record)) {
+    return {
+      type: 'post_not_found',
+      view: record,
+    }
+  } else if (AppBskyEmbedRecord.isViewBlocked(record)) {
+    return {
+      type: 'post_blocked',
+      view: record,
+    }
+  } else if (AppBskyEmbedRecord.isViewDetached(record)) {
+    return {
+      type: 'post_detached',
+      view: record,
+    }
+  } else if (AppBskyFeedDefs.isGeneratorView(record)) {
+    return {
+      type: 'feed',
+      view: record,
+    }
+  } else if (AppBskyGraphDefs.isListView(record)) {
+    return {
+      type: 'list',
+      view: record,
+    }
+  } else if (AppBskyLabelerDefs.isLabelerView(record)) {
+    return {
+      type: 'labeler',
+      view: record,
+    }
+  } else if (AppBskyGraphDefs.isStarterPackViewBasic(record)) {
+    return {
+      type: 'starter_pack',
+      view: record,
+    }
+  } else {
+    return {
+      type: 'unknown',
+      view: null,
+    }
+  }
+}
+
+export function parseEmbed(embed: AppBskyFeedDefs.PostView['embed']): Embed {
+  if (AppBskyEmbedImages.isView(embed)) {
+    return {
+      type: 'images',
+      view: embed,
+    }
+  } else if (AppBskyEmbedExternal.isView(embed)) {
+    return {
+      type: 'link',
+      view: embed,
+    }
+  } else if (AppBskyEmbedVideo.isView(embed)) {
+    return {
+      type: 'video',
+      view: embed,
+    }
+  } else if (AppBskyEmbedRecord.isView(embed)) {
+    return parseEmbedRecordView(embed)
+  } else if (AppBskyEmbedRecordWithMedia.isView(embed)) {
+    return {
+      type: 'post_with_media',
+      view: parseEmbedRecordView(embed.record),
+      media: parseEmbed(embed.media),
+    }
+  } else {
+    return {
+      type: 'unknown',
+      view: null,
+    }
+  }
+}
diff --git a/bskyembed/src/util/pretty-number.ts b/bskyembed/src/util/pretty-number.ts
new file mode 100644
index 000000000..07f7e9577
--- /dev/null
+++ b/bskyembed/src/util/pretty-number.ts
@@ -0,0 +1,9 @@
+const formatter = new Intl.NumberFormat('en-US', {
+  notation: 'compact',
+  maximumFractionDigits: 1,
+  roundingMode: 'trunc',
+})
+
+export function prettyNumber(number: number) {
+  return formatter.format(number)
+}
diff --git a/bskyembed/src/util/rkey.ts b/bskyembed/src/util/rkey.ts
new file mode 100644
index 000000000..71d57f8c6
--- /dev/null
+++ b/bskyembed/src/util/rkey.ts
@@ -0,0 +1,6 @@
+import {AtUri} from '@atproto/api'
+
+export function getRkey({uri}: {uri: string}): string {
+  const at = new AtUri(uri)
+  return at.rkey
+}
diff --git a/bskyembed/src/util/verification-state.ts b/bskyembed/src/util/verification-state.ts
new file mode 100644
index 000000000..29355e511
--- /dev/null
+++ b/bskyembed/src/util/verification-state.ts
@@ -0,0 +1,31 @@
+import * as bsky from '../types/bsky'
+
+export type VerificationState = {
+  role: 'default' | 'verifier'
+  isVerified: boolean
+}
+
+export function getVerificationState({
+  profile,
+}: {
+  profile?: bsky.profile.AnyProfileView
+}): VerificationState {
+  if (!profile || !profile.verification) {
+    return {
+      role: 'default',
+      isVerified: false,
+    }
+  }
+
+  const {verifiedStatus, trustedVerifierStatus} = profile.verification
+  const isVerifiedUser = ['valid', 'invalid'].includes(verifiedStatus)
+  const isVerifierUser = ['valid', 'invalid'].includes(trustedVerifierStatus)
+  const isVerified =
+    (isVerifiedUser && verifiedStatus === 'valid') ||
+    (isVerifierUser && trustedVerifierStatus === 'valid')
+
+  return {
+    role: isVerifierUser ? 'verifier' : 'default',
+    isVerified,
+  }
+}
diff --git a/bskyembed/src/utils.ts b/bskyembed/src/utils.ts
deleted file mode 100644
index cfa4a525b..000000000
--- a/bskyembed/src/utils.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import {AtUri} from '@atproto/api'
-
-export function niceDate(date: number | string | Date) {
-  const d = new Date(date)
-  return `${d.toLocaleDateString('en-us', {
-    year: 'numeric',
-    month: 'short',
-    day: 'numeric',
-  })} at ${d.toLocaleTimeString(undefined, {
-    hour: 'numeric',
-    minute: '2-digit',
-  })}`
-}
-
-export function getRkey({uri}: {uri: string}): string {
-  const at = new AtUri(uri)
-  return at.rkey
-}
-
-const formatter = new Intl.NumberFormat('en-US', {
-  notation: 'compact',
-  maximumFractionDigits: 1,
-  roundingMode: 'trunc',
-})
-
-export function prettyNumber(number: number) {
-  return formatter.format(number)
-}