Add modservice screen and profile-header-card

This commit is contained in:
Paul Frazee
2024-01-11 20:26:50 -08:00
parent f0a35a940b
commit 81820479f3
14 changed files with 972 additions and 0 deletions
+1
View File
@@ -210,6 +210,7 @@ func serve(cctx *cli.Context) error {
e.GET("/profile/:handleOrDID/lists/:rkey", server.WebGeneric)
e.GET("/profile/:handleOrDID/feed/:rkey", server.WebGeneric)
e.GET("/profile/:handleOrDID/feed/:rkey/liked-by", server.WebGeneric)
e.GET("/profile/:handleOrDID/modservice", server.WebGeneric)
// profile RSS feed (DID not handle)
e.GET("/profile/:ident/rss", server.WebProfileRSS)
+6
View File
@@ -56,6 +56,7 @@ import {ProfileFollowersScreen} from './view/screens/ProfileFollowers'
import {ProfileFollowsScreen} from './view/screens/ProfileFollows'
import {ProfileFeedScreen} from './view/screens/ProfileFeed'
import {ProfileFeedLikedByScreen} from './view/screens/ProfileFeedLikedBy'
import {ProfileModserviceScreen} from './view/screens/ProfileModservice'
import {ProfileListScreen} from './view/screens/ProfileList'
import {PostThreadScreen} from './view/screens/PostThread'
import {PostLikedByScreen} from './view/screens/PostLikedBy'
@@ -197,6 +198,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
getComponent={() => ProfileFeedLikedByScreen}
options={{title: title(msg`Liked by`)}}
/>
<Stack.Screen
name="ProfileModservice"
getComponent={() => ProfileModserviceScreen}
options={{title: title(msg`Moderation service`)}}
/>
<Stack.Screen
name="Debug"
getComponent={() => Storybook}
+1
View File
@@ -21,6 +21,7 @@ export type CommonNavigatorParams = {
PostRepostedBy: {name: string; rkey: string}
ProfileFeed: {name: string; rkey: string}
ProfileFeedLikedBy: {name: string; rkey: string}
ProfileModservice: {name: string}
Debug: undefined
Log: undefined
Support: undefined
+1
View File
@@ -21,6 +21,7 @@ export const router = new Router({
PostRepostedBy: '/profile/:name/post/:rkey/reposted-by',
ProfileFeed: '/profile/:name/feed/:rkey',
ProfileFeedLikedBy: '/profile/:name/feed/:rkey/liked-by',
ProfileModservice: '/profile/:name/modservice',
Debug: '/sys/debug',
Log: '/sys/log',
AppPasswords: '/settings/app-passwords',
+14
View File
@@ -0,0 +1,14 @@
import {useQuery} from '@tanstack/react-query'
import {getAgent} from '../session'
export const RQKEY = (did: string) => ['mod-service-info', did]
export function useModServiceInfoQuery({did}: {did: string}) {
return useQuery({
queryKey: RQKEY(did),
queryFn: async () => {
const res = await getAgent().app.bsky.moderation.getService({did})
return res.data
},
})
}
+2
View File
@@ -312,6 +312,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
)
__globalAgent = agent
window.agent = agent
queryClient.clear()
upsertAccount(account)
@@ -351,6 +352,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
{networkErrorCallback: clearCurrentAccount},
),
})
window.agent = agent
let canReusePrevSession = false
try {
@@ -0,0 +1,99 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {s} from 'lib/styles'
import {Text} from '../util/text/Text'
import {TextLink} from '../util/Link'
import {ToggleButton} from '../util/forms/ToggleButton'
import {Button} from '../util/forms/Button'
import {usePalette} from 'lib/hooks/usePalette'
import {isIOS} from 'platform/detection'
import * as Toast from '../util/Toast'
import {logger} from '#/logger'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals'
import {
usePreferencesQuery,
usePreferencesSetAdultContentMutation,
} from '#/state/queries/preferences'
export function AdultContentEnabledPref() {
const pal = usePalette('default')
const {_} = useLingui()
const {data: preferences} = usePreferencesQuery()
const {mutate, variables} = usePreferencesSetAdultContentMutation()
const {openModal} = useModalControls()
const onSetAge = React.useCallback(
() => openModal({name: 'birth-date-settings'}),
[openModal],
)
const onToggleAdultContent = React.useCallback(async () => {
if (isIOS) return
try {
mutate({
enabled: !(variables?.enabled ?? preferences?.adultContentEnabled),
})
} catch (e) {
Toast.show(
_(msg`There was an issue syncing your preferences with the server`),
)
logger.error('Failed to update preferences with server', {error: e})
}
}, [variables, preferences, mutate, _])
return (
<View style={[pal.border, {borderTopWidth: 1, paddingHorizontal: 12}]}>
{isIOS ? (
preferences?.adultContentEnabled ? null : (
<View style={{paddingVertical: 12}}>
<Text type="md" style={pal.textLight}>
<Trans>
Adult content can only be enabled via the Web at{' '}
<TextLink
style={pal.link}
href="https://bsky.app"
text="bsky.app"
/>
.
</Trans>
</Text>
</View>
)
) : (preferences?.userAge || 0) >= 18 ? (
<View style={{paddingVertical: 4}}>
<ToggleButton
type="default-light"
label={_(msg`Enable Adult Content`)}
isSelected={
variables?.enabled ?? preferences?.adultContentEnabled ?? false
}
onPress={onToggleAdultContent}
style={styles.toggleBtn}
/>
</View>
) : (
<View style={styles.agePrompt}>
<Text type="md" style={[pal.text, {flex: 1}]}>
<Trans>You must be 18 or older to enable adult content.</Trans>
</Text>
</View>
)}
</View>
)
}
const styles = StyleSheet.create({
agePrompt: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 12,
},
toggleBtn: {
paddingHorizontal: 0,
backgroundColor: 'transparent',
},
})
+178
View File
@@ -0,0 +1,178 @@
import React from 'react'
import {LabelPreference} from '@atproto/api'
import {StyleSheet, Pressable, View} from 'react-native'
import {s} from 'lib/styles'
import {Text} from '../util/text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {
usePreferencesSetContentLabelMutation,
ConfigurableLabelGroup,
CONFIGURABLE_LABEL_GROUPS,
UsePreferencesQueryResponse,
} from '#/state/queries/preferences'
export function LabelGroupPref({
preferences,
labelGroup,
disabled,
}: {
preferences?: UsePreferencesQueryResponse
labelGroup: ConfigurableLabelGroup
disabled?: boolean
}) {
const pal = usePalette('default')
const visibility = preferences?.contentLabels?.[labelGroup]
const {mutate, variables} = usePreferencesSetContentLabelMutation()
const onChange = React.useCallback(
(vis: LabelPreference) => {
mutate({labelGroup, visibility: vis})
},
[mutate, labelGroup],
)
return (
<View style={[styles.labelGroupPref, pal.border]}>
<View style={s.flex1}>
<Text type="md-medium" style={[pal.text]}>
{CONFIGURABLE_LABEL_GROUPS[labelGroup].title}
</Text>
{typeof CONFIGURABLE_LABEL_GROUPS[labelGroup].subtitle === 'string' && (
<Text type="sm" style={[pal.textLight]}>
{CONFIGURABLE_LABEL_GROUPS[labelGroup].subtitle}
</Text>
)}
</View>
{disabled || !visibility ? (
<Text type="sm-bold" style={pal.textLight}>
<Trans context="action">Hide</Trans>
</Text>
) : (
<SelectGroup
current={variables?.visibility || visibility}
onChange={onChange}
labelGroup={labelGroup}
/>
)}
</View>
)
}
interface SelectGroupProps {
current: LabelPreference
onChange: (v: LabelPreference) => void
labelGroup: ConfigurableLabelGroup
}
function SelectGroup({current, onChange, labelGroup}: SelectGroupProps) {
const {_} = useLingui()
return (
<View style={styles.selectableBtns}>
<SelectableBtn
current={current}
value="hide"
label={_(msg`Hide`)}
left
onChange={onChange}
labelGroup={labelGroup}
/>
<SelectableBtn
current={current}
value="warn"
label={_(msg`Warn`)}
onChange={onChange}
labelGroup={labelGroup}
/>
<SelectableBtn
current={current}
value="ignore"
label={_(msg`Show`)}
right
onChange={onChange}
labelGroup={labelGroup}
/>
</View>
)
}
interface SelectableBtnProps {
current: string
value: LabelPreference
label: string
left?: boolean
right?: boolean
onChange: (v: LabelPreference) => void
labelGroup: ConfigurableLabelGroup
}
function SelectableBtn({
current,
value,
label,
left,
right,
onChange,
labelGroup,
}: SelectableBtnProps) {
const pal = usePalette('default')
const palPrimary = usePalette('inverted')
const {_} = useLingui()
return (
<Pressable
style={[
styles.selectableBtn,
left && styles.selectableBtnLeft,
right && styles.selectableBtnRight,
pal.border,
current === value ? palPrimary.view : pal.view,
]}
onPress={() => onChange(value)}
accessibilityRole="button"
accessibilityLabel={value}
accessibilityHint={_(
msg`Set ${value} for ${labelGroup} content moderation policy`,
)}>
<Text style={current === value ? palPrimary.text : pal.text}>
{label}
</Text>
</Pressable>
)
}
const styles = StyleSheet.create({
labelGroupPref: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 14,
paddingLeft: 14,
paddingRight: 10,
borderTopWidth: 1,
},
selectableBtns: {
flexDirection: 'row',
marginLeft: 10,
},
selectableBtn: {
flexDirection: 'row',
justifyContent: 'center',
borderWidth: 1,
borderLeftWidth: 0,
paddingHorizontal: 10,
paddingVertical: 10,
},
selectableBtnLeft: {
borderTopLeftRadius: 8,
borderBottomLeftRadius: 8,
borderLeftWidth: 1,
},
selectableBtnRight: {
borderTopRightRadius: 8,
borderBottomRightRadius: 8,
},
})
@@ -0,0 +1,56 @@
import React from 'react'
import {View} from 'react-native'
import {Text} from '../util/text/Text'
import {usePalette} from '#/lib/hooks/usePalette'
export function ModServiceGuidelines({}: {}) {
const pal = usePalette('default')
return (
<View testID="modServiceGuidelines">
<View
style={[
pal.border,
{paddingHorizontal: 14, paddingBottom: 8, borderBottomWidth: 1},
]}>
<Text type="2xl-bold">Guidelines</Text>
</View>
<View style={{paddingHorizontal: 14, paddingVertical: 8}}>
<Text type="lg" style={pal.text} lineHeight={1.4}>
These rules will evolve over time as we continually work to cultivate
a healthy and thriving community. Do not:
</Text>
<Text type="lg" style={pal.text} lineHeight={1.4}>
1. Praise or promote material from hate groups or U.S., Canadian, and
E.U. proscribed terror groups.
</Text>
<Text type="lg" style={pal.text} lineHeight={1.4}>
2. Distribute child sexual abuse material
</Text>
<Text type="lg" style={pal.text} lineHeight={1.4}>
3. Engage in human trafficking or sexual exploitation, including any
attempt to distribute, participate or normalize child sexual abuse
</Text>
<Text type="lg" style={pal.text} lineHeight={1.4}>
4. Trade in illegal goods or substances
</Text>
<Text type="lg" style={pal.text} lineHeight={1.4}>
5. Steal or distribute others private personal information without
their permission
</Text>
<Text type="lg" style={pal.text} lineHeight={1.4}>
6. Hack or access systems that you arent authorized to access
</Text>
<Text type="lg" style={pal.text} lineHeight={1.4}>
7. Scam or cheat others for financial gain h. Spam, phish, or
otherwise use technical means to disrupt the experience of others on
Bluesky Social
</Text>
<Text type="lg" style={pal.text} lineHeight={1.4}>
8. Infringe others copyrights, trademarks and/or other intellectual
property
</Text>
</View>
</View>
)
}
@@ -0,0 +1,226 @@
import React from 'react'
import {Pressable, StyleSheet, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {useNavigation} from '@react-navigation/native'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {Text} from '../util/text/Text'
import {TextLink} from '../util/Link'
import {CenteredView} from '../util/Views'
import {sanitizeHandle} from 'lib/strings/handles'
import {makeProfileLink} from 'lib/routes/links'
import {NavigationProp} from 'lib/routes/types'
import {BACK_HITSLOP} from 'lib/constants'
import {isNative} from 'platform/detection'
import {useLingui} from '@lingui/react'
import {Trans, msg} from '@lingui/macro'
import {useSetDrawerOpen} from '#/state/shell'
import {emitSoftReset} from '#/state/events'
import {AppBskyModerationDefs} from '@atproto/api'
import {HandIcon} from '#/lib/icons'
import {shareUrl} from 'lib/sharing'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {Button} from '../util/forms/Button'
import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown'
import {useSession} from '#/state/session'
import {useModalControls} from '#/state/modals'
export function ModServiceHeader({
info,
}: {
info: AppBskyModerationDefs.ModServiceViewDetailed
}) {
const setDrawerOpen = useSetDrawerOpen()
const navigation = useNavigation<NavigationProp>()
const {_} = useLingui()
const {isMobile} = useWebMediaQueries()
const pal = usePalette('default')
const canGoBack = navigation.canGoBack()
const onPressBack = React.useCallback(() => {
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.navigate('Home')
}
}, [navigation])
const onPressMenu = React.useCallback(() => {
setDrawerOpen(true)
}, [setDrawerOpen])
return (
<CenteredView style={pal.view}>
{isMobile && (
<View
style={[
{
flexDirection: 'row',
alignItems: 'center',
borderBottomWidth: 1,
paddingTop: isNative ? 0 : 8,
paddingBottom: 8,
paddingHorizontal: isMobile ? 12 : 14,
},
pal.border,
]}>
<Pressable
testID="headerDrawerBtn"
onPress={canGoBack ? onPressBack : onPressMenu}
hitSlop={BACK_HITSLOP}
style={canGoBack ? styles.backBtn : styles.backBtnWide}
accessibilityRole="button"
accessibilityLabel={canGoBack ? 'Back' : 'Menu'}
accessibilityHint="">
{canGoBack ? (
<FontAwesomeIcon
size={18}
icon="angle-left"
style={[styles.backIcon, pal.text]}
/>
) : (
<FontAwesomeIcon
size={18}
icon="bars"
style={[styles.backIcon, pal.textLight]}
/>
)}
</Pressable>
<View style={{flex: 1}} />
<CommonControls info={info} />
</View>
)}
<View
style={{
flexDirection: 'row',
alignItems: 'flex-start',
gap: 10,
paddingTop: 14,
paddingBottom: 14,
paddingHorizontal: isMobile ? 12 : 14,
}}>
<View style={{alignSelf: 'center'}}>
<HandIcon style={pal.text} size={32} strokeWidth={5.5} />
</View>
<View style={{flex: 1}}>
<TextLink
testID="headerTitle"
type="title-xl"
href={makeProfileLink(info.creator, 'modservice')}
style={[pal.text, {fontWeight: 'bold'}]}
text={
info.creator.displayName
? sanitizeDisplayName(info.creator.displayName)
: sanitizeHandle(info.creator.handle, '@')
}
onPress={emitSoftReset}
numberOfLines={4}
/>
<Text type="xl" style={[pal.textLight]} numberOfLines={1}>
<Trans>Moderation service</Trans>
</Text>
</View>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
}}>
<Button type="primary" label={_(msg`Subscribe`)} />
{!isMobile && <CommonControls info={info} />}
</View>
</View>
</CenteredView>
)
}
function CommonControls({
info,
}: {
info: AppBskyModerationDefs.ModServiceViewDetailed
}) {
const pal = usePalette('default')
const {hasSession} = useSession()
const {openModal} = useModalControls()
const {_} = useLingui()
const onPressShare = React.useCallback(() => {
const url = makeProfileLink(info.creator, 'modservice')
shareUrl(url)
// track('CustomFeed:Share') TODO
}, [info /*, track*/])
const onPressReport = React.useCallback(() => {
if (!info) return
openModal({
name: 'report',
uri: info.uri,
cid: info.cid,
})
}, [openModal, info])
const dropdownItems: DropdownItem[] = React.useMemo(() => {
return [
hasSession && {
testID: 'modHeaderDropdownReportBtn',
label: _(msg`Report mod service`),
onPress: onPressReport,
icon: {
ios: {
name: 'exclamationmark.triangle',
},
android: 'ic_menu_report_image',
web: 'circle-exclamation',
},
},
{
testID: 'modHeaderDropdownShareBtn',
label: _(msg`Share mod service`),
onPress: onPressShare,
icon: {
ios: {
name: 'square.and.arrow.up',
},
android: 'ic_menu_share',
web: 'share',
},
},
].filter(Boolean) as DropdownItem[]
}, [hasSession, onPressReport, onPressShare, _])
return (
<>
<NativeDropdown
testID="headerDropdownBtn"
items={dropdownItems}
accessibilityLabel={_(msg`More options`)}
accessibilityHint="">
<View style={[pal.viewLight, styles.btn, {marginLeft: 6}]}>
<FontAwesomeIcon icon="ellipsis" size={20} color={pal.colors.text} />
</View>
</NativeDropdown>
</>
)
}
const styles = StyleSheet.create({
backBtn: {
width: 20,
height: 30,
},
backBtnWide: {
width: 20,
height: 30,
paddingHorizontal: 6,
},
backIcon: {
marginTop: 6,
},
btn: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingVertical: 7,
paddingHorizontal: 14,
borderRadius: 50,
},
})
@@ -0,0 +1,42 @@
import React from 'react'
import {View} from 'react-native'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {LabelGroupPref} from './LabelGroupPref'
import {Text} from '../util/text/Text'
import {usePalette} from '#/lib/hooks/usePalette'
export function ModServicePrefs({}: {}) {
const pal = usePalette('default')
const {data: preferences} = usePreferencesQuery()
return (
<View testID="modServicePrefs" style={[pal.border, {borderBottomWidth: 1}]}>
<View style={{paddingHorizontal: 14, paddingBottom: 8}}>
<Text type="2xl-bold">Settings</Text>
</View>
<LabelGroupPref
preferences={preferences}
labelGroup="nsfw"
disabled={!preferences?.adultContentEnabled}
/>
<LabelGroupPref
preferences={preferences}
labelGroup="nudity"
disabled={!preferences?.adultContentEnabled}
/>
<LabelGroupPref
preferences={preferences}
labelGroup="suggestive"
disabled={!preferences?.adultContentEnabled}
/>
<LabelGroupPref
preferences={preferences}
labelGroup="gore"
disabled={!preferences?.adultContentEnabled}
/>
<LabelGroupPref preferences={preferences} labelGroup="hate" />
<LabelGroupPref preferences={preferences} labelGroup="spam" />
<LabelGroupPref preferences={preferences} labelGroup="impersonation" />
</View>
)
}
+2
View File
@@ -26,6 +26,7 @@ import {RichText} from '../util/text/RichText'
import {UserAvatar} from '../util/UserAvatar'
import {UserBanner} from '../util/UserBanner'
import {ProfileHeaderAlerts} from '../util/moderation/ProfileHeaderAlerts'
import {ProfileHeaderModCard} from './ProfileHeaderModCard'
import {formatCount} from '../util/numeric/format'
import {NativeDropdown, DropdownItem} from '../util/forms/NativeDropdown'
import {Link} from '../util/Link'
@@ -663,6 +664,7 @@ let ProfileHeaderLoaded = ({
{isMe && (
<LabelInfo details={{did: profile.did}} labels={profile.labels} />
)}
<ProfileHeaderModCard />
</View>
{!isProfilePreview && showSuggestedFollows && (
@@ -0,0 +1,62 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {RichText as RichTextAPI} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {HandIcon} from '#/lib/icons'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
import {Text} from '../util/text/Text'
import {RichText} from '../util/text/RichText'
import {usePalette} from 'lib/hooks/usePalette'
import {s, colors} from 'lib/styles'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
const richtext = new RichTextAPI({
text: "Bluesky's official moderation service",
})
export function ProfileHeaderModCard() {
const pal = usePalette('default')
const {isMobile} = useWebMediaQueries()
return (
<View
style={[
pal.view,
pal.borderDark,
s.mt5,
{
borderWidth: 1,
borderRadius: 8,
paddingVertical: 10,
paddingHorizontal: 12,
},
]}>
<View style={[s.flexRow, s.alignCenter, {gap: 8}]}>
{!isMobile && <HandIcon style={pal.text} size={24} strokeWidth={5.5} />}
<View style={{flex: 1}}>
<Text type="lg-bold" style={pal.text}>
<Trans>Moderation service</Trans>
</Text>
<RichText richText={richtext} />
</View>
{isMobile ? (
<HandIcon style={pal.text} size={24} strokeWidth={5.5} />
) : (
<View
style={[
pal.viewLight,
{paddingHorizontal: 12, paddingVertical: 6, borderRadius: 24},
]}>
<Text type="button" style={pal.text}>
<Trans>View</Trans>
</Text>
</View>
)}
</View>
</View>
)
}
const styles = StyleSheet.create({})
+282
View File
@@ -0,0 +1,282 @@
import React, {useMemo, useCallback} from 'react'
import {Dimensions, StyleSheet, View, ActivityIndicator} from 'react-native'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {useIsFocused, useNavigation} from '@react-navigation/native'
import {AppBskyModerationDefs, RichText as RichTextAPI} from '@atproto/api'
import {usePalette} from 'lib/hooks/usePalette'
import {HeartIcon, HeartIconSolid} from 'lib/icons'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {CommonNavigatorParams} from 'lib/routes/types'
import {makeRecordUri} from 'lib/strings/url-helpers'
import {s} from 'lib/styles'
import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader'
import {TextLink} from 'view/com/util/Link'
import {Button} from 'view/com/util/forms/Button'
import {Text} from 'view/com/util/text/Text'
import {RichText} from 'view/com/util/text/RichText'
import {ModServicePrefs} from '../com/moderation/ModServicePrefs'
import * as Toast from 'view/com/util/Toast'
import {useSetTitle} from 'lib/hooks/useSetTitle'
import {Haptics} from 'lib/haptics'
import {useAnalytics} from 'lib/analytics/analytics'
import {makeCustomFeedLink} from 'lib/routes/links'
import {pluralize} from 'lib/strings/helpers'
import {CenteredView, ScrollView} from 'view/com/util/Views'
import {NavigationProp} from 'lib/routes/types'
import {makeProfileLink} from 'lib/routes/links'
import {logger} from '#/logger'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals'
import {useModServiceInfoQuery} from '#/state/queries/modservice'
import {useResolveDidQuery} from '#/state/queries/resolve-uri'
import {
UsePreferencesQueryResponse,
usePreferencesQuery,
} from '#/state/queries/preferences'
import {useSession} from '#/state/session'
import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like'
import {ModServiceHeader} from '../com/moderation/ModServiceHeader'
import {sanitizeHandle} from '#/lib/strings/handles'
import {ModServiceGuidelines} from '../com/moderation/ModServiceGuidelines'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileModservice'>
export function ProfileModserviceScreen(props: Props) {
const {name: handleOrDid} = props.route.params
const pal = usePalette('default')
const {_} = useLingui()
const navigation = useNavigation<NavigationProp>()
const {error, data: resolvedDid} = useResolveDidQuery(handleOrDid)
const onPressBack = React.useCallback(() => {
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.navigate('Home')
}
}, [navigation])
if (error) {
return (
<CenteredView>
<View style={[pal.view, pal.border, styles.notFoundContainer]}>
<Text type="title-lg" style={[pal.text, s.mb10]}>
<Trans>Could not load moderation service</Trans>
</Text>
<Text type="md" style={[pal.text, s.mb20]}>
{error.toString()}
</Text>
<View style={{flexDirection: 'row'}}>
<Button
type="default"
accessibilityLabel={_(msg`Go Back`)}
accessibilityHint="Return to previous page"
onPress={onPressBack}
style={{flexShrink: 1}}>
<Text type="button" style={pal.text}>
<Trans>Go Back</Trans>
</Text>
</Button>
</View>
</View>
</CenteredView>
)
}
return resolvedDid ? (
<ProfileModservicecreenIntermediate modDid={resolvedDid} />
) : (
<CenteredView>
<View style={s.p20}>
<ActivityIndicator size="large" />
</View>
</CenteredView>
)
}
function ProfileModservicecreenIntermediate({modDid}: {modDid: string}) {
const {data: preferences} = usePreferencesQuery()
const {data: info} = useModServiceInfoQuery({did: modDid})
if (!preferences || !info) {
return (
<CenteredView>
<View style={s.p20}>
<ActivityIndicator size="large" />
</View>
</CenteredView>
)
}
return (
<ProfileModserviceScreenInner preferences={preferences} modInfo={info} />
)
}
export function ProfileModserviceScreenInner({
preferences,
modInfo,
}: {
preferences: UsePreferencesQueryResponse
modInfo: AppBskyModerationDefs.ModServiceViewDetailed
}) {
const {_} = useLingui()
const pal = usePalette('default')
const {hasSession} = useSession()
const {track} = useAnalytics()
const {mutateAsync: likeMod, isPending: isLikePending} = useLikeMutation()
const {mutateAsync: unlikeMod, isPending: isUnlikePending} =
useUnlikeMutation()
const [likeUri, setLikeUri] = React.useState<string>(
modInfo.viewer?.like || '',
)
const isLiked = !!likeUri
const isSaved = false // TODO
// !removedFeed &&
// (!!savedFeed || preferences.feeds.saved.includes(feedInfo.uri))
const isEnabled = false // TODO
// !unpinnedFeed &&
// (!!pinnedFeed || preferences.feeds.pinned.includes(feedInfo.uri))
const descriptionRT = useMemo(
() =>
modInfo.description
? new RichTextAPI({
text: modInfo.description,
facets: modInfo.descriptionFacets,
})
: undefined,
[modInfo],
)
useSetTitle(modInfo.creator.displayName || modInfo.creator.handle)
// event handlers
//
const onToggleLiked = React.useCallback(async () => {
try {
Haptics.default()
if (isLiked && likeUri) {
await unlikeMod({uri: likeUri})
track('CustomFeed:Unlike')
setLikeUri('')
} else {
const res = await likeMod({uri: modInfo.uri, cid: modInfo.cid})
track('CustomFeed:Like')
setLikeUri(res.uri)
}
} catch (err) {
Toast.show(
_(
msg`There was an an issue contacting the server, please check your internet connection and try again.`,
),
)
logger.error('Failed up toggle like', {error: err})
}
}, [likeUri, isLiked, modInfo, likeMod, unlikeMod, track, _])
// render
// =
return (
<View style={s.hContentRegion}>
<ScrollView
scrollEventThrottle={1}
contentContainerStyle={{
minHeight: Dimensions.get('window').height * 1.5,
}}>
<ModServiceHeader info={modInfo} />
<View
style={[
{
borderTopWidth: 1,
paddingVertical: 20,
paddingHorizontal: 14,
gap: 12,
},
pal.border,
]}>
{descriptionRT ? (
<RichText
testID="modinfoDescription"
type="lg"
style={pal.text}
richText={descriptionRT}
/>
) : (
<Text type="lg" style={[{fontStyle: 'italic'}, pal.textLight]}>
<Trans>No description</Trans>
</Text>
)}
<Text type="lg" style={pal.textLight}>
<Trans>
Operated by{' '}
<TextLink
href={makeProfileLink(modInfo.creator)}
text={sanitizeHandle(modInfo.creator.handle, '@')}
style={pal.link}
/>
. Handles reports of anti-social behavior, illegal content,
unwanted sexual content, and misinformation.
</Trans>
</Text>
<View style={{flexDirection: 'row', alignItems: 'center', gap: 10}}>
<Button
type="default"
testID="toggleLikeBtn"
accessibilityLabel={_(msg`Like this feed`)}
accessibilityHint=""
disabled={!hasSession || isLikePending || isUnlikePending}
onPress={onToggleLiked}
style={{paddingHorizontal: 10}}>
{isLiked ? (
<HeartIconSolid size={19} style={s.likeColor} />
) : (
<HeartIcon strokeWidth={3} size={19} style={pal.textLight} />
)}
</Button>
{typeof modInfo.likeCount === 'number' && (
<TextLink
href={'#todo'}
text={_(
msg`Liked by ${modInfo.likeCount} ${pluralize(
modInfo.likeCount,
'user',
)}`,
)}
style={[pal.textLight, s.semiBold]}
/>
)}
</View>
</View>
<ModServicePrefs />
<View style={{height: 20}} />
<ModServiceGuidelines />
</ScrollView>
</View>
)
}
const styles = StyleSheet.create({
btn: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingVertical: 7,
paddingHorizontal: 14,
borderRadius: 50,
marginLeft: 6,
},
notFoundContainer: {
margin: 10,
paddingHorizontal: 18,
paddingVertical: 14,
borderRadius: 6,
},
})