Merge branch 'main' into custom-algos

This commit is contained in:
Paul Frazee 2023-05-17 12:30:54 -05:00
commit 7aa1d9010e
99 changed files with 4234 additions and 716 deletions

View file

@ -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,
},
})

View file

@ -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>
)
})
) : (

View file

@ -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

View 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,
},
})

View 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},
})

View 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},
})

View file

@ -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 ? (

View file

@ -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,
},
})

View 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},
})

View file

@ -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,
},
})

View file

@ -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)

View file

@ -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) => (

View 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,
},
})

View file

@ -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 />

View file

@ -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') {

View file

@ -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: (

View file

@ -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

View file

@ -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
// =

View file

@ -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'

View file

@ -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'

View file

@ -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}>

View file

@ -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

View file

@ -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 ? (

View file

@ -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},

View file

@ -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 {

View file

@ -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} />

View 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,
},
})

View file

@ -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)
},
},
{

View file

@ -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',

View file

@ -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>
)
}

View file

@ -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',
},

View file

@ -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}

View file

@ -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)) {

View file

@ -63,6 +63,5 @@ const styles = StyleSheet.create({
position: 'absolute',
left: 6,
bottom: 6,
width: 46,
},
})

View file

@ -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,
},

View 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',
},
})

View 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',
},
})

View file

@ -210,6 +210,5 @@ const styles = StyleSheet.create({
position: 'absolute',
left: 6,
bottom: 6,
width: 46,
},
})