Add modservice screen and profile-header-card
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
@@ -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 aren’t 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 other’s 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>
|
||||
)
|
||||
}
|
||||
@@ -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({})
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user