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/ComposerReplyTo.tsx8
-rw-r--r--src/view/com/composer/select-language/SuggestedLanguage.tsx32
-rw-r--r--src/view/com/composer/text-input/TextInput.web.tsx1
-rw-r--r--src/view/com/feeds/ProfileFeedgens.tsx6
-rw-r--r--src/view/com/lists/ProfileLists.tsx8
-rw-r--r--src/view/com/modals/AltImage.tsx25
-rw-r--r--src/view/com/modals/ProfilePreview.tsx4
-rw-r--r--src/view/com/modals/UserAddRemoveLists.tsx22
-rw-r--r--src/view/com/notifications/FeedItem.tsx4
-rw-r--r--src/view/com/pager/FeedsTabBar.web.tsx20
-rw-r--r--src/view/com/pager/FeedsTabBarMobile.tsx20
-rw-r--r--src/view/com/pager/TabBar.tsx2
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx36
-rw-r--r--src/view/com/post/Post.tsx3
-rw-r--r--src/view/com/posts/FeedItem.tsx3
-rw-r--r--src/view/com/util/List.web.tsx5
-rw-r--r--src/view/com/util/LoadingPlaceholder.tsx45
-rw-r--r--src/view/com/util/UserInfoText.tsx6
-rw-r--r--src/view/com/util/forms/PostDropdownBtn.tsx8
-rw-r--r--src/view/com/util/images/Gallery.tsx7
-rw-r--r--src/view/com/util/images/ImageLayoutGrid.tsx39
-rw-r--r--src/view/com/util/load-latest/LoadLatestBtn.tsx10
-rw-r--r--src/view/com/util/post-ctrls/PostCtrls.tsx26
23 files changed, 227 insertions, 113 deletions
diff --git a/src/view/com/composer/ComposerReplyTo.tsx b/src/view/com/composer/ComposerReplyTo.tsx
index 678c8581f..39a1473a3 100644
--- a/src/view/com/composer/ComposerReplyTo.tsx
+++ b/src/view/com/composer/ComposerReplyTo.tsx
@@ -83,7 +83,11 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
       accessibilityHint={_(
         msg`Expand or collapse the full post you are replying to`,
       )}>
