Merge branch 'main' into custom-algos
This commit is contained in:
commit
7aa1d9010e
99 changed files with 4234 additions and 716 deletions
|
@ -61,7 +61,6 @@ export const Gallery = observer(function ({gallery}: Props) {
|
|||
borderRadius: 5,
|
||||
paddingHorizontal: 10,
|
||||
position: 'absolute' as const,
|
||||
width: 46,
|
||||
zIndex: 1,
|
||||
...(isOverflow
|
||||
? {
|
||||
|
@ -112,11 +111,11 @@ export const Gallery = observer(function ({gallery}: Props) {
|
|||
testID="altTextButton"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Add alt text"
|
||||
accessibilityHint="Opens modal for inputting image alt text"
|
||||
accessibilityHint=""
|
||||
onPress={() => {
|
||||
handleAddImageAltText(image)
|
||||
}}
|
||||
style={[styles.imageControl, imageControlLabelStyle]}>
|
||||
style={imageControlLabelStyle}>
|
||||
<Text style={styles.imageControlTextContent}>ALT</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={imageControlsSubgroupStyle}>
|
||||
|
@ -187,9 +186,14 @@ const styles = StyleSheet.create({
|
|||
justifyContent: 'center',
|
||||
},
|
||||
imageControlTextContent: {
|
||||
borderRadius: 6,
|
||||
color: 'white',
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold',
|
||||
letterSpacing: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||
borderWidth: 0.5,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 3,
|
||||
},
|
||||
})
|
||||
|
|
|
@ -4,7 +4,7 @@ import React, {
|
|||
useImperativeHandle,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {Pressable, StyleSheet, View} from 'react-native'
|
||||
import {ReactRenderer} from '@tiptap/react'
|
||||
import tippy, {Instance as TippyInstance} from 'tippy.js'
|
||||
import {
|
||||
|
@ -158,7 +158,7 @@ const MentionList = forwardRef<MentionListRef, SuggestionProps>(
|
|||
const isSelected = selectedIndex === index
|
||||
|
||||
return (
|
||||
<View
|
||||
<Pressable
|
||||
key={item.handle}
|
||||
style={[
|
||||
isSelected ? pal.viewLight : undefined,
|
||||
|
@ -169,7 +169,11 @@ const MentionList = forwardRef<MentionListRef, SuggestionProps>(
|
|||
: index === items.length - 1
|
||||
? styles.lastMention
|
||||
: undefined,
|
||||
]}>
|
||||
]}
|
||||
onPress={() => {
|
||||
selectItem(index)
|
||||
}}
|
||||
accessibilityRole="button">
|
||||
<View style={styles.avatarAndDisplayName}>
|
||||
<UserAvatar avatar={item.avatar ?? null} size={26} />
|
||||
<Text style={pal.text} numberOfLines={1}>
|
||||
|
@ -179,7 +183,7 @@ const MentionList = forwardRef<MentionListRef, SuggestionProps>(
|
|||
<Text type="xs" style={pal.textLight} numberOfLines={1}>
|
||||
@{item.handle}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
|
|
|
@ -21,6 +21,9 @@ interface Img {
|
|||
|
||||
export const Lightbox = observer(function Lightbox() {
|
||||
const store = useStores()
|
||||
|
||||
const onClose = useCallback(() => store.shell.closeLightbox(), [store.shell])
|
||||
|
||||
if (!store.shell.isLightboxActive) {
|
||||
return null
|
||||
}
|
||||
|
@ -29,8 +32,6 @@ export const Lightbox = observer(function Lightbox() {
|
|||
const initialIndex =
|
||||
activeLightbox instanceof models.ImagesLightbox ? activeLightbox.index : 0
|
||||
|
||||
const onClose = () => store.shell.closeLightbox()
|
||||
|
||||
let imgs: Img[] | undefined
|
||||
if (activeLightbox instanceof models.ProfileImageLightbox) {
|
||||
const opts = activeLightbox
|
||||
|
|
155
src/view/com/lists/ListCard.tsx
Normal file
155
src/view/com/lists/ListCard.tsx
Normal file
|
@ -0,0 +1,155 @@
|
|||
import React from 'react'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {AtUri, AppBskyGraphDefs, RichText} from '@atproto/api'
|
||||
import {Link} from '../util/Link'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {RichText as RichTextCom} from '../util/text/RichText'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import {s} from 'lib/styles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useStores} from 'state/index'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
|
||||
export const ListCard = ({
|
||||
testID,
|
||||
list,
|
||||
noBg,
|
||||
noBorder,
|
||||
renderButton,
|
||||
}: {
|
||||
testID?: string
|
||||
list: AppBskyGraphDefs.ListView
|
||||
noBg?: boolean
|
||||
noBorder?: boolean
|
||||
renderButton?: () => JSX.Element
|
||||
}) => {
|
||||
const pal = usePalette('default')
|
||||
const store = useStores()
|
||||
|
||||
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,
|
||||
]}
|
||||
href={`/profile/${list.creator.did}/lists/${rkey}`}
|
||||
title={list.name}
|
||||
asAnchor
|
||||
anchorNoUnderline>
|
||||
<View style={styles.layout}>
|
||||
<View style={styles.layoutAvi}>
|
||||
<UserAvatar 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#modlist' && 'Mute list'} by{' '}
|
||||
{list.creator.did === store.me.did
|
||||
? 'you'
|
||||
: `@${list.creator.handle}`}
|
||||
</Text>
|
||||
{!!list.viewer?.muted && (
|
||||
<View style={s.flexRow}>
|
||||
<View style={[s.mt5, pal.btn, styles.pill]}>
|
||||
<Text type="xs" style={pal.text}>
|
||||
Subscribed
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{renderButton ? (
|
||||
<View style={styles.layoutButton}>{renderButton()}</View>
|
||||
) : undefined}
|
||||
</View>
|
||||
{descriptionRichText ? (
|
||||
<View style={styles.details}>
|
||||
<RichTextCom
|
||||
style={pal.text}
|
||||
numberOfLines={20}
|
||||
richText={descriptionRichText}
|
||||
/>
|
||||
</View>
|
||||
) : undefined}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
outer: {
|
||||
borderTopWidth: 1,
|
||||
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,
|
||||
},
|
||||
})
|
387
src/view/com/lists/ListItems.tsx
Normal file
387
src/view/com/lists/ListItems.tsx
Normal file
|
@ -0,0 +1,387 @@
|
|||
import React, {MutableRefObject} from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
RefreshControl,
|
||||
StyleProp,
|
||||
StyleSheet,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from 'react-native'
|
||||
import {AppBskyActorDefs, AppBskyGraphDefs, RichText} from '@atproto/api'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {FlatList} from '../util/Views'
|
||||
import {ProfileCardFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
|
||||
import {ProfileCard} from '../profile/ProfileCard'
|
||||
import {Button} from '../util/forms/Button'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {RichText as RichTextCom} from '../util/text/RichText'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import {TextLink} from '../util/Link'
|
||||
import {ListModel} from 'state/models/content/list'
|
||||
import {useAnalytics} from 'lib/analytics'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useStores} from 'state/index'
|
||||
import {s} from 'lib/styles'
|
||||
import {isDesktopWeb} from 'platform/detection'
|
||||
|
||||
const LOADING_ITEM = {_reactKey: '__loading__'}
|
||||
const HEADER_ITEM = {_reactKey: '__header__'}
|
||||
const EMPTY_ITEM = {_reactKey: '__empty__'}
|
||||
const ERROR_ITEM = {_reactKey: '__error__'}
|
||||
const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
|
||||
|
||||
export const ListItems = observer(
|
||||
({
|
||||
list,
|
||||
style,
|
||||
scrollElRef,
|
||||
onPressTryAgain,
|
||||
onToggleSubscribed,
|
||||
onPressEditList,
|
||||
onPressDeleteList,
|
||||
renderEmptyState,
|
||||
testID,
|
||||
headerOffset = 0,
|
||||
}: {
|
||||
list: ListModel
|
||||
style?: StyleProp<ViewStyle>
|
||||
scrollElRef?: MutableRefObject<FlatList<any> | null>
|
||||
onPressTryAgain?: () => void
|
||||
onToggleSubscribed?: () => void
|
||||
onPressEditList?: () => void
|
||||
onPressDeleteList?: () => void
|
||||
renderEmptyState?: () => JSX.Element
|
||||
testID?: string
|
||||
headerOffset?: number
|
||||
}) => {
|
||||
const pal = usePalette('default')
|
||||
const store = useStores()
|
||||
const {track} = useAnalytics()
|
||||
const [isRefreshing, setIsRefreshing] = React.useState(false)
|
||||
|
||||
const data = React.useMemo(() => {
|
||||
let items: any[] = [HEADER_ITEM]
|
||||
if (list.hasLoaded) {
|
||||
if (list.hasError) {
|
||||
items = items.concat([ERROR_ITEM])
|
||||
}
|
||||
if (list.isEmpty) {
|
||||
items = items.concat([EMPTY_ITEM])
|
||||
} else {
|
||||
items = items.concat(list.items)
|
||||
}
|
||||
if (list.loadMoreError) {
|
||||
items = items.concat([LOAD_MORE_ERROR_ITEM])
|
||||
}
|
||||
} else if (list.isLoading) {
|
||||
items = items.concat([LOADING_ITEM])
|
||||
}
|
||||
return items
|
||||
}, [
|
||||
list.hasError,
|
||||
list.hasLoaded,
|
||||
list.isLoading,
|
||||
list.isEmpty,
|
||||
list.items,
|
||||
list.loadMoreError,
|
||||
])
|
||||
|
||||
// events
|
||||
// =
|
||||
|
||||
const onRefresh = React.useCallback(async () => {
|
||||
track('Lists:onRefresh')
|
||||
setIsRefreshing(true)
|
||||
try {
|
||||
await list.refresh()
|
||||
} catch (err) {
|
||||
list.rootStore.log.error('Failed to refresh lists', err)
|
||||
}
|
||||
setIsRefreshing(false)
|
||||
}, [list, track, setIsRefreshing])
|
||||
|
||||
const onEndReached = React.useCallback(async () => {
|
||||
track('Lists:onEndReached')
|
||||
try {
|
||||
await list.loadMore()
|
||||
} catch (err) {
|
||||
list.rootStore.log.error('Failed to load more lists', err)
|
||||
}
|
||||
}, [list, track])
|
||||
|
||||
const onPressRetryLoadMore = React.useCallback(() => {
|
||||
list.retryLoadMore()
|
||||
}, [list])
|
||||
|
||||
const onPressEditMembership = React.useCallback(
|
||||
(profile: AppBskyActorDefs.ProfileViewBasic) => {
|
||||
store.shell.openModal({
|
||||
name: 'list-add-remove-user',
|
||||
subject: profile.did,
|
||||
displayName: profile.displayName || profile.handle,
|
||||
onUpdate() {
|
||||
list.refresh()
|
||||
},
|
||||
})
|
||||
},
|
||||
[store, list],
|
||||
)
|
||||
|
||||
// rendering
|
||||
// =
|
||||
|
||||
const renderMemberButton = React.useCallback(
|
||||
(profile: AppBskyActorDefs.ProfileViewBasic) => {
|
||||
if (!list.isOwner) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
type="default"
|
||||
label="Edit"
|
||||
onPress={() => onPressEditMembership(profile)}
|
||||
/>
|
||||
)
|
||||
},
|
||||
[list, onPressEditMembership],
|
||||
)
|
||||
|
||||
const renderItem = React.useCallback(
|
||||
({item}: {item: any}) => {
|
||||
if (item === EMPTY_ITEM) {
|
||||
if (renderEmptyState) {
|
||||
return renderEmptyState()
|
||||
}
|
||||
return <View />
|
||||
} else if (item === HEADER_ITEM) {
|
||||
return list.list ? (
|
||||
<ListHeader
|
||||
list={list.list}
|
||||
isOwner={list.isOwner}
|
||||
onToggleSubscribed={onToggleSubscribed}
|
||||
onPressEditList={onPressEditList}
|
||||
onPressDeleteList={onPressDeleteList}
|
||||
/>
|
||||
) : null
|
||||
} else if (item === ERROR_ITEM) {
|
||||
return (
|
||||
<ErrorMessage
|
||||
message={list.error}
|
||||
onPressTryAgain={onPressTryAgain}
|
||||
/>
|
||||
)
|
||||
} else if (item === LOAD_MORE_ERROR_ITEM) {
|
||||
return (
|
||||
<LoadMoreRetryBtn
|
||||
label="There was an issue fetching the list. Tap here to try again."
|
||||
onPress={onPressRetryLoadMore}
|
||||
/>
|
||||
)
|
||||
} else if (item === LOADING_ITEM) {
|
||||
return <ProfileCardFeedLoadingPlaceholder />
|
||||
}
|
||||
return (
|
||||
<ProfileCard
|
||||
testID={`user-${
|
||||
(item as AppBskyGraphDefs.ListItemView).subject.handle
|
||||
}`}
|
||||
profile={(item as AppBskyGraphDefs.ListItemView).subject}
|
||||
renderButton={renderMemberButton}
|
||||
/>
|
||||
)
|
||||
},
|
||||
[
|
||||
list,
|
||||
onPressTryAgain,
|
||||
onPressRetryLoadMore,
|
||||
renderMemberButton,
|
||||
onPressEditList,
|
||||
onPressDeleteList,
|
||||
onToggleSubscribed,
|
||||
renderEmptyState,
|
||||
],
|
||||
)
|
||||
|
||||
const Footer = React.useCallback(
|
||||
() =>
|
||||
list.isLoading ? (
|
||||
<View style={styles.feedFooter}>
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
) : (
|
||||
<View />
|
||||
),
|
||||
[list],
|
||||
)
|
||||
|
||||
return (
|
||||
<View testID={testID} style={style}>
|
||||
{data.length > 0 && (
|
||||
<FlatList
|
||||
testID={testID ? `${testID}-flatlist` : undefined}
|
||||
ref={scrollElRef}
|
||||
data={data}
|
||||
keyExtractor={item => item._reactKey}
|
||||
renderItem={renderItem}
|
||||
ListFooterComponent={Footer}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={pal.colors.text}
|
||||
titleColor={pal.colors.text}
|
||||
progressViewOffset={headerOffset}
|
||||
/>
|
||||
}
|
||||
contentContainerStyle={s.contentContainer}
|
||||
style={{paddingTop: headerOffset}}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={0.6}
|
||||
removeClippedSubviews={true}
|
||||
contentOffset={{x: 0, y: headerOffset * -1}}
|
||||
// @ts-ignore our .web version only -prf
|
||||
desktopFixedHeight
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
const ListHeader = observer(
|
||||
({
|
||||
list,
|
||||
isOwner,
|
||||
onToggleSubscribed,
|
||||
onPressEditList,
|
||||
onPressDeleteList,
|
||||
}: {
|
||||
list: AppBskyGraphDefs.ListView
|
||||
isOwner: boolean
|
||||
onToggleSubscribed?: () => void
|
||||
onPressEditList?: () => void
|
||||
onPressDeleteList?: () => void
|
||||
}) => {
|
||||
const pal = usePalette('default')
|
||||
const store = useStores()
|
||||
const descriptionRT = React.useMemo(
|
||||
() =>
|
||||
list?.description &&
|
||||
new RichText({text: list.description, facets: list.descriptionFacets}),
|
||||
[list],
|
||||
)
|
||||
return (
|
||||
<>
|
||||
<View style={[styles.header, pal.border]}>
|
||||
<View style={s.flex1}>
|
||||
<Text testID="listName" type="title-xl" style={[pal.text, s.bold]}>
|
||||
{list.name}
|
||||
</Text>
|
||||
{list && (
|
||||
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
|
||||
{list.purpose === 'app.bsky.graph.defs#modlist' && 'Mute list '}
|
||||
by{' '}
|
||||
{list.creator.did === store.me.did ? (
|
||||
'you'
|
||||
) : (
|
||||
<TextLink
|
||||
text={`@${list.creator.handle}`}
|
||||
href={`/profile/${list.creator.did}`}
|
||||
/>
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
{descriptionRT && (
|
||||
<RichTextCom
|
||||
testID="listDescription"
|
||||
style={[pal.text, styles.headerDescription]}
|
||||
richText={descriptionRT}
|
||||
/>
|
||||
)}
|
||||
{isDesktopWeb && (
|
||||
<View style={styles.headerBtns}>
|
||||
{list.viewer?.muted ? (
|
||||
<Button
|
||||
type="inverted"
|
||||
label="Unsubscribe"
|
||||
accessibilityLabel="Unsubscribe"
|
||||
accessibilityHint=""
|
||||
onPress={onToggleSubscribed}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
label="Subscribe & Mute"
|
||||
accessibilityLabel="Subscribe and mute"
|
||||
accessibilityHint=""
|
||||
onPress={onToggleSubscribed}
|
||||
/>
|
||||
)}
|
||||
{isOwner && (
|
||||
<Button
|
||||
type="default"
|
||||
label="Edit List"
|
||||
accessibilityLabel="Edit list"
|
||||
accessibilityHint=""
|
||||
onPress={onPressEditList}
|
||||
/>
|
||||
)}
|
||||
{isOwner && (
|
||||
<Button
|
||||
type="default"
|
||||
label="Delete List"
|
||||
accessibilityLabel="Delete list"
|
||||
accessibilityHint=""
|
||||
onPress={onPressDeleteList}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View>
|
||||
<UserAvatar avatar={list.avatar} size={64} />
|
||||
</View>
|
||||
</View>
|
||||
<View style={[styles.fakeSelector, pal.border]}>
|
||||
<View
|
||||
style={[styles.fakeSelectorItem, {borderColor: pal.colors.link}]}>
|
||||
<Text type="md-medium" style={[pal.text]}>
|
||||
Muted users
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 16,
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
headerDescription: {
|
||||
marginTop: 8,
|
||||
},
|
||||
headerBtns: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
marginTop: 12,
|
||||
},
|
||||
fakeSelector: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: isDesktopWeb ? 16 : 6,
|
||||
},
|
||||
fakeSelectorItem: {
|
||||
paddingHorizontal: 12,
|
||||
paddingBottom: 8,
|
||||
borderBottomWidth: 3,
|
||||
},
|
||||
feedFooter: {paddingTop: 20},
|
||||
})
|
240
src/view/com/lists/ListsList.tsx
Normal file
240
src/view/com/lists/ListsList.tsx
Normal file
|
@ -0,0 +1,240 @@
|
|||
import React, {MutableRefObject} from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
RefreshControl,
|
||||
StyleProp,
|
||||
StyleSheet,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from 'react-native'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {AppBskyGraphDefs as GraphDefs} from '@atproto/api'
|
||||
import {FlatList} from '../util/Views'
|
||||
import {ListCard} from './ListCard'
|
||||
import {ProfileCardFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
|
||||
import {Button} from '../util/forms/Button'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {ListsListModel} from 'state/models/lists/lists-list'
|
||||
import {useAnalytics} from 'lib/analytics'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {s} from 'lib/styles'
|
||||
|
||||
const LOADING_ITEM = {_reactKey: '__loading__'}
|
||||
const CREATENEW_ITEM = {_reactKey: '__loading__'}
|
||||
const EMPTY_ITEM = {_reactKey: '__empty__'}
|
||||
const ERROR_ITEM = {_reactKey: '__error__'}
|
||||
const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
|
||||
|
||||
export const ListsList = observer(
|
||||
({
|
||||
listsList,
|
||||
showAddBtns,
|
||||
style,
|
||||
scrollElRef,
|
||||
onPressTryAgain,
|
||||
onPressCreateNew,
|
||||
renderItem,
|
||||
renderEmptyState,
|
||||
testID,
|
||||
headerOffset = 0,
|
||||
}: {
|
||||
listsList: ListsListModel
|
||||
showAddBtns?: boolean
|
||||
style?: StyleProp<ViewStyle>
|
||||
scrollElRef?: MutableRefObject<FlatList<any> | null>
|
||||
onPressCreateNew: () => void
|
||||
onPressTryAgain?: () => void
|
||||
renderItem?: (list: GraphDefs.ListView) => JSX.Element
|
||||
renderEmptyState?: () => JSX.Element
|
||||
testID?: string
|
||||
headerOffset?: number
|
||||
}) => {
|
||||
const pal = usePalette('default')
|
||||
const {track} = useAnalytics()
|
||||
const [isRefreshing, setIsRefreshing] = React.useState(false)
|
||||
|
||||
const data = React.useMemo(() => {
|
||||
let items: any[] = []
|
||||
if (listsList.hasLoaded) {
|
||||
if (listsList.hasError) {
|
||||
items = items.concat([ERROR_ITEM])
|
||||
}
|
||||
if (listsList.isEmpty) {
|
||||
items = items.concat([EMPTY_ITEM])
|
||||
} else {
|
||||
if (showAddBtns) {
|
||||
items = items.concat([CREATENEW_ITEM])
|
||||
}
|
||||
items = items.concat(listsList.lists)
|
||||
}
|
||||
if (listsList.loadMoreError) {
|
||||
items = items.concat([LOAD_MORE_ERROR_ITEM])
|
||||
}
|
||||
} else if (listsList.isLoading) {
|
||||
items = items.concat([LOADING_ITEM])
|
||||
}
|
||||
return items
|
||||
}, [
|
||||
listsList.hasError,
|
||||
listsList.hasLoaded,
|
||||
listsList.isLoading,
|
||||
listsList.isEmpty,
|
||||
listsList.lists,
|
||||
listsList.loadMoreError,
|
||||
showAddBtns,
|
||||
])
|
||||
|
||||
// events
|
||||
// =
|
||||
|
||||
const onRefresh = React.useCallback(async () => {
|
||||
track('Lists:onRefresh')
|
||||
setIsRefreshing(true)
|
||||
try {
|
||||
await listsList.refresh()
|
||||
} catch (err) {
|
||||
listsList.rootStore.log.error('Failed to refresh lists', err)
|
||||
}
|
||||
setIsRefreshing(false)
|
||||
}, [listsList, track, setIsRefreshing])
|
||||
|
||||
const onEndReached = React.useCallback(async () => {
|
||||
track('Lists:onEndReached')
|
||||
try {
|
||||
await listsList.loadMore()
|
||||
} catch (err) {
|
||||
listsList.rootStore.log.error('Failed to load more lists', err)
|
||||
}
|
||||
}, [listsList, track])
|
||||
|
||||
const onPressRetryLoadMore = React.useCallback(() => {
|
||||
listsList.retryLoadMore()
|
||||
}, [listsList])
|
||||
|
||||
// rendering
|
||||
// =
|
||||
|
||||
const renderItemInner = React.useCallback(
|
||||
({item}: {item: any}) => {
|
||||
if (item === EMPTY_ITEM) {
|
||||
if (renderEmptyState) {
|
||||
return renderEmptyState()
|
||||
}
|
||||
return <View />
|
||||
} else if (item === CREATENEW_ITEM) {
|
||||
return <CreateNewItem onPress={onPressCreateNew} />
|
||||
} else if (item === ERROR_ITEM) {
|
||||
return (
|
||||
<ErrorMessage
|
||||
message={listsList.error}
|
||||
onPressTryAgain={onPressTryAgain}
|
||||
/>
|
||||
)
|
||||
} else if (item === LOAD_MORE_ERROR_ITEM) {
|
||||
return (
|
||||
<LoadMoreRetryBtn
|
||||
label="There was an issue fetching your lists. Tap here to try again."
|
||||
onPress={onPressRetryLoadMore}
|
||||
/>
|
||||
)
|
||||
} else if (item === LOADING_ITEM) {
|
||||
return <ProfileCardFeedLoadingPlaceholder />
|
||||
}
|
||||
return renderItem ? (
|
||||
renderItem(item)
|
||||
) : (
|
||||
<ListCard list={item} testID={`list-${item.name}`} />
|
||||
)
|
||||
},
|
||||
[
|
||||
listsList,
|
||||
onPressTryAgain,
|
||||
onPressRetryLoadMore,
|
||||
onPressCreateNew,
|
||||
renderItem,
|
||||
renderEmptyState,
|
||||
],
|
||||
)
|
||||
|
||||
const Footer = React.useCallback(
|
||||
() =>
|
||||
listsList.isLoading ? (
|
||||
<View style={styles.feedFooter}>
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
) : (
|
||||
<View />
|
||||
),
|
||||
[listsList],
|
||||
)
|
||||
|
||||
return (
|
||||
<View testID={testID} style={style}>
|
||||
{data.length > 0 && (
|
||||
<FlatList
|
||||
testID={testID ? `${testID}-flatlist` : undefined}
|
||||
ref={scrollElRef}
|
||||
data={data}
|
||||
keyExtractor={item => item._reactKey}
|
||||
renderItem={renderItemInner}
|
||||
ListFooterComponent={Footer}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={pal.colors.text}
|
||||
titleColor={pal.colors.text}
|
||||
progressViewOffset={headerOffset}
|
||||
/>
|
||||
}
|
||||
contentContainerStyle={s.contentContainer}
|
||||
style={{paddingTop: headerOffset}}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={0.6}
|
||||
removeClippedSubviews={true}
|
||||
contentOffset={{x: 0, y: headerOffset * -1}}
|
||||
// @ts-ignore our .web version only -prf
|
||||
desktopFixedHeight
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
function CreateNewItem({onPress}: {onPress: () => void}) {
|
||||
const pal = usePalette('default')
|
||||
|
||||
return (
|
||||
<View style={[styles.createNewContainer]}>
|
||||
<Button type="default" onPress={onPress} style={styles.createNewButton}>
|
||||
<FontAwesomeIcon icon="plus" style={pal.text as FontAwesomeIconStyle} />
|
||||
<Text type="button" style={pal.text}>
|
||||
New Mute List
|
||||
</Text>
|
||||
</Button>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
createNewContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 18,
|
||||
paddingTop: 18,
|
||||
paddingBottom: 16,
|
||||
},
|
||||
createNewButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
feedFooter: {paddingTop: 20},
|
||||
})
|
|
@ -144,8 +144,11 @@ export function Component({onChanged}: {onChanged: () => void}) {
|
|||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Text type="2xl-bold" style={[styles.titleMiddle, pal.text]}>
|
||||
Change my handle
|
||||
<Text
|
||||
type="2xl-bold"
|
||||
style={[styles.titleMiddle, pal.text]}
|
||||
numberOfLines={1}>
|
||||
Change handle
|
||||
</Text>
|
||||
<View style={styles.titleRight}>
|
||||
{isProcessing ? (
|
||||
|
|
|
@ -7,23 +7,66 @@ import {useStores} from 'state/index'
|
|||
import {LabelPreference} from 'state/models/ui/preferences'
|
||||
import {s, colors, gradients} from 'lib/styles'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {TextLink} from '../util/Link'
|
||||
import {ToggleButton} from '../util/forms/ToggleButton'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {CONFIGURABLE_LABEL_GROUPS} from 'lib/labeling/const'
|
||||
import {isDesktopWeb} from 'platform/detection'
|
||||
import {isDesktopWeb, isIOS} from 'platform/detection'
|
||||
import * as Toast from '../util/Toast'
|
||||
|
||||
export const snapPoints = ['90%']
|
||||
|
||||
export function Component({}: {}) {
|
||||
export const Component = observer(({}: {}) => {
|
||||
const store = useStores()
|
||||
const pal = usePalette('default')
|
||||
|
||||
React.useEffect(() => {
|
||||
store.preferences.sync()
|
||||
}, [store])
|
||||
|
||||
const onToggleAdultContent = React.useCallback(async () => {
|
||||
if (isIOS) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await store.preferences.setAdultContentEnabled(
|
||||
!store.preferences.adultContentEnabled,
|
||||
)
|
||||
} catch (e) {
|
||||
Toast.show('There was an issue syncing your preferences with the server')
|
||||
store.log.error('Failed to update preferences with server', {e})
|
||||
}
|
||||
}, [store])
|
||||
|
||||
const onPressDone = React.useCallback(() => {
|
||||
store.shell.closeModal()
|
||||
}, [store])
|
||||
|
||||
return (
|
||||
<View testID="contentModerationModal" style={[pal.view, styles.container]}>
|
||||
<Text style={[pal.text, styles.title]}>Content Moderation</Text>
|
||||
<View testID="contentFilteringModal" style={[pal.view, styles.container]}>
|
||||
<Text style={[pal.text, styles.title]}>Content Filtering</Text>
|
||||
<ScrollView style={styles.scrollContainer}>
|
||||
<View style={s.mb10}>
|
||||
{isIOS ? (
|
||||
<Text type="md" style={pal.textLight}>
|
||||
Adult content can only be enabled via the Web at{' '}
|
||||
<TextLink
|
||||
style={pal.link}
|
||||
href="https://staging.bsky.app"
|
||||
text="staging.bsky.app"
|
||||
/>
|
||||
.
|
||||
</Text>
|
||||
) : (
|
||||
<ToggleButton
|
||||
type="default-light"
|
||||
label="Enable Adult Content"
|
||||
isSelected={store.preferences.adultContentEnabled}
|
||||
onPress={onToggleAdultContent}
|
||||
style={styles.toggleBtn}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
<ContentLabelPref
|
||||
group="nsfw"
|
||||
disabled={!store.preferences.adultContentEnabled}
|
||||
|
@ -50,7 +93,7 @@ export function Component({}: {}) {
|
|||
testID="sendReportBtn"
|
||||
onPress={onPressDone}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Confirm content moderation settings"
|
||||
accessibilityLabel="Done"
|
||||
accessibilityHint="">
|
||||
<LinearGradient
|
||||
colors={[gradients.blueLight.start, gradients.blueLight.end]}
|
||||
|
@ -63,7 +106,7 @@ export function Component({}: {}) {
|
|||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// TODO: Refactor this component to pass labels down to each tab
|
||||
const ContentLabelPref = observer(
|
||||
|
@ -76,6 +119,21 @@ const ContentLabelPref = observer(
|
|||
}) => {
|
||||
const store = useStores()
|
||||
const pal = usePalette('default')
|
||||
|
||||
const onChange = React.useCallback(
|
||||
async (v: LabelPreference) => {
|
||||
try {
|
||||
await store.preferences.setContentLabelPref(group, v)
|
||||
} catch (e) {
|
||||
Toast.show(
|
||||
'There was an issue syncing your preferences with the server',
|
||||
)
|
||||
store.log.error('Failed to update preferences with server', {e})
|
||||
}
|
||||
},
|
||||
[store, group],
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={[styles.contentLabelPref, pal.border]}>
|
||||
<View style={s.flex1}>
|
||||
|
@ -95,7 +153,7 @@ const ContentLabelPref = observer(
|
|||
) : (
|
||||
<SelectGroup
|
||||
current={store.preferences.contentLabels[group]}
|
||||
onChange={v => store.preferences.setContentLabelPref(group, v)}
|
||||
onChange={onChange}
|
||||
group={group}
|
||||
/>
|
||||
)}
|
||||
|
@ -250,4 +308,7 @@ const styles = StyleSheet.create({
|
|||
padding: 14,
|
||||
backgroundColor: colors.gray1,
|
||||
},
|
||||
toggleBtn: {
|
||||
paddingHorizontal: 0,
|
||||
},
|
||||
})
|
||||
|
|
279
src/view/com/modals/CreateOrEditMuteList.tsx
Normal file
279
src/view/com/modals/CreateOrEditMuteList.tsx
Normal file
|
@ -0,0 +1,279 @@
|
|||
import React, {useState, useCallback} from 'react'
|
||||
import * as Toast from '../util/Toast'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import LinearGradient from 'react-native-linear-gradient'
|
||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {useStores} from 'state/index'
|
||||
import {ListModel} from 'state/models/content/list'
|
||||
import {s, colors, gradients} from 'lib/styles'
|
||||
import {enforceLen} from 'lib/strings/helpers'
|
||||
import {compressIfNeeded} from 'lib/media/manip'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useTheme} from 'lib/ThemeContext'
|
||||
import {useAnalytics} from 'lib/analytics'
|
||||
import {cleanError, isNetworkError} from 'lib/strings/errors'
|
||||
import {isDesktopWeb} from 'platform/detection'
|
||||
|
||||
const MAX_NAME = 64 // todo
|
||||
const MAX_DESCRIPTION = 300 // todo
|
||||
|
||||
export const snapPoints = ['fullscreen']
|
||||
|
||||
export function Component({
|
||||
onSave,
|
||||
list,
|
||||
}: {
|
||||
onSave?: (uri: string) => void
|
||||
list?: ListModel
|
||||
}) {
|
||||
const store = useStores()
|
||||
const [error, setError] = useState<string>('')
|
||||
const pal = usePalette('default')
|
||||
const theme = useTheme()
|
||||
const {track} = useAnalytics()
|
||||
|
||||
const [isProcessing, setProcessing] = useState<boolean>(false)
|
||||
const [name, setName] = useState<string>(list?.list.name || '')
|
||||
const [description, setDescription] = useState<string>(
|
||||
list?.list.description || '',
|
||||
)
|
||||
const [avatar, setAvatar] = useState<string | undefined>(list?.list.avatar)
|
||||
const [newAvatar, setNewAvatar] = useState<RNImage | undefined | null>()
|
||||
|
||||
const onPressCancel = useCallback(() => {
|
||||
store.shell.closeModal()
|
||||
}, [store])
|
||||
|
||||
const onSelectNewAvatar = useCallback(
|
||||
async (img: RNImage | null) => {
|
||||
if (!img) {
|
||||
setNewAvatar(null)
|
||||
setAvatar(null)
|
||||
return
|
||||
}
|
||||
track('CreateMuteList:AvatarSelected')
|
||||
try {
|
||||
const finalImg = await compressIfNeeded(img, 1000000)
|
||||
setNewAvatar(finalImg)
|
||||
setAvatar(finalImg.path)
|
||||
} catch (e: any) {
|
||||
setError(cleanError(e))
|
||||
}
|
||||
},
|
||||
[track, setNewAvatar, setAvatar, setError],
|
||||
)
|
||||
|
||||
const onPressSave = useCallback(async () => {
|
||||
track('CreateMuteList:Save')
|
||||
const nameTrimmed = name.trim()
|
||||
if (!nameTrimmed) {
|
||||
setError('Name is required')
|
||||
return
|
||||
}
|
||||
setProcessing(true)
|
||||
if (error) {
|
||||
setError('')
|
||||
}
|
||||
try {
|
||||
if (list) {
|
||||
await list.updateMetadata({
|
||||
name: nameTrimmed,
|
||||
description: description.trim(),
|
||||
avatar: newAvatar,
|
||||
})
|
||||
Toast.show('Mute list updated')
|
||||
onSave?.(list.uri)
|
||||
} else {
|
||||
const res = await ListModel.createModList(store, {
|
||||
name,
|
||||
description,
|
||||
avatar: newAvatar,
|
||||
})
|
||||
Toast.show('Mute list created')
|
||||
onSave?.(res.uri)
|
||||
}
|
||||
store.shell.closeModal()
|
||||
} catch (e: any) {
|
||||
if (isNetworkError(e)) {
|
||||
setError(
|
||||
'Failed to create the mute list. Check your internet connection and try again.',
|
||||
)
|
||||
} else {
|
||||
setError(cleanError(e))
|
||||
}
|
||||
}
|
||||
setProcessing(false)
|
||||
}, [
|
||||
track,
|
||||
setProcessing,
|
||||
setError,
|
||||
error,
|
||||
onSave,
|
||||
store,
|
||||
name,
|
||||
description,
|
||||
newAvatar,
|
||||
list,
|
||||
])
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView behavior="height">
|
||||
<ScrollView
|
||||
style={[pal.view, styles.container]}
|
||||
testID="createOrEditMuteListModal">
|
||||
<Text style={[styles.title, pal.text]}>
|
||||
{list ? 'Edit Mute List' : 'New Mute List'}
|
||||
</Text>
|
||||
{error !== '' && (
|
||||
<View style={styles.errorContainer}>
|
||||
<ErrorMessage message={error} />
|
||||
</View>
|
||||
)}
|
||||
<Text style={[styles.label, pal.text]}>List Avatar</Text>
|
||||
<View style={[styles.avi, {borderColor: pal.colors.background}]}>
|
||||
<UserAvatar
|
||||
size={80}
|
||||
avatar={avatar}
|
||||
onSelectNewAvatar={onSelectNewAvatar}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.form}>
|
||||
<View>
|
||||
<Text style={[styles.label, pal.text]} nativeID="list-name">
|
||||
List Name
|
||||
</Text>
|
||||
<TextInput
|
||||
testID="editNameInput"
|
||||
style={[styles.textInput, pal.border, pal.text]}
|
||||
placeholder="e.g. Spammers"
|
||||
placeholderTextColor={colors.gray4}
|
||||
value={name}
|
||||
onChangeText={v => setName(enforceLen(v, MAX_NAME))}
|
||||
accessible={true}
|
||||
accessibilityLabel="Name"
|
||||
accessibilityHint=""
|
||||
accessibilityLabelledBy="list-name"
|
||||
/>
|
||||
</View>
|
||||
<View style={s.pb10}>
|
||||
<Text style={[styles.label, pal.text]} nativeID="list-description">
|
||||
Description
|
||||
</Text>
|
||||
<TextInput
|
||||
testID="editDescriptionInput"
|
||||
style={[styles.textArea, pal.border, pal.text]}
|
||||
placeholder="e.g. Users that repeatedly reply with ads."
|
||||
placeholderTextColor={colors.gray4}
|
||||
keyboardAppearance={theme.colorScheme}
|
||||
multiline
|
||||
value={description}
|
||||
onChangeText={v => setDescription(enforceLen(v, MAX_DESCRIPTION))}
|
||||
accessible={true}
|
||||
accessibilityLabel="Description"
|
||||
accessibilityHint=""
|
||||
accessibilityLabelledBy="list-description"
|
||||
/>
|
||||
</View>
|
||||
{isProcessing ? (
|
||||
<View style={[styles.btn, s.mt10, {backgroundColor: colors.gray2}]}>
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
testID="saveBtn"
|
||||
style={s.mt10}
|
||||
onPress={onPressSave}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Save"
|
||||
accessibilityHint="Creates the mute list">
|
||||
<LinearGradient
|
||||
colors={[gradients.blueLight.start, gradients.blueLight.end]}
|
||||
start={{x: 0, y: 0}}
|
||||
end={{x: 1, y: 1}}
|
||||
style={[styles.btn]}>
|
||||
<Text style={[s.white, s.bold]}>Save</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
testID="cancelBtn"
|
||||
style={s.mt5}
|
||||
onPress={onPressCancel}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Cancel"
|
||||
accessibilityHint=""
|
||||
onAccessibilityEscape={onPressCancel}>
|
||||
<View style={[styles.btn]}>
|
||||
<Text style={[s.black, s.bold, pal.text]}>Cancel</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingHorizontal: isDesktopWeb ? 0 : 16,
|
||||
},
|
||||
title: {
|
||||
textAlign: 'center',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 24,
|
||||
marginBottom: 18,
|
||||
},
|
||||
label: {
|
||||
fontWeight: 'bold',
|
||||
paddingHorizontal: 4,
|
||||
paddingBottom: 4,
|
||||
marginTop: 20,
|
||||
},
|
||||
form: {
|
||||
paddingHorizontal: 6,
|
||||
},
|
||||
textInput: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
fontSize: 16,
|
||||
},
|
||||
textArea: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 12,
|
||||
paddingTop: 10,
|
||||
fontSize: 16,
|
||||
height: 100,
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
btn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
borderRadius: 32,
|
||||
padding: 10,
|
||||
marginBottom: 10,
|
||||
},
|
||||
avi: {
|
||||
width: 84,
|
||||
height: 84,
|
||||
borderWidth: 2,
|
||||
borderRadius: 42,
|
||||
marginTop: 4,
|
||||
},
|
||||
errorContainer: {marginTop: 20},
|
||||
})
|
|
@ -18,148 +18,114 @@ import {Slider} from '@miblanchard/react-native-slider'
|
|||
import {MaterialIcons} from '@expo/vector-icons'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {getKeys} from 'lib/type-assertions'
|
||||
import {isDesktopWeb} from 'platform/detection'
|
||||
|
||||
export const snapPoints = ['80%']
|
||||
|
||||
const RATIOS = {
|
||||
'4:3': {
|
||||
Icon: RectWideIcon,
|
||||
},
|
||||
'1:1': {
|
||||
Icon: SquareIcon,
|
||||
},
|
||||
'3:4': {
|
||||
Icon: RectTallIcon,
|
||||
},
|
||||
None: {
|
||||
label: 'None',
|
||||
Icon: MaterialIcons,
|
||||
name: 'do-not-disturb-alt',
|
||||
},
|
||||
} as const
|
||||
|
||||
type AspectRatio = keyof typeof RATIOS
|
||||
|
||||
interface Props {
|
||||
image: ImageModel
|
||||
gallery: GalleryModel
|
||||
}
|
||||
|
||||
// This is only used for desktop web
|
||||
export const Component = observer(function ({image, gallery}: Props) {
|
||||
const pal = usePalette('default')
|
||||
const store = useStores()
|
||||
const {shell} = store
|
||||
const theme = useTheme()
|
||||
const winDim = useWindowDimensions()
|
||||
const store = useStores()
|
||||
const windowDimensions = useWindowDimensions()
|
||||
|
||||
const [altText, setAltText] = useState(image.altText)
|
||||
const [aspectRatio, setAspectRatio] = useState<AspectRatio>(
|
||||
image.aspectRatio ?? 'None',
|
||||
)
|
||||
const [flipHorizontal, setFlipHorizontal] = useState<boolean>(
|
||||
image.flipHorizontal ?? false,
|
||||
)
|
||||
const [flipVertical, setFlipVertical] = useState<boolean>(
|
||||
image.flipVertical ?? false,
|
||||
)
|
||||
const {
|
||||
aspectRatio,
|
||||
// rotate = 0
|
||||
} = image.attributes
|
||||
|
||||
// TODO: doesn't seem to be working correctly with crop
|
||||
// const [rotation, setRotation] = useState(image.rotation ?? 0)
|
||||
const [scale, setScale] = useState<number>(image.scale ?? 1)
|
||||
const [position, setPosition] = useState<Position>()
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const editorRef = useRef<ImageEditor>(null)
|
||||
|
||||
const imgEditorStyles = useMemo(() => {
|
||||
const dim = Math.min(425, winDim.width - 24)
|
||||
return {width: dim, height: dim}
|
||||
}, [winDim.width])
|
||||
|
||||
const manipulationAttributes = useMemo(
|
||||
() => ({
|
||||
// TODO: doesn't seem to be working correctly with crop
|
||||
// ...(rotation !== undefined ? {rotate: rotation} : {}),
|
||||
...(flipHorizontal !== undefined ? {flipHorizontal} : {}),
|
||||
...(flipVertical !== undefined ? {flipVertical} : {}),
|
||||
}),
|
||||
[flipHorizontal, flipVertical],
|
||||
const [scale, setScale] = useState<number>(image.attributes.scale ?? 1)
|
||||
const [position, setPosition] = useState<Position | undefined>(
|
||||
image.attributes.position,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const manipulateImage = async () => {
|
||||
await image.manipulate(manipulationAttributes)
|
||||
}
|
||||
|
||||
manipulateImage()
|
||||
}, [image, manipulationAttributes])
|
||||
|
||||
const ratios = useMemo(
|
||||
() =>
|
||||
({
|
||||
'4:3': {
|
||||
hint: 'Sets image aspect ratio to wide',
|
||||
Icon: RectWideIcon,
|
||||
},
|
||||
'1:1': {
|
||||
hint: 'Sets image aspect ratio to square',
|
||||
Icon: SquareIcon,
|
||||
},
|
||||
'3:4': {
|
||||
hint: 'Sets image aspect ratio to tall',
|
||||
Icon: RectTallIcon,
|
||||
},
|
||||
None: {
|
||||
label: 'None',
|
||||
hint: 'Sets image aspect ratio to tall',
|
||||
Icon: MaterialIcons,
|
||||
name: 'do-not-disturb-alt',
|
||||
},
|
||||
} as const),
|
||||
[],
|
||||
)
|
||||
|
||||
type AspectRatio = keyof typeof ratios
|
||||
const [altText, setAltText] = useState('')
|
||||
|
||||
const onFlipHorizontal = useCallback(() => {
|
||||
setFlipHorizontal(!flipHorizontal)
|
||||
image.manipulate({flipHorizontal})
|
||||
}, [flipHorizontal, image])
|
||||
image.flipHorizontal()
|
||||
}, [image])
|
||||
|
||||
const onFlipVertical = useCallback(() => {
|
||||
setFlipVertical(!flipVertical)
|
||||
image.manipulate({flipVertical})
|
||||
}, [flipVertical, image])
|
||||
image.flipVertical()
|
||||
}, [image])
|
||||
|
||||
// const onSetRotate = useCallback(
|
||||
// (direction: 'left' | 'right') => {
|
||||
// const rotation = (rotate + 90 * (direction === 'left' ? -1 : 1)) % 360
|
||||
// image.setRotate(rotation)
|
||||
// },
|
||||
// [rotate, image],
|
||||
// )
|
||||
|
||||
const onSetRatio = useCallback(
|
||||
(ratio: AspectRatio) => {
|
||||
image.setRatio(ratio)
|
||||
},
|
||||
[image],
|
||||
)
|
||||
|
||||
const adjustments = useMemo(
|
||||
() =>
|
||||
[
|
||||
// {
|
||||
// name: 'rotate-left',
|
||||
// label: 'Rotate left',
|
||||
// hint: 'Rotate image left',
|
||||
// onPress: () => {
|
||||
// const rotate = (rotation - 90) % 360
|
||||
// setRotation(rotate)
|
||||
// image.manipulate({rotate})
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// name: 'rotate-right',
|
||||
// label: 'Rotate right',
|
||||
// hint: 'Rotate image right',
|
||||
// onPress: () => {
|
||||
// const rotate = (rotation + 90) % 360
|
||||
// setRotation(rotate)
|
||||
// image.manipulate({rotate})
|
||||
// },
|
||||
// },
|
||||
{
|
||||
name: 'flip',
|
||||
label: 'Flip horizontal',
|
||||
hint: 'Flip image horizontally',
|
||||
onPress: onFlipHorizontal,
|
||||
},
|
||||
{
|
||||
name: 'flip',
|
||||
label: 'Flip vertically',
|
||||
hint: 'Flip image vertically',
|
||||
onPress: onFlipVertical,
|
||||
},
|
||||
] as const,
|
||||
() => [
|
||||
// {
|
||||
// name: 'rotate-left' as const,
|
||||
// label: 'Rotate left',
|
||||
// onPress: () => {
|
||||
// onSetRotate('left')
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// name: 'rotate-right' as const,
|
||||
// label: 'Rotate right',
|
||||
// onPress: () => {
|
||||
// onSetRotate('right')
|
||||
// },
|
||||
// },
|
||||
{
|
||||
name: 'flip' as const,
|
||||
label: 'Flip horizontal',
|
||||
onPress: onFlipHorizontal,
|
||||
},
|
||||
{
|
||||
name: 'flip' as const,
|
||||
label: 'Flip vertically',
|
||||
onPress: onFlipVertical,
|
||||
},
|
||||
],
|
||||
[onFlipHorizontal, onFlipVertical],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
image.prev = image.compressed
|
||||
setIsEditing(true)
|
||||
image.prevAttributes = image.attributes
|
||||
image.resetCompressed()
|
||||
}, [image])
|
||||
|
||||
const onCloseModal = useCallback(() => {
|
||||
shell.closeModal()
|
||||
setIsEditing(false)
|
||||
}, [shell])
|
||||
store.shell.closeModal()
|
||||
}, [store.shell])
|
||||
|
||||
const onPressCancel = useCallback(async () => {
|
||||
await gallery.previous(image)
|
||||
|
@ -184,25 +150,12 @@ export const Component = observer(function ({image, gallery}: Props) {
|
|||
...(position !== undefined ? {position} : {}),
|
||||
}
|
||||
: {}),
|
||||
...manipulationAttributes,
|
||||
aspectRatio,
|
||||
})
|
||||
|
||||
image.prevAttributes = manipulationAttributes
|
||||
image.prev = image.compressed
|
||||
image.prevAttributes = image.attributes
|
||||
onCloseModal()
|
||||
}, [
|
||||
altText,
|
||||
aspectRatio,
|
||||
image,
|
||||
manipulationAttributes,
|
||||
position,
|
||||
scale,
|
||||
onCloseModal,
|
||||
])
|
||||
|
||||
const onPressRatio = useCallback((as: AspectRatio) => {
|
||||
setAspectRatio(as)
|
||||
}, [])
|
||||
}, [altText, image, position, scale, onCloseModal])
|
||||
|
||||
const getLabelIconSize = useCallback((as: AspectRatio) => {
|
||||
switch (as) {
|
||||
|
@ -220,40 +173,55 @@ export const Component = observer(function ({image, gallery}: Props) {
|
|||
return null
|
||||
}
|
||||
|
||||
const {width, height} = image.getDisplayDimensions(
|
||||
aspectRatio,
|
||||
imgEditorStyles.width,
|
||||
)
|
||||
const computedWidth =
|
||||
windowDimensions.width > 500 ? 410 : windowDimensions.width - 80
|
||||
const sideLength = isDesktopWeb ? 300 : computedWidth
|
||||
|
||||
const dimensions = image.getDisplayDimensions(aspectRatio, sideLength)
|
||||
const imgContainerStyles = {width: sideLength, height: sideLength}
|
||||
|
||||
const imgControlStyles = {
|
||||
alignItems: 'center' as const,
|
||||
flexDirection: isDesktopWeb ? ('row' as const) : ('column' as const),
|
||||
gap: isDesktopWeb ? 5 : 0,
|
||||
}
|
||||
|
||||
return (
|
||||
<View testID="editImageModal" style={[pal.view, styles.container, s.flex1]}>
|
||||
<Text style={[styles.title, pal.text]}>Edit image</Text>
|
||||
<View>
|
||||
<View style={[styles.imgContainer, imgEditorStyles, pal.borderDark]}>
|
||||
<ImageEditor
|
||||
ref={editorRef}
|
||||
style={styles.imgEditor}
|
||||
image={isEditing ? image.compressed.path : image.path}
|
||||
width={width}
|
||||
height={height}
|
||||
scale={scale}
|
||||
border={0}
|
||||
position={position}
|
||||
onPositionChange={setPosition}
|
||||
<View style={[styles.gap18, s.flexRow]}>
|
||||
<View>
|
||||
<View
|
||||
style={[styles.imgContainer, pal.borderDark, imgContainerStyles]}>
|
||||
<ImageEditor
|
||||
ref={editorRef}
|
||||
style={styles.imgEditor}
|
||||
image={image.compressed.path}
|
||||
scale={scale}
|
||||
border={0}
|
||||
position={position}
|
||||
onPositionChange={setPosition}
|
||||
{...dimensions}
|
||||
/>
|
||||
</View>
|
||||
<Slider
|
||||
value={scale}
|
||||
onValueChange={(v: number | number[]) =>
|
||||
setScale(Array.isArray(v) ? v[0] : v)
|
||||
}
|
||||
minimumValue={1}
|
||||
maximumValue={3}
|
||||
/>
|
||||
</View>
|
||||
<Slider
|
||||
value={scale}
|
||||
onValueChange={(v: number | number[]) =>
|
||||
setScale(Array.isArray(v) ? v[0] : v)
|
||||
}
|
||||
minimumValue={1}
|
||||
maximumValue={3}
|
||||
/>
|
||||
<View style={[s.flexRow, styles.gap18]}>
|
||||
<View style={styles.imgControls}>
|
||||
{getKeys(ratios).map(ratio => {
|
||||
const {hint, Icon, ...props} = ratios[ratio]
|
||||
<View>
|
||||
{isDesktopWeb ? (
|
||||
<Text type="sm-bold" style={pal.text}>
|
||||
Ratios
|
||||
</Text>
|
||||
) : null}
|
||||
<View style={imgControlStyles}>
|
||||
{getKeys(RATIOS).map(ratio => {
|
||||
const {Icon, ...props} = RATIOS[ratio]
|
||||
const labelIconSize = getLabelIconSize(ratio)
|
||||
const isSelected = aspectRatio === ratio
|
||||
|
||||
|
@ -261,10 +229,10 @@ export const Component = observer(function ({image, gallery}: Props) {
|
|||
<Pressable
|
||||
key={ratio}
|
||||
onPress={() => {
|
||||
onPressRatio(ratio)
|
||||
onSetRatio(ratio)
|
||||
}}
|
||||
accessibilityLabel={ratio}
|
||||
accessibilityHint={hint}>
|
||||
accessibilityHint="">
|
||||
<Icon
|
||||
size={labelIconSize}
|
||||
style={[styles.imgControl, isSelected ? s.blue3 : pal.text]}
|
||||
|
@ -281,18 +249,22 @@ export const Component = observer(function ({image, gallery}: Props) {
|
|||
)
|
||||
})}
|
||||
</View>
|
||||
<View style={[styles.verticalSep, pal.border]} />
|
||||
<View style={styles.imgControls}>
|
||||
{adjustments.map(({label, hint, name, onPress}) => (
|
||||
{isDesktopWeb ? (
|
||||
<Text type="sm-bold" style={[pal.text, styles.subsection]}>
|
||||
Transformations
|
||||
</Text>
|
||||
) : null}
|
||||
<View style={imgControlStyles}>
|
||||
{adjustments.map(({label, name, onPress}) => (
|
||||
<Pressable
|
||||
key={label}
|
||||
onPress={onPress}
|
||||
accessibilityLabel={label}
|
||||
accessibilityHint={hint}
|
||||
accessibilityHint=""
|
||||
style={styles.flipBtn}>
|
||||
<MaterialIcons
|
||||
name={name}
|
||||
size={label.startsWith('Flip') ? 22 : 24}
|
||||
size={label?.startsWith('Flip') ? 22 : 24}
|
||||
style={[
|
||||
pal.text,
|
||||
label === 'Flip vertically'
|
||||
|
@ -305,7 +277,10 @@ export const Component = observer(function ({image, gallery}: Props) {
|
|||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[styles.gap18]}>
|
||||
<View style={[styles.gap18, styles.bottomSection, pal.border]}>
|
||||
<Text type="sm-bold" style={pal.text} nativeID="alt-text">
|
||||
Accessibility
|
||||
</Text>
|
||||
<TextInput
|
||||
testID="altTextImageInput"
|
||||
style={[styles.textArea, pal.border, pal.text]}
|
||||
|
@ -313,11 +288,9 @@ export const Component = observer(function ({image, gallery}: Props) {
|
|||
multiline
|
||||
value={altText}
|
||||
onChangeText={text => setAltText(enforceLen(text, MAX_ALT_TEXT))}
|
||||
placeholder="Image description"
|
||||
placeholderTextColor={pal.colors.textLight}
|
||||
accessibilityLabel="Image alt text"
|
||||
accessibilityHint="Sets image alt text for screenreaders"
|
||||
accessibilityLabelledBy="imageAltText"
|
||||
accessibilityLabel="Alt text"
|
||||
accessibilityHint=""
|
||||
accessibilityLabelledBy="alt-text"
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.btns}>
|
||||
|
@ -345,30 +318,16 @@ export const Component = observer(function ({image, gallery}: Props) {
|
|||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
gap: 18,
|
||||
paddingVertical: 18,
|
||||
paddingHorizontal: 12,
|
||||
paddingHorizontal: isDesktopWeb ? undefined : 16,
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
},
|
||||
gap18: {
|
||||
gap: 18,
|
||||
},
|
||||
|
||||
subsection: {marginTop: 12},
|
||||
gap18: {gap: 18},
|
||||
title: {
|
||||
fontWeight: 'bold',
|
||||
fontSize: 24,
|
||||
},
|
||||
|
||||
textArea: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 6,
|
||||
paddingTop: 10,
|
||||
paddingHorizontal: 12,
|
||||
fontSize: 16,
|
||||
height: 100,
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
|
||||
btns: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
@ -379,28 +338,12 @@ const styles = StyleSheet.create({
|
|||
paddingVertical: 8,
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
|
||||
verticalSep: {
|
||||
borderLeftWidth: 1,
|
||||
},
|
||||
|
||||
imgControls: {
|
||||
flexDirection: 'row',
|
||||
gap: 5,
|
||||
},
|
||||
imgControl: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: 40,
|
||||
},
|
||||
flipVertical: {
|
||||
transform: [{rotate: '90deg'}],
|
||||
},
|
||||
flipBtn: {
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
imgEditor: {
|
||||
maxWidth: '100%',
|
||||
},
|
||||
|
@ -408,11 +351,29 @@ const styles = StyleSheet.create({
|
|||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: 425,
|
||||
width: 425,
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
borderStyle: 'solid',
|
||||
overflow: 'hidden',
|
||||
marginBottom: 4,
|
||||
},
|
||||
flipVertical: {
|
||||
transform: [{rotate: '90deg'}],
|
||||
},
|
||||
flipBtn: {
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
textArea: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 6,
|
||||
paddingTop: 10,
|
||||
paddingHorizontal: 12,
|
||||
fontSize: 16,
|
||||
height: 100,
|
||||
textAlignVertical: 'top',
|
||||
maxHeight: isDesktopWeb ? undefined : 50,
|
||||
},
|
||||
bottomSection: {
|
||||
borderTopWidth: 1,
|
||||
paddingTop: 18,
|
||||
},
|
||||
})
|
||||
|
|
|
@ -65,7 +65,7 @@ export function Component({
|
|||
}
|
||||
const onSelectNewAvatar = useCallback(
|
||||
async (img: RNImage | null) => {
|
||||
if (!img) {
|
||||
if (img === null) {
|
||||
setNewUserAvatar(null)
|
||||
setUserAvatar(null)
|
||||
return
|
||||
|
@ -81,6 +81,7 @@ export function Component({
|
|||
},
|
||||
[track, setNewUserAvatar, setUserAvatar, setError],
|
||||
)
|
||||
|
||||
const onSelectNewBanner = useCallback(
|
||||
async (img: RNImage | null) => {
|
||||
if (!img) {
|
||||
|
@ -99,6 +100,7 @@ export function Component({
|
|||
},
|
||||
[track, setNewUserBanner, setUserBanner, setError],
|
||||
)
|
||||
|
||||
const onPressSave = useCallback(async () => {
|
||||
track('EditProfile:Save')
|
||||
setProcessing(true)
|
||||
|
|
|
@ -57,7 +57,7 @@ export function Component({}: {}) {
|
|||
code works once!
|
||||
</Text>
|
||||
<Text type="sm" style={[styles.description, pal.textLight]}>
|
||||
( You'll receive one invite code every two weeks. )
|
||||
(You'll receive one invite code every two weeks.)
|
||||
</Text>
|
||||
<ScrollView style={[styles.scrollContainer, pal.border]}>
|
||||
{store.me.invites.map((invite, i) => (
|
||||
|
|
253
src/view/com/modals/ListAddRemoveUser.tsx
Normal file
253
src/view/com/modals/ListAddRemoveUser.tsx
Normal file
|
@ -0,0 +1,253 @@
|
|||
import React, {useCallback} from 'react'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {Pressable, StyleSheet, View} from 'react-native'
|
||||
import {AppBskyGraphDefs as GraphDefs} from '@atproto/api'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import {ListsList} from '../lists/ListsList'
|
||||
import {ListsListModel} from 'state/models/lists/lists-list'
|
||||
import {ListMembershipModel} from 'state/models/content/list-membership'
|
||||
import {EmptyStateWithButton} from '../util/EmptyStateWithButton'
|
||||
import {Button} from '../util/forms/Button'
|
||||
import * as Toast from '../util/Toast'
|
||||
import {useStores} from 'state/index'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {s} from 'lib/styles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {isDesktopWeb, isAndroid} from 'platform/detection'
|
||||
|
||||
export const snapPoints = ['fullscreen']
|
||||
|
||||
export const Component = observer(
|
||||
({
|
||||
subject,
|
||||
displayName,
|
||||
onUpdate,
|
||||
}: {
|
||||
subject: string
|
||||
displayName: string
|
||||
onUpdate?: () => void
|
||||
}) => {
|
||||
const store = useStores()
|
||||
const pal = usePalette('default')
|
||||
const palPrimary = usePalette('primary')
|
||||
const palInverted = usePalette('inverted')
|
||||
const [selected, setSelected] = React.useState([])
|
||||
|
||||
const listsList: ListsListModel = React.useMemo(
|
||||
() => new ListsListModel(store, store.me.did),
|
||||
[store],
|
||||
)
|
||||
const memberships: ListMembershipModel = React.useMemo(
|
||||
() => new ListMembershipModel(store, subject),
|
||||
[store, subject],
|
||||
)
|
||||
React.useEffect(() => {
|
||||
listsList.refresh()
|
||||
memberships.fetch().then(
|
||||
() => {
|
||||
setSelected(memberships.memberships.map(m => m.value.list))
|
||||
},
|
||||
err => {
|
||||
store.log.error('Failed to fetch memberships', {err})
|
||||
},
|
||||
)
|
||||
}, [memberships, listsList, store, setSelected])
|
||||
|
||||
const onPressCancel = useCallback(() => {
|
||||
store.shell.closeModal()
|
||||
}, [store])
|
||||
|
||||
const onPressSave = useCallback(async () => {
|
||||
try {
|
||||
await memberships.updateTo(selected)
|
||||
} catch (err) {
|
||||
store.log.error('Failed to update memberships', {err})
|
||||
return
|
||||
}
|
||||
Toast.show('Lists updated')
|
||||
onUpdate?.()
|
||||
store.shell.closeModal()
|
||||
}, [store, selected, memberships, onUpdate])
|
||||
|
||||
const onPressNewMuteList = useCallback(() => {
|
||||
store.shell.openModal({
|
||||
name: 'create-or-edit-mute-list',
|
||||
onSave: (_uri: string) => {
|
||||
listsList.refresh()
|
||||
},
|
||||
})
|
||||
}, [store, listsList])
|
||||
|
||||
const onToggleSelected = useCallback(
|
||||
(uri: string) => {
|
||||
if (selected.includes(uri)) {
|
||||
setSelected(selected.filter(uri2 => uri2 !== uri))
|
||||
} else {
|
||||
setSelected([...selected, uri])
|
||||
}
|
||||
},
|
||||
[selected, setSelected],
|
||||
)
|
||||
|
||||
const renderItem = useCallback(
|
||||
(list: GraphDefs.ListView) => {
|
||||
const isSelected = selected.includes(list.uri)
|
||||
return (
|
||||
<Pressable
|
||||
testID={`toggleBtn-${list.name}`}
|
||||
style={[styles.listItem, pal.border]}
|
||||
accessibilityLabel={`${isSelected ? 'Remove from' : 'Add to'} ${
|
||||
list.name
|
||||
}`}
|
||||
accessibilityHint=""
|
||||
onPress={() => onToggleSelected(list.uri)}>
|
||||
<View style={styles.listItemAvi}>
|
||||
<UserAvatar size={40} avatar={list.avatar} />
|
||||
</View>
|
||||
<View style={styles.listItemContent}>
|
||||
<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#modlist' && 'Mute list'}{' '}
|
||||
by{' '}
|
||||
{list.creator.did === store.me.did
|
||||
? 'you'
|
||||
: `@${list.creator.handle}`}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={
|
||||
isSelected
|
||||
? [styles.checkbox, palPrimary.border, palPrimary.view]
|
||||
: [styles.checkbox, pal.borderDark]
|
||||
}>
|
||||
{isSelected && (
|
||||
<FontAwesomeIcon
|
||||
icon="check"
|
||||
style={palInverted.text as FontAwesomeIconStyle}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
)
|
||||
},
|
||||
[pal, palPrimary, palInverted, onToggleSelected, selected, store.me.did],
|
||||
)
|
||||
|
||||
const renderEmptyState = React.useCallback(() => {
|
||||
return (
|
||||
<EmptyStateWithButton
|
||||
icon="users-slash"
|
||||
message="You can subscribe to mute lists to automatically mute all of the users they include. Mute lists are public but your subscription to a mute list is private."
|
||||
buttonLabel="New Mute List"
|
||||
onPress={onPressNewMuteList}
|
||||
/>
|
||||
)
|
||||
}, [onPressNewMuteList])
|
||||
|
||||
return (
|
||||
<View testID="listAddRemoveUserModal" style={s.hContentRegion}>
|
||||
<Text style={[styles.title, pal.text]}>Add {displayName} to lists</Text>
|
||||
<ListsList
|
||||
listsList={listsList}
|
||||
showAddBtns
|
||||
onPressCreateNew={onPressNewMuteList}
|
||||
renderItem={renderItem}
|
||||
renderEmptyState={renderEmptyState}
|
||||
style={[styles.list, pal.border]}
|
||||
/>
|
||||
<View style={[styles.btns, pal.border]}>
|
||||
<Button
|
||||
testID="cancelBtn"
|
||||
type="default"
|
||||
onPress={onPressCancel}
|
||||
style={styles.footerBtn}
|
||||
accessibilityLabel="Cancel"
|
||||
accessibilityHint=""
|
||||
onAccessibilityEscape={onPressCancel}
|
||||
label="Cancel"
|
||||
/>
|
||||
<Button
|
||||
testID="saveBtn"
|
||||
type="primary"
|
||||
onPress={onPressSave}
|
||||
style={styles.footerBtn}
|
||||
accessibilityLabel="Save changes"
|
||||
accessibilityHint=""
|
||||
onAccessibilityEscape={onPressSave}
|
||||
label="Save Changes"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingHorizontal: isDesktopWeb ? 0 : 16,
|
||||
},
|
||||
title: {
|
||||
textAlign: 'center',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 24,
|
||||
marginBottom: 10,
|
||||
},
|
||||
list: {
|
||||
flex: 1,
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
btns: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 10,
|
||||
paddingTop: 10,
|
||||
paddingBottom: isAndroid ? 10 : 0,
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
footerBtn: {
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
|
||||
listItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderTopWidth: 1,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
listItemAvi: {
|
||||
width: 54,
|
||||
paddingLeft: 4,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 10,
|
||||
},
|
||||
listItemContent: {
|
||||
flex: 1,
|
||||
paddingRight: 10,
|
||||
paddingTop: 10,
|
||||
paddingBottom: 10,
|
||||
},
|
||||
checkbox: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 1,
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 6,
|
||||
marginRight: 8,
|
||||
},
|
||||
})
|
|
@ -12,6 +12,8 @@ import * as EditProfileModal from './EditProfile'
|
|||
import * as ServerInputModal from './ServerInput'
|
||||
import * as ReportPostModal from './ReportPost'
|
||||
import * as RepostModal from './Repost'
|
||||
import * as CreateOrEditMuteListModal from './CreateOrEditMuteList'
|
||||
import * as ListAddRemoveUserModal from './ListAddRemoveUser'
|
||||
import * as AltImageModal from './AltImage'
|
||||
import * as ReportAccountModal from './ReportAccount'
|
||||
import * as DeleteAccountModal from './DeleteAccount'
|
||||
|
@ -66,6 +68,12 @@ export const ModalsContainer = observer(function ModalsContainer() {
|
|||
} else if (activeModal?.name === 'report-account') {
|
||||
snapPoints = ReportAccountModal.snapPoints
|
||||
element = <ReportAccountModal.Component {...activeModal} />
|
||||
} else if (activeModal?.name === 'create-or-edit-mute-list') {
|
||||
snapPoints = CreateOrEditMuteListModal.snapPoints
|
||||
element = <CreateOrEditMuteListModal.Component {...activeModal} />
|
||||
} else if (activeModal?.name === 'list-add-remove-user') {
|
||||
snapPoints = ListAddRemoveUserModal.snapPoints
|
||||
element = <ListAddRemoveUserModal.Component {...activeModal} />
|
||||
} else if (activeModal?.name === 'delete-account') {
|
||||
snapPoints = DeleteAccountModal.snapPoints
|
||||
element = <DeleteAccountModal.Component />
|
||||
|
|
|
@ -11,6 +11,8 @@ import * as EditProfileModal from './EditProfile'
|
|||
import * as ServerInputModal from './ServerInput'
|
||||
import * as ReportPostModal from './ReportPost'
|
||||
import * as ReportAccountModal from './ReportAccount'
|
||||
import * as CreateOrEditMuteListModal from './CreateOrEditMuteList'
|
||||
import * as ListAddRemoveUserModal from './ListAddRemoveUser'
|
||||
import * as DeleteAccountModal from './DeleteAccount'
|
||||
import * as RepostModal from './Repost'
|
||||
import * as CropImageModal from './crop-image/CropImage.web'
|
||||
|
@ -69,6 +71,10 @@ function Modal({modal}: {modal: ModalIface}) {
|
|||
element = <ReportPostModal.Component {...modal} />
|
||||
} else if (modal.name === 'report-account') {
|
||||
element = <ReportAccountModal.Component {...modal} />
|
||||
} else if (modal.name === 'create-or-edit-mute-list') {
|
||||
element = <CreateOrEditMuteListModal.Component {...modal} />
|
||||
} else if (modal.name === 'list-add-remove-user') {
|
||||
element = <ListAddRemoveUserModal.Component {...modal} />
|
||||
} else if (modal.name === 'crop-image') {
|
||||
element = <CropImageModal.Component {...modal} />
|
||||
} else if (modal.name === 'delete-account') {
|
||||
|
|
|
@ -19,7 +19,7 @@ import {usePalette} from 'lib/hooks/usePalette'
|
|||
|
||||
const DMCA_LINK = 'https://bsky.app/support/copyright'
|
||||
|
||||
export const snapPoints = [500]
|
||||
export const snapPoints = [550]
|
||||
|
||||
export function Component({
|
||||
postUri,
|
||||
|
@ -72,6 +72,19 @@ export function Component({
|
|||
</View>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: ComAtprotoModerationDefs.REASONRUDE,
|
||||
label: (
|
||||
<View>
|
||||
<Text style={pal.text} type="md-bold">
|
||||
Anti-Social Behavior
|
||||
</Text>
|
||||
<Text style={pal.textLight}>
|
||||
Harassment, trolling, or intolerance
|
||||
</Text>
|
||||
</View>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: ComAtprotoModerationDefs.REASONVIOLATION,
|
||||
label: (
|
||||
|
|
|
@ -77,7 +77,7 @@ export function TabBar({
|
|||
],
|
||||
)
|
||||
|
||||
const onLayout = () => {
|
||||
const onLayout = React.useCallback(() => {
|
||||
const promises = []
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
promises.push(
|
||||
|
@ -98,14 +98,17 @@ export function TabBar({
|
|||
Promise.all(promises).then((layouts: Layout[]) => {
|
||||
setItemLayouts(layouts)
|
||||
})
|
||||
}
|
||||
}, [containerRef, itemRefs, setItemLayouts, items.length])
|
||||
|
||||
const onPressItem = (index: number) => {
|
||||
onSelect?.(index)
|
||||
if (index === selectedPage) {
|
||||
onPressSelected?.()
|
||||
}
|
||||
}
|
||||
const onPressItem = React.useCallback(
|
||||
(index: number) => {
|
||||
onSelect?.(index)
|
||||
if (index === selectedPage) {
|
||||
onPressSelected?.()
|
||||
}
|
||||
},
|
||||
[onSelect, onPressSelected, selectedPage],
|
||||
)
|
||||
|
||||
return (
|
||||
<View
|
||||
|
|
|
@ -24,8 +24,10 @@ import {Text} from '../util/text/Text'
|
|||
import {s} from 'lib/styles'
|
||||
import {isDesktopWeb, isMobileWeb} from 'platform/detection'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useSetTitle} from 'lib/hooks/useSetTitle'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
|
||||
const REPLY_PROMPT = {_reactKey: '__reply__', _isHighlightedPost: false}
|
||||
const DELETED = {_reactKey: '__deleted__', _isHighlightedPost: false}
|
||||
|
@ -59,6 +61,13 @@ export const PostThread = observer(function PostThread({
|
|||
}
|
||||
return []
|
||||
}, [view.thread])
|
||||
useSetTitle(
|
||||
view.thread?.postRecord &&
|
||||
`${sanitizeDisplayName(
|
||||
view.thread.post.author.displayName ||
|
||||
`@${view.thread.post.author.handle}`,
|
||||
)}: "${view.thread?.postRecord?.text}"`,
|
||||
)
|
||||
|
||||
// events
|
||||
// =
|
||||
|
|
|
@ -21,7 +21,7 @@ import {pluralize} from 'lib/strings/helpers'
|
|||
import {useStores} from 'state/index'
|
||||
import {PostMeta} from '../util/PostMeta'
|
||||
import {PostEmbeds} from '../util/post-embeds'
|
||||
import {PostCtrls} from '../util/PostCtrls'
|
||||
import {PostCtrls} from '../util/post-ctrls/PostCtrls'
|
||||
import {PostHider} from '../util/moderation/PostHider'
|
||||
import {ContentHider} from '../util/moderation/ContentHider'
|
||||
import {ImageHider} from '../util/moderation/ImageHider'
|
||||
|
|
|
@ -20,7 +20,7 @@ import {Link} from '../util/Link'
|
|||
import {UserInfoText} from '../util/UserInfoText'
|
||||
import {PostMeta} from '../util/PostMeta'
|
||||
import {PostEmbeds} from '../util/post-embeds'
|
||||
import {PostCtrls} from '../util/PostCtrls'
|
||||
import {PostCtrls} from '../util/post-ctrls/PostCtrls'
|
||||
import {PostHider} from '../util/moderation/PostHider'
|
||||
import {ContentHider} from '../util/moderation/ContentHider'
|
||||
import {ImageHider} from '../util/moderation/ImageHider'
|
||||
|
|
|
@ -8,11 +8,12 @@ import {
|
|||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {PostsFeedItemModel} from 'state/models/feeds/posts'
|
||||
import {ModerationBehaviorCode} from 'lib/labeling/types'
|
||||
import {Link, DesktopWebTextLink} from '../util/Link'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {UserInfoText} from '../util/UserInfoText'
|
||||
import {PostMeta} from '../util/PostMeta'
|
||||
import {PostCtrls} from '../util/PostCtrls'
|
||||
import {PostCtrls} from '../util/post-ctrls/PostCtrls'
|
||||
import {PostEmbeds} from '../util/post-embeds'
|
||||
import {PostHider} from '../util/moderation/PostHider'
|
||||
import {ContentHider} from '../util/moderation/ContentHider'
|
||||
|
@ -31,13 +32,14 @@ export const FeedItem = observer(function ({
|
|||
isThreadChild,
|
||||
isThreadParent,
|
||||
showFollowBtn,
|
||||
ignoreMuteFor,
|
||||
}: {
|
||||
item: PostsFeedItemModel
|
||||
isThreadChild?: boolean
|
||||
isThreadParent?: boolean
|
||||
showReplyLine?: boolean
|
||||
showFollowBtn?: boolean
|
||||
ignoreMuteFor?: string // NOTE currently disabled, will be addressed in the next PR -prf
|
||||
ignoreMuteFor?: string
|
||||
}) {
|
||||
const store = useStores()
|
||||
const pal = usePalette('default')
|
||||
|
@ -142,12 +144,22 @@ export const FeedItem = observer(function ({
|
|||
isThreadParent ? styles.outerNoBottom : undefined,
|
||||
]
|
||||
|
||||
// moderation override
|
||||
let moderation = item.moderation.list
|
||||
if (
|
||||
ignoreMuteFor === item.post.author.did &&
|
||||
moderation.isMute &&
|
||||
!moderation.noOverride
|
||||
) {
|
||||
moderation = {behavior: ModerationBehaviorCode.Show}
|
||||
}
|
||||
|
||||
return (
|
||||
<PostHider
|
||||
testID={`feedItem-by-${item.post.author.handle}`}
|
||||
style={outerStyles}
|
||||
href={itemHref}
|
||||
moderation={item.moderation.list}>
|
||||
moderation={moderation}>
|
||||
{isThreadChild && (
|
||||
<View
|
||||
style={[styles.topReplyLine, {borderColor: pal.colors.replyLine}]}
|
||||
|
@ -237,7 +249,7 @@ export const FeedItem = observer(function ({
|
|||
</View>
|
||||
)}
|
||||
<ContentHider
|
||||
moderation={item.moderation.list}
|
||||
moderation={moderation}
|
||||
containerStyle={styles.contentHider}>
|
||||
{item.richText?.text ? (
|
||||
<View style={styles.postTextContainer}>
|
||||
|
|
|
@ -19,7 +19,9 @@ export function FeedSlice({
|
|||
ignoreMuteFor?: string
|
||||
}) {
|
||||
if (slice.moderation.list.behavior === ModerationBehaviorCode.Hide) {
|
||||
return null
|
||||
if (!ignoreMuteFor && !slice.moderation.list.noOverride) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
if (slice.isThread && slice.items.length > 3) {
|
||||
const last = slice.items.length - 1
|
||||
|
|
|
@ -32,7 +32,7 @@ export const ProfileCard = observer(
|
|||
noBorder?: boolean
|
||||
followers?: AppBskyActorDefs.ProfileView[] | undefined
|
||||
overrideModeration?: boolean
|
||||
renderButton?: () => JSX.Element
|
||||
renderButton?: (profile: AppBskyActorDefs.ProfileViewBasic) => JSX.Element
|
||||
}) => {
|
||||
const store = useStores()
|
||||
const pal = usePalette('default')
|
||||
|
@ -92,7 +92,7 @@ export const ProfileCard = observer(
|
|||
)}
|
||||
</View>
|
||||
{renderButton ? (
|
||||
<View style={styles.layoutButton}>{renderButton()}</View>
|
||||
<View style={styles.layoutButton}>{renderButton(profile)}</View>
|
||||
) : undefined}
|
||||
</View>
|
||||
{profile.description ? (
|
||||
|
|
|
@ -23,6 +23,7 @@ import {DropdownButton, DropdownItem} from '../util/forms/DropdownButton'
|
|||
import * as Toast from '../util/Toast'
|
||||
import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {TextLink} from '../util/Link'
|
||||
import {RichText} from '../util/text/RichText'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import {UserBanner} from '../util/UserBanner'
|
||||
|
@ -30,6 +31,7 @@ import {ProfileHeaderWarnings} from '../util/moderation/ProfileHeaderWarnings'
|
|||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useAnalytics} from 'lib/analytics'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {listUriToHref} from 'lib/strings/url-helpers'
|
||||
import {isDesktopWeb, isNative} from 'platform/detection'
|
||||
import {FollowState} from 'state/models/cache/my-follows'
|
||||
import {shareUrl} from 'lib/sharing'
|
||||
|
@ -146,12 +148,21 @@ const ProfileHeaderLoaded = observer(
|
|||
navigation.push('ProfileFollows', {name: view.handle})
|
||||
}, [track, navigation, view])
|
||||
|
||||
const onPressShare = React.useCallback(async () => {
|
||||
const onPressShare = React.useCallback(() => {
|
||||
track('ProfileHeader:ShareButtonClicked')
|
||||
const url = toShareUrl(`/profile/${view.handle}`)
|
||||
shareUrl(url)
|
||||
}, [track, view])
|
||||
|
||||
const onPressAddRemoveLists = React.useCallback(() => {
|
||||
track('ProfileHeader:AddToListsButtonClicked')
|
||||
store.shell.openModal({
|
||||
name: 'list-add-remove-user',
|
||||
subject: view.did,
|
||||
displayName: view.displayName || view.handle,
|
||||
})
|
||||
}, [track, view, store])
|
||||
|
||||
const onPressMuteAccount = React.useCallback(async () => {
|
||||
track('ProfileHeader:MuteAccountButtonClicked')
|
||||
try {
|
||||
|
@ -233,6 +244,11 @@ const ProfileHeaderLoaded = observer(
|
|||
label: 'Share',
|
||||
onPress: onPressShare,
|
||||
},
|
||||
{
|
||||
testID: 'profileHeaderDropdownListAddRemoveBtn',
|
||||
label: 'Add to Lists',
|
||||
onPress: onPressAddRemoveLists,
|
||||
},
|
||||
]
|
||||
if (!isMe) {
|
||||
items.push({sep: true})
|
||||
|
@ -269,6 +285,7 @@ const ProfileHeaderLoaded = observer(
|
|||
onPressUnblockAccount,
|
||||
onPressBlockAccount,
|
||||
onPressReportAccount,
|
||||
onPressAddRemoveLists,
|
||||
])
|
||||
|
||||
const blockHide = !isMe && (view.viewer.blocking || view.viewer.blockedBy)
|
||||
|
@ -422,31 +439,42 @@ const ProfileHeaderLoaded = observer(
|
|||
{view.viewer.blocking ? (
|
||||
<View
|
||||
testID="profileHeaderBlockedNotice"
|
||||
style={[styles.moderationNotice, pal.view, pal.border]}>
|
||||
<FontAwesomeIcon icon="ban" style={[pal.text, s.mr5]} />
|
||||
<Text type="md" style={[s.mr2, pal.text]}>
|
||||
style={[styles.moderationNotice, pal.viewLight]}>
|
||||
<FontAwesomeIcon icon="ban" style={[pal.text]} />
|
||||
<Text type="lg-medium" style={pal.text}>
|
||||
Account blocked
|
||||
</Text>
|
||||
</View>
|
||||
) : view.viewer.muted ? (
|
||||
<View
|
||||
testID="profileHeaderMutedNotice"
|
||||
style={[styles.moderationNotice, pal.view, pal.border]}>
|
||||
style={[styles.moderationNotice, pal.viewLight]}>
|
||||
<FontAwesomeIcon
|
||||
icon={['far', 'eye-slash']}
|
||||
style={[pal.text, s.mr5]}
|
||||
style={[pal.text]}
|
||||
/>
|
||||
<Text type="md" style={[s.mr2, pal.text]}>
|
||||
Account muted
|
||||
<Text type="lg-medium" style={pal.text}>
|
||||
Account muted{' '}
|
||||
{view.viewer.mutedByList && (
|
||||
<Text type="lg-medium" style={pal.text}>
|
||||
by{' '}
|
||||
<TextLink
|
||||
type="lg-medium"
|
||||
style={pal.link}
|
||||
href={listUriToHref(view.viewer.mutedByList.uri)}
|
||||
text={view.viewer.mutedByList.name}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
) : undefined}
|
||||
{view.viewer.blockedBy && (
|
||||
<View
|
||||
testID="profileHeaderBlockedNotice"
|
||||
style={[styles.moderationNotice, pal.view, pal.border]}>
|
||||
<FontAwesomeIcon icon="ban" style={[pal.text, s.mr5]} />
|
||||
<Text type="md" style={[s.mr2, pal.text]}>
|
||||
style={[styles.moderationNotice, pal.viewLight]}>
|
||||
<FontAwesomeIcon icon="ban" style={[pal.text]} />
|
||||
<Text type="lg-medium" style={pal.text}>
|
||||
This account has blocked you
|
||||
</Text>
|
||||
</View>
|
||||
|
@ -595,10 +623,10 @@ const styles = StyleSheet.create({
|
|||
moderationNotice: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 14,
|
||||
gap: 8,
|
||||
},
|
||||
|
||||
br40: {borderRadius: 40},
|
||||
|
|
|
@ -14,7 +14,8 @@ export const BlurView = ({
|
|||
...props
|
||||
}: React.PropsWithChildren<BlurViewProps>) => {
|
||||
// @ts-ignore using an RNW-specific attribute here -prf
|
||||
style = addStyle(style, {backdropFilter: `blur(${blurAmount || 10}px`})
|
||||
let blur = `blur(${blurAmount || 10}px`
|
||||
style = addStyle(style, {backdropFilter: blur, WebkitBackdropFilter: blur})
|
||||
if (blurType === 'dark') {
|
||||
style = addStyle(style, styles.dark)
|
||||
} else {
|
||||
|
|
|
@ -10,17 +10,19 @@ import {UserGroupIcon} from 'lib/icons'
|
|||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
|
||||
export function EmptyState({
|
||||
testID,
|
||||
icon,
|
||||
message,
|
||||
style,
|
||||
}: {
|
||||
testID?: string
|
||||
icon: IconProp | 'user-group'
|
||||
message: string
|
||||
style?: StyleProp<ViewStyle>
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
<View testID={testID} style={[styles.container, style]}>
|
||||
<View style={styles.iconContainer}>
|
||||
{icon === 'user-group' ? (
|
||||
<UserGroupIcon size="64" style={styles.icon} />
|
||||
|
|
88
src/view/com/util/EmptyStateWithButton.tsx
Normal file
88
src/view/com/util/EmptyStateWithButton.tsx
Normal file
|
@ -0,0 +1,88 @@
|
|||
import React from 'react'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {IconProp} from '@fortawesome/fontawesome-svg-core'
|
||||
import {Text} from './text/Text'
|
||||
import {Button} from './forms/Button'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {s} from 'lib/styles'
|
||||
|
||||
interface Props {
|
||||
testID?: string
|
||||
icon: IconProp
|
||||
message: string
|
||||
buttonLabel: string
|
||||
onPress: () => void
|
||||
}
|
||||
|
||||
export function EmptyStateWithButton(props: Props) {
|
||||
const pal = usePalette('default')
|
||||
const palInverted = usePalette('inverted')
|
||||
|
||||
return (
|
||||
<View testID={props.testID} style={styles.container}>
|
||||
<View style={styles.iconContainer}>
|
||||
<FontAwesomeIcon
|
||||
icon={props.icon}
|
||||
style={[styles.icon, pal.text]}
|
||||
size={62}
|
||||
/>
|
||||
</View>
|
||||
<Text type="xl-medium" style={[s.textCenter, pal.text]}>
|
||||
{props.message}
|
||||
</Text>
|
||||
<View style={styles.btns}>
|
||||
<Button
|
||||
testID={props.testID ? `${props.testID}-button` : undefined}
|
||||
type="inverted"
|
||||
style={styles.btn}
|
||||
onPress={props.onPress}>
|
||||
<FontAwesomeIcon
|
||||
icon="plus"
|
||||
style={palInverted.text as FontAwesomeIconStyle}
|
||||
size={14}
|
||||
/>
|
||||
<Text type="lg-medium" style={palInverted.text}>
|
||||
{props.buttonLabel}
|
||||
</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
height: '100%',
|
||||
paddingVertical: 40,
|
||||
paddingHorizontal: 30,
|
||||
},
|
||||
iconContainer: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
icon: {
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
},
|
||||
btns: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
btn: {
|
||||
gap: 10,
|
||||
marginVertical: 20,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 24,
|
||||
borderRadius: 30,
|
||||
},
|
||||
notice: {
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
marginHorizontal: 30,
|
||||
},
|
||||
})
|
|
@ -66,6 +66,7 @@ export function UserAvatar({
|
|||
if (!(await requestCameraAccessIfNeeded())) {
|
||||
return
|
||||
}
|
||||
|
||||
onSelectNewAvatar?.(
|
||||
await openCamera(store, {
|
||||
width: 1000,
|
||||
|
@ -83,20 +84,21 @@ export function UserAvatar({
|
|||
if (!(await requestPhotoAccessIfNeeded())) {
|
||||
return
|
||||
}
|
||||
|
||||
const items = await openPicker(store, {
|
||||
aspect: [1, 1],
|
||||
})
|
||||
const item = items[0]
|
||||
|
||||
const croppedImage = await openCropper(store, {
|
||||
mediaType: 'photo',
|
||||
multiple: false,
|
||||
cropperCircleOverlay: true,
|
||||
height: item.height,
|
||||
width: item.width,
|
||||
path: item.path,
|
||||
})
|
||||
|
||||
onSelectNewAvatar?.(
|
||||
await openCropper(store, {
|
||||
mediaType: 'photo',
|
||||
path: items[0].path,
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
cropperCircleOverlay: true,
|
||||
}),
|
||||
)
|
||||
onSelectNewAvatar?.(croppedImage)
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
@ -55,10 +55,8 @@ export function UserBanner({
|
|||
if (!(await requestPhotoAccessIfNeeded())) {
|
||||
return
|
||||
}
|
||||
const items = await openPicker(store, {
|
||||
mediaType: 'photo',
|
||||
multiple: false,
|
||||
})
|
||||
const items = await openPicker(store)
|
||||
|
||||
onSelectNewBanner?.(
|
||||
await openCropper(store, {
|
||||
mediaType: 'photo',
|
||||
|
|
|
@ -20,11 +20,13 @@ export const ViewHeader = observer(function ({
|
|||
canGoBack,
|
||||
hideOnScroll,
|
||||
showOnDesktop,
|
||||
renderButton,
|
||||
}: {
|
||||
title: string
|
||||
canGoBack?: boolean
|
||||
hideOnScroll?: boolean
|
||||
showOnDesktop?: boolean
|
||||
renderButton?: () => JSX.Element
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const store = useStores()
|
||||
|
@ -46,7 +48,7 @@ export const ViewHeader = observer(function ({
|
|||
|
||||
if (isDesktopWeb) {
|
||||
if (showOnDesktop) {
|
||||
return <DesktopWebHeader title={title} />
|
||||
return <DesktopWebHeader title={title} renderButton={renderButton} />
|
||||
}
|
||||
return null
|
||||
} else {
|
||||
|
@ -79,13 +81,23 @@ export const ViewHeader = observer(function ({
|
|||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={canGoBack ? styles.backBtn : styles.backBtnWide} />
|
||||
{renderButton ? (
|
||||
renderButton()
|
||||
) : (
|
||||
<View style={canGoBack ? styles.backBtn : styles.backBtnWide} />
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function DesktopWebHeader({title}: {title: string}) {
|
||||
function DesktopWebHeader({
|
||||
title,
|
||||
renderButton,
|
||||
}: {
|
||||
title: string
|
||||
renderButton?: () => JSX.Element
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
return (
|
||||
<CenteredView style={[styles.header, styles.desktopHeader, pal.border]}>
|
||||
|
@ -94,6 +106,7 @@ function DesktopWebHeader({title}: {title: string}) {
|
|||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
{renderButton?.()}
|
||||
</CenteredView>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -22,7 +22,7 @@ import {
|
|||
View,
|
||||
ViewProps,
|
||||
} from 'react-native'
|
||||
import {addStyle, colors} from 'lib/styles'
|
||||
import {addStyle} from 'lib/styles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
|
||||
interface AddedProps {
|
||||
|
@ -124,12 +124,6 @@ const styles = StyleSheet.create({
|
|||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
},
|
||||
containerLight: {
|
||||
backgroundColor: colors.gray1,
|
||||
},
|
||||
containerDark: {
|
||||
backgroundColor: colors.gray7,
|
||||
},
|
||||
fixedHeight: {
|
||||
height: '100vh',
|
||||
},
|
||||
|
|
|
@ -38,6 +38,7 @@ export function Button({
|
|||
accessibilityLabel,
|
||||
accessibilityHint,
|
||||
accessibilityLabelledBy,
|
||||
onAccessibilityEscape,
|
||||
}: React.PropsWithChildren<{
|
||||
type?: ButtonType
|
||||
label?: string
|
||||
|
@ -48,6 +49,7 @@ export function Button({
|
|||
accessibilityLabel?: string
|
||||
accessibilityHint?: string
|
||||
accessibilityLabelledBy?: string
|
||||
onAccessibilityEscape?: () => void
|
||||
}>) {
|
||||
const theme = useTheme()
|
||||
const typeOuterStyle = choose<ViewStyle, Record<ButtonType, ViewStyle>>(
|
||||
|
@ -126,6 +128,7 @@ export function Button({
|
|||
},
|
||||
},
|
||||
)
|
||||
|
||||
const onPressWrapped = React.useCallback(
|
||||
(event: Event) => {
|
||||
event.stopPropagation()
|
||||
|
@ -134,15 +137,30 @@ export function Button({
|
|||
},
|
||||
[onPress],
|
||||
)
|
||||
|
||||
const getStyle = React.useCallback(
|
||||
state => {
|
||||
const arr = [typeOuterStyle, styles.outer, style]
|
||||
if (state.pressed) {
|
||||
arr.push({opacity: 0.6})
|
||||
} else if (state.hovered) {
|
||||
arr.push({opacity: 0.8})
|
||||
}
|
||||
return arr
|
||||
},
|
||||
[typeOuterStyle, style],
|
||||
)
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={[typeOuterStyle, styles.outer, style]}
|
||||
style={getStyle}
|
||||
onPress={onPressWrapped}
|
||||
testID={testID}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
accessibilityHint={accessibilityHint}
|
||||
accessibilityLabelledBy={accessibilityLabelledBy}>
|
||||
accessibilityLabelledBy={accessibilityLabelledBy}
|
||||
onAccessibilityEscape={onAccessibilityEscape}>
|
||||
{label ? (
|
||||
<Text type="button" style={[typeLabelStyle, labelStyle]}>
|
||||
{label}
|
||||
|
|
|
@ -209,7 +209,7 @@ export function PostDropdownBtn({
|
|||
},
|
||||
},
|
||||
{sep: true},
|
||||
{
|
||||
!isAuthor && {
|
||||
testID: 'postDropdownReportBtn',
|
||||
icon: 'circle-exclamation',
|
||||
label: 'Report post',
|
||||
|
@ -339,7 +339,9 @@ const DropdownItems = ({
|
|||
color={pal.text.color as string}
|
||||
/>
|
||||
)}
|
||||
<Text style={[styles.label, pal.text]}>{item.label}</Text>
|
||||
<Text style={[styles.label, pal.text]} numberOfLines={1}>
|
||||
{item.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
} else if (isSep(item)) {
|
||||
|
|
|
@ -63,6 +63,5 @@ const styles = StyleSheet.create({
|
|||
position: 'absolute',
|
||||
left: 6,
|
||||
bottom: 6,
|
||||
width: 46,
|
||||
},
|
||||
})
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React from 'react'
|
||||
import React, {useCallback} from 'react'
|
||||
import {
|
||||
StyleProp,
|
||||
StyleSheet,
|
||||
|
@ -18,18 +18,14 @@ import ReactNativeHapticFeedback, {
|
|||
// TriggerableAnimated,
|
||||
// TriggerableAnimatedRef,
|
||||
// } from './anim/TriggerableAnimated'
|
||||
import {Text} from './text/Text'
|
||||
import {PostDropdownBtn} from './forms/DropdownButton'
|
||||
import {
|
||||
HeartIcon,
|
||||
HeartIconSolid,
|
||||
RepostIcon,
|
||||
CommentBottomArrow,
|
||||
} from 'lib/icons'
|
||||
import {Text} from '../text/Text'
|
||||
import {PostDropdownBtn} from '../forms/DropdownButton'
|
||||
import {HeartIcon, HeartIconSolid, CommentBottomArrow} from 'lib/icons'
|
||||
import {s, colors} from 'lib/styles'
|
||||
import {useTheme} from 'lib/ThemeContext'
|
||||
import {useStores} from 'state/index'
|
||||
import {isIOS} from 'platform/detection'
|
||||
import {isIOS, isNative} from 'platform/detection'
|
||||
import {RepostButton} from './RepostButton'
|
||||
|
||||
interface PostCtrlsOpts {
|
||||
itemUri: string
|
||||
|
@ -112,10 +108,12 @@ export function PostCtrls(opts: PostCtrlsOpts) {
|
|||
// DISABLED see #135
|
||||
// const repostRef = React.useRef<TriggerableAnimatedRef | null>(null)
|
||||
// const likeRef = React.useRef<TriggerableAnimatedRef | null>(null)
|
||||
const onRepost = () => {
|
||||
const onRepost = useCallback(() => {
|
||||
store.shell.closeModal()
|
||||
if (!opts.isReposted) {
|
||||
ReactNativeHapticFeedback.trigger(hapticImpact)
|
||||
if (isNative) {
|
||||
ReactNativeHapticFeedback.trigger(hapticImpact)
|
||||
}
|
||||
opts.onPressToggleRepost().catch(_e => undefined)
|
||||
// DISABLED see #135
|
||||
// repostRef.current?.trigger(
|
||||
|
@ -128,9 +126,9 @@ export function PostCtrls(opts: PostCtrlsOpts) {
|
|||
} else {
|
||||
opts.onPressToggleRepost().catch(_e => undefined)
|
||||
}
|
||||
}
|
||||
}, [opts, store.shell])
|
||||
|
||||
const onQuote = () => {
|
||||
const onQuote = useCallback(() => {
|
||||
store.shell.closeModal()
|
||||
store.shell.openComposer({
|
||||
quote: {
|
||||
|
@ -141,17 +139,18 @@ export function PostCtrls(opts: PostCtrlsOpts) {
|
|||
indexedAt: opts.indexedAt,
|
||||
},
|
||||
})
|
||||
ReactNativeHapticFeedback.trigger(hapticImpact)
|
||||
}
|
||||
|
||||
const onPressToggleRepostWrapper = () => {
|
||||
store.shell.openModal({
|
||||
name: 'repost',
|
||||
onRepost: onRepost,
|
||||
onQuote: onQuote,
|
||||
isReposted: opts.isReposted,
|
||||
})
|
||||
}
|
||||
if (isNative) {
|
||||
ReactNativeHapticFeedback.trigger(hapticImpact)
|
||||
}
|
||||
}, [
|
||||
opts.author,
|
||||
opts.indexedAt,
|
||||
opts.itemCid,
|
||||
opts.itemUri,
|
||||
opts.text,
|
||||
store.shell,
|
||||
])
|
||||
|
||||
const onPressToggleLikeWrapper = async () => {
|
||||
if (!opts.isLiked) {
|
||||
|
@ -181,7 +180,7 @@ export function PostCtrls(opts: PostCtrlsOpts) {
|
|||
onPress={opts.onPressReply}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Reply"
|
||||
accessibilityHint="Opens reply composer">
|
||||
accessibilityHint="reply composer">
|
||||
<CommentBottomArrow
|
||||
style={[defaultCtrlColor, opts.big ? s.mt2 : styles.mt1]}
|
||||
strokeWidth={3}
|
||||
|
@ -193,39 +192,7 @@ export function PostCtrls(opts: PostCtrlsOpts) {
|
|||
</Text>
|
||||
) : undefined}
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
testID="repostBtn"
|
||||
hitSlop={HITSLOP}
|
||||
onPress={onPressToggleRepostWrapper}
|
||||
style={styles.ctrl}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={opts.isReposted ? 'Undo repost' : 'Repost'}
|
||||
accessibilityHint={
|
||||
opts.isReposted
|
||||
? `Remove your repost of ${opts.author}'s post`
|
||||
: `Repost or quote post ${opts.author}'s post`
|
||||
}>
|
||||
<RepostIcon
|
||||
style={
|
||||
opts.isReposted
|
||||
? (styles.ctrlIconReposted as StyleProp<ViewStyle>)
|
||||
: defaultCtrlColor
|
||||
}
|
||||
strokeWidth={2.4}
|
||||
size={opts.big ? 24 : 20}
|
||||
/>
|
||||
{typeof opts.repostCount !== 'undefined' ? (
|
||||
<Text
|
||||
testID="repostCount"
|
||||
style={
|
||||
opts.isReposted
|
||||
? [s.bold, s.green3, s.f15, s.ml5]
|
||||
: [defaultCtrlColor, s.f15, s.ml5]
|
||||
}>
|
||||
{opts.repostCount}
|
||||
</Text>
|
||||
) : undefined}
|
||||
</TouchableOpacity>
|
||||
<RepostButton {...opts} onRepost={onRepost} onQuote={onQuote} />
|
||||
<TouchableOpacity
|
||||
testID="likeBtn"
|
||||
style={styles.ctrl}
|
||||
|
@ -234,9 +201,7 @@ export function PostCtrls(opts: PostCtrlsOpts) {
|
|||
accessibilityRole="button"
|
||||
accessibilityLabel={opts.isLiked ? 'Unlike' : 'Like'}
|
||||
accessibilityHint={
|
||||
opts.isReposted
|
||||
? `Removes like from ${opts.author}'s post`
|
||||
: `Like ${opts.author}'s post`
|
||||
opts.isReposted ? `Removes like from the post` : `Like the post`
|
||||
}>
|
||||
{opts.isLiked ? (
|
||||
<HeartIconSolid
|
||||
|
@ -309,9 +274,6 @@ const styles = StyleSheet.create({
|
|||
padding: 5,
|
||||
margin: -5,
|
||||
},
|
||||
ctrlIconReposted: {
|
||||
color: colors.green3,
|
||||
},
|
||||
ctrlIconLiked: {
|
||||
color: colors.red3,
|
||||
},
|
95
src/view/com/util/post-ctrls/RepostButton.tsx
Normal file
95
src/view/com/util/post-ctrls/RepostButton.tsx
Normal file
|
@ -0,0 +1,95 @@
|
|||
import React, {useCallback} from 'react'
|
||||
import {StyleProp, StyleSheet, TouchableOpacity, ViewStyle} from 'react-native'
|
||||
import {RepostIcon} from 'lib/icons'
|
||||
import {s, colors} from 'lib/styles'
|
||||
import {useTheme} from 'lib/ThemeContext'
|
||||
import {Text} from '../text/Text'
|
||||
import {useStores} from 'state/index'
|
||||
|
||||
const HITSLOP = {top: 5, left: 5, bottom: 5, right: 5}
|
||||
|
||||
interface Props {
|
||||
isReposted: boolean
|
||||
repostCount?: number
|
||||
big?: boolean
|
||||
onRepost: () => void
|
||||
onQuote: () => void
|
||||
}
|
||||
|
||||
export const RepostButton = ({
|
||||
isReposted,
|
||||
repostCount,
|
||||
big,
|
||||
onRepost,
|
||||
onQuote,
|
||||
}: Props) => {
|
||||
const store = useStores()
|
||||
const theme = useTheme()
|
||||
|
||||
const defaultControlColor = React.useMemo(
|
||||
() => ({
|
||||
color: theme.palette.default.postCtrl,
|
||||
}),
|
||||
[theme],
|
||||
)
|
||||
|
||||
const onPressToggleRepostWrapper = useCallback(() => {
|
||||
store.shell.openModal({
|
||||
name: 'repost',
|
||||
onRepost: onRepost,
|
||||
onQuote: onQuote,
|
||||
isReposted,
|
||||
})
|
||||
}, [onRepost, onQuote, isReposted, store.shell])
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
testID="repostBtn"
|
||||
hitSlop={HITSLOP}
|
||||
onPress={onPressToggleRepostWrapper}
|
||||
style={styles.control}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={isReposted ? 'Undo repost' : 'Repost'}
|
||||
accessibilityHint={
|
||||
isReposted
|
||||
? `Remove your repost of the post`
|
||||
: `Repost or quote post the post`
|
||||
}>
|
||||
<RepostIcon
|
||||
style={
|
||||
isReposted
|
||||
? (styles.reposted as StyleProp<ViewStyle>)
|
||||
: defaultControlColor
|
||||
}
|
||||
strokeWidth={2.4}
|
||||
size={big ? 24 : 20}
|
||||
/>
|
||||
{typeof repostCount !== 'undefined' ? (
|
||||
<Text
|
||||
testID="repostCount"
|
||||
style={
|
||||
isReposted
|
||||
? [s.bold, s.green3, s.f15, s.ml5]
|
||||
: [defaultControlColor, s.f15, s.ml5]
|
||||
}>
|
||||
{repostCount}
|
||||
</Text>
|
||||
) : undefined}
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
control: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 5,
|
||||
margin: -5,
|
||||
},
|
||||
reposted: {
|
||||
color: colors.green3,
|
||||
},
|
||||
repostCount: {
|
||||
color: 'currentColor',
|
||||
},
|
||||
})
|
86
src/view/com/util/post-ctrls/RepostButton.web.tsx
Normal file
86
src/view/com/util/post-ctrls/RepostButton.web.tsx
Normal file
|
@ -0,0 +1,86 @@
|
|||
import React, {useMemo} from 'react'
|
||||
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
|
||||
import {RepostIcon} from 'lib/icons'
|
||||
import {DropdownButton} from '../forms/DropdownButton'
|
||||
import {colors} from 'lib/styles'
|
||||
import {useTheme} from 'lib/ThemeContext'
|
||||
import {Text} from '../text/Text'
|
||||
|
||||
interface Props {
|
||||
isReposted: boolean
|
||||
repostCount?: number
|
||||
big?: boolean
|
||||
onRepost: () => void
|
||||
onQuote: () => void
|
||||
}
|
||||
|
||||
export const RepostButton = ({
|
||||
isReposted,
|
||||
repostCount,
|
||||
big,
|
||||
onRepost,
|
||||
onQuote,
|
||||
}: Props) => {
|
||||
const theme = useTheme()
|
||||
|
||||
const defaultControlColor = React.useMemo(
|
||||
() => ({
|
||||
color: theme.palette.default.postCtrl,
|
||||
}),
|
||||
[theme],
|
||||
)
|
||||
|
||||
const items = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: isReposted ? 'Undo repost' : 'Repost',
|
||||
icon: 'retweet' as const,
|
||||
onPress: onRepost,
|
||||
},
|
||||
{label: 'Quote post', icon: 'quote-left' as const, onPress: onQuote},
|
||||
],
|
||||
[isReposted, onRepost, onQuote],
|
||||
)
|
||||
|
||||
return (
|
||||
<DropdownButton
|
||||
type="bare"
|
||||
items={items}
|
||||
bottomOffset={4}
|
||||
openToRight
|
||||
rightOffset={-40}>
|
||||
<View
|
||||
style={[
|
||||
styles.control,
|
||||
(isReposted
|
||||
? styles.reposted
|
||||
: defaultControlColor) as StyleProp<ViewStyle>,
|
||||
]}>
|
||||
<RepostIcon strokeWidth={2.4} size={big ? 24 : 20} />
|
||||
{typeof repostCount !== 'undefined' ? (
|
||||
<Text
|
||||
testID="repostCount"
|
||||
type={isReposted ? 'md-bold' : 'md-medium'}
|
||||
style={styles.repostCount}>
|
||||
{repostCount ?? 0}
|
||||
</Text>
|
||||
) : undefined}
|
||||
</View>
|
||||
</DropdownButton>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
control: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
reposted: {
|
||||
color: colors.green3,
|
||||
},
|
||||
repostCount: {
|
||||
color: 'currentColor',
|
||||
},
|
||||
})
|
|
@ -210,6 +210,5 @@ const styles = StyleSheet.create({
|
|||
position: 'absolute',
|
||||
left: 6,
|
||||
bottom: 6,
|
||||
width: 46,
|
||||
},
|
||||
})
|
||||
|
|
|
@ -39,6 +39,8 @@ import {faEye} from '@fortawesome/free-solid-svg-icons/faEye'
|
|||
import {faEyeSlash as farEyeSlash} from '@fortawesome/free-regular-svg-icons/faEyeSlash'
|
||||
import {faGear} from '@fortawesome/free-solid-svg-icons/faGear'
|
||||
import {faGlobe} from '@fortawesome/free-solid-svg-icons/faGlobe'
|
||||
import {faHand} from '@fortawesome/free-solid-svg-icons/faHand'
|
||||
import {faHand as farHand} from '@fortawesome/free-regular-svg-icons/faHand'
|
||||
import {faHeart} from '@fortawesome/free-regular-svg-icons/faHeart'
|
||||
import {faHeart as fasHeart} from '@fortawesome/free-solid-svg-icons/faHeart'
|
||||
import {faHouse} from '@fortawesome/free-solid-svg-icons/faHouse'
|
||||
|
@ -47,6 +49,7 @@ import {faImage} from '@fortawesome/free-solid-svg-icons/faImage'
|
|||
import {faInfo} from '@fortawesome/free-solid-svg-icons/faInfo'
|
||||
import {faLanguage} from '@fortawesome/free-solid-svg-icons/faLanguage'
|
||||
import {faLink} from '@fortawesome/free-solid-svg-icons/faLink'
|
||||
import {faListUl} from '@fortawesome/free-solid-svg-icons/faListUl'
|
||||
import {faLock} from '@fortawesome/free-solid-svg-icons/faLock'
|
||||
import {faMagnifyingGlass} from '@fortawesome/free-solid-svg-icons/faMagnifyingGlass'
|
||||
import {faMessage} from '@fortawesome/free-regular-svg-icons/faMessage'
|
||||
|
@ -68,8 +71,10 @@ import {faRss} from '@fortawesome/free-solid-svg-icons/faRss'
|
|||
import {faUser} from '@fortawesome/free-regular-svg-icons/faUser'
|
||||
import {faUsers} from '@fortawesome/free-solid-svg-icons/faUsers'
|
||||
import {faUserCheck} from '@fortawesome/free-solid-svg-icons/faUserCheck'
|
||||
import {faUserSlash} from '@fortawesome/free-solid-svg-icons/faUserSlash'
|
||||
import {faUserPlus} from '@fortawesome/free-solid-svg-icons/faUserPlus'
|
||||
import {faUserXmark} from '@fortawesome/free-solid-svg-icons/faUserXmark'
|
||||
import {faUsersSlash} from '@fortawesome/free-solid-svg-icons/faUsersSlash'
|
||||
import {faTicket} from '@fortawesome/free-solid-svg-icons/faTicket'
|
||||
import {faTrashCan} from '@fortawesome/free-regular-svg-icons/faTrashCan'
|
||||
import {faX} from '@fortawesome/free-solid-svg-icons/faX'
|
||||
|
@ -119,6 +124,8 @@ export function setup() {
|
|||
farEyeSlash,
|
||||
faGear,
|
||||
faGlobe,
|
||||
faHand,
|
||||
farHand,
|
||||
faHeart,
|
||||
fasHeart,
|
||||
faHouse,
|
||||
|
@ -127,6 +134,7 @@ export function setup() {
|
|||
faInfo,
|
||||
faLanguage,
|
||||
faLink,
|
||||
faListUl,
|
||||
faLock,
|
||||
faMagnifyingGlass,
|
||||
faMessage,
|
||||
|
@ -148,8 +156,10 @@ export function setup() {
|
|||
faUser,
|
||||
faUsers,
|
||||
faUserCheck,
|
||||
faUserSlash,
|
||||
faUserPlus,
|
||||
faUserXmark,
|
||||
faUsersSlash,
|
||||
faTicket,
|
||||
faTrashCan,
|
||||
faThumbtack,
|
||||
|
|
|
@ -140,8 +140,8 @@ function AppPasswordsHeader() {
|
|||
pal.text,
|
||||
isDesktopWeb && styles.descriptionDesktop,
|
||||
]}>
|
||||
These passwords can be used to log onto Bluesky in other apps without
|
||||
giving them full access to your account or your password.
|
||||
Use app passwords to login to other Bluesky clients without giving full
|
||||
access to your account or password.
|
||||
</Text>
|
||||
</>
|
||||
)
|
||||
|
@ -289,5 +289,6 @@ const styles = StyleSheet.create({
|
|||
|
||||
trashIcon: {
|
||||
color: 'red',
|
||||
minWidth: 16,
|
||||
},
|
||||
})
|
||||
|
|
|
@ -62,7 +62,7 @@ export const HomeScreen = withAuthRequired(
|
|||
setSelectedPage(index)
|
||||
store.shell.setIsDrawerSwipeDisabled(index > 0)
|
||||
},
|
||||
[store],
|
||||
[store, setSelectedPage],
|
||||
)
|
||||
|
||||
const onPressSelected = React.useCallback(() => {
|
||||
|
|
137
src/view/screens/Moderation.tsx
Normal file
137
src/view/screens/Moderation.tsx
Normal file
|
@ -0,0 +1,137 @@
|
|||
import React from 'react'
|
||||
import {StyleSheet, TouchableOpacity, View} from 'react-native'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
|
||||
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
|
||||
import {useStores} from 'state/index'
|
||||
import {s} from 'lib/styles'
|
||||
import {CenteredView} from '../com/util/Views'
|
||||
import {ViewHeader} from '../com/util/ViewHeader'
|
||||
import {Link} from '../com/util/Link'
|
||||
import {Text} from '../com/util/text/Text'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useAnalytics} from 'lib/analytics'
|
||||
import {isDesktopWeb} from 'platform/detection'
|
||||
|
||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Moderation'>
|
||||
export const ModerationScreen = withAuthRequired(
|
||||
observer(function Moderation({}: Props) {
|
||||
const pal = usePalette('default')
|
||||
const store = useStores()
|
||||
const {screen, track} = useAnalytics()
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
screen('Moderation')
|
||||
store.shell.setMinimalShellMode(false)
|
||||
}, [screen, store]),
|
||||
)
|
||||
|
||||
const onPressContentFiltering = React.useCallback(() => {
|
||||
track('Moderation:ContentfilteringButtonClicked')
|
||||
store.shell.openModal({name: 'content-filtering-settings'})
|
||||
}, [track, store])
|
||||
|
||||
return (
|
||||
<CenteredView
|
||||
style={[
|
||||
s.hContentRegion,
|
||||
pal.border,
|
||||
isDesktopWeb ? styles.desktopContainer : pal.viewLight,
|
||||
]}
|
||||
testID="moderationScreen">
|
||||
<ViewHeader title="Moderation" showOnDesktop />
|
||||
<View style={styles.spacer} />
|
||||
<TouchableOpacity
|
||||
testID="contentFilteringBtn"
|
||||
style={[styles.linkCard, pal.view]}
|
||||
onPress={onPressContentFiltering}
|
||||
accessibilityRole="tab"
|
||||
accessibilityHint="Content filtering"
|
||||
accessibilityLabel="">
|
||||
<View style={[styles.iconContainer, pal.btn]}>
|
||||
<FontAwesomeIcon
|
||||
icon="eye"
|
||||
style={pal.text as FontAwesomeIconStyle}
|
||||
/>
|
||||
</View>
|
||||
<Text type="lg" style={pal.text}>
|
||||
Content filtering
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<Link
|
||||
testID="mutelistsBtn"
|
||||
style={[styles.linkCard, pal.view]}
|
||||
href="/moderation/mute-lists">
|
||||
<View style={[styles.iconContainer, pal.btn]}>
|
||||
<FontAwesomeIcon
|
||||
icon="users-slash"
|
||||
style={pal.text as FontAwesomeIconStyle}
|
||||
/>
|
||||
</View>
|
||||
<Text type="lg" style={pal.text}>
|
||||
Mute lists
|
||||
</Text>
|
||||
</Link>
|
||||
<Link
|
||||
testID="mutedAccountsBtn"
|
||||
style={[styles.linkCard, pal.view]}
|
||||
href="/moderation/muted-accounts">
|
||||
<View style={[styles.iconContainer, pal.btn]}>
|
||||
<FontAwesomeIcon
|
||||
icon="user-slash"
|
||||
style={pal.text as FontAwesomeIconStyle}
|
||||
/>
|
||||
</View>
|
||||
<Text type="lg" style={pal.text}>
|
||||
Muted accounts
|
||||
</Text>
|
||||
</Link>
|
||||
<Link
|
||||
testID="blockedAccountsBtn"
|
||||
style={[styles.linkCard, pal.view]}
|
||||
href="/moderation/blocked-accounts">
|
||||
<View style={[styles.iconContainer, pal.btn]}>
|
||||
<FontAwesomeIcon
|
||||
icon="ban"
|
||||
style={pal.text as FontAwesomeIconStyle}
|
||||
/>
|
||||
</View>
|
||||
<Text type="lg" style={pal.text}>
|
||||
Blocked accounts
|
||||
</Text>
|
||||
</Link>
|
||||
</CenteredView>
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
desktopContainer: {
|
||||
borderLeftWidth: 1,
|
||||
borderRightWidth: 1,
|
||||
},
|
||||
spacer: {
|
||||
height: 6,
|
||||
},
|
||||
linkCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 18,
|
||||
marginBottom: 1,
|
||||
},
|
||||
iconContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 30,
|
||||
marginRight: 12,
|
||||
},
|
||||
})
|
|
@ -22,8 +22,11 @@ import {ViewHeader} from '../com/util/ViewHeader'
|
|||
import {CenteredView} from 'view/com/util/Views'
|
||||
import {ProfileCard} from 'view/com/profile/ProfileCard'
|
||||
|
||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'BlockedAccounts'>
|
||||
export const BlockedAccounts = withAuthRequired(
|
||||
type Props = NativeStackScreenProps<
|
||||
CommonNavigatorParams,
|
||||
'ModerationBlockedAccounts'
|
||||
>
|
||||
export const ModerationBlockedAccounts = withAuthRequired(
|
||||
observer(({}: Props) => {
|
||||
const pal = usePalette('default')
|
||||
const store = useStores()
|
122
src/view/screens/ModerationMuteLists.tsx
Normal file
122
src/view/screens/ModerationMuteLists.tsx
Normal file
|
@ -0,0 +1,122 @@
|
|||
import React from 'react'
|
||||
import {StyleSheet} from 'react-native'
|
||||
import {useFocusEffect, useNavigation} from '@react-navigation/native'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {AtUri} from '@atproto/api'
|
||||
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
|
||||
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
|
||||
import {EmptyStateWithButton} from 'view/com/util/EmptyStateWithButton'
|
||||
import {useStores} from 'state/index'
|
||||
import {ListsListModel} from 'state/models/lists/lists-list'
|
||||
import {ListsList} from 'view/com/lists/ListsList'
|
||||
import {Button} from 'view/com/util/forms/Button'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {CenteredView} from 'view/com/util/Views'
|
||||
import {ViewHeader} from 'view/com/util/ViewHeader'
|
||||
import {isDesktopWeb} from 'platform/detection'
|
||||
|
||||
type Props = NativeStackScreenProps<
|
||||
CommonNavigatorParams,
|
||||
'ModerationMuteLists'
|
||||
>
|
||||
export const ModerationMuteListsScreen = withAuthRequired(({}: Props) => {
|
||||
const pal = usePalette('default')
|
||||
const store = useStores()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
|
||||
const mutelists: ListsListModel = React.useMemo(
|
||||
() => new ListsListModel(store, 'my-modlists'),
|
||||
[store],
|
||||
)
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
store.shell.setMinimalShellMode(false)
|
||||
mutelists.refresh()
|
||||
}, [store, mutelists]),
|
||||
)
|
||||
|
||||
const onPressNewMuteList = React.useCallback(() => {
|
||||
store.shell.openModal({
|
||||
name: 'create-or-edit-mute-list',
|
||||
onSave: (uri: string) => {
|
||||
try {
|
||||
const urip = new AtUri(uri)
|
||||
navigation.navigate('ProfileList', {
|
||||
name: urip.hostname,
|
||||
rkey: urip.rkey,
|
||||
})
|
||||
} catch {}
|
||||
},
|
||||
})
|
||||
}, [store, navigation])
|
||||
|
||||
const renderEmptyState = React.useCallback(() => {
|
||||
return (
|
||||
<EmptyStateWithButton
|
||||
testID="emptyMuteLists"
|
||||
icon="users-slash"
|
||||
message="You can subscribe to mute lists to automatically mute all of the users they include. Mute lists are public but your subscription to a mute list is private."
|
||||
buttonLabel="New Mute List"
|
||||
onPress={onPressNewMuteList}
|
||||
/>
|
||||
)
|
||||
}, [onPressNewMuteList])
|
||||
|
||||
const renderHeaderButton = React.useCallback(
|
||||
() => (
|
||||
<Button
|
||||
type="primary-light"
|
||||
onPress={onPressNewMuteList}
|
||||
style={styles.createBtn}>
|
||||
<FontAwesomeIcon
|
||||
icon="plus"
|
||||
style={pal.link as FontAwesomeIconStyle}
|
||||
size={18}
|
||||
/>
|
||||
</Button>
|
||||
),
|
||||
[onPressNewMuteList, pal],
|
||||
)
|
||||
|
||||
return (
|
||||
<CenteredView
|
||||
style={[
|
||||
styles.container,
|
||||
isDesktopWeb && styles.containerDesktop,
|
||||
pal.view,
|
||||
pal.border,
|
||||
]}
|
||||
testID="moderationMutelistsScreen">
|
||||
<ViewHeader
|
||||
title="Mute Lists"
|
||||
showOnDesktop
|
||||
renderButton={renderHeaderButton}
|
||||
/>
|
||||
<ListsList
|
||||
listsList={mutelists}
|
||||
showAddBtns={isDesktopWeb}
|
||||
renderEmptyState={renderEmptyState}
|
||||
onPressCreateNew={onPressNewMuteList}
|
||||
/>
|
||||
</CenteredView>
|
||||
)
|
||||
})
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
paddingBottom: isDesktopWeb ? 0 : 100,
|
||||
},
|
||||
containerDesktop: {
|
||||
borderLeftWidth: 1,
|
||||
borderRightWidth: 1,
|
||||
},
|
||||
createBtn: {
|
||||
width: 40,
|
||||
},
|
||||
})
|
|
@ -22,8 +22,11 @@ import {ViewHeader} from '../com/util/ViewHeader'
|
|||
import {CenteredView} from 'view/com/util/Views'
|
||||
import {ProfileCard} from 'view/com/profile/ProfileCard'
|
||||
|
||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'MutedAccounts'>
|
||||
export const MutedAccounts = withAuthRequired(
|
||||
type Props = NativeStackScreenProps<
|
||||
CommonNavigatorParams,
|
||||
'ModerationMutedAccounts'
|
||||
>
|
||||
export const ModerationMutedAccounts = withAuthRequired(
|
||||
observer(({}: Props) => {
|
||||
const pal = usePalette('default')
|
||||
const store = useStores()
|
|
@ -7,12 +7,16 @@ import {withAuthRequired} from 'view/com/auth/withAuthRequired'
|
|||
import {ViewSelector} from '../com/util/ViewSelector'
|
||||
import {CenteredView} from '../com/util/Views'
|
||||
import {ScreenHider} from 'view/com/util/moderation/ScreenHider'
|
||||
import {ProfileUiModel} from 'state/models/ui/profile'
|
||||
import {ProfileUiModel, Sections} from 'state/models/ui/profile'
|
||||
import {useStores} from 'state/index'
|
||||
import {PostsFeedSliceModel} from 'state/models/feeds/posts'
|
||||
import {ProfileHeader} from '../com/profile/ProfileHeader'
|
||||
import {FeedSlice} from '../com/posts/FeedSlice'
|
||||
import {PostFeedLoadingPlaceholder} from '../com/util/LoadingPlaceholder'
|
||||
import {ListCard} from 'view/com/lists/ListCard'
|
||||
import {
|
||||
PostFeedLoadingPlaceholder,
|
||||
ProfileCardFeedLoadingPlaceholder,
|
||||
} from '../com/util/LoadingPlaceholder'
|
||||
import {ErrorScreen} from '../com/util/error/ErrorScreen'
|
||||
import {ErrorMessage} from '../com/util/error/ErrorMessage'
|
||||
import {EmptyState} from '../com/util/EmptyState'
|
||||
|
@ -23,6 +27,8 @@ import {useAnalytics} from 'lib/analytics'
|
|||
import {ComposeIcon2} from 'lib/icons'
|
||||
import AlgoItem from 'view/com/algos/AlgoItem'
|
||||
import {AlgoItemModel} from 'state/models/feeds/algo/algo-item'
|
||||
import {useSetTitle} from 'lib/hooks/useSetTitle'
|
||||
import {combinedDisplayName} from 'lib/strings/display-names'
|
||||
|
||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'>
|
||||
export const ProfileScreen = withAuthRequired(
|
||||
|
@ -39,6 +45,7 @@ export const ProfileScreen = withAuthRequired(
|
|||
() => new ProfileUiModel(store, {user: route.params.name}),
|
||||
[route.params.name, store],
|
||||
)
|
||||
useSetTitle(combinedDisplayName(uiState.profile))
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
|
@ -113,53 +120,87 @@ export const ProfileScreen = withAuthRequired(
|
|||
}, [uiState.showLoadingMoreFooter])
|
||||
const renderItem = React.useCallback(
|
||||
(item: any) => {
|
||||
if (item === ProfileUiModel.END_ITEM) {
|
||||
return <Text style={styles.endItem}>- end of feed -</Text>
|
||||
} else if (item === ProfileUiModel.LOADING_ITEM) {
|
||||
return <PostFeedLoadingPlaceholder />
|
||||
} else if (item._reactKey === '__error__') {
|
||||
if (uiState.feed.isBlocking) {
|
||||
if (uiState.selectedView === Sections.Lists) {
|
||||
if (item === ProfileUiModel.LOADING_ITEM) {
|
||||
return <ProfileCardFeedLoadingPlaceholder />
|
||||
} else if (item._reactKey === '__error__') {
|
||||
return (
|
||||
<View style={s.p5}>
|
||||
<ErrorMessage
|
||||
message={item.error}
|
||||
onPressTryAgain={onPressTryAgain}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
} else if (item === ProfileUiModel.EMPTY_ITEM) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon="ban"
|
||||
message="Posts hidden"
|
||||
testID="listsEmpty"
|
||||
icon="list-ul"
|
||||
message="No lists yet!"
|
||||
style={styles.emptyState}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
return <ListCard testID={`list-${item.name}`} list={item} />
|
||||
}
|
||||
if (uiState.feed.isBlockedBy) {
|
||||
} else {
|
||||
if (item === ProfileUiModel.END_ITEM) {
|
||||
return <Text style={styles.endItem}>- end of feed -</Text>
|
||||
} else if (item === ProfileUiModel.LOADING_ITEM) {
|
||||
return <PostFeedLoadingPlaceholder />
|
||||
} else if (item._reactKey === '__error__') {
|
||||
if (uiState.feed.isBlocking) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon="ban"
|
||||
message="Posts hidden"
|
||||
style={styles.emptyState}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (uiState.feed.isBlockedBy) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon="ban"
|
||||
message="Posts hidden"
|
||||
style={styles.emptyState}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<View style={s.p5}>
|
||||
<ErrorMessage
|
||||
message={item.error}
|
||||
onPressTryAgain={onPressTryAgain}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
} else if (item === ProfileUiModel.EMPTY_ITEM) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon="ban"
|
||||
message="Posts hidden"
|
||||
icon={['far', 'message']}
|
||||
message="No posts yet!"
|
||||
style={styles.emptyState}
|
||||
/>
|
||||
)
|
||||
} else if (item instanceof PostsFeedSliceModel) {
|
||||
return (
|
||||
<FeedSlice slice={item} ignoreMuteFor={uiState.profile.did} />
|
||||
)
|
||||
} else if (item instanceof AlgoItemModel) {
|
||||
return <AlgoItem item={item} />
|
||||
}
|
||||
return (
|
||||
<View style={s.p5}>
|
||||
<ErrorMessage
|
||||
message={item.error}
|
||||
onPressTryAgain={onPressTryAgain}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
} else if (item === ProfileUiModel.EMPTY_ITEM) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={['far', 'message']}
|
||||
message="No posts yet!"
|
||||
style={styles.emptyState}
|
||||
/>
|
||||
)
|
||||
} else if (item instanceof PostsFeedSliceModel) {
|
||||
return <FeedSlice slice={item} ignoreMuteFor={uiState.profile.did} />
|
||||
} else if (item instanceof AlgoItemModel) {
|
||||
return <AlgoItem item={item} />
|
||||
}
|
||||
return <View />
|
||||
},
|
||||
[onPressTryAgain, uiState],
|
||||
[
|
||||
onPressTryAgain,
|
||||
uiState.selectedView,
|
||||
uiState.profile.did,
|
||||
uiState.feed.isBlocking,
|
||||
uiState.feed.isBlockedBy,
|
||||
],
|
||||
)
|
||||
|
||||
return (
|
||||
|
|
177
src/view/screens/ProfileList.tsx
Normal file
177
src/view/screens/ProfileList.tsx
Normal file
|
@ -0,0 +1,177 @@
|
|||
import React from 'react'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
|
||||
import {ViewHeader} from 'view/com/util/ViewHeader'
|
||||
import {CenteredView} from 'view/com/util/Views'
|
||||
import {ListItems} from 'view/com/lists/ListItems'
|
||||
import {EmptyState} from 'view/com/util/EmptyState'
|
||||
import {Button} from 'view/com/util/forms/Button'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
import {ListModel} from 'state/models/content/list'
|
||||
import {useStores} from 'state/index'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useSetTitle} from 'lib/hooks/useSetTitle'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {isDesktopWeb} from 'platform/detection'
|
||||
|
||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileList'>
|
||||
export const ProfileListScreen = withAuthRequired(
|
||||
observer(({route}: Props) => {
|
||||
const store = useStores()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const pal = usePalette('default')
|
||||
const {name, rkey} = route.params
|
||||
|
||||
const list: ListModel = React.useMemo(() => {
|
||||
const model = new ListModel(
|
||||
store,
|
||||
`at://${name}/app.bsky.graph.list/${rkey}`,
|
||||
)
|
||||
return model
|
||||
}, [store, name, rkey])
|
||||
useSetTitle(list.list?.name)
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
store.shell.setMinimalShellMode(false)
|
||||
list.loadMore(true)
|
||||
}, [store, list]),
|
||||
)
|
||||
|
||||
const onToggleSubscribed = React.useCallback(async () => {
|
||||
try {
|
||||
if (list.list?.viewer?.muted) {
|
||||
await list.unsubscribe()
|
||||
} else {
|
||||
await list.subscribe()
|
||||
}
|
||||
} catch (err) {
|
||||
Toast.show(
|
||||
'There was an an issue updating your subscription, please check your internet connection and try again.',
|
||||
)
|
||||
store.log.error('Failed up update subscription', {err})
|
||||
}
|
||||
}, [store, list])
|
||||
|
||||
const onPressEditList = React.useCallback(() => {
|
||||
store.shell.openModal({
|
||||
name: 'create-or-edit-mute-list',
|
||||
list,
|
||||
onSave() {
|
||||
list.refresh()
|
||||
},
|
||||
})
|
||||
}, [store, list])
|
||||
|
||||
const onPressDeleteList = React.useCallback(() => {
|
||||
store.shell.openModal({
|
||||
name: 'confirm',
|
||||
title: 'Delete List',
|
||||
message: 'Are you sure?',
|
||||
async onPressConfirm() {
|
||||
await list.delete()
|
||||
if (navigation.canGoBack()) {
|
||||
navigation.goBack()
|
||||
} else {
|
||||
navigation.navigate('Home')
|
||||
}
|
||||
},
|
||||
})
|
||||
}, [store, list, navigation])
|
||||
|
||||
const renderEmptyState = React.useCallback(() => {
|
||||
return <EmptyState icon="users-slash" message="This list is empty!" />
|
||||
}, [])
|
||||
|
||||
const renderHeaderBtn = React.useCallback(() => {
|
||||
return (
|
||||
<View style={styles.headerBtns}>
|
||||
{list?.isOwner && (
|
||||
<Button
|
||||
type="default"
|
||||
label="Delete List"
|
||||
testID="deleteListBtn"
|
||||
accessibilityLabel="Delete list"
|
||||
accessibilityHint=""
|
||||
onPress={onPressDeleteList}
|
||||
/>
|
||||
)}
|
||||
{list?.isOwner && (
|
||||
<Button
|
||||
type="default"
|
||||
label="Edit List"
|
||||
testID="editListBtn"
|
||||
accessibilityLabel="Edit list"
|
||||
accessibilityHint=""
|
||||
onPress={onPressEditList}
|
||||
/>
|
||||
)}
|
||||
{list.list?.viewer?.muted ? (
|
||||
<Button
|
||||
type="inverted"
|
||||
label="Unsubscribe"
|
||||
testID="unsubscribeListBtn"
|
||||
accessibilityLabel="Unsubscribe from list"
|
||||
accessibilityHint=""
|
||||
onPress={onToggleSubscribed}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
label="Subscribe & Mute"
|
||||
testID="subscribeListBtn"
|
||||
accessibilityLabel="Subscribe to this list"
|
||||
accessibilityHint="Mutes the users included in this list"
|
||||
onPress={onToggleSubscribed}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}, [
|
||||
list?.isOwner,
|
||||
list.list?.viewer?.muted,
|
||||
onPressDeleteList,
|
||||
onPressEditList,
|
||||
onToggleSubscribed,
|
||||
])
|
||||
|
||||
return (
|
||||
<CenteredView
|
||||
style={[
|
||||
styles.container,
|
||||
isDesktopWeb && styles.containerDesktop,
|
||||
pal.view,
|
||||
pal.border,
|
||||
]}
|
||||
testID="moderationMutelistsScreen">
|
||||
<ViewHeader title="" renderButton={renderHeaderBtn} />
|
||||
<ListItems
|
||||
list={list}
|
||||
renderEmptyState={renderEmptyState}
|
||||
onToggleSubscribed={onToggleSubscribed}
|
||||
onPressEditList={onPressEditList}
|
||||
onPressDeleteList={onPressDeleteList}
|
||||
/>
|
||||
</CenteredView>
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
headerBtns: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
paddingBottom: isDesktopWeb ? 0 : 100,
|
||||
},
|
||||
containerDesktop: {
|
||||
borderLeftWidth: 1,
|
||||
borderRightWidth: 1,
|
||||
},
|
||||
})
|
|
@ -121,7 +121,7 @@ export const SearchScreen = withAuthRequired(
|
|||
<TouchableWithoutFeedback onPress={onPress} accessible={false}>
|
||||
<View style={[pal.view, styles.container]}>
|
||||
<HeaderWithInput
|
||||
isInputFocused={true}
|
||||
isInputFocused={isInputFocused}
|
||||
query={query}
|
||||
setIsInputFocused={setIsInputFocused}
|
||||
onChangeQuery={onChangeQuery}
|
||||
|
|
|
@ -127,11 +127,6 @@ export const SettingsScreen = withAuthRequired(
|
|||
store.shell.openModal({name: 'invite-codes'})
|
||||
}, [track, store])
|
||||
|
||||
const onPressContentFiltering = React.useCallback(() => {
|
||||
track('Settings:ContentfilteringButtonClicked')
|
||||
store.shell.openModal({name: 'content-filtering-settings'})
|
||||
}, [track, store])
|
||||
|
||||
const onPressContentLanguages = React.useCallback(() => {
|
||||
track('Settings:ContentlanguagesButtonClicked')
|
||||
store.shell.openModal({name: 'content-languages-settings'})
|
||||
|
@ -252,7 +247,9 @@ export const SettingsScreen = withAuthRequired(
|
|||
Add account
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.spacer20} />
|
||||
|
||||
<Text type="xl-bold" style={[pal.text, styles.heading]}>
|
||||
Invite a friend
|
||||
</Text>
|
||||
|
@ -287,9 +284,6 @@ export const SettingsScreen = withAuthRequired(
|
|||
|
||||
<View style={styles.spacer20} />
|
||||
|
||||
<Text type="xl-bold" style={[pal.text, styles.heading]}>
|
||||
Moderation
|
||||
</Text>
|
||||
<Link
|
||||
testID="bookmarkedAlgosBtn"
|
||||
style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
|
||||
|
@ -306,51 +300,7 @@ export const SettingsScreen = withAuthRequired(
|
|||
Custom Algorithms
|
||||
</Text>
|
||||
</Link>
|
||||
<TouchableOpacity
|
||||
testID="contentFilteringBtn"
|
||||
style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
|
||||
onPress={isSwitching ? undefined : onPressContentFiltering}
|
||||
accessibilityHint="Content moderation"
|
||||
accessibilityLabel="Opens configurable content moderation settings">
|
||||
<View style={[styles.iconContainer, pal.btn]}>
|
||||
<FontAwesomeIcon
|
||||
icon="eye"
|
||||
style={pal.text as FontAwesomeIconStyle}
|
||||
/>
|
||||
</View>
|
||||
<Text type="lg" style={pal.text}>
|
||||
Content moderation
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<Link
|
||||
testID="mutedAccountsBtn"
|
||||
style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
|
||||
href="/settings/muted-accounts">
|
||||
<View style={[styles.iconContainer, pal.btn]}>
|
||||
<FontAwesomeIcon
|
||||
icon={['far', 'eye-slash']}
|
||||
style={pal.text as FontAwesomeIconStyle}
|
||||
/>
|
||||
</View>
|
||||
<Text type="lg" style={pal.text}>
|
||||
Muted accounts
|
||||
</Text>
|
||||
</Link>
|
||||
<Link
|
||||
testID="blockedAccountsBtn"
|
||||
style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
|
||||
href="/settings/blocked-accounts">
|
||||
<View style={[styles.iconContainer, pal.btn]}>
|
||||
<FontAwesomeIcon
|
||||
icon="ban"
|
||||
style={pal.text as FontAwesomeIconStyle}
|
||||
/>
|
||||
</View>
|
||||
<Text type="lg" style={pal.text}>
|
||||
Blocked accounts
|
||||
</Text>
|
||||
</Link>
|
||||
<View style={styles.spacer20} />
|
||||
|
||||
<Text type="xl-bold" style={[pal.text, styles.heading]}>
|
||||
Advanced
|
||||
</Text>
|
||||
|
@ -398,8 +348,8 @@ export const SettingsScreen = withAuthRequired(
|
|||
style={pal.text as FontAwesomeIconStyle}
|
||||
/>
|
||||
</View>
|
||||
<Text type="lg" style={pal.text}>
|
||||
Change my handle
|
||||
<Text type="lg" style={pal.text} numberOfLines={1}>
|
||||
Change handle
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.spacer20} />
|
||||
|
|
|
@ -28,6 +28,7 @@ import {
|
|||
MagnifyingGlassIcon2Solid,
|
||||
MoonIcon,
|
||||
UserIconSolid,
|
||||
HandIcon,
|
||||
} from 'lib/icons'
|
||||
import {UserAvatar} from 'view/com/util/UserAvatar'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
|
@ -94,6 +95,12 @@ export const DrawerContent = observer(() => {
|
|||
onPressTab('MyProfile')
|
||||
}, [onPressTab])
|
||||
|
||||
const onPressModeration = React.useCallback(() => {
|
||||
track('Menu:ItemClicked', {url: 'Moderation'})
|
||||
navigation.navigate('Moderation')
|
||||
store.shell.closeDrawer()
|
||||
}, [navigation, track, store.shell])
|
||||
|
||||
const onPressSettings = React.useCallback(() => {
|
||||
track('Menu:ItemClicked', {url: 'Settings'})
|
||||
navigation.navigate('Settings')
|
||||
|
@ -215,11 +222,28 @@ export const DrawerContent = observer(() => {
|
|||
}
|
||||
label="Notifications"
|
||||
accessibilityLabel="Notifications"
|
||||
accessibilityHint={`${store.me.notifications.unreadCountLabel} unread`}
|
||||
accessibilityHint={
|
||||
notifications.unreadCountLabel === ''
|
||||
? ''
|
||||
: `${notifications.unreadCountLabel} unread`
|
||||
}
|
||||
count={notifications.unreadCountLabel}
|
||||
bold={isAtNotifications}
|
||||
onPress={onPressNotifications}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={
|
||||
<HandIcon
|
||||
strokeWidth={5}
|
||||
style={pal.text as FontAwesomeIconStyle}
|
||||
size={24}
|
||||
/>
|
||||
}
|
||||
label="Moderation"
|
||||
accessibilityLabel="Moderation"
|
||||
accessibilityHint=""
|
||||
onPress={onPressModeration}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={
|
||||
isAtMyProfile ? (
|
||||
|
@ -404,6 +428,7 @@ const styles = StyleSheet.create({
|
|||
flex: 1,
|
||||
paddingTop: 20,
|
||||
paddingBottom: 50,
|
||||
maxWidth: 300,
|
||||
},
|
||||
viewDarkMode: {
|
||||
backgroundColor: '#1B1919',
|
||||
|
|
|
@ -38,6 +38,7 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => {
|
|||
useNavigationTabState()
|
||||
|
||||
const {footerMinimalShellTransform} = useMinimalShellMode()
|
||||
const {notifications} = store.me
|
||||
|
||||
const onPressTab = React.useCallback(
|
||||
(tab: string) => {
|
||||
|
@ -138,11 +139,15 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => {
|
|||
)
|
||||
}
|
||||
onPress={onPressNotifications}
|
||||
notificationCount={store.me.notifications.unreadCountLabel}
|
||||
notificationCount={notifications.unreadCountLabel}
|
||||
accessible={true}
|
||||
accessibilityRole="tab"
|
||||
accessibilityLabel="Notifications"
|
||||
accessibilityHint={`${store.me.notifications.unreadCountLabel} unread`}
|
||||
accessibilityHint={
|
||||
notifications.unreadCountLabel === ''
|
||||
? ''
|
||||
: `${notifications.unreadCountLabel} unread`
|
||||
}
|
||||
/>
|
||||
<Btn
|
||||
testID="bottomBarProfileBtn"
|
||||
|
|
|
@ -29,6 +29,7 @@ import {
|
|||
CogIcon,
|
||||
CogIconSolid,
|
||||
ComposeIcon2,
|
||||
HandIcon,
|
||||
} from 'lib/icons'
|
||||
import {getCurrentRoute, isTab, isStateAtTabRoot} from 'lib/routes/helpers'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
|
@ -203,6 +204,24 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() {
|
|||
}
|
||||
label="Notifications"
|
||||
/>
|
||||
<NavItem
|
||||
href="/moderation"
|
||||
icon={
|
||||
<HandIcon
|
||||
strokeWidth={5.5}
|
||||
style={pal.text as FontAwesomeIconStyle}
|
||||
size={24}
|
||||
/>
|
||||
}
|
||||
iconFilled={
|
||||
<FontAwesomeIcon
|
||||
icon="hand"
|
||||
style={pal.text as FontAwesomeIconStyle}
|
||||
size={20}
|
||||
/>
|
||||
}
|
||||
label="Moderation"
|
||||
/>
|
||||
{store.session.hasSession && (
|
||||
<NavItem
|
||||
href={`/profile/${store.me.handle}`}
|
||||
|
@ -279,7 +298,7 @@ const styles = StyleSheet.create({
|
|||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 138,
|
||||
width: 140,
|
||||
borderRadius: 24,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 16,
|
||||
|
|
|
@ -11,13 +11,14 @@ import {s} from 'lib/styles'
|
|||
import {useStores} from 'state/index'
|
||||
import {pluralize} from 'lib/strings/helpers'
|
||||
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
|
||||
import {MoonIcon} from 'lib/icons'
|
||||
import {MoonIcon, SunIcon} from 'lib/icons'
|
||||
import {formatCount} from 'view/com/util/numeric/format'
|
||||
|
||||
export const DesktopRightNav = observer(function DesktopRightNav() {
|
||||
const store = useStores()
|
||||
const pal = usePalette('default')
|
||||
const mode = useColorSchemeStyle('Light', 'Dark')
|
||||
const otherMode = mode === 'Dark' ? 'Light' : 'Dark'
|
||||
|
||||
const onDarkmodePress = React.useCallback(() => {
|
||||
store.shell.setDarkMode(!store.shell.darkMode)
|
||||
|
@ -71,10 +72,14 @@ export const DesktopRightNav = observer(function DesktopRightNav() {
|
|||
: 'Sets display to dark mode'
|
||||
}>
|
||||
<View style={[pal.viewLight, styles.darkModeToggleIcon]}>
|
||||
<MoonIcon size={18} style={pal.textLight} />
|
||||
{mode === 'Dark' ? (
|
||||
<SunIcon size={18} style={pal.textLight} />
|
||||
) : (
|
||||
<MoonIcon size={18} style={pal.textLight} />
|
||||
)}
|
||||
</View>
|
||||
<Text type="sm" style={pal.textLight}>
|
||||
{mode} mode
|
||||
{otherMode} mode
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React from 'react'
|
||||
import React, {useEffect} from 'react'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {View, StyleSheet, TouchableOpacity} from 'react-native'
|
||||
import {useStores} from 'state/index'
|
||||
|
@ -14,11 +14,21 @@ import {RoutesContainer, FlatNavigator} from '../../Navigation'
|
|||
import {DrawerContent} from './Drawer'
|
||||
import {useWebMediaQueries} from '../../lib/hooks/useWebMediaQueries'
|
||||
import {BottomBarWeb} from './bottom-bar/BottomBarWeb'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
|
||||
const ShellInner = observer(() => {
|
||||
const store = useStores()
|
||||
const {isDesktop} = useWebMediaQueries()
|
||||
|
||||
const navigator = useNavigation<NavigationProp>()
|
||||
|
||||
useEffect(() => {
|
||||
navigator.addListener('state', () => {
|
||||
store.shell.closeAnyActiveElement()
|
||||
})
|
||||
}, [navigator, store.shell])
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={s.hContentRegion}>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue