about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/components/Error.tsx32
-rw-r--r--src/components/ListCard.tsx48
-rw-r--r--src/components/moderation/Hider.tsx89
-rw-r--r--src/components/moderation/ModerationDetailsDialog.tsx4
-rw-r--r--src/lib/hooks/useGoBack.ts23
-rw-r--r--src/screens/List/ListHiddenScreen.tsx216
-rw-r--r--src/state/queries/list.ts2
-rw-r--r--src/view/com/lists/ListCard.tsx183
-rw-r--r--src/view/com/lists/MyLists.tsx40
-rw-r--r--src/view/com/util/post-embeds/ListEmbed.tsx32
-rw-r--r--src/view/com/util/post-embeds/index.tsx16
-rw-r--r--src/view/screens/ProfileList.tsx148
12 files changed, 494 insertions, 339 deletions
diff --git a/src/components/Error.tsx b/src/components/Error.tsx
index 481532434..59d219831 100644
--- a/src/components/Error.tsx
+++ b/src/components/Error.tsx
@@ -2,21 +2,18 @@ import React from 'react'
 import {View} from 'react-native'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
-import {useNavigation} from '@react-navigation/core'
-import {StackActions} from '@react-navigation/native'
 
-import {NavigationProp} from 'lib/routes/types'
+import {useGoBack} from 'lib/hooks/useGoBack'
 import {CenteredView} from 'view/com/util/Views'
 import {atoms as a, useBreakpoints, useTheme} from '#/alf'
 import {Button, ButtonText} from '#/components/Button'
 import {Text} from '#/components/Typography'