-      <UserAvatar avatar={replyTo.author.avatar} size={50} />
+      <UserAvatar
+        avatar={replyTo.author.avatar}
+        size={50}
+        moderation={replyTo.moderation?.avatar}
+      />
       <View style={styles.replyToPost}>
         <Text type="xl-medium" style={[pal.text]}>
           {sanitizeDisplayName(
@@ -99,7 +103,7 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
               {replyTo.text}
             </Text>
           </View>
-          {images && (
+          {images && !replyTo.moderation?.embed.blur && (
             <ComposerReplyToImages images={images} showFull={showFull} />
           )}
         </View>
diff --git a/src/view/com/composer/select-language/SuggestedLanguage.tsx b/src/view/com/composer/select-language/SuggestedLanguage.tsx
index 987d89d36..0bf62ae0d 100644
--- a/src/view/com/composer/select-language/SuggestedLanguage.tsx
+++ b/src/view/com/composer/select-language/SuggestedLanguage.tsx
@@ -23,7 +23,9 @@ const onIdle = globalThis.requestIdleCallback || (cb => setTimeout(cb, 1))
 const cancelIdle = globalThis.cancelIdleCallback || clearTimeout
 
 export function SuggestedLanguage({text}: {text: string}) {
-  const [suggestedLanguage, setSuggestedLanguage] = useState<string>()
+  const [suggestedLanguage, setSuggestedLanguage] = useState<
+    string | undefined
+  >()
   const langPrefs = useLanguagePrefs()
   const setLangPrefs = useLanguagePrefsApi()
   const pal = usePalette('default')
@@ -40,14 +42,7 @@ export function SuggestedLanguage({text}: {text: string}) {
     }
 
     const idle = onIdle(() => {
-      // Only select languages that have a high confidence and convert to code2
-      const result = lande(textTrimmed).filter(
-        ([lang, value]) => value >= 0.97 && code3ToCode2Strict(lang),
-      )
-
-      setSuggestedLanguage(
-        result.length > 0 ? code3ToCode2Strict(result[0][0]) : undefined,
-      )
+      setSuggestedLanguage(guessLanguage(textTrimmed))
     })
 
     return () => cancelIdle(idle)
@@ -99,3 +94,22 @@ const styles = StyleSheet.create({
     marginBottom: 10,
   },
 })
+
+/**
+ * This function is using the lande language model to attempt to detect the language
+ * We want to only make suggestions when we feel a high degree of certainty
+ * The magic numbers are based on debugging sessions against some test strings
+ */
+function guessLanguage(text: string): string | undefined {
+  const scores = lande(text).filter(([_lang, value]) => value >= 0.0002)
+  // if the model has multiple items with a score higher than 0.0002, it isn't certain enough
+  if (scores.length !== 1) {
+    return undefined
+  }
+  const [lang, value] = scores[0]
+  // if the model doesn't give a score of 0.97 or above, it isn't certain enough
+  if (value < 0.97) {
+    return undefined
+  }
+  return code3ToCode2Strict(lang)
+}
diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx
index f2012a630..af0d18743 100644
--- a/src/view/com/composer/text-input/TextInput.web.tsx
+++ b/src/view/com/composer/text-input/TextInput.web.tsx
@@ -316,7 +316,6 @@ function getImageFromUri(
     const type = item.type
 
     if (type === 'text/plain') {
-      console.log('hit')
       item.getAsString(async itemString => {
         if (isUriImage(itemString)) {
           const response = await fetch(itemString)
diff --git a/src/view/com/feeds/ProfileFeedgens.tsx b/src/view/com/feeds/ProfileFeedgens.tsx
index 96a04bad0..e9cf9e535 100644
--- a/src/view/com/feeds/ProfileFeedgens.tsx
+++ b/src/view/com/feeds/ProfileFeedgens.tsx
@@ -1,5 +1,5 @@
 import React from 'react'
-import {Dimensions, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
+import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
 import {useQueryClient} from '@tanstack/react-query'
 import {List, ListRef} from '../util/List'
 import {FeedSourceCardLoaded} from './FeedSourceCard'
@@ -180,9 +180,7 @@ export const ProfileFeedgens = React.forwardRef<
         refreshing={isPTRing}
         onRefresh={onRefresh}
         headerOffset={headerOffset}
-        contentContainerStyle={{
-          minHeight: Dimensions.get('window').height * 1.5,
-        }}
+        contentContainerStyle={isNative && {paddingBottom: headerOffset + 100}}
         indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'}
         removeClippedSubviews={true}
         // @ts-ignore our .web version only -prf
diff --git a/src/view/com/lists/ProfileLists.tsx b/src/view/com/lists/ProfileLists.tsx
index ba3e95b54..a47b25bed 100644
--- a/src/view/com/lists/ProfileLists.tsx
+++ b/src/view/com/lists/ProfileLists.tsx
@@ -1,5 +1,5 @@
 import React from 'react'
-import {Dimensions, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
+import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
 import {useQueryClient} from '@tanstack/react-query'
 import {List, ListRef} from '../util/List'
 import {ListCard} from './ListCard'
@@ -182,9 +182,9 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
           refreshing={isPTRing}
           onRefresh={onRefresh}
           headerOffset={headerOffset}
-          contentContainerStyle={{
-            minHeight: Dimensions.get('window').height * 1.5,
-          }}
+          contentContainerStyle={
+            isNative && {paddingBottom: headerOffset + 100}
+          }
           indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'}
           removeClippedSubviews={true}
           // @ts-ignore our .web version only -prf
diff --git a/src/view/com/modals/AltImage.tsx b/src/view/com/modals/AltImage.tsx
index 5156511d6..7671c29c8 100644
--- a/src/view/com/modals/AltImage.tsx
+++ b/src/view/com/modals/AltImage.tsx
@@ -4,7 +4,9 @@ import {
   StyleSheet,
   TouchableOpacity,
   View,
+  TextInput as RNTextInput,
   useWindowDimensions,
+  ScrollView as RNScrollView,
 } from 'react-native'
 import {ScrollView, TextInput} from './util'
 import {Image} from 'expo-image'
@@ -13,6 +15,7 @@ import {gradients, s} from 'lib/styles'
 import {enforceLen} from 'lib/strings/helpers'
 import {MAX_ALT_TEXT} from 'lib/constants'
 import {useTheme} from 'lib/ThemeContext'
+import {useIsKeyboardVisible} from 'lib/hooks/useIsKeyboardVisible'
 import {Text} from '../util/text/Text'
 import LinearGradient from 'react-native-linear-gradient'
 import {isWeb} from 'platform/detection'
@@ -34,6 +37,24 @@ export function Component({image}: Props) {
   const [altText, setAltText] = useState(image.altText)
   const windim = useWindowDimensions()
   const {closeModal} = useModalControls()
+  const inputRef = React.useRef<RNTextInput>(null)
+  const scrollViewRef = React.useRef<RNScrollView>(null)
+  const keyboardShown = useIsKeyboardVisible()
+
+  // Autofocus hack when we open the modal. We have to wait for the animation to complete first
+  React.useEffect(() => {
+    setTimeout(() => {
+      inputRef.current?.focus()
+    }, 500)
+  }, [])
+
+  // We'd rather be at the bottom here so that we can easily dismiss the modal instead of having to scroll
+  // (especially on android, it acts weird)
+  React.useEffect(() => {
+    if (keyboardShown[0]) {
+      scrollViewRef.current?.scrollToEnd()
+    }
+  }, [keyboardShown])
 
   const imageStyles = useMemo<ImageStyle>(() => {
     const maxWidth = isWeb ? 450 : windim.width
@@ -71,6 +92,7 @@ export function Component({image}: Props) {
       testID="altTextImageModal"
       style={[pal.view, styles.scrollContainer]}
       keyboardShouldPersistTaps="always"
+      ref={scrollViewRef}
       nativeID="imageAltText">
       <View style={styles.scrollInner}>
         <View style={[pal.viewLight, styles.imageContainer]}>
@@ -97,7 +119,8 @@ export function Component({image}: Props) {
           accessibilityLabel={_(msg`Image alt text`)}
           accessibilityHint=""
           accessibilityLabelledBy="imageAltText"
-          autoFocus
+          // @ts-ignore This is fine, type is weird on the BottomSheetTextInput
+          ref={inputRef}
         />
         <View style={styles.buttonControls}>
           <TouchableOpacity
diff --git a/src/view/com/modals/ProfilePreview.tsx b/src/view/com/modals/ProfilePreview.tsx
index 77e68db70..88b0df71d 100644
--- a/src/view/com/modals/ProfilePreview.tsx
+++ b/src/view/com/modals/ProfilePreview.tsx
@@ -27,12 +27,12 @@ export function Component({did}: {did: string}) {
     data: profile,
     error: profileError,
     refetch: refetchProfile,
-    isFetching: isFetchingProfile,
+    isLoading: isLoadingProfile,
   } = useProfileQuery({
     did: did,
   })
 
-  if (isFetchingProfile || !moderationOpts) {
+  if (isLoadingProfile || !moderationOpts) {
     return (
       <CenteredView style={[pal.view, s.flex1]}>
         <ProfileHeader
diff --git a/src/view/com/modals/UserAddRemoveLists.tsx b/src/view/com/modals/UserAddRemoveLists.tsx
index 23adbe1a8..8452f2513 100644
--- a/src/view/com/modals/UserAddRemoveLists.tsx
+++ b/src/view/com/modals/UserAddRemoveLists.tsx
@@ -1,5 +1,10 @@
 import React, {useCallback} from 'react'
-import {ActivityIndicator, StyleSheet, View} from 'react-native'
+import {
+  ActivityIndicator,
+  StyleSheet,
+  useWindowDimensions,
+  View,
+} from 'react-native'
 import {AppBskyGraphDefs as GraphDefs} from '@atproto/api'
 import {Text} from '../util/text/Text'
 import {UserAvatar} from '../util/UserAvatar'
@@ -10,7 +15,7 @@ import {sanitizeDisplayName} from 'lib/strings/display-names'
 import {sanitizeHandle} from 'lib/strings/handles'
 import {s} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
-import {isWeb, isAndroid} from 'platform/detection'
+import {isWeb, isAndroid, isMobileWeb} from 'platform/detection'
 import {Trans, msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useModalControls} from '#/state/modals'
@@ -41,6 +46,7 @@ export function Component({
 }) {
   const {closeModal} = useModalControls()
   const pal = usePalette('default')
+  const {height: screenHeight} = useWindowDimensions()
   const {_} = useLingui()
   const {data: memberships} = useDangerousListMembershipsQuery()
 
@@ -48,6 +54,16 @@ export function Component({
     closeModal()
   }, [closeModal])
 
+  const listStyle = React.useMemo(() => {
+    if (isMobileWeb) {
+      return [pal.border, {height: screenHeight / 2}]
+    } else if (isWeb) {
+      return [pal.border, {height: screenHeight / 1.5}]
+    }
+
+    return [pal.border, {flex: 1}]
+  }, [pal.border, screenHeight])
+
   return (
     <View testID="userAddRemoveListsModal" style={s.hContentRegion}>
       <Text style={[styles.title, pal.text]}>
@@ -68,7 +84,7 @@ export function Component({
             onRemove={onRemove}
           />
         )}
-        style={[styles.list, pal.border]}
+        style={listStyle}
       />
       <View style={[styles.btns, pal.border]}>
         <Button
diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx
index 0dfac2a83..f037097df 100644
--- a/src/view/com/notifications/FeedItem.tsx
+++ b/src/view/com/notifications/FeedItem.tsx
@@ -167,9 +167,7 @@ let FeedItem = ({
     icon = 'user-plus'
     iconStyle = [s.blue3 as FontAwesomeIconStyle]
   } else if (item.type === 'feedgen-like') {
-    action = item.subjectUri
-      ? _(msg`liked your custom feed '${new AtUri(item.subjectUri).rkey}'`)
-      : _(msg`liked your custom feed`)
+    action = _(msg`liked your custom feed`)
     icon = 'HeartIconSolid'
     iconStyle = [
       s.likeColor as FontAwesomeIconStyle,
diff --git a/src/view/com/pager/FeedsTabBar.web.tsx b/src/view/com/pager/FeedsTabBar.web.tsx
index 385da5544..9fe03b7e9 100644
--- a/src/view/com/pager/FeedsTabBar.web.tsx
+++ b/src/view/com/pager/FeedsTabBar.web.tsx
@@ -88,11 +88,17 @@ function FeedsTabBarTablet(
   const navigation = useNavigation<NavigationProp>()
   const {headerMinimalShellTransform} = useMinimalShellMode()
   const {headerHeight} = useShellLayout()
-  const pinnedDisplayNames = hasSession ? feeds.map(f => f.displayName) : []
-  const showFeedsLinkInTabBar = hasSession && !hasPinnedCustom
-  const items = showFeedsLinkInTabBar
-    ? pinnedDisplayNames.concat('Feeds ✨')
-    : pinnedDisplayNames
+
+  const items = React.useMemo(() => {
+    if (!hasSession) return []
+
+    const pinnedNames = feeds.map(f => f.displayName)
+
+    if (!hasPinnedCustom) {
+      return pinnedNames.concat('Feeds ✨')
+    }
+    return pinnedNames
+  }, [hasSession, hasPinnedCustom, feeds])
 
   const onPressDiscoverFeeds = React.useCallback(() => {
     if (isWeb) {
@@ -105,13 +111,13 @@ function FeedsTabBarTablet(
 
   const onSelect = React.useCallback(
     (index: number) => {
-      if (showFeedsLinkInTabBar && index === items.length - 1) {
+      if (hasSession && !hasPinnedCustom && index === items.length - 1) {
         onPressDiscoverFeeds()
       } else if (props.onSelect) {
         props.onSelect(index)
       }
     },
-    [items.length, onPressDiscoverFeeds, props, showFeedsLinkInTabBar],
+    [items.length, onPressDiscoverFeeds, props, hasSession, hasPinnedCustom],
   )
 
   return (
diff --git a/src/view/com/pager/FeedsTabBarMobile.tsx b/src/view/com/pager/FeedsTabBarMobile.tsx
index b9959a6d9..4eba241ae 100644
--- a/src/view/com/pager/FeedsTabBarMobile.tsx
+++ b/src/view/com/pager/FeedsTabBarMobile.tsx
@@ -36,11 +36,17 @@ export function FeedsTabBar(
   const {feeds, hasPinnedCustom} = usePinnedFeedsInfos()
   const {headerHeight} = useShellLayout()
   const {headerMinimalShellTransform} = useMinimalShellMode()
-  const pinnedDisplayNames = hasSession ? feeds.map(f => f.displayName) : []
-  const showFeedsLinkInTabBar = hasSession && !hasPinnedCustom
-  const items = showFeedsLinkInTabBar
-    ? pinnedDisplayNames.concat('Feeds ✨')
-    : pinnedDisplayNames
+
+  const items = React.useMemo(() => {
+    if (!hasSession) return []
+
+    const pinnedNames = feeds.map(f => f.displayName)
+
+    if (!hasPinnedCustom) {
+      return pinnedNames.concat('Feeds ✨')
+    }
+    return pinnedNames
+  }, [hasSession, hasPinnedCustom, feeds])
 
   const onPressFeedsLink = React.useCallback(() => {
     if (isWeb) {
@@ -53,13 +59,13 @@ export function FeedsTabBar(
 
   const onSelect = React.useCallback(
     (index: number) => {
-      if (showFeedsLinkInTabBar && index === items.length - 1) {
+      if (hasSession && !hasPinnedCustom && index === items.length - 1) {
         onPressFeedsLink()
       } else if (props.onSelect) {
         props.onSelect(index)
       }
     },
-    [items.length, onPressFeedsLink, props, showFeedsLinkInTabBar],
+    [items.length, onPressFeedsLink, props, hasSession, hasPinnedCustom],
   )
 
   const onPressAvi = React.useCallback(() => {
diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx
index c3a95c5c0..dadcfcebd 100644
--- a/src/view/com/pager/TabBar.tsx
+++ b/src/view/com/pager/TabBar.tsx
@@ -78,7 +78,7 @@ export function TabBar({
           return (
             <PressableWithHover
               testID={`${testID}-selector-${i}`}
-              key={item}
+              key={`${item}-${i}`}
               onLayout={e => onItemLayout(e, i)}
               style={[styles.item, selected && indicatorStyle]}
               hoverStyle={pal.viewLight}
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 95fd5aefb..d11c2781b 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -40,6 +40,7 @@ import {useLingui} from '@lingui/react'
 import {useLanguagePrefs} from '#/state/preferences'
 import {useComposerControls} from '#/state/shell/composer'
 import {useModerationOpts} from '#/state/queries/preferences'
+import {useOpenLink} from '#/state/preferences/in-app-browser'
 import {Shadow, usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow'
 import {ThreadPost} from '#/state/queries/post-thread'
 import {useSession} from '#/state/session'
@@ -216,10 +217,11 @@ let PostThreadItemLoaded = ({
           avatar: post.author.avatar,
         },
         embed: post.embed,
+        moderation,
       },
       onPost: onPostReply,
     })
-  }, [openComposer, post, record, onPostReply])
+  }, [openComposer, post, record, onPostReply, moderation])
 
   const onPressShowMore = React.useCallback(() => {
     setLimitLines(false)
@@ -250,13 +252,7 @@ let PostThreadItemLoaded = ({
 
         <View
           testID={`postThreadItem-by-${post.author.handle}`}
-          style={[
-            styles.outer,
-            styles.outerHighlighted,
-            rootUri === post.uri && styles.outerHighlightedRoot,
-            pal.border,
-            pal.view,
-          ]}
+          style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]}
           accessible={false}>
           <PostSandboxWarning />
           <View style={styles.layout}>
@@ -707,17 +703,23 @@ function ExpandedPostDetails({
 }) {
   const pal = usePalette('default')
   const {_} = useLingui()
+  const openLink = useOpenLink()
+  const onTranslatePress = React.useCallback(
+    () => openLink(translatorUrl),
+    [openLink, translatorUrl],
+  )
   return (
     <View style={[s.flexRow, s.mt2, s.mb10]}>
       <Text style={pal.textLight}>{niceDate(post.indexedAt)}</Text>
       {needsTranslation && (
         <>
           <Text style={pal.textLight}> &middot; </Text>
-          <Link href={translatorUrl} title={_(msg`Translate`)}>
-            <Text style={pal.link}>
-              <Trans>Translate</Trans>
-            </Text>
-          </Link>
+          <Text
+            style={pal.link}
+            title={_(msg`Translate`)}
+            onPress={onTranslatePress}>
+            <Trans>Translate</Trans>
+          </Text>
         </>
       )}
     </View>
@@ -732,15 +734,10 @@ const useStyles = () => {
       paddingLeft: 8,
     },
     outerHighlighted: {
-      borderTopWidth: 0,
-      paddingTop: 4,
+      paddingTop: 16,
       paddingLeft: 8,
       paddingRight: 8,
     },
-    outerHighlightedRoot: {
-      borderTopWidth: 1,
-      paddingTop: 16,
-    },
     noTopBorder: {
       borderTopWidth: 0,
     },
@@ -779,6 +776,7 @@ const useStyles = () => {
     },
     postTextLargeContainer: {
       paddingHorizontal: 0,
+      paddingRight: 0,
       paddingBottom: 10,
     },
     translateLink: {
diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx
index f035c32ad..2f1c0d37b 100644
--- a/src/view/com/post/Post.tsx
+++ b/src/view/com/post/Post.tsx
@@ -122,9 +122,10 @@ function PostInner({
           avatar: post.author.avatar,
         },
         embed: post.embed,
+        moderation,
       },
     })
-  }, [openComposer, post, record])
+  }, [openComposer, post, record, moderation])
 
   const onPressShowMore = React.useCallback(() => {
     setLimitLines(false)
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index 225607ca9..920409ec6 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -135,9 +135,10 @@ let FeedItemInner = ({
           avatar: post.author.avatar,
         },
         embed: post.embed,
+        moderation,
       },
     })
-  }, [post, record, openComposer])
+  }, [post, record, openComposer, moderation])
 
   const outerStyles = [
     styles.outer,
diff --git a/src/view/com/util/List.web.tsx b/src/view/com/util/List.web.tsx
index 3e81a8c37..29bad2db8 100644
--- a/src/view/com/util/List.web.tsx
+++ b/src/view/com/util/List.web.tsx
@@ -300,6 +300,9 @@ export const List = memo(React.forwardRef(ListImpl)) as <ItemT>(
   props: ListProps<ItemT> & {ref?: React.Ref<ListMethods>},
 ) => React.ReactElement
 
+// https://stackoverflow.com/questions/7944460/detect-safari-browser
+const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
+
 const styles = StyleSheet.create({
   contentContainer: {
     borderLeftWidth: 1,
@@ -313,7 +316,7 @@ const styles = StyleSheet.create({
   },
   row: {
     // @ts-ignore web only
-    contentVisibility: 'auto',
+    contentVisibility: isSafari ? '' : 'auto', // Safari support for this is buggy.
   },
   minHeightViewport: {
     // @ts-ignore web only
diff --git a/src/view/com/util/LoadingPlaceholder.tsx b/src/view/com/util/LoadingPlaceholder.tsx
index 8941bfb9c..6dfe12598 100644
--- a/src/view/com/util/LoadingPlaceholder.tsx
+++ b/src/view/com/util/LoadingPlaceholder.tsx
@@ -67,28 +67,36 @@ export function PostLoadingPlaceholder({
         <LoadingPlaceholder width="95%" height={6} style={{marginBottom: 8}} />
         <LoadingPlaceholder width="80%" height={6} style={{marginBottom: 11}} />
         <View style={styles.postCtrls}>
-          <View style={[styles.postCtrl, {paddingLeft: 0}]}>
-            <CommentBottomArrow
-              style={[{color: theme.palette.default.icon, marginTop: 1}]}
-              strokeWidth={3}
-              size={15}
-            />
+          <View style={styles.postCtrl}>
+            <View style={[styles.postBtn, {paddingLeft: 0}]}>
+              <CommentBottomArrow
+                style={[{color: theme.palette.default.icon, marginTop: 1}]}
+                strokeWidth={3}
+                size={15}
+              />
+            </View>
+          </View>
+          <View style={styles.postCtrl}>
+            <View style={styles.postBtn}>
+              <RepostIcon
+                style={{color: theme.palette.default.icon}}
+                strokeWidth={3}
+                size={20}
+              />
+            </View>
           </View>
           <View style={styles.postCtrl}>
-            <RepostIcon
-              style={{color: theme.palette.default.icon}}
-              strokeWidth={3}
-              size={20}
-            />
+            <View style={styles.postBtn}>
+              <HeartIcon
+                style={{color: theme.palette.default.icon} as ViewStyle}
+                size={16}
+                strokeWidth={3}
+              />
+            </View>
           </View>
           <View style={styles.postCtrl}>
-            <HeartIcon
-              style={{color: theme.palette.default.icon} as ViewStyle}
-              size={16}
-              strokeWidth={3}
-            />
+            <View style={styles.postBtn} />
           </View>
-          <View style={{width: 30, height: 30}} />
         </View>
       </View>
     </View>
@@ -279,6 +287,9 @@ const styles = StyleSheet.create({
     justifyContent: 'space-between',
   },
   postCtrl: {
+    flex: 1,
+  },
+  postBtn: {
     padding: 5,
     flex: 1,
     flexDirection: 'row',
diff --git a/src/view/com/util/UserInfoText.tsx b/src/view/com/util/UserInfoText.tsx
index e5d2ceb03..9cb9997f6 100644
--- a/src/view/com/util/UserInfoText.tsx
+++ b/src/view/com/util/UserInfoText.tsx
@@ -9,6 +9,7 @@ import {sanitizeDisplayName} from 'lib/strings/display-names'
 import {sanitizeHandle} from 'lib/strings/handles'
 import {makeProfileLink} from 'lib/routes/links'
 import {useProfileQuery} from '#/state/queries/profile'
+import {STALE} from '#/state/queries'
 
 export function UserInfoText({
   type = 'md',
@@ -29,7 +30,10 @@ export function UserInfoText({
   attr = attr || 'handle'
   failed = failed || 'user'
 
-  const {data: profile, isError} = useProfileQuery({did})
+  const {data: profile, isError} = useProfileQuery({
+    did,
+    staleTime: STALE.INFINITY,
+  })
 
   let inner
   if (isError) {
diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx
index 940f39057..e56c88d2c 100644
--- a/src/view/com/util/forms/PostDropdownBtn.tsx
+++ b/src/view/com/util/forms/PostDropdownBtn.tsx
@@ -1,5 +1,5 @@
 import React, {memo} from 'react'
-import {Linking, StyleProp, View, ViewStyle} from 'react-native'
+import {StyleProp, View, ViewStyle} from 'react-native'
 import Clipboard from '@react-native-clipboard/clipboard'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {
@@ -24,6 +24,7 @@ import {usePostDeleteMutation} from '#/state/queries/post'
 import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads'
 import {useLanguagePrefs} from '#/state/preferences'
 import {useHiddenPosts, useHiddenPostsApi} from '#/state/preferences'
+import {useOpenLink} from '#/state/preferences/in-app-browser'
 import {logger} from '#/logger'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
@@ -61,6 +62,7 @@ let PostDropdownBtn = ({
   const postDeleteMutation = usePostDeleteMutation()
   const hiddenPosts = useHiddenPosts()
   const {hidePost} = useHiddenPostsApi()
+  const openLink = useOpenLink()
 
   const rootUri = record.reply?.root?.uri || postUri
   const isThreadMuted = mutedThreads.includes(rootUri)
@@ -111,8 +113,8 @@ let PostDropdownBtn = ({
   }, [_, richText])
 
   const onOpenTranslate = React.useCallback(() => {
-    Linking.openURL(translatorUrl)
-  }, [translatorUrl])
+    openLink(translatorUrl)
+  }, [openLink, translatorUrl])
 
   const onHidePost = React.useCallback(() => {
     hidePost({uri: postUri})
diff --git a/src/view/com/util/images/Gallery.tsx b/src/view/com/util/images/Gallery.tsx
index e7110372c..7de3b093a 100644
--- a/src/view/com/util/images/Gallery.tsx
+++ b/src/view/com/util/images/Gallery.tsx
@@ -4,6 +4,7 @@ import {StyleSheet, Text, Pressable, View} from 'react-native'
 import {Image} from 'expo-image'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
+import {isWeb} from 'platform/detection'
 
 type EventFunction = (index: number) => void
 
@@ -70,8 +71,10 @@ const styles = StyleSheet.create({
     paddingHorizontal: 6,
     paddingVertical: 3,
     position: 'absolute',
-    left: 8,
-    bottom: 8,
+    // 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,
   },
   alt: {
     color: 'white',
diff --git a/src/view/com/util/images/ImageLayoutGrid.tsx b/src/view/com/util/images/ImageLayoutGrid.tsx
index 23e807b6a..ba6c04f50 100644
--- a/src/view/com/util/images/ImageLayoutGrid.tsx
+++ b/src/view/com/util/images/ImageLayoutGrid.tsx
@@ -2,6 +2,7 @@ import React from 'react'
 import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
 import {AppBskyEmbedImages} from '@atproto/api'
 import {GalleryItem} from './Gallery'
+import {isWeb} from 'platform/detection'
 
 interface ImageLayoutGridProps {
   images: AppBskyEmbedImages.ViewImage[]
@@ -47,10 +48,10 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
     case 3:
       return (
         <View style={styles.flexRow}>
-          <View style={{flex: 2, aspectRatio: 1}}>
+          <View style={styles.threeSingle}>
             <GalleryItem {...props} index={0} imageStyle={styles.image} />
           </View>
-          <View style={{flex: 1}}>
+          <View style={styles.threeDouble}>
             <View style={styles.smallItem}>
               <GalleryItem {...props} index={1} imageStyle={styles.image} />
             </View>
@@ -88,18 +89,38 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
   }
 }
 
-// This is used to compute margins (rather than flexbox gap) due to Yoga bugs:
+// On web we use margin to calculate gap, as aspectRatio does not properly size
+// all images on web. On native though we cannot rely on margin, since the
+// negative margin interferes with the swipe controls on pagers.
 // https://github.com/facebook/yoga/issues/1418
+// https://github.com/bluesky-social/social-app/issues/2601
 const IMAGE_GAP = 5
 
 const styles = StyleSheet.create({
-  container: {
-    marginHorizontal: -IMAGE_GAP / 2,
-    marginVertical: -IMAGE_GAP / 2,
+  container: isWeb
+    ? {
+        marginHorizontal: -IMAGE_GAP / 2,
+        marginVertical: -IMAGE_GAP / 2,
+      }
+    : {
+        gap: IMAGE_GAP,
+      },
+  flexRow: {
+    flexDirection: 'row',
+    gap: isWeb ? undefined : IMAGE_GAP,
   },
-  flexRow: {flexDirection: 'row'},
   smallItem: {flex: 1, aspectRatio: 1},
-  image: {
-    margin: IMAGE_GAP / 2,
+  image: isWeb
+    ? {
+        margin: IMAGE_GAP / 2,
+      }
+    : {},
+  threeSingle: {
+    flex: 2,
+    aspectRatio: isWeb ? 1 : undefined,
+  },
+  threeDouble: {
+    flex: 1,
+    gap: isWeb ? undefined : IMAGE_GAP,
   },
 })
diff --git a/src/view/com/util/load-latest/LoadLatestBtn.tsx b/src/view/com/util/load-latest/LoadLatestBtn.tsx
index 970d3a73a..5fad11760 100644
--- a/src/view/com/util/load-latest/LoadLatestBtn.tsx
+++ b/src/view/com/util/load-latest/LoadLatestBtn.tsx
@@ -10,6 +10,7 @@ import Animated from 'react-native-reanimated'
 const AnimatedTouchableOpacity =
   Animated.createAnimatedComponent(TouchableOpacity)
 import {isWeb} from 'platform/detection'
+import {useSession} from 'state/session'
 
 export function LoadLatestBtn({
   onPress,
@@ -21,9 +22,14 @@ export function LoadLatestBtn({
   showIndicator: boolean
 }) {
   const pal = usePalette('default')
-  const {isDesktop, isTablet, isMobile} = useWebMediaQueries()
+  const {hasSession} = useSession()
+  const {isDesktop, isTablet, isMobile, isTabletOrMobile} = useWebMediaQueries()
   const {fabMinimalShellTransform} = useMinimalShellMode()
 
+  // Adjust height of the fab if we have a session only on mobile web. If we don't have a session, we want to adjust
+  // it on both tablet and mobile since we are showing the bottom bar (see createNativeStackNavigatorWithAuth)
+  const showBottomBar = hasSession ? isMobile : isTabletOrMobile
+
   return (
     <AnimatedTouchableOpacity
       style={[
@@ -32,7 +38,7 @@ export function LoadLatestBtn({
         isTablet && styles.loadLatestTablet,
         pal.borderDark,
         pal.view,
-        isMobile && fabMinimalShellTransform,
+        showBottomBar && fabMinimalShellTransform,
       ]}
       onPress={onPress}
       hitSlop={HITSLOP_20}
diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx
index a6d7e38c3..249111a04 100644
--- a/src/view/com/util/post-ctrls/PostCtrls.tsx
+++ b/src/view/com/util/post-ctrls/PostCtrls.tsx
@@ -149,7 +149,7 @@ let PostCtrls = ({
           ) : undefined}
         </TouchableOpacity>
       </View>
-      <View style={[styles.ctrl]}>
+      <View style={styles.ctrl}>
         <RepostButton
           big={big}
           isReposted={!!post.viewer?.repost}
@@ -194,19 +194,19 @@ let PostCtrls = ({
         </TouchableOpacity>
       </View>
       {big ? undefined : (
-        <PostDropdownBtn
-          testID="postDropdownBtn"
-          postAuthor={post.author}
-          postCid={post.cid}
-          postUri={post.uri}
-          record={record}
-          richText={richText}
-          showAppealLabelItem={showAppealLabelItem}
-          style={styles.btnPad}
-        />
+        <View style={styles.ctrl}>
+          <PostDropdownBtn
+            testID="postDropdownBtn"
+            postAuthor={post.author}
+            postCid={post.cid}
+            postUri={post.uri}
+            record={record}
+            richText={richText}
+            showAppealLabelItem={showAppealLabelItem}
+            style={styles.btnPad}
+          />
+        </View>
       )}
-      {/* used for adding pad to the right side */}
-      <View />
     </View>
   )
 }