bsky-app/src/view/screens/ProfileList.tsx
Paul Frazee 20d463ff2f
3p moderation services [WIP] (#2550)
* Add modservice screen and profile-header-card

* Drop the guidelines for now

* Remove ununsed constants

* Add label & label group descriptions

* Not found state

* Reorg, add icon

* Subheader

* Header

* Complete header

* Clean up

* Add all groups

* Fix scroll view

* Dialogs side quest

* Remove log

* Add (WIP) debug mod page

* Dialog solution

* Add note

* Clean up and reorganize localized moderation strings

* Memoize

* Add example

* Add first ReportDialog screen

* Report dialog step 2

* Submit

* Integrate updates

* Move moderation screen

* Migrate buttons

* Migrate everything

* Rough sketch

* Fix types

* Update atoms values

* Abstract ModerationServiceCard

* Hook up data to settings page

* Handle subscription

* Rough enablement

* Rough enablement

* Some validation, fixes

* More work on the mod debug screen

* Hook up data

* Update invalidation

* Hook up data to ReportDialog

* Fix native error

* Refactor/rewrite the entire moderation-application system

* Fix toggles

* Add copyright and other option to report

* Handle reports on profile vs content

* Little cleanup

* Get post hiding back in gear

* Better loading flow on Mod screen

* Clean up Mod screen

* Clean up ProfileMod screen

* Handle muting correctly

* Update enablement on ProfileMod screen

* Improve Moderation screen and dialog

* Styling, handle disabled labelers

* Rework list of labels on own content

* Use moderateNotification()

* ReportDialog updates

* Fix button overflow

* Simplify the ProfileModerationService ui

* Mod screen design

* Move moderation card from the profile header to a tab

* Small tweaks to the moderation screen

* Enable toggle on mod page

* Add notifs to debugmod and dont filter notifs from followed users

* Add moderator-service profile view

* Wire up more of the modservice data to profiles

* A bunch of speculative non-working UI

* Cleanup: delete old code

* Update ModerationDetailsDialog

* Update ReportDialog

* Update LabelsOnMe dialog

* Handle ReportDialog load better

* Rename LabelsOnMeDialog, fix close

* Experiment to put labeling under a tab of a normal profile

* Moderator variation of profile

* Remove dead code and start moving toward latest modsdk

* Remove a bunch of now-dead label strings

* Update ModDebug to be a bit more intuitive and support custom labels

* Minor ui tweaks

* Improve consistency of display name blurring

* Fix profile-card warning rendering

* More debugmod UI tuning

* Update to use new labeler semantics

* Delete some dead code and do some refactoring

* Update profile to pull from labeler definition

* Implement new label config controls (wip)

* Tweak ui

* Implement preference controls on labelers

* Rework label pref ui

* Get moderation screen working

* Add asyncstorage query persistence

* Implement label handling

* Small cleanup

* Implement Likes dialog

* Fix: remove text outside of text element

* Cleanup

* Fix likes dialog on mobile

* Implement the label appeal flow

* Get report flow working again with temporarily fixed report options

* Update onboarding

* Enforce limit of ten labeler subscriptions

* Fix type errors

* Fix lint errors

* Improve types of RQ

* Some work on Likes dialog, needs discussion

* Bit of ReportDialog cleanup

* Replace non-single-path SVG

* Update nudity descriptions

* Update to use new sdk updates

* Add adult-content-enabled behavior to label config

* Use the default setting of custom labels

* Handle global moderation label prefs with the global settings

* Fix missing postAuthor

* Fix empty moderation page

* Add mutewords control back to Mod screen

* Tweak adult setting styles

* Remove deprecated global labels

* Handle underage users on mod screen

* Adjust font sizes

* Swap in RichText

* Like button improvements

* Tweaks to Labeler profile

* Design tweaks for mod pref dialog

* Add tertiary button color

* Switch moderation UIs to tertiary color

* Update mutewords and hiddenposts to use the new sdk

* Add test-environment mod authority

* Switch 'gore' to 'graphic-media'

* Move nudity out of the adult content control

* Remove focus styles from buttons - let the browser behavior handle it

* Fixes to the adult content age-gating in moderaiton

* Ditch tertiary button color, lighten secondary button

* Fix some colors

* Remove focused overrides from toggles

* Liked by screen

* Rework the moderationlabelpref

* Fix optimistic like

* Cleanup

* Change how onboarding handles adult content enabled/disabled

* Add special handling of the mod authorities

* Tweaks

* Update the default labeler avatar to a shield

* Add route to go server

* Avoid dups due to bad config

* Fix attrs

* Fix: dont try to detect link/label mismatches on post meta

* Correctly show the label behavior when adult content is disabled

* Readd the local hiddenPosts handling

* WIP

* Fix bad merge

* Conten hider design tweaks

* Fix text string breakage

* Adjust source text in ContentHider

* Fix link bug

* Design tweaks to ContentHider and ModDetailsDialog

* Adjust spacing of inform badges

* Adjust spacing of embeds in posts

* Style tweaks to post/profile alerts

* Labels on me and dialog

* Remove bad focus styles from post dropdown

* Better spacing solution

* Tune moderation UIs

* Moderation UI tweaks for mobile

* Move labelers query on Mod screen

* Update to use new SDK appLabelers semantics

* Implement report submission

* Replace the report modal entirely with the report dialog

* Add @ to mod details dialog handle

* Bump SDK package

* Remove silly type

* Add to AWS build CI

* Fix ToggleButton overflow

* Clean up ModServiceCard, rename to LabelingServiceCard

* Hackfix to translate gore labels to graphic-media

* Tune content hider sizing on web desktop

* Handle self labels

* Fix spacing below text-only posts

* Fix: send appeals to the right labeler

* Give mod page links interactive states

* Fix references

* Remove focus handling

* Remove remnant

* Remove the like count from the subscribed labeler listing

* Bump @atproto/api@0.11.1

* Remove extra @

* Fix: persist labels to local storage to reduce coverage gaps

* update dipendencies

* revert dipendencies

* Add some explainers on how blocking affects labelers

* Tweak copy

* Fix underline color in header

* Fix profile menu

* Handle card overflow

* Remove metrics from header

* Mute 'account' not 'user'

* Show metrics if self

* Show the labels tab on logged out view

* Fix bad merge

* Use purple theming on labelers

* Tighten space on LabelerCard

* Set staleTime to 6hrs for labeler details

* Memoize the memoizers

* Drop staleTime to 60s

* Move label defs into a context to reduce recomputes

* Submit view tweaks

* Move labeler fetch below auth

* Mitigation: hardcode the bluesky moderation labeler name

* Bump sdk

* Add missing translated string

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Add missing translated string

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Hailey's fix for incorrect profile tabs

Co-authored-by: Hailey <me@haileyok.com>

* Feedback

* Fix borders, add bottom space

* Hailey's fix pt 2

Co-authored-by: Hailey <me@haileyok.com>

* Fix post tabs

* Integrate feedback pt 1

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Integrate feedback pt 2

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Integrate feedback pt 3

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Integrate feedback pt 4

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Integrate feedback pt 5

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Integrate feedback pt 6

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Integrate feedback pt 7

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Integrate feedback pt 8

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Format

* Integrate new bday modal

* Use public agent for getServices

* Update casing

---------

Co-authored-by: Eric Bailey <git@esb.lol>
Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>
Co-authored-by: Hailey <me@haileyok.com>
2024-03-18 12:46:28 -07:00

939 lines
27 KiB
TypeScript

import React, {useCallback, useMemo} from 'react'
import {Pressable, StyleSheet, View} from 'react-native'
import {useFocusEffect, useIsFocused} from '@react-navigation/native'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {useNavigation} from '@react-navigation/native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {AppBskyGraphDefs, AtUri, RichText as RichTextAPI} from '@atproto/api'
import {useQueryClient} from '@tanstack/react-query'
import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader'
import {Feed} from 'view/com/posts/Feed'
import {Text} from 'view/com/util/text/Text'
import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown'
import {CenteredView} from 'view/com/util/Views'
import {EmptyState} from 'view/com/util/EmptyState'
import {LoadingScreen} from 'view/com/util/LoadingScreen'
import {RichText} from '#/components/RichText'
import {Button} from 'view/com/util/forms/Button'
import {TextLink} from 'view/com/util/Link'
import {ListRef} from 'view/com/util/List'
import * as Toast from 'view/com/util/Toast'
import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
import {FAB} from 'view/com/util/fab/FAB'
import {Haptics} from 'lib/haptics'
import {FeedDescriptor} from '#/state/queries/post-feed'
import {usePalette} from 'lib/hooks/usePalette'
import {useSetTitle} from 'lib/hooks/useSetTitle'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
import {NavigationProp} from 'lib/routes/types'
import {toShareUrl} from 'lib/strings/url-helpers'
import {shareUrl} from 'lib/sharing'
import {s} from 'lib/styles'
import {sanitizeHandle} from 'lib/strings/handles'
import {makeProfileLink, makeListLink} from 'lib/routes/links'
import {ComposeIcon2} from 'lib/icons'
import {ListMembers} from '#/view/com/lists/ListMembers'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useSetMinimalShellMode} from '#/state/shell'
import {useModalControls} from '#/state/modals'
import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog'
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
import {
useListQuery,
useListMuteMutation,
useListBlockMutation,
useListDeleteMutation,
} from '#/state/queries/list'
import {cleanError} from '#/lib/strings/errors'
import {useSession} from '#/state/session'
import {useComposerControls} from '#/state/shell/composer'
import {isNative, isWeb} from '#/platform/detection'
import {truncateAndInvalidate} from '#/state/queries/util'
import {
usePreferencesQuery,
usePinFeedMutation,
useUnpinFeedMutation,
useSetSaveFeedsMutation,
} from '#/state/queries/preferences'
import {logger} from '#/logger'
import {useAnalytics} from '#/lib/analytics/analytics'
import {listenSoftReset} from '#/state/events'
import {atoms as a, useTheme} from '#/alf'
import * as Prompt from '#/components/Prompt'
import {useDialogControl} from '#/components/Dialog'
const SECTION_TITLES_CURATE = ['Posts', 'About']
const SECTION_TITLES_MOD = ['About']
interface SectionRef {
scrollToTop: () => void
}
type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileList'>
export function ProfileListScreen(props: Props) {
const {_} = useLingui()
const {name: handleOrDid, rkey} = props.route.params
const {data: resolvedUri, error: resolveError} = useResolveUriQuery(
AtUri.make(handleOrDid, 'app.bsky.graph.list', rkey).toString(),
)
const {data: list, error: listError} = useListQuery(resolvedUri?.uri)
if (resolveError) {
return (
<CenteredView>
<ErrorScreen
error={_(
msg`We're sorry, but we were unable to resolve this list. If this persists, please contact the list creator, @${handleOrDid}.`,
)}
/>
</CenteredView>
)
}
if (listError) {
return (
<CenteredView>
<ErrorScreen error={cleanError(listError)} />
</CenteredView>
)
}
return resolvedUri && list ? (
<ProfileListScreenLoaded {...props} uri={resolvedUri.uri} list={list} />
) : (
<LoadingScreen />
)
}
function ProfileListScreenLoaded({
route,
uri,
list,
}: Props & {uri: string; list: AppBskyGraphDefs.ListView}) {
const {_} = useLingui()
const queryClient = useQueryClient()
const {openComposer} = useComposerControls()
const setMinimalShellMode = useSetMinimalShellMode()
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 isScreenFocused = useIsFocused()
useSetTitle(list.name)
useFocusEffect(
useCallback(() => {
setMinimalShellMode(false)
}, [setMinimalShellMode]),
)
const onPressAddUser = useCallback(() => {
openModal({
name: 'list-add-remove-users',
list,
onChange() {
if (isCurateList) {
truncateAndInvalidate(queryClient, FEED_RQKEY(`list|${list.uri}`))
}
},
})
}, [openModal, list, isCurateList, queryClient])
const onCurrentPageSelected = React.useCallback(
(index: number) => {
if (index === 0) {
feedSectionRef.current?.scrollToTop()
} else if (index === 1) {
aboutSectionRef.current?.scrollToTop()
}
},
[feedSectionRef],
)
const renderHeader = useCallback(() => {
return <Header rkey={rkey} list={list} />
}, [rkey, list])
if (isCurateList) {
return (
<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>
)
}
return (
<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>
)
}
function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
const pal = usePalette('default')
const palInverted = usePalette('inverted')
const {_} = useLingui()
const navigation = useNavigation<NavigationProp>()
const {currentAccount} = useSession()
const reportDialogControl = useReportDialogControl()
const {openModal} = useModalControls()
const listMuteMutation = useListMuteMutation()
const listBlockMutation = useListBlockMutation()
const listDeleteMutation = useListDeleteMutation()
const isCurateList = list.purpose === 'app.bsky.graph.defs#curatelist'
const isModList = list.purpose === 'app.bsky.graph.defs#modlist'
const isBlocking = !!list.viewer?.blocked
const isMuting = !!list.viewer?.muted
const isOwner = list.creator.did === currentAccount?.did
const {isPending: isPinPending, mutateAsync: pinFeed} = usePinFeedMutation()
const {isPending: isUnpinPending, mutateAsync: unpinFeed} =
useUnpinFeedMutation()
const isPending = isPinPending || isUnpinPending
const {data: preferences} = usePreferencesQuery()
const {mutate: setSavedFeeds} = useSetSaveFeedsMutation()
const {track} = useAnalytics()
const deleteListPromptControl = useDialogControl()
const subscribeMutePromptControl = useDialogControl()
const subscribeBlockPromptControl = useDialogControl()
const isPinned = preferences?.feeds?.pinned?.includes(list.uri)
const isSaved = preferences?.feeds?.saved?.includes(list.uri)
const onTogglePinned = React.useCallback(async () => {
Haptics.default()
try {
if (isPinned) {
await unpinFeed({uri: list.uri})
} else {
await pinFeed({uri: list.uri})
}
} catch (e) {
Toast.show(_(msg`There was an issue contacting the server`))
logger.error('Failed to toggle pinned feed', {message: e})
}
}, [list.uri, isPinned, pinFeed, unpinFeed, _])
const onSubscribeMute = useCallback(async () => {
try {
await listMuteMutation.mutateAsync({uri: list.uri, mute: true})
Toast.show(_(msg`List muted`))
track('Lists:Mute')
} catch {
Toast.show(
_(
msg`There was an issue. Please check your internet connection and try again.`,
),
)
}
}, [list, listMuteMutation, track, _])
const onUnsubscribeMute = useCallback(async () => {
try {
await listMuteMutation.mutateAsync({uri: list.uri, mute: false})
Toast.show(_(msg`List unmuted`))
track('Lists:Unmute')
} catch {
Toast.show(
_(
msg`There was an issue. Please check your internet connection and try again.`,
),
)
}
}, [list, listMuteMutation, track, _])
const onSubscribeBlock = useCallback(async () => {
try {
await listBlockMutation.mutateAsync({uri: list.uri, block: true})
Toast.show(_(msg`List blocked`))
track('Lists:Block')
} catch {
Toast.show(
_(
msg`There was an issue. Please check your internet connection and try again.`,
),
)
}
}, [list, listBlockMutation, track, _])
const onUnsubscribeBlock = useCallback(async () => {
try {
await listBlockMutation.mutateAsync({uri: list.uri, block: false})
Toast.show(_(msg`List unblocked`))
track('Lists:Unblock')
} catch {
Toast.show(
_(
msg`There was an issue. Please check your internet connection and try again.`,
),
)
}
}, [list, listBlockMutation, track, _])
const onPressEdit = useCallback(() => {
openModal({
name: 'create-or-edit-list',
list,
})
}, [openModal, list])
const onPressDelete = useCallback(async () => {
await listDeleteMutation.mutateAsync({uri: list.uri})
if (isSaved || isPinned) {
const {saved, pinned} = preferences!.feeds
setSavedFeeds({
saved: isSaved ? saved.filter(uri => uri !== list.uri) : saved,
pinned: isPinned ? pinned.filter(uri => uri !== list.uri) : pinned,
})
}
Toast.show(_(msg`List deleted`))
track('Lists:Delete')
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.navigate('Home')
}
}, [
list,
listDeleteMutation,
navigation,
track,
_,
preferences,
isPinned,
isSaved,
setSavedFeeds,
])
const onPressReport = useCallback(() => {
reportDialogControl.open()
}, [reportDialogControl])
const onPressShare = useCallback(() => {
const url = toShareUrl(`/profile/${list.creator.did}/lists/${rkey}`)
shareUrl(url)
track('Lists:Share')
}, [list, rkey, track])
const dropdownItems: DropdownItem[] = useMemo(() => {
let items: DropdownItem[] = [
{
testID: 'listHeaderDropdownShareBtn',
label: isWeb ? _(msg`Copy link to list`) : _(msg`Share`),
onPress: onPressShare,
icon: {
ios: {
name: 'square.and.arrow.up',
},
android: '',
web: 'share',
},
},
]
if (isOwner) {
items.push({label: 'separator'})
items.push({
testID: 'listHeaderDropdownEditBtn',
label: _(msg`Edit list details`),
onPress: onPressEdit,
icon: {
ios: {
name: 'pencil',
},
android: '',
web: 'pen',
},
})
items.push({
testID: 'listHeaderDropdownDeleteBtn',
label: _(msg`Delete List`),
onPress: deleteListPromptControl.open,
icon: {
ios: {
name: 'trash',
},
android: '',
web: ['far', 'trash-can'],
},
})
} else {
items.push({label: 'separator'})
items.push({
testID: 'listHeaderDropdownReportBtn',
label: _(msg`Report List`),
onPress: onPressReport,
icon: {
ios: {
name: 'exclamationmark.triangle',
},
android: '',
web: 'circle-exclamation',
},
})
}
if (isModList && isPinned) {
items.push({label: 'separator'})
items.push({
testID: 'listHeaderDropdownUnpinBtn',
label: _(msg`Unpin moderation list`),
onPress: isPending ? undefined : () => unpinFeed({uri: list.uri}),
icon: {
ios: {
name: 'pin',
},
android: '',
web: 'thumbtack',
},
})
}
if (isCurateList) {
items.push({label: 'separator'})
if (!isBlocking) {
items.push({
testID: 'listHeaderDropdownMuteBtn',
label: isMuting ? _(msg`Un-mute list`) : _(msg`Mute list`),
onPress: isMuting
? onUnsubscribeMute
: subscribeMutePromptControl.open,
icon: {
ios: {
name: isMuting ? 'eye' : 'eye.slash',
},
android: '',
web: isMuting ? 'eye' : ['far', 'eye-slash'],
},
})
}
if (!isMuting) {
items.push({
testID: 'listHeaderDropdownBlockBtn',
label: isBlocking ? _(msg`Un-block list`) : _(msg`Block list`),
onPress: isBlocking
? onUnsubscribeBlock
: subscribeBlockPromptControl.open,
icon: {
ios: {
name: 'person.fill.xmark',
},
android: '',
web: 'user-slash',
},
})
}
}
return items
}, [
_,
onPressShare,
isOwner,
isModList,
isPinned,
isCurateList,
onPressEdit,
deleteListPromptControl.open,
onPressReport,
isPending,
unpinFeed,
list.uri,
isBlocking,
isMuting,
onUnsubscribeMute,
subscribeMutePromptControl.open,
onUnsubscribeBlock,
subscribeBlockPromptControl.open,
])
const subscribeDropdownItems: DropdownItem[] = useMemo(() => {
return [
{
testID: 'subscribeDropdownMuteBtn',
label: _(msg`Mute accounts`),
onPress: subscribeMutePromptControl.open,
icon: {
ios: {
name: 'speaker.slash',
},
android: '',
web: 'user-slash',
},
},
{
testID: 'subscribeDropdownBlockBtn',
label: _(msg`Block accounts`),
onPress: subscribeBlockPromptControl.open,
icon: {
ios: {
name: 'person.fill.xmark',
},
android: '',
web: 'ban',
},
},
]
}, [_, subscribeMutePromptControl.open, subscribeBlockPromptControl.open])
return (
<ProfileSubpageHeader
href={makeListLink(list.creator.handle || list.creator.did || '', rkey)}
title={list.name}
avatar={list.avatar}
isOwner={list.creator.did === currentAccount?.did}
creator={list.creator}
avatarType="list">
<ReportDialog
control={reportDialogControl}
params={{
type: 'list',
uri: list.uri,
cid: list.cid,
}}
/>
{isCurateList || isPinned ? (
<Button
testID={isPinned ? 'unpinBtn' : 'pinBtn'}
type={isPinned ? 'default' : 'inverted'}
label={isPinned ? _(msg`Unpin`) : _(msg`Pin to home`)}
onPress={onTogglePinned}
disabled={isPending}
/>
) : isModList ? (
isBlocking ? (
<Button
testID="unblockBtn"
type="default"
label={_(msg`Unblock`)}
onPress={onUnsubscribeBlock}
/>
) : isMuting ? (
<Button
testID="unmuteBtn"
type="default"
label={_(msg`Unmute`)}
onPress={onUnsubscribeMute}
/>
) : (
<NativeDropdown
testID="subscribeBtn"
items={subscribeDropdownItems}
accessibilityLabel={_(msg`Subscribe to this list`)}
accessibilityHint="">
<View style={[palInverted.view, styles.btn]}>
<Text style={palInverted.text}>
<Trans>Subscribe</Trans>
</Text>
</View>
</NativeDropdown>
)
) : null}
<NativeDropdown
testID="headerDropdownBtn"
items={dropdownItems}
accessibilityLabel={_(msg`More options`)}
accessibilityHint="">
<View style={[pal.viewLight, styles.btn]}>
<FontAwesomeIcon icon="ellipsis" size={20} color={pal.colors.text} />
</View>
</NativeDropdown>
<Prompt.Basic
control={deleteListPromptControl}
title={_(msg`Delete this list?`)}
description={_(
msg`If you delete this list, you won't be able to recover it.`,
)}
onConfirm={onPressDelete}
confirmButtonCta={_(msg`Delete`)}
confirmButtonColor="negative"
/>
<Prompt.Basic
control={subscribeMutePromptControl}
title={_(msg`Mute these accounts?`)}
description={_(
msg`Muting is private. Muted accounts can interact with you, but you will not see their posts or receive notifications from them.`,
)}
onConfirm={onSubscribeMute}
confirmButtonCta={_(msg`Mute list`)}
/>
<Prompt.Basic
control={subscribeBlockPromptControl}
title={_(msg`Block these accounts?`)}
description={_(
msg`Blocking is public. Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`,
)}
onConfirm={onSubscribeBlock}
confirmButtonCta={_(msg`Block list`)}
confirmButtonColor="negative"
/>
</ProfileSubpageHeader>
)
}
interface FeedSectionProps {
feed: FeedDescriptor
headerHeight: number
scrollElRef: ListRef
isFocused: boolean
}
const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
function FeedSectionImpl({feed, scrollElRef, headerHeight, isFocused}, ref) {
const queryClient = useQueryClient()
const [hasNew, setHasNew] = React.useState(false)
const [isScrolledDown, setIsScrolledDown] = React.useState(false)
const isScreenFocused = useIsFocused()
const {_} = useLingui()
const onScrollToTop = useCallback(() => {
scrollElRef.current?.scrollToOffset({
animated: isNative,
offset: -headerHeight,
})
queryClient.resetQueries({queryKey: FEED_RQKEY(feed)})
setHasNew(false)
}, [scrollElRef, headerHeight, queryClient, feed, setHasNew])
React.useImperativeHandle(ref, () => ({
scrollToTop: onScrollToTop,
}))
React.useEffect(() => {
if (!isScreenFocused) {
return
}
return listenSoftReset(onScrollToTop)
}, [onScrollToTop, isScreenFocused])
const renderPostsEmpty = useCallback(() => {
return <EmptyState icon="feed" message={_(msg`This feed is empty!`)} />
}, [_])
return (
<View>
<Feed
testID="listFeed"
enabled={isFocused}
feed={feed}
pollInterval={60e3}
disablePoll={hasNew}
scrollElRef={scrollElRef}
onHasNew={setHasNew}
onScrolledDownChange={setIsScrolledDown}
renderEmptyState={renderPostsEmpty}
headerOffset={headerHeight}
/>
{(isScrolledDown || hasNew) && (
<LoadLatestBtn
onPress={onScrollToTop}
label={_(msg`Load new posts`)}
showIndicator={hasNew}
/>
)}
</View>
)
},
)
interface AboutSectionProps {
list: AppBskyGraphDefs.ListView
onPressAddUser: () => void
headerHeight: number
scrollElRef: ListRef
}
const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>(
function AboutSectionImpl(
{list, onPressAddUser, headerHeight, scrollElRef},
ref,
) {
const pal = usePalette('default')
const t = useTheme()
const {_} = useLingui()
const {isMobile} = useWebMediaQueries()
const {currentAccount} = useSession()
const [isScrolledDown, setIsScrolledDown] = React.useState(false)
const isCurateList = list.purpose === 'app.bsky.graph.defs#curatelist'
const isOwner = list.creator.did === currentAccount?.did
const descriptionRT = useMemo(
() =>
list.description
? new RichTextAPI({
text: list.description,
facets: list.descriptionFacets,
})
: undefined,
[list],
)
const onScrollToTop = useCallback(() => {
scrollElRef.current?.scrollToOffset({
animated: isNative,
offset: -headerHeight,
})
}, [scrollElRef, headerHeight])
React.useImperativeHandle(ref, () => ({
scrollToTop: onScrollToTop,
}))
const renderHeader = React.useCallback(() => {
return (
<View>
<View
style={[
{
borderTopWidth: 1,
padding: isMobile ? 14 : 20,
gap: 12,
},
pal.border,
]}>
{descriptionRT ? (
<RichText
testID="listDescription"
style={[a.text_md]}
value={descriptionRT}
/>
) : (
<Text
testID="listDescriptionEmpty"
type="lg"
style={[{fontStyle: 'italic'}, pal.textLight]}>
<Trans>No description</Trans>
</Text>
)}
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
{isCurateList ? (
isOwner ? (
<Trans>User list by you</Trans>
) : (
<Trans>
User list by{' '}
<TextLink
text={sanitizeHandle(list.creator.handle || '', '@')}
href={makeProfileLink(list.creator)}
style={pal.textLight}
/>
</Trans>
)
) : isOwner ? (
<Trans>Moderation list by you</Trans>
) : (
<Trans>
Moderation list by{' '}
<TextLink
text={sanitizeHandle(list.creator.handle || '', '@')}
href={makeProfileLink(list.creator)}
style={pal.textLight}
/>
</Trans>
)}
</Text>
</View>
<View
style={[
{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: isMobile ? 14 : 20,
paddingBottom: isMobile ? 14 : 18,
},
]}>
<Text type="lg-bold" style={t.atoms.text}>
<Trans>Users</Trans>
</Text>
{isOwner && (
<Pressable
testID="addUserBtn"
accessibilityRole="button"
accessibilityLabel={_(msg`Add a user to this list`)}
accessibilityHint=""
onPress={onPressAddUser}
style={{flexDirection: 'row', alignItems: 'center', gap: 6}}>
<FontAwesomeIcon
icon="user-plus"
color={pal.colors.link}
size={16}
/>
<Text style={pal.link}>
<Trans>Add</Trans>
</Text>
</Pressable>
)}
</View>
</View>
)
}, [
isMobile,
pal.border,
pal.textLight,
pal.colors.link,
pal.link,
descriptionRT,
isCurateList,
isOwner,
list.creator,
t.atoms.text,
_,
onPressAddUser,
])
const renderEmptyState = useCallback(() => {
return (
<EmptyState
icon="users-slash"
message={_(msg`This list is empty!`)}
style={{paddingTop: 40}}
/>
)
}, [_])
return (
<View>
<ListMembers
testID="listItems"
list={list.uri}
scrollElRef={scrollElRef}
renderHeader={renderHeader}
renderEmptyState={renderEmptyState}
headerOffset={headerHeight}
onScrolledDownChange={setIsScrolledDown}
/>
{isScrolledDown && (
<LoadLatestBtn
onPress={onScrollToTop}
label={_(msg`Scroll to top`)}
showIndicator={false}
/>
)}
</View>
)
},
)
function ErrorScreen({error}: {error: string}) {
const pal = usePalette('default')
const navigation = useNavigation<NavigationProp>()
const {_} = useLingui()
const onPressBack = useCallback(() => {
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.navigate('Home')
}
}, [navigation])
return (
<View
style={[
pal.view,
pal.border,
{
marginTop: 10,
paddingHorizontal: 18,
paddingVertical: 14,
borderTopWidth: 1,
},
]}>
<Text type="title-lg" style={[pal.text, s.mb10]}>
<Trans>Could not load list</Trans>
</Text>
<Text type="md" style={[pal.text, s.mb20]}>
{error}
</Text>
<View style={{flexDirection: 'row'}}>
<Button
type="default"
accessibilityLabel={_(msg`Go Back`)}
accessibilityHint={_(msg`Return to previous page`)}
onPress={onPressBack}
style={{flexShrink: 1}}>
<Text type="button" style={pal.text}>
<Trans>Go Back</Trans>
</Text>
</Button>
</View>
</View>
)
}
const styles = StyleSheet.create({
btn: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingVertical: 7,
paddingHorizontal: 14,
borderRadius: 50,
marginLeft: 6,
},
})