-import {router} from '#/routes'
 
 export function Error({
   title,
   message,
   onRetry,
-  onGoBack: onGoBackProp,
+  onGoBack,
   hideBackButton,
   sideBorders = true,
 }: {
@@ -27,31 +24,10 @@ export function Error({
   hideBackButton?: boolean
   sideBorders?: boolean
 }) {
-  const navigation = useNavigation<NavigationProp>()
   const {_} = useLingui()
   const t = useTheme()
   const {gtMobile} = useBreakpoints()
-
-  const canGoBack = navigation.canGoBack()
-  const onGoBack = React.useCallback(() => {
-    if (onGoBackProp) {
-      onGoBackProp()
-      return
-    }
-    if (canGoBack) {
-      navigation.goBack()
-    } else {
-      navigation.navigate('HomeTab')
-
-      // Checking the state for routes ensures that web doesn't encounter errors while going back
-      if (navigation.getState()?.routes) {
-        navigation.dispatch(StackActions.push(...router.matchPath('/')))
-      } else {
-        navigation.navigate('HomeTab')
-        navigation.dispatch(StackActions.popToTop())
-      }
-    }
-  }, [navigation, canGoBack, onGoBackProp])
+  const goBack = useGoBack(onGoBack)
 
   return (
     <CenteredView
@@ -96,7 +72,7 @@ export function Error({
             variant="solid"
             color={onRetry ? 'secondary' : 'primary'}
             label={_(msg`Return to previous page`)}
-            onPress={onGoBack}
+            onPress={goBack}
             size="large"
             style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}>
             <ButtonText>
diff --git a/src/components/ListCard.tsx b/src/components/ListCard.tsx
index 0ed27cf50..829f36d47 100644
--- a/src/components/ListCard.tsx
+++ b/src/components/ListCard.tsx
@@ -1,13 +1,20 @@
 import React from 'react'
 import {View} from 'react-native'
-import {AppBskyActorDefs, AppBskyGraphDefs, AtUri} from '@atproto/api'
+import {
+  AppBskyActorDefs,
+  AppBskyGraphDefs,
+  AtUri,
+  moderateUserList,
+  ModerationUI,
+} from '@atproto/api'
 import {Trans} from '@lingui/macro'
 import {useQueryClient} from '@tanstack/react-query'
 
 import {sanitizeHandle} from 'lib/strings/handles'
+import {useModerationOpts} from 'state/preferences/moderation-opts'
 import {precacheList} from 'state/queries/feed'
-import {useTheme} from '#/alf'
-import {atoms as a} from '#/alf'
+import {useSession} from 'state/session'
+import {atoms as a, useTheme} from '#/alf'
 import {
   Avatar,
   Description,
@@ -16,6 +23,7 @@ import {
   SaveButton,
 } from '#/components/FeedCard'
 import {Link as InternalLink, LinkProps} from '#/components/Link'
+import * as Hider from '#/components/moderation/Hider'
 import {Text} from '#/components/Typography'
 
 /*
@@ -43,6 +51,11 @@ type Props = {
 
 export function Default(props: Props) {
   const {view, showPinButton} = props
+  const moderationOpts = useModerationOpts()
+  const moderation = moderationOpts
+    ? moderateUserList(view, moderationOpts)
+    : undefined
+
   return (
     <Link {...props}>
       <Outer>
@@ -52,6 +65,7 @@ export function Default(props: Props) {
             title={view.name}
             creator={view.creator}
             purpose={view.purpose}
+            modUi={moderation?.ui('contentView')}
           />
           {showPinButton && view.purpose === CURATELIST && (
             <SaveButton view={view} pin />
@@ -89,18 +103,40 @@ export function TitleAndByline({
   title,
   creator,
   purpose = CURATELIST,
+  modUi,
 }: {
   title: string
   creator?: AppBskyActorDefs.ProfileViewBasic
   purpose?: AppBskyGraphDefs.ListView['purpose']
+  modUi?: ModerationUI
 }) {
   const t = useTheme()
+  const {currentAccount} = useSession()
 
   return (
     <View style={[a.flex_1]}>
-      <Text style={[a.text_md, a.font_bold, a.leading_snug]} numberOfLines={1}>
-        {title}
-      </Text>
+      <Hider.Outer
+        modui={modUi}
+        isContentVisibleInitialState={
+          creator && currentAccount?.did === creator.did
+        }
+        allowOverride={creator && currentAccount?.did === creator.did}>
+        <Hider.Mask>
+          <Text
+            style={[a.text_md, a.font_bold, a.leading_snug, a.italic]}
+            numberOfLines={1}>
+            <Trans>Hidden list</Trans>
+          </Text>
+        </Hider.Mask>
+        <Hider.Content>
+          <Text
+            style={[a.text_md, a.font_bold, a.leading_snug]}
+            numberOfLines={1}>
+            {title}
+          </Text>
+        </Hider.Content>
+      </Hider.Outer>
+
       {creator && (
         <Text
           style={[a.leading_snug, t.atoms.text_contrast_medium]}
diff --git a/src/components/moderation/Hider.tsx b/src/components/moderation/Hider.tsx
new file mode 100644
index 000000000..fcb88ddd9
--- /dev/null
+++ b/src/components/moderation/Hider.tsx
@@ -0,0 +1,89 @@
+import React from 'react'
+import {ModerationUI} from '@atproto/api'
+
+import {
+  ModerationCauseDescription,
+  useModerationCauseDescription,
+} from '#/lib/moderation/useModerationCauseDescription'
+import {
+  ModerationDetailsDialog,
+  useModerationDetailsDialogControl,
+} from '#/components/moderation/ModerationDetailsDialog'
+
+type Context = {
+  isContentVisible: boolean
+  setIsContentVisible: (show: boolean) => void
+  info: ModerationCauseDescription
+  showInfoDialog: () => void
+  meta: {
+    isNoPwi: boolean
+    allowOverride: boolean
+  }
+}
+
+const Context = React.createContext<Context>({} as Context)
+
+export const useHider = () => React.useContext(Context)
+
+export function Outer({
+  modui,
+  isContentVisibleInitialState,
+  allowOverride,
+  children,
+}: React.PropsWithChildren<{
+  isContentVisibleInitialState?: boolean
+  allowOverride?: boolean
+  modui: ModerationUI | undefined
+}>) {
+  const control = useModerationDetailsDialogControl()
+  const blur = modui?.blurs[0]
+  const [isContentVisible, setIsContentVisible] = React.useState(
+    isContentVisibleInitialState || !blur,
+  )
+  const info = useModerationCauseDescription(blur)
+
+  const meta = {
+    isNoPwi: Boolean(
+      modui?.blurs.find(
+        cause =>
+          cause.type === 'label' &&
+          cause.labelDef.identifier === '!no-unauthenticated',
+      ),
+    ),
+    allowOverride: allowOverride ?? !modui?.noOverride,
+  }
+
+  const showInfoDialog = () => {
+    control.open()
+  }
+
+  const onSetContentVisible = (show: boolean) => {
+    if (meta.allowOverride) return
+    setIsContentVisible(show)
+  }
+
+  const ctx = {
+    isContentVisible,
+    setIsContentVisible: onSetContentVisible,
+    showInfoDialog,
+    info,
+    meta,
+  }
+
+  return (
+    <Context.Provider value={ctx}>
+      {children}
+      <ModerationDetailsDialog control={control} modcause={blur} />
+    </Context.Provider>
+  )
+}
+
+export function Content({children}: {children: React.ReactNode}) {
+  const ctx = useHider()
+  return ctx.isContentVisible ? children : null
+}
+
+export function Mask({children}: {children: React.ReactNode}) {
+  const ctx = useHider()
+  return ctx.isContentVisible ? null : children
+}
diff --git a/src/components/moderation/ModerationDetailsDialog.tsx b/src/components/moderation/ModerationDetailsDialog.tsx
index ebfe45232..b8f02582c 100644
--- a/src/components/moderation/ModerationDetailsDialog.tsx
+++ b/src/components/moderation/ModerationDetailsDialog.tsx
@@ -18,7 +18,7 @@ export {useDialogControl as useModerationDetailsDialogControl} from '#/component
 
 export interface ModerationDetailsDialogProps {
   control: Dialog.DialogOuterProps['control']
-  modcause: ModerationCause
+  modcause?: ModerationCause
 }
 
 export function ModerationDetailsDialog(props: ModerationDetailsDialogProps) {
@@ -123,7 +123,7 @@ function ModerationDetailsDialogInner({
         {description}
       </Text>
 
-      {modcause.type === 'label' && (
+      {modcause?.type === 'label' && (
         <>
           <Divider />
           <Text style={[t.atoms.text, a.text_md, a.leading_snug, a.mt_lg]}>
diff --git a/src/lib/hooks/useGoBack.ts b/src/lib/hooks/useGoBack.ts
new file mode 100644
index 000000000..59555bdac
--- /dev/null
+++ b/src/lib/hooks/useGoBack.ts
@@ -0,0 +1,23 @@
+import {StackActions, useNavigation} from '@react-navigation/native'
+
+import {NavigationProp} from 'lib/routes/types'
+import {router} from '#/routes'
+
+export function useGoBack(onGoBack?: () => unknown) {
+  const navigation = useNavigation<NavigationProp>()
+  return () => {
+    onGoBack?.()
+    if (navigation.canGoBack()) {
+      navigation.goBack()
+    } else {
+      navigation.navigate('HomeTab')
+      // Checking the state for routes ensures that web doesn't encounter errors while going back
+      if (navigation.getState()?.routes) {
+        navigation.dispatch(StackActions.push(...router.matchPath('/')))
+      } else {
+        navigation.navigate('HomeTab')
+        navigation.dispatch(StackActions.popToTop())
+      }
+    }
+  }
+}
diff --git a/src/screens/List/ListHiddenScreen.tsx b/src/screens/List/ListHiddenScreen.tsx
new file mode 100644
index 000000000..473bb08ea
--- /dev/null
+++ b/src/screens/List/ListHiddenScreen.tsx
@@ -0,0 +1,216 @@
+import React from 'react'
+import {View} from 'react-native'
+import {AppBskyGraphDefs} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useQueryClient} from '@tanstack/react-query'
+
+import {logger} from '#/logger'
+import {RQKEY_ROOT as listQueryRoot} from '#/state/queries/list'
+import {useGoBack} from 'lib/hooks/useGoBack'
+import {sanitizeHandle} from 'lib/strings/handles'
+import {useListBlockMutation, useListMuteMutation} from 'state/queries/list'
+import {
+  UsePreferencesQueryResponse,
+  useRemoveFeedMutation,
+} from 'state/queries/preferences'
+import {useSession} from 'state/session'
+import * as Toast from 'view/com/util/Toast'
+import {CenteredView} from 'view/com/util/Views'
+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
+import {Loader} from '#/components/Loader'
+import {useHider} from '#/components/moderation/Hider'
+import {Text} from '#/components/Typography'
+
+export function ListHiddenScreen({
+  list,
+  preferences,
+}: {
+  list: AppBskyGraphDefs.ListView
+  preferences: UsePreferencesQueryResponse
+}) {
+  const {_} = useLingui()
+  const t = useTheme()
+  const {currentAccount} = useSession()
+  const {gtMobile} = useBreakpoints()
+  const isOwner = currentAccount?.did === list.creator.did
+  const goBack = useGoBack()
+  const queryClient = useQueryClient()
+
+  const isModList = list.purpose === AppBskyGraphDefs.MODLIST
+
+  const [isProcessing, setIsProcessing] = React.useState(false)
+  const listBlockMutation = useListBlockMutation()
+  const listMuteMutation = useListMuteMutation()
+  const {mutateAsync: removeSavedFeed} = useRemoveFeedMutation()
+
+  const {setIsContentVisible} = useHider()
+
+  const savedFeedConfig = preferences.savedFeeds.find(f => f.value === list.uri)
+
+  const onUnsubscribe = async () => {
+    setIsProcessing(true)
+    if (list.viewer?.muted) {
+      try {
+        await listMuteMutation.mutateAsync({uri: list.uri, mute: false})
+      } catch (e) {
+        setIsProcessing(false)
+        logger.error('Failed to unmute list', {message: e})
+        Toast.show(
+          _(
+            msg`There was an issue. Please check your internet connection and try again.`,
+          ),
+        )
+        return
+      }
+    }
+    if (list.viewer?.blocked) {
+      try {
+        await listBlockMutation.mutateAsync({uri: list.uri, block: false})
+      } catch (e) {
+        setIsProcessing(false)
+        logger.error('Failed to unblock list', {message: e})
+        Toast.show(
+          _(
+            msg`There was an issue. Please check your internet connection and try again.`,
+          ),
+        )
+        return
+      }
+    }
+    queryClient.invalidateQueries({
+      queryKey: [listQueryRoot],
+    })
+    Toast.show(_(msg`Unsubscribed from list`))
+    setIsProcessing(false)
+  }
+
+  const onRemoveList = async () => {
+    if (!savedFeedConfig) return
+    try {
+      await removeSavedFeed(savedFeedConfig)
+      Toast.show(_(msg`Removed from saved feeds`))
+    } catch (e) {
+      logger.error('Failed to remove list from saved feeds', {message: e})
+      Toast.show(
+        _(
+          msg`There was an issue. Please check your internet connection and try again.`,
+        ),
+      )
+    } finally {
+      setIsProcessing(false)
+    }
+  }
+
+  return (
+    <CenteredView
+      style={[
+        a.flex_1,
+        a.align_center,
+        a.gap_5xl,
+        !gtMobile && a.justify_between,
+        t.atoms.border_contrast_low,
+        {paddingTop: 175, paddingBottom: 110},
+      ]}
+      sideBorders={true}>
+      <View style={[a.w_full, a.align_center, a.gap_lg]}>
+        <EyeSlash
+          style={{color: t.atoms.text_contrast_medium.color}}
+          height={42}
+          width={42}
+        />
+        <View style={[a.gap_sm, a.align_center]}>
+          <Text style={[a.font_bold, a.text_3xl]}>
+            <Trans>List has been hidden</Trans>
+          </Text>
+          <Text
+            style={[
+              a.text_md,
+              a.text_center,
+              a.px_md,
+              t.atoms.text_contrast_high,
+              {lineHeight: 1.4},
+            ]}>
+            <Trans>
+              This list - created by{' '}
+              <Text style={[a.text_md, !isOwner && a.font_bold]}>
+                {isOwner
+                  ? _(msg`you`)
+                  : sanitizeHandle(list.creator.handle, '@')}
+              </Text>{' '}
+              - contains possible violations of Bluesky's community guidelines
+              in its name or description.
+            </Trans>
+          </Text>
+        </View>
+      </View>
+      <View style={[a.gap_md, gtMobile ? {width: 350} : [a.w_full, a.px_lg]]}>
+        <View style={[a.gap_md]}>
+          {savedFeedConfig ? (
+            <Button
+              variant="solid"
+              color="secondary"
+              size="medium"
+              label={_(msg`Remove from saved feeds`)}
+              onPress={onRemoveList}
+              disabled={isProcessing}>
+              <ButtonText>
+                <Trans>Removed from saved feeds</Trans>
+              </ButtonText>
+              {isProcessing ? (
+                <ButtonIcon icon={Loader} position="right" />
+              ) : null}
+            </Button>
+          ) : null}
+          {isOwner ? (
+            <Button
+              variant="solid"
+              color="secondary"
+              size="medium"
+              label={_(msg`Show list anyway`)}
+              onPress={() => setIsContentVisible(true)}
+              disabled={isProcessing}>
+              <ButtonText>
+                <Trans>Show anyway</Trans>
+              </ButtonText>
+            </Button>
+          ) : list.viewer?.muted || list.viewer?.blocked ? (
+            <Button
+              variant="solid"
+              color="secondary"
+              size="medium"
+              label={_(msg`Unsubscribe from list`)}
+              onPress={() => {
+                if (isModList) {
+                  onUnsubscribe()
+                } else {
+                  onRemoveList()
+                }
+              }}
+              disabled={isProcessing}>
+              <ButtonText>
+                <Trans>Unsubscribe from list</Trans>
+              </ButtonText>
+              {isProcessing ? (
+                <ButtonIcon icon={Loader} position="right" />
+              ) : null}
+            </Button>
+          ) : null}
+        </View>
+        <Button
+          variant="solid"
+          color="primary"
+          label={_(msg`Return to previous page`)}
+          onPress={goBack}
+          size="medium"
+          disabled={isProcessing}>
+          <ButtonText>
+            <Trans>Go Back</Trans>
+          </ButtonText>
+        </Button>
+      </View>
+    </CenteredView>
+  )
+}
diff --git a/src/state/queries/list.ts b/src/state/queries/list.ts
index eeb9c3b38..405cb4ae3 100644
--- a/src/state/queries/list.ts
+++ b/src/state/queries/list.ts
@@ -17,7 +17,7 @@ import {useAgent, useSession} from '../session'
 import {invalidate as invalidateMyLists} from './my-lists'
 import {RQKEY as PROFILE_LISTS_RQKEY} from './profile-lists'
 
-const RQKEY_ROOT = 'list'
+export const RQKEY_ROOT = 'list'
 export const RQKEY = (uri: string) => [RQKEY_ROOT, uri]
 
 export function useListQuery(uri?: string) {
diff --git a/src/view/com/lists/ListCard.tsx b/src/view/com/lists/ListCard.tsx
deleted file mode 100644
index 587885502..000000000
--- a/src/view/com/lists/ListCard.tsx
+++ /dev/null
@@ -1,183 +0,0 @@
-import React from 'react'
-import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
-import {AppBskyGraphDefs, AtUri, RichText} from '@atproto/api'
-import {Trans} from '@lingui/macro'
-
-import {useSession} from '#/state/session'
-import {usePalette} from 'lib/hooks/usePalette'
-import {makeProfileLink} from 'lib/routes/links'
-import {sanitizeDisplayName} from 'lib/strings/display-names'
-import {sanitizeHandle} from 'lib/strings/handles'
-import {s} from 'lib/styles'
-import {atoms as a} from '#/alf'
-import {RichText as RichTextCom} from '#/components/RichText'
-import {Link} from '../util/Link'
-import {Text} from '../util/text/Text'
-import {UserAvatar} from '../util/UserAvatar'
-
-export const ListCard = ({
-  testID,
-  list,
-  noBg,
-  noBorder,
-  renderButton,
-  style,
-}: {
-  testID?: string
-  list: AppBskyGraphDefs.ListView
-  noBg?: boolean
-  noBorder?: boolean
-  renderButton?: () => JSX.Element
-  style?: StyleProp<ViewStyle>
-}) => {
-  const pal = usePalette('default')
-  const {currentAccount} = useSession()
-
-  const rkey = React.useMemo(() => {
-    try {
-      const urip = new AtUri(list.uri)
-      return urip.rkey
-    } catch {
-      return ''
-    }
-  }, [list])
-
-  const descriptionRichText = React.useMemo(() => {
-    if (list.description) {
-      return new RichText({
-        text: list.description,
-        facets: list.descriptionFacets,
-      })
-    }
-    return undefined
-  }, [list])
-
-  return (
-    <Link
-      testID={testID}
-      style={[
-        styles.outer,
-        pal.border,
-        noBorder && styles.outerNoBorder,
-        !noBg && pal.view,
-        style,
-      ]}
-      href={makeProfileLink(list.creator, 'lists', rkey)}
-      title={list.name}
-      asAnchor
-      anchorNoUnderline>
-      <View style={styles.layout}>
-        <View style={styles.layoutAvi}>
-          <UserAvatar type="list" size={40} avatar={list.avatar} />
-        </View>
-        <View style={styles.layoutContent}>
-          <Text
-            type="lg"
-            style={[s.bold, pal.text]}
-            numberOfLines={1}
-            lineHeight={1.2}>
-            {sanitizeDisplayName(list.name)}
-          </Text>
-          <Text type="md" style={[pal.textLight]} numberOfLines={1}>
-            {list.purpose === 'app.bsky.graph.defs#curatelist' &&
-              (list.creator.did === currentAccount?.did ? (
-                <Trans>User list by you</Trans>
-              ) : (
-                <Trans>
-                  User list by {sanitizeHandle(list.creator.handle, '@')}
-                </Trans>
-              ))}
-            {list.purpose === 'app.bsky.graph.defs#modlist' &&
-              (list.creator.did === currentAccount?.did ? (
-                <Trans>Moderation list by you</Trans>
-              ) : (
-                <Trans>
-                  Moderation list by {sanitizeHandle(list.creator.handle, '@')}
-                </Trans>
-              ))}
-          </Text>
-          <View style={s.flexRow}>
-            {list.viewer?.muted ? (
-              <View style={[s.mt5, pal.btn, styles.pill]}>
-                <Text type="xs" style={pal.text}>
-                  <Trans>Muted</Trans>
-                </Text>
-              </View>
-            ) : null}
-
-            {list.viewer?.blocked ? (
-              <View style={[s.mt5, pal.btn, styles.pill]}>
-                <Text type="xs" style={pal.text}>
-                  <Trans>Blocked</Trans>
-                </Text>
-              </View>
-            ) : null}
-          </View>
-        </View>
-        {renderButton ? (
-          <View style={styles.layoutButton}>{renderButton()}</View>
-        ) : undefined}
-      </View>
-      {descriptionRichText ? (
-        <View style={styles.details}>
-          <RichTextCom
-            style={[a.flex_1]}
-            numberOfLines={20}
-            value={descriptionRichText}
-          />
-        </View>
-      ) : undefined}
-    </Link>
-  )
-}
-
-const styles = StyleSheet.create({
-  outer: {
-    borderTopWidth: StyleSheet.hairlineWidth,
-    paddingHorizontal: 6,
-  },
-  outerNoBorder: {
-    borderTopWidth: 0,
-  },
-  layout: {
-    flexDirection: 'row',
-    alignItems: 'center',
-  },
-  layoutAvi: {
-    width: 54,
-    paddingLeft: 4,
-    paddingTop: 8,
-    paddingBottom: 10,
-  },
-  avi: {
-    width: 40,
-    height: 40,
-    borderRadius: 20,
-    resizeMode: 'cover',
-  },
-  layoutContent: {
-    flex: 1,
-    paddingRight: 10,
-    paddingTop: 10,
-    paddingBottom: 10,
-  },
-  layoutButton: {
-    paddingRight: 10,
-  },
-  details: {
-    paddingLeft: 54,
-    paddingRight: 10,
-    paddingBottom: 10,
-  },
-  pill: {
-    borderRadius: 4,
-    paddingHorizontal: 6,
-    paddingVertical: 2,
-  },
-  btn: {
-    paddingVertical: 7,
-    borderRadius: 50,
-    marginLeft: 6,
-    paddingHorizontal: 14,
-  },
-})
diff --git a/src/view/com/lists/MyLists.tsx b/src/view/com/lists/MyLists.tsx
index 472d2688c..b56fa6c75 100644
--- a/src/view/com/lists/MyLists.tsx
+++ b/src/view/com/lists/MyLists.tsx
@@ -4,7 +4,6 @@ import {
   FlatList as RNFlatList,
   RefreshControl,
   StyleProp,
-  StyleSheet,
   View,
   ViewStyle,
 } from 'react-native'
@@ -18,10 +17,13 @@ import {MyListsFilter, useMyListsQuery} from '#/state/queries/my-lists'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {usePalette} from 'lib/hooks/usePalette'
 import {s} from 'lib/styles'
+import {isWeb} from 'platform/detection'
+import {useModerationOpts} from 'state/preferences/moderation-opts'
 import {EmptyState} from 'view/com/util/EmptyState'
+import {atoms as a, useTheme} from '#/alf'
+import * as ListCard from '#/components/ListCard'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {List} from '../util/List'
-import {ListCard} from './ListCard'
 
 const LOADING = {_reactKey: '__loading__'}
 const EMPTY = {_reactKey: '__empty__'}
@@ -41,8 +43,10 @@ export function MyLists({
   testID?: string
 }) {
   const pal = usePalette('default')
+  const t = useTheme()
   const {track} = useAnalytics()
   const {_} = useLingui()
+  const moderationOpts = useModerationOpts()
   const [isPTRing, setIsPTRing] = React.useState(false)
   const {data, isFetching, isFetched, isError, error, refetch} =
     useMyListsQuery(filter)
@@ -53,7 +57,7 @@ export function MyLists({
     if (isError && isEmpty) {
       items = items.concat([ERROR_ITEM])
     }
-    if (!isFetched && isFetching) {
+    if ((!isFetched && isFetching) || !moderationOpts) {
       items = items.concat([LOADING])
     } else if (isEmpty) {
       items = items.concat([EMPTY])
@@ -61,7 +65,7 @@ export function MyLists({
       items = items.concat(data)
     }
     return items
-  }, [isError, isEmpty, isFetched, isFetching, data])
+  }, [isError, isEmpty, isFetched, isFetching, moderationOpts, data])
 
   // events
   // =
@@ -85,7 +89,6 @@ export function MyLists({
       if (item === EMPTY) {
         return (
           <EmptyState
-            key={item._reactKey}
             icon="list-ul"
             message={_(msg`You have no lists.`)}
             testID="listsEmpty"
@@ -94,14 +97,13 @@ export function MyLists({
       } else if (item === ERROR_ITEM) {
         return (
           <ErrorMessage
-            key={item._reactKey}
             message={cleanError(error)}
             onPressTryAgain={onRefresh}
           />
         )
       } else if (item === LOADING) {
         return (
-          <View key={item._reactKey} style={{padding: 20}}>
+          <View style={{padding: 20}}>
             <ActivityIndicator />
           </View>
         )
@@ -109,15 +111,18 @@ export function MyLists({
       return renderItem ? (
         renderItem(item, index)
       ) : (
-        <ListCard
-          key={item.uri}
-          list={item}
-          testID={`list-${item.name}`}
-          style={styles.item}
-        />
+        <View
+          style={[
+            (index !== 0 || isWeb) && a.border_t,
+            t.atoms.border_contrast_low,
+            a.px_lg,
+            a.py_lg,
+          ]}>
+          <ListCard.Default view={item} />
+        </View>
       )
     },
-    [error, onRefresh, renderItem, _],
+    [renderItem, t.atoms.border_contrast_low, _, error, onRefresh],
   )
 
   if (inline) {
@@ -166,10 +171,3 @@ export function MyLists({
     )
   }
 }
-
-const styles = StyleSheet.create({
-  item: {
-    paddingHorizontal: 18,
-    paddingVertical: 4,
-  },
-})
diff --git a/src/view/com/util/post-embeds/ListEmbed.tsx b/src/view/com/util/post-embeds/ListEmbed.tsx
deleted file mode 100644
index fc5ad270f..000000000
--- a/src/view/com/util/post-embeds/ListEmbed.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import React from 'react'
-import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
-import {usePalette} from 'lib/hooks/usePalette'
-import {ListCard} from 'view/com/lists/ListCard'
-import {AppBskyGraphDefs} from '@atproto/api'
-import {s} from 'lib/styles'
-
-export function ListEmbed({
-  item,
-  style,
-}: {
-  item: AppBskyGraphDefs.ListView
-  style?: StyleProp<ViewStyle>
-}) {
-  const pal = usePalette('default')
-
-  return (
-    <View style={[pal.view, pal.border, s.border1, styles.container]}>
-      <ListCard list={item} style={[style, styles.card]} />
-    </View>
-  )
-}
-
-const styles = StyleSheet.create({
-  container: {
-    borderRadius: 8,
-  },
-  card: {
-    borderTopWidth: 0,
-    borderRadius: 8,
-  },
-})
diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx
index 0462212fb..9c1364483 100644
--- a/src/view/com/util/post-embeds/index.tsx
+++ b/src/view/com/util/post-embeds/index.tsx
@@ -25,13 +25,13 @@ import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
 import {useModerationOpts} from '#/state/preferences/moderation-opts'
 import {usePalette} from 'lib/hooks/usePalette'
 import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
-import {atoms as a} from '#/alf'
+import {atoms as a, useTheme} from '#/alf'
+import * as ListCard from '#/components/ListCard'
 import {Embed as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
 import {ContentHider} from '../../../../components/moderation/ContentHider'
 import {AutoSizedImage} from '../images/AutoSizedImage'
 import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
 import {ExternalLinkEmbed} from './ExternalLinkEmbed'
-import {ListEmbed} from './ListEmbed'
 import {MaybeQuoteEmbed} from './QuoteEmbed'
 
 type Embed =
@@ -203,10 +203,20 @@ function MaybeListCard({view}: {view: AppBskyGraphDefs.ListView}) {
   const moderation = React.useMemo(() => {
     return moderationOpts ? moderateUserList(view, moderationOpts) : undefined
   }, [view, moderationOpts])
+  const t = useTheme()
 
   return (
     <ContentHider modui={moderation?.ui('contentList')}>
-      <ListEmbed item={view} />
+      <View
+        style={[
+          a.border,
+          t.atoms.border_contrast_medium,
+          a.p_md,
+          a.rounded_sm,
+          a.mt_sm,
+        ]}>
+        <ListCard.Default view={view} />
+      </View>
     </ContentHider>
   )
 }
diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx
index bf13791ae..0c2c6405f 100644
--- a/src/view/screens/ProfileList.tsx
+++ b/src/view/screens/ProfileList.tsx
@@ -32,6 +32,7 @@ import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
 import {
   useAddSavedFeedsMutation,
   usePreferencesQuery,
+  UsePreferencesQueryResponse,
   useRemoveFeedMutation,
   useUpdateSavedFeedsMutation,
 } from '#/state/queries/preferences'
@@ -67,9 +68,10 @@ import {LoadingScreen} from 'view/com/util/LoadingScreen'
 import {Text} from 'view/com/util/text/Text'
 import * as Toast from 'view/com/util/Toast'
 import {CenteredView} from 'view/com/util/Views'
+import {ListHiddenScreen} from '#/screens/List/ListHiddenScreen'
 import {atoms as a, useTheme} from '#/alf'
 import {useDialogControl} from '#/components/Dialog'
-import {ScreenHider} from '#/components/moderation/ScreenHider'
+import * as Hider from '#/components/moderation/Hider'
 import * as Prompt from '#/components/Prompt'
 import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog'
 import {RichText} from '#/components/RichText'
@@ -88,6 +90,7 @@ export function ProfileListScreen(props: Props) {
   const {data: resolvedUri, error: resolveError} = useResolveUriQuery(
     AtUri.make(handleOrDid, 'app.bsky.graph.list', rkey).toString(),
   )
+  const {data: preferences} = usePreferencesQuery()
   const {data: list, error: listError} = useListQuery(resolvedUri?.uri)
   const moderationOpts = useModerationOpts()
 
@@ -110,12 +113,13 @@ export function ProfileListScreen(props: Props) {
     )
   }
 
-  return resolvedUri && list && moderationOpts ? (
+  return resolvedUri && list && moderationOpts && preferences ? (
     <ProfileListScreenLoaded
       {...props}
       uri={resolvedUri.uri}
       list={list}
       moderationOpts={moderationOpts}
+      preferences={preferences}
     />
   ) : (
     <LoadingScreen />
@@ -127,27 +131,32 @@ function ProfileListScreenLoaded({
   uri,
   list,
   moderationOpts,
+  preferences,
 }: Props & {
   uri: string
   list: AppBskyGraphDefs.ListView
   moderationOpts: ModerationOpts
+  preferences: UsePreferencesQueryResponse
 }) {
   const {_} = useLingui()
   const queryClient = useQueryClient()
   const {openComposer} = useComposerControls()
   const setMinimalShellMode = useSetMinimalShellMode()
+  const {currentAccount} = useSession()
   const {rkey} = route.params
   const feedSectionRef = React.useRef<SectionRef>(null)
   const aboutSectionRef = React.useRef<SectionRef>(null)
   const {openModal} = useModalControls()
-  const isCurateList = list.purpose === 'app.bsky.graph.defs#curatelist'
+  const isCurateList = list.purpose === AppBskyGraphDefs.CURATELIST
   const isScreenFocused = useIsFocused()
+  const isHidden = list.labels?.findIndex(l => l.val === '!hide') !== -1
+  const isOwner = currentAccount?.did === list.creator.did
 
   const moderation = React.useMemo(() => {
     return moderateUserList(list, moderationOpts)
   }, [list, moderationOpts])
 
-  useSetTitle(list.name)
+  useSetTitle(isHidden ? _(msg`List Hidden`) : list.name)
 
   useFocusEffect(
     useCallback(() => {
@@ -179,34 +188,75 @@ function ProfileListScreenLoaded({
   )
 
   const renderHeader = useCallback(() => {
-    return <Header rkey={rkey} list={list} />
-  }, [rkey, list])
+    return <Header rkey={rkey} list={list} preferences={preferences} />
+  }, [rkey, list, preferences])
 
   if (isCurateList) {
     return (
-      <ScreenHider
-        screenDescription={'list'}
-        modui={moderation.ui('contentView')}>
+      <Hider.Outer modui={moderation.ui('contentView')} allowOverride={isOwner}>
+        <Hider.Mask>
+          <ListHiddenScreen list={list} preferences={preferences} />
+        </Hider.Mask>
+        <Hider.Content>
+          <View style={s.hContentRegion}>
+            <PagerWithHeader
+              items={SECTION_TITLES_CURATE}
+              isHeaderReady={true}
+              renderHeader={renderHeader}
+              onCurrentPageSelected={onCurrentPageSelected}>
+              {({headerHeight, scrollElRef, isFocused}) => (
+                <FeedSection
+                  ref={feedSectionRef}
+                  feed={`list|${uri}`}
+                  scrollElRef={scrollElRef as ListRef}
+                  headerHeight={headerHeight}
+                  isFocused={isScreenFocused && isFocused}
+                />
+              )}
+              {({headerHeight, scrollElRef}) => (
+                <AboutSection
+                  ref={aboutSectionRef}
+                  scrollElRef={scrollElRef as ListRef}
+                  list={list}
+                  onPressAddUser={onPressAddUser}
+                  headerHeight={headerHeight}
+                />
+              )}
+            </PagerWithHeader>
+            <FAB
+              testID="composeFAB"
+              onPress={() => openComposer({})}
+              icon={
+                <ComposeIcon2
+                  strokeWidth={1.5}
+                  size={29}
+                  style={{color: 'white'}}
+                />
+              }
+              accessibilityRole="button"
+              accessibilityLabel={_(msg`New post`)}
+              accessibilityHint=""
+            />
+          </View>
+        </Hider.Content>
+      </Hider.Outer>
+    )
+  }
+  return (
+    <Hider.Outer modui={moderation.ui('contentView')} allowOverride={isOwner}>
+      <Hider.Mask>
+        <ListHiddenScreen list={list} preferences={preferences} />
+      </Hider.Mask>
+      <Hider.Content>
         <View style={s.hContentRegion}>
           <PagerWithHeader
-            items={SECTION_TITLES_CURATE}
+            items={SECTION_TITLES_MOD}
             isHeaderReady={true}
-            renderHeader={renderHeader}
-            onCurrentPageSelected={onCurrentPageSelected}>
-            {({headerHeight, scrollElRef, isFocused}) => (
-              <FeedSection
-                ref={feedSectionRef}
-                feed={`list|${uri}`}
-                scrollElRef={scrollElRef as ListRef}
-                headerHeight={headerHeight}
-                isFocused={isScreenFocused && isFocused}
-              />
-            )}
+            renderHeader={renderHeader}>
             {({headerHeight, scrollElRef}) => (
               <AboutSection
-                ref={aboutSectionRef}
-                scrollElRef={scrollElRef as ListRef}
                 list={list}
+                scrollElRef={scrollElRef as ListRef}
                 onPressAddUser={onPressAddUser}
                 headerHeight={headerHeight}
               />
@@ -227,47 +277,20 @@ function ProfileListScreenLoaded({
             accessibilityHint=""
           />
         </View>
-      </ScreenHider>
-    )
-  }
-  return (
-    <ScreenHider
-      screenDescription={_(msg`list`)}
-      modui={moderation.ui('contentView')}>
-      <View style={s.hContentRegion}>
-        <PagerWithHeader
-          items={SECTION_TITLES_MOD}
-          isHeaderReady={true}
-          renderHeader={renderHeader}>
-          {({headerHeight, scrollElRef}) => (
-            <AboutSection
-              list={list}
-              scrollElRef={scrollElRef as ListRef}
-              onPressAddUser={onPressAddUser}
-              headerHeight={headerHeight}
-            />
-          )}
-        </PagerWithHeader>
-        <FAB
-          testID="composeFAB"
-          onPress={() => openComposer({})}
-          icon={
-            <ComposeIcon2
-              strokeWidth={1.5}
-              size={29}
-              style={{color: 'white'}}
-            />
-          }
-          accessibilityRole="button"
-          accessibilityLabel={_(msg`New post`)}
-          accessibilityHint=""
-        />
-      </View>
-    </ScreenHider>
+      </Hider.Content>
+    </Hider.Outer>
   )
 }
 
-function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
+function Header({
+  rkey,
+  list,
+  preferences,
+}: {
+  rkey: string
+  list: AppBskyGraphDefs.ListView
+  preferences: UsePreferencesQueryResponse
+}) {
   const pal = usePalette('default')
   const palInverted = usePalette('inverted')
   const {_} = useLingui()
@@ -283,7 +306,6 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
   const isBlocking = !!list.viewer?.blocked
   const isMuting = !!list.viewer?.muted
   const isOwner = list.creator.did === currentAccount?.did
-  const {data: preferences} = usePreferencesQuery()
   const {track} = useAnalytics()
   const playHaptic = useHaptics()
 
@@ -644,7 +666,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
           cid: list.cid,
         }}
       />
-      {isCurateList || isPinned ? (
+      {isCurateList ? (
         <Button
           testID={isPinned ? 'unpinBtn' : 'pinBtn'}
           type={isPinned ? 'default' : 'inverted'}