about summary refs log tree commit diff
path: root/src/view/com
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com')
-rw-r--r--src/view/com/composer/Composer.tsx46
-rw-r--r--src/view/com/composer/ExternalEmbed.tsx9
-rw-r--r--src/view/com/composer/GifAltText.tsx177
-rw-r--r--src/view/com/composer/photos/Gallery.tsx67
-rw-r--r--src/view/com/feeds/FeedSourceCard.tsx12
-rw-r--r--src/view/com/modals/crop-image/CropImage.web.tsx94
-rw-r--r--src/view/com/modals/crop-image/cropImageUtil.ts13
-rw-r--r--src/view/com/notifications/FeedItem.tsx10
-rw-r--r--src/view/com/post-thread/PostThread.tsx6
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx12
-rw-r--r--src/view/com/util/List.tsx7
-rw-r--r--src/view/com/util/List.web.tsx201
-rw-r--r--src/view/com/util/UserAvatar.tsx23
-rw-r--r--src/view/com/util/UserBanner.tsx43
-rw-r--r--src/view/com/util/Views.jsx10
-rw-r--r--src/view/com/util/forms/PostDropdownBtn.tsx4
-rw-r--r--src/view/com/util/images/Gallery.tsx5
-rw-r--r--src/view/com/util/post-ctrls/PostCtrls.tsx24
-rw-r--r--src/view/com/util/post-ctrls/RepostButton.tsx5
-rw-r--r--src/view/com/util/post-embeds/ExternalLinkEmbed.tsx4
-rw-r--r--src/view/com/util/post-embeds/GifEmbed.tsx75
21 files changed, 668 insertions, 179 deletions
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 0ac4ac56e..f472bb2e2 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -59,6 +59,7 @@ import * as Toast from '../util/Toast'
 import {UserAvatar} from '../util/UserAvatar'
 import {CharProgress} from './char-progress/CharProgress'
 import {ExternalEmbed} from './ExternalEmbed'
+import {GifAltText} from './GifAltText'
 import {LabelsBtn} from './labels/LabelsBtn'
 import {Gallery} from './photos/Gallery'
 import {OpenCameraBtn} from './photos/OpenCameraBtn'
@@ -327,7 +328,7 @@ export const ComposePost = observer(function ComposePost({
           image: gif.media_formats.preview.url,
           likelyType: LikelyType.HTML,
           title: gif.content_description,
-          description: `ALT: ${gif.content_description}`,
+          description: '',
         },
       })
       setExtGif(gif)
@@ -335,6 +336,26 @@ export const ComposePost = observer(function ComposePost({
     [setExtLink],
   )
 
+  const handleChangeGifAltText = useCallback(
+    (altText: string) => {
+      setExtLink(ext =>
+        ext && ext.meta
+          ? {
+              ...ext,
+              meta: {
+                ...ext?.meta,
+                description:
+                  altText.trim().length === 0
+                    ? ''
+                    : `Alt text: ${altText.trim()}`,
+              },
+            }
+          : ext,
+      )
+    },
+    [setExtLink],
+  )
+
   return (
     <KeyboardAvoidingView
       testID="composePostView"
@@ -474,14 +495,21 @@ export const ComposePost = observer(function ComposePost({
 
           <Gallery gallery={gallery} />
           {gallery.isEmpty && extLink && (
-            <ExternalEmbed
-              link={extLink}
-              gif={extGif}
-              onRemove={() => {
-                setExtLink(undefined)
-                setExtGif(undefined)
-              }}
-            />
+            <View style={a.relative}>
+              <ExternalEmbed
+                link={extLink}
+                gif={extGif}
+                onRemove={() => {
+                  setExtLink(undefined)
+                  setExtGif(undefined)
+                }}
+              />
+              <GifAltText
+                link={extLink}
+                gif={extGif}
+                onSubmit={handleChangeGifAltText}
+              />
+            </View>
           )}
           {quote ? (
             <View style={[s.mt5, isWeb && s.mb10]}>
diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx
index 321e29b30..b81065e99 100644
--- a/src/view/com/composer/ExternalEmbed.tsx
+++ b/src/view/com/composer/ExternalEmbed.tsx
@@ -46,7 +46,12 @@ export const ExternalEmbed = ({
     : undefined
 
   return (
-    <View style={[a.mb_xl, a.overflow_hidden, t.atoms.border_contrast_medium]}>
+    <View
+      style={[
+        !gif && a.mb_xl,
+        a.overflow_hidden,
+        t.atoms.border_contrast_medium,
+      ]}>
       {link.isLoading ? (
         <Container style={loadingStyle}>
           <Loader size="xl" />
@@ -62,7 +67,7 @@ export const ExternalEmbed = ({
         </Container>
       ) : linkInfo ? (
         <View style={{pointerEvents: !gif ? 'none' : 'auto'}}>
-          <ExternalLinkEmbed link={linkInfo} />
+          <ExternalLinkEmbed link={linkInfo} hideAlt />
         </View>
       ) : null}
       <TouchableOpacity
diff --git a/src/view/com/composer/GifAltText.tsx b/src/view/com/composer/GifAltText.tsx
new file mode 100644
index 000000000..9e41a328f
--- /dev/null
+++ b/src/view/com/composer/GifAltText.tsx
@@ -0,0 +1,177 @@
+import React, {useCallback, useState} from 'react'
+import {TouchableOpacity, View} from 'react-native'
+import {AppBskyEmbedExternal} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {ExternalEmbedDraft} from '#/lib/api'
+import {HITSLOP_10, MAX_ALT_TEXT} from '#/lib/constants'
+import {
+  EmbedPlayerParams,
+  parseEmbedPlayerFromUrl,
+} from '#/lib/strings/embed-player'
+import {enforceLen} from '#/lib/strings/helpers'
+import {isAndroid} from '#/platform/detection'
+import {Gif} from '#/state/queries/tenor'
+import {atoms as a, native, useTheme} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import * as TextField from '#/components/forms/TextField'
+import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
+import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
+import {Text} from '#/components/Typography'
+import {GifEmbed} from '../util/post-embeds/GifEmbed'
+import {AltTextReminder} from './photos/Gallery'
+
+export function GifAltText({
+  link: linkProp,
+  gif,
+  onSubmit,
+}: {
+  link: ExternalEmbedDraft
+  gif?: Gif
+  onSubmit: (alt: string) => void
+}) {
+  const control = Dialog.useDialogControl()
+  const {_} = useLingui()
+  const t = useTheme()
+
+  const {link, params} = React.useMemo(() => {
+    return {
+      link: {
+        title: linkProp.meta?.title ?? linkProp.uri,
+        uri: linkProp.uri,
+        description: linkProp.meta?.description ?? '',
+        thumb: linkProp.localThumb?.path,
+      },
+      params: parseEmbedPlayerFromUrl(linkProp.uri),
+    }
+  }, [linkProp])
+
+  const onPressSubmit = useCallback(
+    (alt: string) => {
+      control.close(() => {
+        onSubmit(alt)
+      })
+    },
+    [onSubmit, control],
+  )
+
+  if (!gif || !params) return null
+
+  return (
+    <>
+      <TouchableOpacity
+        testID="altTextButton"
+        accessibilityRole="button"
+        accessibilityLabel={_(msg`Add alt text`)}
+        accessibilityHint=""
+        hitSlop={HITSLOP_10}
+        onPress={control.open}
+        style={[
+          a.absolute,
+          {top: 20, left: 12},
+          {borderRadius: 6},
+          a.pl_xs,
+          a.pr_sm,
+          a.py_2xs,
+          a.flex_row,
+          a.gap_xs,
+          a.align_center,
+          {backgroundColor: 'rgba(0, 0, 0, 0.75)'},
+        ]}>
+        {link.description ? (
+          <Check size="xs" fill={t.palette.white} style={a.ml_xs} />
+        ) : (
+          <Plus size="sm" fill={t.palette.white} />
+        )}
+        <Text
+          style={[a.font_bold, {color: t.palette.white}]}
+          accessible={false}>
+          <Trans>ALT</Trans>
+        </Text>
+      </TouchableOpacity>
+
+      <AltTextReminder />
+
+      <Dialog.Outer
+        control={control}
+        nativeOptions={isAndroid ? {sheet: {snapPoints: ['100%']}} : {}}>
+        <Dialog.Handle />
+        <AltTextInner
+          onSubmit={onPressSubmit}
+          link={link}
+          params={params}
+          initalValue={link.description.replace('Alt text: ', '')}
+          key={link.uri}
+        />
+      </Dialog.Outer>
+    </>
+  )
+}
+
+function AltTextInner({
+  onSubmit,
+  link,
+  params,
+  initalValue,
+}: {
+  onSubmit: (text: string) => void
+  link: AppBskyEmbedExternal.ViewExternal
+  params: EmbedPlayerParams
+  initalValue: string
+}) {
+  const {_} = useLingui()
+  const [altText, setAltText] = useState(initalValue)
+
+  const onPressSubmit = useCallback(() => {
+    onSubmit(altText)
+  }, [onSubmit, altText])
+
+  return (
+    <Dialog.ScrollableInner label={_(msg`Add alt text`)}>
+      <View style={a.flex_col_reverse}>
+        <View style={[a.mt_md, a.gap_md]}>
+          <View>
+            <TextField.LabelText>
+              <Trans>Descriptive alt text</Trans>
+            </TextField.LabelText>
+            <TextField.Root>
+              <Dialog.Input
+                label={_(msg`Alt text`)}
+                placeholder={link.title}
+                onChangeText={text =>
+                  setAltText(enforceLen(text, MAX_ALT_TEXT))
+                }
+                value={altText}
+                multiline
+                numberOfLines={3}
+                autoFocus
+              />
+            </TextField.Root>
+          </View>
+          <Button
+            label={_(msg`Save`)}
+            size="medium"
+            color="primary"
+            variant="solid"
+            onPress={onPressSubmit}>
+            <ButtonText>
+              <Trans>Save</Trans>
+            </ButtonText>
+          </Button>
+        </View>
+        {/* below the text input to force tab order */}
+        <View>
+          <Text style={[a.text_2xl, a.font_bold, a.leading_tight, a.pb_sm]}>
+            <Trans>Add ALT text</Trans>
+          </Text>
+          <View style={[a.w_full, a.align_center, native({maxHeight: 200})]}>
+            <GifEmbed link={link} params={params} hideAlt />
+          </View>
+        </View>
+      </View>
+      <Dialog.Close />
+    </Dialog.ScrollableInner>
+  )
+}
diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx
index 69c8debb0..7ff1b7b9a 100644
--- a/src/view/com/composer/photos/Gallery.tsx
+++ b/src/view/com/composer/photos/Gallery.tsx
@@ -1,19 +1,20 @@
 import React, {useState} from 'react'
 import {ImageStyle, Keyboard, LayoutChangeEvent} from 'react-native'
-import {GalleryModel} from 'state/models/media/gallery'
-import {observer} from 'mobx-react-lite'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {s, colors} from 'lib/styles'
 import {StyleSheet, TouchableOpacity, View} from 'react-native'
 import {Image} from 'expo-image'
-import {Text} from 'view/com/util/text/Text'
-import {Dimensions} from 'lib/media/types'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {Trans, msg} from '@lingui/macro'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
+import {observer} from 'mobx-react-lite'
+
 import {useModalControls} from '#/state/modals'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {Dimensions} from 'lib/media/types'
+import {colors, s} from 'lib/styles'
 import {isNative} from 'platform/detection'
+import {GalleryModel} from 'state/models/media/gallery'
+import {Text} from 'view/com/util/text/Text'
+import {useTheme} from '#/alf'
 
 const IMAGE_GAP = 8
 
@@ -49,10 +50,10 @@ const GalleryInner = observer(function GalleryImpl({
   gallery,
   containerInfo,
 }: GalleryInnerProps) {
-  const pal = usePalette('default')
   const {_} = useLingui()
   const {isMobile} = useWebMediaQueries()
   const {openModal} = useModalControls()
+  const t = useTheme()
 
   let side: number
 
@@ -126,16 +127,22 @@ const GalleryInner = observer(function GalleryImpl({
                 })
               }}
               style={[styles.altTextControl, altTextControlStyle]}>
-              <Text style={styles.altTextControlLabel} accessible={false}>
-                <Trans>ALT</Trans>
-              </Text>
               {image.altText.length > 0 ? (
                 <FontAwesomeIcon
                   icon="check"
                   size={10}
-                  style={{color: colors.green3}}
+                  style={{color: t.palette.white}}
+                />
+              ) : (
+                <FontAwesomeIcon
+                  icon="plus"
+                  size={10}
+                  style={{color: t.palette.white}}
                 />
-              ) : undefined}
+              )}
+              <Text style={styles.altTextControlLabel} accessible={false}>
+                <Trans>ALT</Trans>
+              </Text>
             </TouchableOpacity>
             <View style={imageControlsStyle}>
               <TouchableOpacity
@@ -201,21 +208,28 @@ const GalleryInner = observer(function GalleryImpl({
           </View>
         ))}
       </View>
-      <View style={[styles.reminder]}>
-        <View style={[styles.infoIcon, pal.viewLight]}>
-          <FontAwesomeIcon icon="info" size={12} color={pal.colors.text} />
-        </View>
-        <Text type="sm" style={[pal.textLight, s.flex1]}>
-          <Trans>
-            Alt text describes images for blind and low-vision users, and helps
-            give context to everyone.
-          </Trans>
-        </Text>
-      </View>
+      <AltTextReminder />
     </>
   ) : null
 })
 
+export function AltTextReminder() {
+  const t = useTheme()
+  return (
+    <View style={[styles.reminder]}>
+      <View style={[styles.infoIcon, t.atoms.bg_contrast_25]}>
+        <FontAwesomeIcon icon="info" size={12} color={t.atoms.text.color} />
+      </View>
+      <Text type="sm" style={[t.atoms.text_contrast_medium, s.flex1]}>
+        <Trans>
+          Alt text describes images for blind and low-vision users, and helps
+          give context to everyone.
+        </Trans>
+      </Text>
+    </View>
+  )
+}
+
 const styles = StyleSheet.create({
   gallery: {
     flex: 1,
@@ -244,6 +258,7 @@ const styles = StyleSheet.create({
     paddingVertical: 3,
     flexDirection: 'row',
     alignItems: 'center',
+    gap: 4,
   },
   altTextControlLabel: {
     color: 'white',
diff --git a/src/view/com/feeds/FeedSourceCard.tsx b/src/view/com/feeds/FeedSourceCard.tsx
index 9300b4159..8a21d86ae 100644
--- a/src/view/com/feeds/FeedSourceCard.tsx
+++ b/src/view/com/feeds/FeedSourceCard.tsx
@@ -6,12 +6,11 @@ import {RichText} from '#/components/RichText'
 import {usePalette} from 'lib/hooks/usePalette'
 import {s} from 'lib/styles'
 import {UserAvatar} from '../util/UserAvatar'
-import {pluralize} from 'lib/strings/helpers'
 import {AtUri} from '@atproto/api'
 import * as Toast from 'view/com/util/Toast'
 import {sanitizeHandle} from 'lib/strings/handles'
 import {logger} from '#/logger'
-import {Trans, msg} from '@lingui/macro'
+import {Trans, msg, Plural} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {
   usePinFeedMutation,
@@ -265,10 +264,11 @@ export function FeedSourceCardLoaded({
 
         {showLikes && feed.type === 'feed' ? (
           <Text type="sm-medium" style={[pal.text, pal.textLight]}>
-            <Trans>
-              Liked by {feed.likeCount || 0}{' '}
-              {pluralize(feed.likeCount || 0, 'user')}
-            </Trans>
+            <Plural
+              value={feed.likeCount || 0}
+              one="Liked by # user"
+              other="Liked by # users"
+            />
           </Text>
         ) : null}
       </Pressable>
diff --git a/src/view/com/modals/crop-image/CropImage.web.tsx b/src/view/com/modals/crop-image/CropImage.web.tsx
index 79ff5a02a..10cae2f17 100644
--- a/src/view/com/modals/crop-image/CropImage.web.tsx
+++ b/src/view/com/modals/crop-image/CropImage.web.tsx
@@ -14,11 +14,13 @@ import {Dimensions} from 'lib/media/types'
 import {getDataUriSize} from 'lib/media/util'
 import {gradients, s} from 'lib/styles'
 import {Text} from 'view/com/util/text/Text'
+import {calculateDimensions} from './cropImageUtil'
 
 enum AspectRatio {
   Square = 'square',
   Wide = 'wide',
   Tall = 'tall',
+  Custom = 'custom',
 }
 
 const DIMS: Record<string, Dimensions> = {
@@ -31,17 +33,24 @@ export const snapPoints = ['0%']
 
 export function Component({
   uri,
+  dimensions,
   onSelect,
 }: {
   uri: string
+  dimensions?: Dimensions
   onSelect: (img?: RNImage) => void
 }) {
   const {closeModal} = useModalControls()
   const pal = usePalette('default')
   const {_} = useLingui()
-  const [as, setAs] = React.useState<AspectRatio>(AspectRatio.Square)
+  const defaultAspectStyle = dimensions
+    ? AspectRatio.Custom
+    : AspectRatio.Square
+  const [as, setAs] = React.useState<AspectRatio>(defaultAspectStyle)
   const [scale, setScale] = React.useState<number>(1)
   const editorRef = React.useRef<ImageEditor>(null)
+  const imageEditorWidth = dimensions ? dimensions.width : DIMS[as].width
+  const imageEditorHeight = dimensions ? dimensions.height : DIMS[as].height
 
   const doSetAs = (v: AspectRatio) => () => setAs(v)
 
@@ -57,8 +66,8 @@ export function Component({
         path: dataUri,
         mime: 'image/jpeg',
         size: getDataUriSize(dataUri),
-        width: DIMS[as].width,
-        height: DIMS[as].height,
+        width: imageEditorWidth,
+        height: imageEditorHeight,
       })
     } else {
       onSelect(undefined)
@@ -73,7 +82,18 @@ export function Component({
     cropperStyle = styles.cropperWide
   } else if (as === AspectRatio.Tall) {
     cropperStyle = styles.cropperTall
+  } else if (as === AspectRatio.Custom) {
+    const cropperDimensions = calculateDimensions(
+      550,
+      imageEditorHeight,
+      imageEditorWidth,
+    )
+    cropperStyle = {
+      width: cropperDimensions.width,
+      height: cropperDimensions.height,
+    }
   }
+
   return (
     <View>
       <View style={[styles.cropper, pal.borderDark, cropperStyle]}>
@@ -81,8 +101,8 @@ export function Component({
           ref={editorRef}
           style={styles.imageEditor}
           image={uri}
-          width={DIMS[as].width}
-          height={DIMS[as].height}
+          width={imageEditorWidth}
+          height={imageEditorHeight}
           scale={scale}
           border={0}
         />
@@ -97,36 +117,40 @@ export function Component({
           maximumValue={3}
           containerStyle={styles.slider}
         />
-        <TouchableOpacity
-          onPress={doSetAs(AspectRatio.Wide)}
-          accessibilityRole="button"
-          accessibilityLabel={_(msg`Wide`)}
-          accessibilityHint={_(msg`Sets image aspect ratio to wide`)}>
-          <RectWideIcon
-            size={24}
-            style={as === AspectRatio.Wide ? s.blue3 : pal.text}
-          />
-        </TouchableOpacity>
-        <TouchableOpacity
-          onPress={doSetAs(AspectRatio.Tall)}
-          accessibilityRole="button"
-          accessibilityLabel={_(msg`Tall`)}
-          accessibilityHint={_(msg`Sets image aspect ratio to tall`)}>
-          <RectTallIcon
-            size={24}
-            style={as === AspectRatio.Tall ? s.blue3 : pal.text}
-          />
-        </TouchableOpacity>
-        <TouchableOpacity
-          onPress={doSetAs(AspectRatio.Square)}
-          accessibilityRole="button"
-          accessibilityLabel={_(msg`Square`)}
-          accessibilityHint={_(msg`Sets image aspect ratio to square`)}>
-          <SquareIcon
-            size={24}
-            style={as === AspectRatio.Square ? s.blue3 : pal.text}
-          />
-        </TouchableOpacity>
+        {as === AspectRatio.Custom ? null : (
+          <>
+            <TouchableOpacity
+              onPress={doSetAs(AspectRatio.Wide)}
+              accessibilityRole="button"
+              accessibilityLabel={_(msg`Wide`)}
+              accessibilityHint={_(msg`Sets image aspect ratio to wide`)}>
+              <RectWideIcon
+                size={24}
+                style={as === AspectRatio.Wide ? s.blue3 : pal.text}
+              />
+            </TouchableOpacity>
+            <TouchableOpacity
+              onPress={doSetAs(AspectRatio.Tall)}
+              accessibilityRole="button"
+              accessibilityLabel={_(msg`Tall`)}
+              accessibilityHint={_(msg`Sets image aspect ratio to tall`)}>
+              <RectTallIcon
+                size={24}
+                style={as === AspectRatio.Tall ? s.blue3 : pal.text}
+              />
+            </TouchableOpacity>
+            <TouchableOpacity
+              onPress={doSetAs(AspectRatio.Square)}
+              accessibilityRole="button"
+              accessibilityLabel={_(msg`Square`)}
+              accessibilityHint={_(msg`Sets image aspect ratio to square`)}>
+              <SquareIcon
+                size={24}
+                style={as === AspectRatio.Square ? s.blue3 : pal.text}
+              />
+            </TouchableOpacity>
+          </>
+        )}
       </View>
       <View style={styles.btns}>
         <TouchableOpacity
diff --git a/src/view/com/modals/crop-image/cropImageUtil.ts b/src/view/com/modals/crop-image/cropImageUtil.ts
new file mode 100644
index 000000000..303d15ba5
--- /dev/null
+++ b/src/view/com/modals/crop-image/cropImageUtil.ts
@@ -0,0 +1,13 @@
+export const calculateDimensions = (
+  maxWidth: number,
+  originalHeight: number,
+  originalWidth: number,
+) => {
+  const aspectRatio = originalWidth / originalHeight
+  const newHeight = maxWidth / aspectRatio
+  const newWidth = maxWidth
+  return {
+    width: newWidth,
+    height: newHeight,
+  }
+}
diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx
index 94844cb1a..c20a8e9ee 100644
--- a/src/view/com/notifications/FeedItem.tsx
+++ b/src/view/com/notifications/FeedItem.tsx
@@ -22,7 +22,7 @@ import {
   FontAwesomeIconStyle,
   Props,
 } from '@fortawesome/react-native-fontawesome'
-import {msg, Trans} from '@lingui/macro'
+import {msg, plural, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useQueryClient} from '@tanstack/react-query'
 
@@ -33,7 +33,6 @@ import {HeartIconSolid} from 'lib/icons'
 import {makeProfileLink} from 'lib/routes/links'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
 import {sanitizeHandle} from 'lib/strings/handles'
-import {pluralize} from 'lib/strings/helpers'
 import {niceDate} from 'lib/strings/time'
 import {colors, s} from 'lib/styles'
 import {isWeb} from 'platform/detection'
@@ -176,6 +175,7 @@ let FeedItem = ({
     return null
   }
 
+  let formattedCount = authors.length > 1 ? formatCount(authors.length - 1) : ''
   return (
     <Link
       testID={`feedItem-by-${item.notification.author.handle}`}
@@ -236,8 +236,10 @@ let FeedItem = ({
                   <Trans>and</Trans>{' '}
                 </Text>
                 <Text style={[pal.text, s.bold]}>
-                  {formatCount(authors.length - 1)}{' '}
-                  {pluralize(authors.length - 1, 'other')}
+                  {plural(authors.length - 1, {
+                    one: `${formattedCount} other`,
+                    other: `${formattedCount} others`,
+                  })}
                 </Text>
               </>
             ) : undefined}
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index 69b26ad44..a52818fd1 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -371,11 +371,11 @@ export function PostThread({
     ],
   )
 
-  if (error || !thread) {
+  if (!thread || !preferences || error) {
     return (
       <ListMaybePlaceholder
-        isLoading={(!preferences || !thread) && !error}
-        isError={!!error}
+        isLoading={!error}
+        isError={Boolean(error)}
         noEmpty
         onRetry={refetch}
         errorTitle={error?.title}
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index cfb8bd93f..f644a5366 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -8,7 +8,7 @@ import {
   RichText as RichTextAPI,
 } from '@atproto/api'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {msg, Trans} from '@lingui/macro'
+import {msg, Plural, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
 import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
@@ -24,7 +24,7 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {makeProfileLink} from 'lib/routes/links'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
 import {sanitizeHandle} from 'lib/strings/handles'
-import {countLines, pluralize} from 'lib/strings/helpers'
+import {countLines} from 'lib/strings/helpers'
 import {niceDate} from 'lib/strings/time'
 import {s} from 'lib/styles'
 import {isWeb} from 'platform/detection'
@@ -336,7 +336,11 @@ let PostThreadItemLoaded = ({
                       <Text type="xl-bold" style={pal.text}>
                         {formatCount(post.repostCount)}
                       </Text>{' '}
-                      {pluralize(post.repostCount, 'repost')}
+                      <Plural
+                        value={post.repostCount}
+                        one="repost"
+                        other="reposts"
+                      />
                     </Text>
                   </Link>
                 ) : null}
@@ -352,7 +356,7 @@ let PostThreadItemLoaded = ({
                       <Text type="xl-bold" style={pal.text}>
                         {formatCount(post.likeCount)}
                       </Text>{' '}
-                      {pluralize(post.likeCount, 'like')}
+                      <Plural value={post.likeCount} one="like" other="likes" />
                     </Text>
                   </Link>
                 ) : null}
diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx
index ff60e94cd..84b401e63 100644
--- a/src/view/com/util/List.tsx
+++ b/src/view/com/util/List.tsx
@@ -5,9 +5,7 @@ import {runOnJS, useSharedValue} from 'react-native-reanimated'
 import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
 import {usePalette} from '#/lib/hooks/usePalette'
 import {useScrollHandlers} from '#/lib/ScrollContext'
-import {useGate} from 'lib/statsig/statsig'
 import {addStyle} from 'lib/styles'
-import {isWeb} from 'platform/detection'
 import {FlatList_INTERNAL} from './Views'
 
 export type ListMethods = FlatList_INTERNAL
@@ -25,6 +23,7 @@ export type ListProps<ItemT> = Omit<
   headerOffset?: number
   refreshing?: boolean
   onRefresh?: () => void
+  containWeb?: boolean
 }
 export type ListRef = React.MutableRefObject<FlatList_INTERNAL | null>
 
@@ -44,7 +43,6 @@ function ListImpl<ItemT>(
   const isScrolledDown = useSharedValue(false)
   const contextScrollHandlers = useScrollHandlers()
   const pal = usePalette('default')
-  const gate = useGate()
 
   function handleScrolledDownChange(didScrollDown: boolean) {
     onScrolledDownChange?.(didScrollDown)
@@ -106,9 +104,6 @@ function ListImpl<ItemT>(
       scrollEventThrottle={1}
       style={style}
       ref={ref}
-      showsVerticalScrollIndicator={
-        isWeb || !gate('hide_vertical_scroll_indicators')
-      }
     />
   )
 }
diff --git a/src/view/com/util/List.web.tsx b/src/view/com/util/List.web.tsx
index 936bac198..9bea2d795 100644
--- a/src/view/com/util/List.web.tsx
+++ b/src/view/com/util/List.web.tsx
@@ -1,11 +1,13 @@
-import React, {isValidElement, memo, useRef, startTransition} from 'react'
+import React, {isValidElement, memo, startTransition, useRef} from 'react'
 import {FlatListProps, StyleSheet, View, ViewProps} from 'react-native'
-import {addStyle} from 'lib/styles'
+import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/reanimated2/hook/commonTypes'
+
+import {batchedUpdates} from '#/lib/batchedUpdates'
+import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
+import {useScrollHandlers} from '#/lib/ScrollContext'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {useScrollHandlers} from '#/lib/ScrollContext'
-import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
-import {batchedUpdates} from '#/lib/batchedUpdates'
+import {addStyle} from 'lib/styles'
 
 export type ListMethods = any // TODO: Better types.
 export type ListProps<ItemT> = Omit<
@@ -19,6 +21,7 @@ export type ListProps<ItemT> = Omit<
   refreshing?: boolean
   onRefresh?: () => void
   desktopFixedHeight: any // TODO: Better types.
+  containWeb?: boolean
 }
 export type ListRef = React.MutableRefObject<any | null> // TODO: Better types.
 
@@ -26,12 +29,15 @@ function ListImpl<ItemT>(
   {
     ListHeaderComponent,
     ListFooterComponent,
+    containWeb,
     contentContainerStyle,
     data,
     desktopFixedHeight,
     headerOffset,
     keyExtractor,
     refreshing: _unsupportedRefreshing,
+    onStartReached,
+    onStartReachedThreshold = 0,
     onEndReached,
     onEndReachedThreshold = 0,
     onRefresh: _unsupportedOnRefresh,
@@ -80,14 +86,88 @@ function ListImpl<ItemT>(
     })
   }
 
-  const nativeRef = React.useRef(null)
+  const getScrollableNode = React.useCallback(() => {
+    if (containWeb) {
+      const element = nativeRef.current as HTMLDivElement | null
+      if (!element) return
+
+      return {
+        get scrollWidth() {
+          return element.scrollWidth
+        },
+        get scrollHeight() {
+          return element.scrollHeight
+        },
+        get clientWidth() {
+          return element.clientWidth
+        },
+        get clientHeight() {
+          return element.clientHeight
+        },
+        get scrollY() {
+          return element.scrollTop
+        },
+        get scrollX() {
+          return element.scrollLeft
+        },
+        scrollTo(options?: ScrollToOptions) {
+          element.scrollTo(options)
+        },
+        scrollBy(options: ScrollToOptions) {
+          element.scrollBy(options)
+        },
+        addEventListener(event: string, handler: any) {
+          element.addEventListener(event, handler)
+        },
+        removeEventListener(event: string, handler: any) {
+          element.removeEventListener(event, handler)
+        },
+      }
+    } else {
+      return {
+        get scrollWidth() {
+          return document.documentElement.scrollWidth
+        },
+        get scrollHeight() {
+          return document.documentElement.scrollHeight
+        },
+        get clientWidth() {
+          return window.innerWidth
+        },
+        get clientHeight() {
+          return window.innerHeight
+        },
+        get scrollY() {
+          return window.scrollY
+        },
+        get scrollX() {
+          return window.scrollX
+        },
+        scrollTo(options: ScrollToOptions) {
+          window.scrollTo(options)
+        },
+        scrollBy(options: ScrollToOptions) {
+          window.scrollBy(options)
+        },
+        addEventListener(event: string, handler: any) {
+          window.addEventListener(event, handler)
+        },
+        removeEventListener(event: string, handler: any) {
+          window.removeEventListener(event, handler)
+        },
+      }
+    }
+  }, [containWeb])
+
+  const nativeRef = React.useRef<HTMLDivElement>(null)
   React.useImperativeHandle(
     ref,
     () =>
       ({
         scrollToTop() {
-          window.scrollTo({top: 0})
+          getScrollableNode()?.scrollTo({top: 0})
         },
+
         scrollToOffset({
           animated,
           offset,
@@ -95,46 +175,74 @@ function ListImpl<ItemT>(
           animated: boolean
           offset: number
         }) {
-          window.scrollTo({
+          getScrollableNode()?.scrollTo({
             left: 0,
             top: offset,
             behavior: animated ? 'smooth' : 'instant',
           })
         },
+        scrollToEnd({animated = true}: {animated?: boolean}) {
+          const element = getScrollableNode()
+          element?.scrollTo({
+            left: 0,
+            top: element.scrollHeight,
+            behavior: animated ? 'smooth' : 'instant',
+          })
+        },
       } as any), // TODO: Better types.
-    [],
+    [getScrollableNode],
   )
 
-  // --- onContentSizeChange ---
+  // --- onContentSizeChange, maintainVisibleContentPosition ---
   const containerRef = useRef(null)
   useResizeObserver(containerRef, onContentSizeChange)
 
   // --- onScroll ---
   const [isInsideVisibleTree, setIsInsideVisibleTree] = React.useState(false)
-  const handleWindowScroll = useNonReactiveCallback(() => {
-    if (isInsideVisibleTree) {
-      contextScrollHandlers.onScroll?.(
-        {
-          contentOffset: {
-            x: Math.max(0, window.scrollX),
-            y: Math.max(0, window.scrollY),
-          },
-        } as any, // TODO: Better types.
-        null as any,
-      )
-    }
+  const handleScroll = useNonReactiveCallback(() => {
+    if (!isInsideVisibleTree) return
+
+    const element = getScrollableNode()
+    contextScrollHandlers.onScroll?.(
+      {
+        contentOffset: {
+          x: Math.max(0, element?.scrollX ?? 0),
+          y: Math.max(0, element?.scrollY ?? 0),
+        },
+        layoutMeasurement: {
+          width: element?.clientWidth,
+          height: element?.clientHeight,
+        },
+        contentSize: {
+          width: element?.scrollWidth,
+          height: element?.scrollHeight,
+        },
+      } as Exclude<
+        ReanimatedScrollEvent,
+        | 'velocity'
+        | 'eventName'
+        | 'zoomScale'
+        | 'targetContentOffset'
+        | 'contentInset'
+      >,
+      null as any,
+    )
   })
+
   React.useEffect(() => {
     if (!isInsideVisibleTree) {
       // Prevents hidden tabs from firing scroll events.
       // Only one list is expected to be firing these at a time.
       return
     }
-    window.addEventListener('scroll', handleWindowScroll)
+
+    const element = getScrollableNode()
+
+    element?.addEventListener('scroll', handleScroll)
     return () => {
-      window.removeEventListener('scroll', handleWindowScroll)
+      element?.removeEventListener('scroll', handleScroll)
     }
-  }, [isInsideVisibleTree, handleWindowScroll])
+  }, [isInsideVisibleTree, handleScroll, containWeb, getScrollableNode])
 
   // --- onScrolledDownChange ---
   const isScrolledDown = useRef(false)
@@ -148,6 +256,17 @@ function ListImpl<ItemT>(
     }
   }
 
+  // --- onStartReached ---
+  const onHeadVisibilityChange = useNonReactiveCallback(
+    (isHeadVisible: boolean) => {
+      if (isHeadVisible) {
+        onStartReached?.({
+          distanceFromStart: onStartReachedThreshold || 0,
+        })
+      }
+    },
+  )
+
   // --- onEndReached ---
   const onTailVisibilityChange = useNonReactiveCallback(
     (isTailVisible: boolean) => {
@@ -160,7 +279,17 @@ function ListImpl<ItemT>(
   )
 
   return (
-    <View {...props} style={style} ref={nativeRef}>
+    <View
+      {...props}
+      style={[
+        style,
+        containWeb && {
+          flex: 1,
+          // @ts-expect-error web only
+          'overflow-y': 'scroll',
+        },
+      ]}
+      ref={nativeRef as any}>
       <Visibility
         onVisibleChange={setIsInsideVisibleTree}
         style={
@@ -178,9 +307,17 @@ function ListImpl<ItemT>(
           pal.border,
         ]}>
         <Visibility
+          root={containWeb ? nativeRef : null}
           onVisibleChange={handleAboveTheFoldVisibleChange}
           style={[styles.aboveTheFoldDetector, {height: headerOffset}]}
         />
+        {onStartReached && (
+          <Visibility
+            root={containWeb ? nativeRef : null}
+            onVisibleChange={onHeadVisibilityChange}
+            topMargin={(onStartReachedThreshold ?? 0) * 100 + '%'}
+          />
+        )}
         {header}
         {(data as Array<ItemT>).map((item, index) => (
           <Row<ItemT>
@@ -193,8 +330,9 @@ function ListImpl<ItemT>(
         ))}
         {onEndReached && (
           <Visibility
-            topMargin={(onEndReachedThreshold ?? 0) * 100 + '%'}
+            root={containWeb ? nativeRef : null}
             onVisibleChange={onTailVisibilityChange}
+            bottomMargin={(onEndReachedThreshold ?? 0) * 100 + '%'}
           />
         )}
         {footer}
@@ -255,11 +393,15 @@ let Row = function RowImpl<ItemT>({
 Row = React.memo(Row)
 
 let Visibility = ({
+  root,
   topMargin = '0px',
+  bottomMargin = '0px',
   onVisibleChange,
   style,
 }: {
+  root?: React.RefObject<HTMLDivElement> | null
   topMargin?: string
+  bottomMargin?: string
   onVisibleChange: (isVisible: boolean) => void
   style?: ViewProps['style']
 }): React.ReactNode => {
@@ -281,14 +423,15 @@ let Visibility = ({
 
   React.useEffect(() => {
     const observer = new IntersectionObserver(handleIntersection, {
-      rootMargin: `${topMargin} 0px 0px 0px`,
+      root: root?.current ?? null,
+      rootMargin: `${topMargin} 0px ${bottomMargin} 0px`,
     })
     const tail: Element | null = tailRef.current!
     observer.observe(tail)
     return () => {
       observer.unobserve(tail)
     }
-  }, [handleIntersection, topMargin])
+  }, [bottomMargin, handleIntersection, topMargin, root])
 
   return (
     <View ref={tailRef} style={addStyle(styles.visibilityDetector, style)} />
diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx
index 118e2ce2b..45327669b 100644
--- a/src/view/com/util/UserAvatar.tsx
+++ b/src/view/com/util/UserAvatar.tsx
@@ -8,6 +8,7 @@ import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useQueryClient} from '@tanstack/react-query'
 
+import {logger} from '#/logger'
 import {usePalette} from 'lib/hooks/usePalette'
 import {
   useCameraPermission,
@@ -282,15 +283,21 @@ let EditableUserAvatar = ({
       return
     }
 
-    const croppedImage = await openCropper({
-      mediaType: 'photo',
-      cropperCircleOverlay: true,
-      height: item.height,
-      width: item.width,
-      path: item.path,
-    })
+    try {
+      const croppedImage = await openCropper({
+        mediaType: 'photo',
+        cropperCircleOverlay: true,
+        height: item.height,
+        width: item.width,
+        path: item.path,
+      })
 
-    onSelectNewAvatar(croppedImage)
+      onSelectNewAvatar(croppedImage)
+    } catch (e: any) {
+      if (!String(e).includes('Canceled')) {
+        logger.error('Failed to crop banner', {error: e})
+      }
+    }
   }, [onSelectNewAvatar, requestPhotoAccessIfNeeded])
 
   const onRemoveAvatar = React.useCallback(() => {
diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx
index 4d73b853b..93ea32750 100644
--- a/src/view/com/util/UserBanner.tsx
+++ b/src/view/com/util/UserBanner.tsx
@@ -1,29 +1,30 @@
 import React from 'react'
 import {StyleSheet, TouchableOpacity, View} from 'react-native'
-import {ModerationUI} from '@atproto/api'
+import {Image as RNImage} from 'react-native-image-crop-picker'
 import {Image} from 'expo-image'
-import {useLingui} from '@lingui/react'
+import {ModerationUI} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
-import {colors} from 'lib/styles'
-import {useTheme} from 'lib/ThemeContext'
-import {useTheme as useAlfTheme, tokens} from '#/alf'
-import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
+import {logger} from '#/logger'
+import {usePalette} from 'lib/hooks/usePalette'
 import {
-  usePhotoLibraryPermission,
   useCameraPermission,
+  usePhotoLibraryPermission,
 } from 'lib/hooks/usePermissions'
-import {usePalette} from 'lib/hooks/usePalette'
+import {colors} from 'lib/styles'
+import {useTheme} from 'lib/ThemeContext'
 import {isAndroid, isNative} from 'platform/detection'
-import {Image as RNImage} from 'react-native-image-crop-picker'
 import {EventStopper} from 'view/com/util/EventStopper'
-import * as Menu from '#/components/Menu'
+import {tokens, useTheme as useAlfTheme} from '#/alf'
 import {
   Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled,
   Camera_Stroke2_Corner0_Rounded as Camera,
 } from '#/components/icons/Camera'
 import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/icons/StreamingLive'
 import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
+import * as Menu from '#/components/Menu'
+import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
 
 export function UserBanner({
   type,
@@ -64,14 +65,20 @@ export function UserBanner({
       return
     }
 
-    onSelectNewBanner?.(
-      await openCropper({
-        mediaType: 'photo',
-        path: items[0].path,
-        width: 3000,
-        height: 1000,
-      }),
-    )
+    try {
+      onSelectNewBanner?.(
+        await openCropper({
+          mediaType: 'photo',
+          path: items[0].path,
+          width: 3000,
+          height: 1000,
+        }),
+      )
+    } catch (e: any) {
+      if (!String(e).includes('Canceled')) {
+        logger.error('Failed to crop banner', {error: e})
+      }
+    }
   }, [onSelectNewBanner, requestPhotoAccessIfNeeded])
 
   const onRemoveBanner = React.useCallback(() => {
diff --git a/src/view/com/util/Views.jsx b/src/view/com/util/Views.jsx
index 75f2b5081..2984a2d2d 100644
--- a/src/view/com/util/Views.jsx
+++ b/src/view/com/util/Views.jsx
@@ -2,19 +2,11 @@ import React from 'react'
 import {View} from 'react-native'
 import Animated from 'react-native-reanimated'
 
-import {useGate} from 'lib/statsig/statsig'
-
 export const FlatList_INTERNAL = Animated.FlatList
 export function CenteredView(props) {
   return <View {...props} />
 }
 
 export function ScrollView(props) {
-  const gate = useGate()
-  return (
-    <Animated.ScrollView
-      {...props}
-      showsVerticalScrollIndicator={!gate('hide_vertical_scroll_indicators')}
-    />
-  )
+  return <Animated.ScrollView {...props} />
 }
diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx
index 32520182e..ac97f3da2 100644
--- a/src/view/com/util/forms/PostDropdownBtn.tsx
+++ b/src/view/com/util/forms/PostDropdownBtn.tsx
@@ -1,6 +1,6 @@
 import React, {memo} from 'react'
 import {Pressable, PressableProps, StyleProp, ViewStyle} from 'react-native'
-import {setStringAsync} from 'expo-clipboard'
+import * as Clipboard from 'expo-clipboard'
 import {
   AppBskyActorDefs,
   AppBskyFeedPost,
@@ -160,7 +160,7 @@ let PostDropdownBtn = ({
   const onCopyPostText = React.useCallback(() => {
     const str = richTextToString(richText, true)
 
-    setStringAsync(str)
+    Clipboard.setStringAsync(str)
     Toast.show(_(msg`Copied to clipboard`))
   }, [_, richText])
 
diff --git a/src/view/com/util/images/Gallery.tsx b/src/view/com/util/images/Gallery.tsx
index 7de3b093a..f6d2c7a1b 100644
--- a/src/view/com/util/images/Gallery.tsx
+++ b/src/view/com/util/images/Gallery.tsx
@@ -1,9 +1,10 @@
-import {AppBskyEmbedImages} from '@atproto/api'
 import React, {ComponentProps, FC} from 'react'
-import {StyleSheet, Text, Pressable, View} from 'react-native'
+import {Pressable, StyleSheet, Text, View} from 'react-native'
 import {Image} from 'expo-image'
+import {AppBskyEmbedImages} from '@atproto/api'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
+
 import {isWeb} from 'platform/detection'
 
 type EventFunction = (index: number) => void
diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx
index cb50ee6dc..7ebcde9a0 100644
--- a/src/view/com/util/post-ctrls/PostCtrls.tsx
+++ b/src/view/com/util/post-ctrls/PostCtrls.tsx
@@ -12,14 +12,13 @@ import {
   AtUri,
   RichText as RichTextAPI,
 } from '@atproto/api'
-import {msg} from '@lingui/macro'
+import {msg, plural} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
 import {HITSLOP_10, HITSLOP_20} from '#/lib/constants'
 import {CommentBottomArrow, HeartIcon, HeartIconSolid} from '#/lib/icons'
 import {makeProfileLink} from '#/lib/routes/links'
 import {shareUrl} from '#/lib/sharing'
-import {pluralize} from '#/lib/strings/helpers'
 import {toShareUrl} from '#/lib/strings/url-helpers'
 import {s} from '#/lib/styles'
 import {useTheme} from '#/lib/ThemeContext'
@@ -159,9 +158,10 @@ let PostCtrls = ({
             }
           }}
           accessibilityRole="button"
-          accessibilityLabel={`Reply (${post.replyCount} ${
-            post.replyCount === 1 ? 'reply' : 'replies'
-          })`}
+          accessibilityLabel={plural(post.replyCount || 0, {
+            one: 'Reply (# reply)',
+            other: 'Reply (# replies)',
+          })}
           accessibilityHint=""
           hitSlop={big ? HITSLOP_20 : HITSLOP_10}>
           <CommentBottomArrow
@@ -193,9 +193,17 @@ let PostCtrls = ({
             requireAuth(() => onPressToggleLike())
           }}
           accessibilityRole="button"
-          accessibilityLabel={`${
-            post.viewer?.like ? _(msg`Unlike`) : _(msg`Like`)
-          } (${post.likeCount} ${pluralize(post.likeCount || 0, 'like')})`}
+          accessibilityLabel={
+            post.viewer?.like
+              ? plural(post.likeCount || 0, {
+                  one: 'Unlike (# like)',
+                  other: 'Unlike (# likes)',
+                })
+              : plural(post.likeCount || 0, {
+                  one: 'Like (# like)',
+                  other: 'Like (# likes)',
+                })
+          }
           accessibilityHint=""
           hitSlop={big ? HITSLOP_20 : HITSLOP_10}>
           {post.viewer?.like ? (
diff --git a/src/view/com/util/post-ctrls/RepostButton.tsx b/src/view/com/util/post-ctrls/RepostButton.tsx
index cc3db50c8..c1af39a5d 100644
--- a/src/view/com/util/post-ctrls/RepostButton.tsx
+++ b/src/view/com/util/post-ctrls/RepostButton.tsx
@@ -4,11 +4,10 @@ import {RepostIcon} from 'lib/icons'
 import {s, colors} from 'lib/styles'
 import {useTheme} from 'lib/ThemeContext'
 import {Text} from '../text/Text'
-import {pluralize} from 'lib/strings/helpers'
 import {HITSLOP_10, HITSLOP_20} from 'lib/constants'
 import {useModalControls} from '#/state/modals'
 import {useRequireAuth} from '#/state/session'
-import {msg} from '@lingui/macro'
+import {msg, plural} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
 interface Props {
@@ -59,7 +58,7 @@ let RepostButton = ({
         isReposted
           ? _(msg`Undo repost`)
           : _(msg({message: 'Repost', context: 'action'}))
-      } (${repostCount} ${pluralize(repostCount || 0, 'repost')})`}
+      } (${plural(repostCount || 0, {one: '# repost', other: '# reposts'})})`}
       accessibilityHint=""
       hitSlop={big ? HITSLOP_20 : HITSLOP_10}>
       <RepostIcon
diff --git a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
index 1fe75c44e..b84c04b83 100644
--- a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
+++ b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
@@ -20,9 +20,11 @@ import {Text} from '../text/Text'
 export const ExternalLinkEmbed = ({
   link,
   style,
+  hideAlt,
 }: {
   link: AppBskyEmbedExternal.ViewExternal
   style?: StyleProp<ViewStyle>
+  hideAlt?: boolean
 }) => {
   const pal = usePalette('default')
   const {isMobile} = useWebMediaQueries()
@@ -37,7 +39,7 @@ export const ExternalLinkEmbed = ({
   }, [link.uri, externalEmbedPrefs])
 
   if (embedPlayerParams?.source === 'tenor') {
-    return <GifEmbed params={embedPlayerParams} link={link} />
+    return <GifEmbed params={embedPlayerParams} link={link} hideAlt={hideAlt} />
   }
 
   return (
diff --git a/src/view/com/util/post-embeds/GifEmbed.tsx b/src/view/com/util/post-embeds/GifEmbed.tsx
index 5d21ce064..286b57992 100644
--- a/src/view/com/util/post-embeds/GifEmbed.tsx
+++ b/src/view/com/util/post-embeds/GifEmbed.tsx
@@ -1,14 +1,18 @@
 import React from 'react'
-import {Pressable, View} from 'react-native'
+import {Pressable, StyleSheet, TouchableOpacity, View} from 'react-native'
 import {AppBskyEmbedExternal} from '@atproto/api'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {msg} from '@lingui/macro'
+import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
+import {HITSLOP_10} from '#/lib/constants'
+import {isWeb} from '#/platform/detection'
 import {EmbedPlayerParams} from 'lib/strings/embed-player'
 import {useAutoplayDisabled} from 'state/preferences'
 import {atoms as a, useTheme} from '#/alf'
 import {Loader} from '#/components/Loader'
+import * as Prompt from '#/components/Prompt'
+import {Text} from '#/components/Typography'
 import {GifView} from '../../../../../modules/expo-bluesky-gif-view'
 import {GifViewStateChangeEvent} from '../../../../../modules/expo-bluesky-gif-view/src/GifView.types'
 
@@ -82,9 +86,11 @@ function PlaybackControls({
 export function GifEmbed({
   params,
   link,
+  hideAlt,
 }: {
   params: EmbedPlayerParams
   link: AppBskyEmbedExternal.ViewExternal
+  hideAlt?: boolean
 }) {
   const {_} = useLingui()
   const autoplayDisabled = useAutoplayDisabled()
@@ -111,7 +117,8 @@ export function GifEmbed({
   }, [])
 
   return (
-    <View style={[a.rounded_sm, a.overflow_hidden, a.mt_sm]}>
+    <View
+      style={[a.rounded_sm, a.overflow_hidden, a.mt_sm, {maxWidth: '100%'}]}>
       <View
         style={[
           a.rounded_sm,
@@ -133,9 +140,69 @@ export function GifEmbed({
           onPlayerStateChange={onPlayerStateChange}
           ref={playerRef}
           accessibilityHint={_(msg`Animated GIF`)}
-          accessibilityLabel={link.description.replace('ALT: ', '')}
+          accessibilityLabel={link.description.replace('Alt text: ', '')}
         />
+
+        {!hideAlt && link.description.startsWith('Alt text: ') && (
+          <AltText text={link.description.replace('Alt text: ', '')} />
+        )}
       </View>
     </View>
   )
 }
+
+function AltText({text}: {text: string}) {
+  const control = Prompt.usePromptControl()
+
+  const {_} = useLingui()
+  return (
+    <>
+      <TouchableOpacity
+        testID="altTextButton"
+        accessibilityRole="button"
+        accessibilityLabel={_(msg`Show alt text`)}
+        accessibilityHint=""
+        hitSlop={HITSLOP_10}
+        onPress={control.open}
+        style={styles.altContainer}>
+        <Text style={styles.alt} accessible={false}>
+          <Trans>ALT</Trans>
+        </Text>
+      </TouchableOpacity>
+
+      <Prompt.Outer control={control}>
+        <Prompt.TitleText>
+          <Trans>Alt Text</Trans>
+        </Prompt.TitleText>
+        <Prompt.DescriptionText selectable>{text}</Prompt.DescriptionText>
+        <Prompt.Actions>
+          <Prompt.Action
+            onPress={control.close}
+            cta={_(msg`Close`)}
+            color="secondary"
+          />
+        </Prompt.Actions>
+      </Prompt.Outer>
+    </>
+  )
+}
+
+const styles = StyleSheet.create({
+  altContainer: {
+    backgroundColor: 'rgba(0, 0, 0, 0.75)',
+    borderRadius: 6,
+    paddingHorizontal: 6,
+    paddingVertical: 3,
+    position: 'absolute',
+    // Related to margin/gap hack. This keeps the alt label in the same position
+    // on all platforms
+    left: isWeb ? 8 : 5,
+    bottom: isWeb ? 8 : 5,
+    zIndex: 2,
+  },
+  alt: {
+    color: 'white',
+    fontSize: 10,
+    fontWeight: 'bold',
+  },
+})