Give explicit names to MobX observer components (#1413)
* Consider observer(...) as components * Add display names to MobX observers * Temporarily suppress nested components * Suppress new false positives for react/prop-typeszio/stable
parent
69209c988f
commit
8a93321fb1
|
@ -26,4 +26,7 @@ module.exports = {
|
||||||
'*.html',
|
'*.html',
|
||||||
'bskyweb',
|
'bskyweb',
|
||||||
],
|
],
|
||||||
|
settings: {
|
||||||
|
componentWrapperFunctions: ['observer'],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,7 +19,7 @@ import {handleLink} from './Navigation'
|
||||||
|
|
||||||
SplashScreen.preventAutoHideAsync()
|
SplashScreen.preventAutoHideAsync()
|
||||||
|
|
||||||
const App = observer(() => {
|
const App = observer(function AppImpl() {
|
||||||
const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
|
const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
)
|
)
|
||||||
|
|
|
@ -10,7 +10,7 @@ import {ToastContainer} from './view/com/util/Toast.web'
|
||||||
import {ThemeProvider} from 'lib/ThemeContext'
|
import {ThemeProvider} from 'lib/ThemeContext'
|
||||||
import {observer} from 'mobx-react-lite'
|
import {observer} from 'mobx-react-lite'
|
||||||
|
|
||||||
const App = observer(() => {
|
const App = observer(function AppImpl() {
|
||||||
const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
|
const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
)
|
)
|
||||||
|
|
|
@ -330,7 +330,7 @@ function NotificationsTabNavigator() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const MyProfileTabNavigator = observer(() => {
|
const MyProfileTabNavigator = observer(function MyProfileTabNavigatorImpl() {
|
||||||
const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark)
|
const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark)
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
return (
|
return (
|
||||||
|
@ -360,7 +360,7 @@ const MyProfileTabNavigator = observer(() => {
|
||||||
* The FlatNavigator is used by Web to represent the routes
|
* The FlatNavigator is used by Web to represent the routes
|
||||||
* in a single ("flat") stack.
|
* in a single ("flat") stack.
|
||||||
*/
|
*/
|
||||||
const FlatNavigator = observer(() => {
|
const FlatNavigator = observer(function FlatNavigatorImpl() {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const unreadCountLabel = useStores().me.notifications.unreadCountLabel
|
const unreadCountLabel = useStores().me.notifications.unreadCountLabel
|
||||||
const title = (page: string) => bskyTitle(page, unreadCountLabel)
|
const title = (page: string) => bskyTitle(page, unreadCountLabel)
|
||||||
|
|
|
@ -16,7 +16,7 @@ enum ScreenState {
|
||||||
S_CreateAccount,
|
S_CreateAccount,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LoggedOut = observer(() => {
|
export const LoggedOut = observer(function LoggedOutImpl() {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const {screen} = useAnalytics()
|
const {screen} = useAnalytics()
|
||||||
|
|
|
@ -8,7 +8,7 @@ import {useStores} from 'state/index'
|
||||||
import {Welcome} from './onboarding/Welcome'
|
import {Welcome} from './onboarding/Welcome'
|
||||||
import {RecommendedFeeds} from './onboarding/RecommendedFeeds'
|
import {RecommendedFeeds} from './onboarding/RecommendedFeeds'
|
||||||
|
|
||||||
export const Onboarding = observer(() => {
|
export const Onboarding = observer(function OnboardingImpl() {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
|
|
||||||
|
|
|
@ -20,114 +20,116 @@ import {Step1} from './Step1'
|
||||||
import {Step2} from './Step2'
|
import {Step2} from './Step2'
|
||||||
import {Step3} from './Step3'
|
import {Step3} from './Step3'
|
||||||
|
|
||||||
export const CreateAccount = observer(
|
export const CreateAccount = observer(function CreateAccountImpl({
|
||||||
({onPressBack}: {onPressBack: () => void}) => {
|
onPressBack,
|
||||||
const {track, screen} = useAnalytics()
|
}: {
|
||||||
const pal = usePalette('default')
|
onPressBack: () => void
|
||||||
const store = useStores()
|
}) {
|
||||||
const model = React.useMemo(() => new CreateAccountModel(store), [store])
|
const {track, screen} = useAnalytics()
|
||||||
|
const pal = usePalette('default')
|
||||||
|
const store = useStores()
|
||||||
|
const model = React.useMemo(() => new CreateAccountModel(store), [store])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
screen('CreateAccount')
|
screen('CreateAccount')
|
||||||
}, [screen])
|
}, [screen])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
model.fetchServiceDescription()
|
model.fetchServiceDescription()
|
||||||
}, [model])
|
}, [model])
|
||||||
|
|
||||||
const onPressRetryConnect = React.useCallback(
|
const onPressRetryConnect = React.useCallback(
|
||||||
() => model.fetchServiceDescription(),
|
() => model.fetchServiceDescription(),
|
||||||
[model],
|
[model],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onPressBackInner = React.useCallback(() => {
|
const onPressBackInner = React.useCallback(() => {
|
||||||
if (model.canBack) {
|
if (model.canBack) {
|
||||||
model.back()
|
model.back()
|
||||||
} else {
|
} else {
|
||||||
onPressBack()
|
onPressBack()
|
||||||
|
}
|
||||||
|
}, [model, onPressBack])
|
||||||
|
|
||||||
|
const onPressNext = React.useCallback(async () => {
|
||||||
|
if (!model.canNext) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (model.step < 3) {
|
||||||
|
model.next()
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await model.submit()
|
||||||
|
} catch {
|
||||||
|
// dont need to handle here
|
||||||
|
} finally {
|
||||||
|
track('Try Create Account')
|
||||||
}
|
}
|
||||||
}, [model, onPressBack])
|
}
|
||||||
|
}, [model, track])
|
||||||
|
|
||||||
const onPressNext = React.useCallback(async () => {
|
return (
|
||||||
if (!model.canNext) {
|
<LoggedOutLayout
|
||||||
return
|
leadin={`Step ${model.step}`}
|
||||||
}
|
title="Create Account"
|
||||||
if (model.step < 3) {
|
description="We're so excited to have you join us!">
|
||||||
model.next()
|
<ScrollView testID="createAccount" style={pal.view}>
|
||||||
} else {
|
<KeyboardAvoidingView behavior="padding">
|
||||||
try {
|
<View style={styles.stepContainer}>
|
||||||
await model.submit()
|
{model.step === 1 && <Step1 model={model} />}
|
||||||
} catch {
|
{model.step === 2 && <Step2 model={model} />}
|
||||||
// dont need to handle here
|
{model.step === 3 && <Step3 model={model} />}
|
||||||
} finally {
|
</View>
|
||||||
track('Try Create Account')
|
<View style={[s.flexRow, s.pl20, s.pr20]}>
|
||||||
}
|
<TouchableOpacity
|
||||||
}
|
onPress={onPressBackInner}
|
||||||
}, [model, track])
|
testID="backBtn"
|
||||||
|
accessibilityRole="button">
|
||||||
return (
|
<Text type="xl" style={pal.link}>
|
||||||
<LoggedOutLayout
|
Back
|
||||||
leadin={`Step ${model.step}`}
|
</Text>
|
||||||
title="Create Account"
|
</TouchableOpacity>
|
||||||
description="We're so excited to have you join us!">
|
<View style={s.flex1} />
|
||||||
<ScrollView testID="createAccount" style={pal.view}>
|
{model.canNext ? (
|
||||||
<KeyboardAvoidingView behavior="padding">
|
|
||||||
<View style={styles.stepContainer}>
|
|
||||||
{model.step === 1 && <Step1 model={model} />}
|
|
||||||
{model.step === 2 && <Step2 model={model} />}
|
|
||||||
{model.step === 3 && <Step3 model={model} />}
|
|
||||||
</View>
|
|
||||||
<View style={[s.flexRow, s.pl20, s.pr20]}>
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={onPressBackInner}
|
testID="nextBtn"
|
||||||
testID="backBtn"
|
onPress={onPressNext}
|
||||||
accessibilityRole="button">
|
accessibilityRole="button">
|
||||||
<Text type="xl" style={pal.link}>
|
{model.isProcessing ? (
|
||||||
Back
|
<ActivityIndicator />
|
||||||
|
) : (
|
||||||
|
<Text type="xl-bold" style={[pal.link, s.pr5]}>
|
||||||
|
Next
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : model.didServiceDescriptionFetchFail ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
testID="retryConnectBtn"
|
||||||
|
onPress={onPressRetryConnect}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Retry"
|
||||||
|
accessibilityHint="Retries account creation"
|
||||||
|
accessibilityLiveRegion="polite">
|
||||||
|
<Text type="xl-bold" style={[pal.link, s.pr5]}>
|
||||||
|
Retry
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<View style={s.flex1} />
|
) : model.isFetchingServiceDescription ? (
|
||||||
{model.canNext ? (
|
<>
|
||||||
<TouchableOpacity
|
<ActivityIndicator color="#fff" />
|
||||||
testID="nextBtn"
|
<Text type="xl" style={[pal.text, s.pr5]}>
|
||||||
onPress={onPressNext}
|
Connecting...
|
||||||
accessibilityRole="button">
|
</Text>
|
||||||
{model.isProcessing ? (
|
</>
|
||||||
<ActivityIndicator />
|
) : undefined}
|
||||||
) : (
|
</View>
|
||||||
<Text type="xl-bold" style={[pal.link, s.pr5]}>
|
<View style={s.footerSpacer} />
|
||||||
Next
|
</KeyboardAvoidingView>
|
||||||
</Text>
|
</ScrollView>
|
||||||
)}
|
</LoggedOutLayout>
|
||||||
</TouchableOpacity>
|
)
|
||||||
) : model.didServiceDescriptionFetchFail ? (
|
})
|
||||||
<TouchableOpacity
|
|
||||||
testID="retryConnectBtn"
|
|
||||||
onPress={onPressRetryConnect}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel="Retry"
|
|
||||||
accessibilityHint="Retries account creation"
|
|
||||||
accessibilityLiveRegion="polite">
|
|
||||||
<Text type="xl-bold" style={[pal.link, s.pr5]}>
|
|
||||||
Retry
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
) : model.isFetchingServiceDescription ? (
|
|
||||||
<>
|
|
||||||
<ActivityIndicator color="#fff" />
|
|
||||||
<Text type="xl" style={[pal.text, s.pr5]}>
|
|
||||||
Connecting...
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
) : undefined}
|
|
||||||
</View>
|
|
||||||
<View style={s.footerSpacer} />
|
|
||||||
</KeyboardAvoidingView>
|
|
||||||
</ScrollView>
|
|
||||||
</LoggedOutLayout>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
stepContainer: {
|
stepContainer: {
|
||||||
|
|
|
@ -20,7 +20,11 @@ import {LOGIN_INCLUDE_DEV_SERVERS} from 'lib/build-flags'
|
||||||
* @field Bluesky (default)
|
* @field Bluesky (default)
|
||||||
* @field Other (staging, local dev, your own PDS, etc.)
|
* @field Other (staging, local dev, your own PDS, etc.)
|
||||||
*/
|
*/
|
||||||
export const Step1 = observer(({model}: {model: CreateAccountModel}) => {
|
export const Step1 = observer(function Step1Impl({
|
||||||
|
model,
|
||||||
|
}: {
|
||||||
|
model: CreateAccountModel
|
||||||
|
}) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const [isDefaultSelected, setIsDefaultSelected] = React.useState(true)
|
const [isDefaultSelected, setIsDefaultSelected] = React.useState(true)
|
||||||
|
|
||||||
|
|
|
@ -21,7 +21,11 @@ import {useStores} from 'state/index'
|
||||||
* @field Birth date
|
* @field Birth date
|
||||||
* @readonly Terms of service & privacy policy
|
* @readonly Terms of service & privacy policy
|
||||||
*/
|
*/
|
||||||
export const Step2 = observer(({model}: {model: CreateAccountModel}) => {
|
export const Step2 = observer(function Step2Impl({
|
||||||
|
model,
|
||||||
|
}: {
|
||||||
|
model: CreateAccountModel
|
||||||
|
}) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,11 @@ import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
|
||||||
/** STEP 3: Your user handle
|
/** STEP 3: Your user handle
|
||||||
* @field User handle
|
* @field User handle
|
||||||
*/
|
*/
|
||||||
export const Step3 = observer(({model}: {model: CreateAccountModel}) => {
|
export const Step3 = observer(function Step3Impl({
|
||||||
|
model,
|
||||||
|
}: {
|
||||||
|
model: CreateAccountModel
|
||||||
|
}) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
|
|
|
@ -15,7 +15,9 @@ import {RECOMMENDED_FEEDS} from 'lib/constants'
|
||||||
type Props = {
|
type Props = {
|
||||||
next: () => void
|
next: () => void
|
||||||
}
|
}
|
||||||
export const RecommendedFeeds = observer(({next}: Props) => {
|
export const RecommendedFeeds = observer(function RecommendedFeedsImpl({
|
||||||
|
next,
|
||||||
|
}: Props) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const {isTabletOrMobile} = useWebMediaQueries()
|
const {isTabletOrMobile} = useWebMediaQueries()
|
||||||
|
|
||||||
|
|
|
@ -13,130 +13,134 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||||
import {makeRecordUri} from 'lib/strings/url-helpers'
|
import {makeRecordUri} from 'lib/strings/url-helpers'
|
||||||
import {sanitizeHandle} from 'lib/strings/handles'
|
import {sanitizeHandle} from 'lib/strings/handles'
|
||||||
|
|
||||||
export const RecommendedFeedsItem = observer(
|
export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({
|
||||||
({did, rkey}: {did: string; rkey: string}) => {
|
did,
|
||||||
const {isMobile} = useWebMediaQueries()
|
rkey,
|
||||||
const pal = usePalette('default')
|
}: {
|
||||||
const uri = makeRecordUri(did, 'app.bsky.feed.generator', rkey)
|
did: string
|
||||||
const item = useCustomFeed(uri)
|
rkey: string
|
||||||
if (!item) return null
|
}) {
|
||||||
const onToggle = async () => {
|
const {isMobile} = useWebMediaQueries()
|
||||||
if (item.isSaved) {
|
const pal = usePalette('default')
|
||||||
try {
|
const uri = makeRecordUri(did, 'app.bsky.feed.generator', rkey)
|
||||||
await item.unsave()
|
const item = useCustomFeed(uri)
|
||||||
} catch (e) {
|
if (!item) return null
|
||||||
Toast.show('There was an issue contacting your server')
|
const onToggle = async () => {
|
||||||
console.error('Failed to unsave feed', {e})
|
if (item.isSaved) {
|
||||||
}
|
try {
|
||||||
} else {
|
await item.unsave()
|
||||||
try {
|
} catch (e) {
|
||||||
await item.save()
|
Toast.show('There was an issue contacting your server')
|
||||||
await item.pin()
|
console.error('Failed to unsave feed', {e})
|
||||||
} catch (e) {
|
}
|
||||||
Toast.show('There was an issue contacting your server')
|
} else {
|
||||||
console.error('Failed to pin feed', {e})
|
try {
|
||||||
}
|
await item.save()
|
||||||
|
await item.pin()
|
||||||
|
} catch (e) {
|
||||||
|
Toast.show('There was an issue contacting your server')
|
||||||
|
console.error('Failed to pin feed', {e})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return (
|
}
|
||||||
<View testID={`feed-${item.displayName}`}>
|
return (
|
||||||
<View
|
<View testID={`feed-${item.displayName}`}>
|
||||||
style={[
|
<View
|
||||||
pal.border,
|
style={[
|
||||||
{
|
pal.border,
|
||||||
flex: isMobile ? 1 : undefined,
|
{
|
||||||
flexDirection: 'row',
|
flex: isMobile ? 1 : undefined,
|
||||||
gap: 18,
|
flexDirection: 'row',
|
||||||
maxWidth: isMobile ? undefined : 670,
|
gap: 18,
|
||||||
borderRightWidth: isMobile ? undefined : 1,
|
maxWidth: isMobile ? undefined : 670,
|
||||||
paddingHorizontal: 24,
|
borderRightWidth: isMobile ? undefined : 1,
|
||||||
paddingVertical: isMobile ? 12 : 24,
|
paddingHorizontal: 24,
|
||||||
borderTopWidth: 1,
|
paddingVertical: isMobile ? 12 : 24,
|
||||||
},
|
borderTopWidth: 1,
|
||||||
]}>
|
},
|
||||||
<View style={{marginTop: 2}}>
|
]}>
|
||||||
<UserAvatar type="algo" size={42} avatar={item.data.avatar} />
|
<View style={{marginTop: 2}}>
|
||||||
</View>
|
<UserAvatar type="algo" size={42} avatar={item.data.avatar} />
|
||||||
<View style={{flex: isMobile ? 1 : undefined}}>
|
</View>
|
||||||
|
<View style={{flex: isMobile ? 1 : undefined}}>
|
||||||
|
<Text
|
||||||
|
type="2xl-bold"
|
||||||
|
numberOfLines={1}
|
||||||
|
style={[pal.text, {fontSize: 19}]}>
|
||||||
|
{item.displayName}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text style={[pal.textLight, {marginBottom: 8}]} numberOfLines={1}>
|
||||||
|
by {sanitizeHandle(item.data.creator.handle, '@')}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{item.data.description ? (
|
||||||
<Text
|
<Text
|
||||||
type="2xl-bold"
|
type="xl"
|
||||||
numberOfLines={1}
|
style={[
|
||||||
style={[pal.text, {fontSize: 19}]}>
|
pal.text,
|
||||||
{item.displayName}
|
{
|
||||||
|
flex: isMobile ? 1 : undefined,
|
||||||
|
maxWidth: 550,
|
||||||
|
marginBottom: 18,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
numberOfLines={6}>
|
||||||
|
{item.data.description}
|
||||||
</Text>
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Text style={[pal.textLight, {marginBottom: 8}]} numberOfLines={1}>
|
<View style={{flexDirection: 'row', alignItems: 'center', gap: 12}}>
|
||||||
by {sanitizeHandle(item.data.creator.handle, '@')}
|
<Button
|
||||||
</Text>
|
type="inverted"
|
||||||
|
style={{paddingVertical: 6}}
|
||||||
{item.data.description ? (
|
onPress={onToggle}>
|
||||||
<Text
|
<View
|
||||||
type="xl"
|
style={{
|
||||||
style={[
|
flexDirection: 'row',
|
||||||
pal.text,
|
alignItems: 'center',
|
||||||
{
|
paddingRight: 2,
|
||||||
flex: isMobile ? 1 : undefined,
|
gap: 6,
|
||||||
maxWidth: 550,
|
}}>
|
||||||
marginBottom: 18,
|
{item.isSaved ? (
|
||||||
},
|
<>
|
||||||
]}
|
<FontAwesomeIcon
|
||||||
numberOfLines={6}>
|
icon="check"
|
||||||
{item.data.description}
|
size={16}
|
||||||
</Text>
|
color={pal.colors.textInverted}
|
||||||
) : null}
|
/>
|
||||||
|
<Text type="lg-medium" style={pal.textInverted}>
|
||||||
<View style={{flexDirection: 'row', alignItems: 'center', gap: 12}}>
|
Added
|
||||||
<Button
|
</Text>
|
||||||
type="inverted"
|
</>
|
||||||
style={{paddingVertical: 6}}
|
) : (
|
||||||
onPress={onToggle}>
|
<>
|
||||||
<View
|
<FontAwesomeIcon
|
||||||
style={{
|
icon="plus"
|
||||||
flexDirection: 'row',
|
size={16}
|
||||||
alignItems: 'center',
|
color={pal.colors.textInverted}
|
||||||
paddingRight: 2,
|
/>
|
||||||
gap: 6,
|
<Text type="lg-medium" style={pal.textInverted}>
|
||||||
}}>
|
Add
|
||||||
{item.isSaved ? (
|
</Text>
|
||||||
<>
|
</>
|
||||||
<FontAwesomeIcon
|
)}
|
||||||
icon="check"
|
|
||||||
size={16}
|
|
||||||
color={pal.colors.textInverted}
|
|
||||||
/>
|
|
||||||
<Text type="lg-medium" style={pal.textInverted}>
|
|
||||||
Added
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon="plus"
|
|
||||||
size={16}
|
|
||||||
color={pal.colors.textInverted}
|
|
||||||
/>
|
|
||||||
<Text type="lg-medium" style={pal.textInverted}>
|
|
||||||
Add
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<View style={{flexDirection: 'row', gap: 4}}>
|
|
||||||
<HeartIcon
|
|
||||||
size={16}
|
|
||||||
strokeWidth={2.5}
|
|
||||||
style={[pal.textLight, {position: 'relative', top: 2}]}
|
|
||||||
/>
|
|
||||||
<Text type="lg-medium" style={[pal.text, pal.textLight]}>
|
|
||||||
{item.data.likeCount || 0}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<View style={{flexDirection: 'row', gap: 4}}>
|
||||||
|
<HeartIcon
|
||||||
|
size={16}
|
||||||
|
strokeWidth={2.5}
|
||||||
|
style={[pal.textLight, {position: 'relative', top: 2}]}
|
||||||
|
/>
|
||||||
|
<Text type="lg-medium" style={[pal.text, pal.textLight]}>
|
||||||
|
{item.data.likeCount || 0}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)
|
</View>
|
||||||
},
|
)
|
||||||
)
|
})
|
||||||
|
|
|
@ -14,7 +14,9 @@ type Props = {
|
||||||
skip: () => void
|
skip: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WelcomeDesktop = observer(({next}: Props) => {
|
export const WelcomeDesktop = observer(function WelcomeDesktopImpl({
|
||||||
|
next,
|
||||||
|
}: Props) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const horizontal = useMediaQuery({minWidth: 1300})
|
const horizontal = useMediaQuery({minWidth: 1300})
|
||||||
const title = (
|
const title = (
|
||||||
|
|
|
@ -13,7 +13,10 @@ type Props = {
|
||||||
skip: () => void
|
skip: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WelcomeMobile = observer(({next, skip}: Props) => {
|
export const WelcomeMobile = observer(function WelcomeMobileImpl({
|
||||||
|
next,
|
||||||
|
skip,
|
||||||
|
}: Props) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -17,7 +17,7 @@ import {STATUS_PAGE_URL} from 'lib/constants'
|
||||||
export const withAuthRequired = <P extends object>(
|
export const withAuthRequired = <P extends object>(
|
||||||
Component: React.ComponentType<P>,
|
Component: React.ComponentType<P>,
|
||||||
): React.FC<P> =>
|
): React.FC<P> =>
|
||||||
observer((props: P) => {
|
observer(function AuthRequired(props: P) {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
if (store.session.isResumingSession) {
|
if (store.session.isResumingSession) {
|
||||||
return <Loading />
|
return <Loading />
|
||||||
|
|
|
@ -16,7 +16,7 @@ interface Props {
|
||||||
gallery: GalleryModel
|
gallery: GalleryModel
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Gallery = observer(function ({gallery}: Props) {
|
export const Gallery = observer(function GalleryImpl({gallery}: Props) {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const {isMobile} = useWebMediaQueries()
|
const {isMobile} = useWebMediaQueries()
|
||||||
|
|
|
@ -8,90 +8,88 @@ import {Text} from 'view/com/util/text/Text'
|
||||||
import {UserAvatar} from 'view/com/util/UserAvatar'
|
import {UserAvatar} from 'view/com/util/UserAvatar'
|
||||||
import {useGrapheme} from '../hooks/useGrapheme'
|
import {useGrapheme} from '../hooks/useGrapheme'
|
||||||
|
|
||||||
export const Autocomplete = observer(
|
export const Autocomplete = observer(function AutocompleteImpl({
|
||||||
({
|
view,
|
||||||
view,
|
onSelect,
|
||||||
onSelect,
|
}: {
|
||||||
}: {
|
view: UserAutocompleteModel
|
||||||
view: UserAutocompleteModel
|
onSelect: (item: string) => void
|
||||||
onSelect: (item: string) => void
|
}) {
|
||||||
}) => {
|
const pal = usePalette('default')
|
||||||
const pal = usePalette('default')
|
const positionInterp = useAnimatedValue(0)
|
||||||
const positionInterp = useAnimatedValue(0)
|
const {getGraphemeString} = useGrapheme()
|
||||||
const {getGraphemeString} = useGrapheme()
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Animated.timing(positionInterp, {
|
Animated.timing(positionInterp, {
|
||||||
toValue: view.isActive ? 1 : 0,
|
toValue: view.isActive ? 1 : 0,
|
||||||
duration: 200,
|
duration: 200,
|
||||||
useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
}).start()
|
}).start()
|
||||||
}, [positionInterp, view.isActive])
|
}, [positionInterp, view.isActive])
|
||||||
|
|
||||||
const topAnimStyle = {
|
const topAnimStyle = {
|
||||||
transform: [
|
transform: [
|
||||||
{
|
{
|
||||||
translateY: positionInterp.interpolate({
|
translateY: positionInterp.interpolate({
|
||||||
inputRange: [0, 1],
|
inputRange: [0, 1],
|
||||||
outputRange: [200, 0],
|
outputRange: [200, 0],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View style={topAnimStyle}>
|
<Animated.View style={topAnimStyle}>
|
||||||
{view.isActive ? (
|
{view.isActive ? (
|
||||||
<View style={[pal.view, styles.container, pal.border]}>
|
<View style={[pal.view, styles.container, pal.border]}>
|
||||||
{view.suggestions.length > 0 ? (
|
{view.suggestions.length > 0 ? (
|
||||||
view.suggestions.slice(0, 5).map(item => {
|
view.suggestions.slice(0, 5).map(item => {
|
||||||
// Eventually use an average length
|
// Eventually use an average length
|
||||||
const MAX_CHARS = 40
|
const MAX_CHARS = 40
|
||||||
const MAX_HANDLE_CHARS = 20
|
const MAX_HANDLE_CHARS = 20
|
||||||
|
|
||||||
// Using this approach because styling is not respecting
|
// Using this approach because styling is not respecting
|
||||||
// bounding box wrapping (before converting to ellipsis)
|
// bounding box wrapping (before converting to ellipsis)
|
||||||
const {name: displayHandle, remainingCharacters} =
|
const {name: displayHandle, remainingCharacters} =
|
||||||
getGraphemeString(item.handle, MAX_HANDLE_CHARS)
|
getGraphemeString(item.handle, MAX_HANDLE_CHARS)
|
||||||
|
|
||||||
const {name: displayName} = getGraphemeString(
|
const {name: displayName} = getGraphemeString(
|
||||||
item.displayName ?? item.handle,
|
item.displayName ?? item.handle,
|
||||||
MAX_CHARS -
|
MAX_CHARS -
|
||||||
MAX_HANDLE_CHARS +
|
MAX_HANDLE_CHARS +
|
||||||
(remainingCharacters > 0 ? remainingCharacters : 0),
|
(remainingCharacters > 0 ? remainingCharacters : 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
testID="autocompleteButton"
|
testID="autocompleteButton"
|
||||||
key={item.handle}
|
key={item.handle}
|
||||||
style={[pal.border, styles.item]}
|
style={[pal.border, styles.item]}
|
||||||
onPress={() => onSelect(item.handle)}
|
onPress={() => onSelect(item.handle)}
|
||||||
accessibilityLabel={`Select ${item.handle}`}
|
accessibilityLabel={`Select ${item.handle}`}
|
||||||
accessibilityHint="">
|
accessibilityHint="">
|
||||||
<View style={styles.avatarAndHandle}>
|
<View style={styles.avatarAndHandle}>
|
||||||
<UserAvatar avatar={item.avatar ?? null} size={24} />
|
<UserAvatar avatar={item.avatar ?? null} size={24} />
|
||||||
<Text type="md-medium" style={pal.text}>
|
<Text type="md-medium" style={pal.text}>
|
||||||
{displayName}
|
{displayName}
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Text type="sm" style={pal.textLight} numberOfLines={1}>
|
|
||||||
@{displayHandle}
|
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
)
|
<Text type="sm" style={pal.textLight} numberOfLines={1}>
|
||||||
})
|
@{displayHandle}
|
||||||
) : (
|
</Text>
|
||||||
<Text type="sm" style={[pal.text, pal.border, styles.noResults]}>
|
</TouchableOpacity>
|
||||||
No result
|
)
|
||||||
</Text>
|
})
|
||||||
)}
|
) : (
|
||||||
</View>
|
<Text type="sm" style={[pal.text, pal.border, styles.noResults]}>
|
||||||
) : null}
|
No result
|
||||||
</Animated.View>
|
</Text>
|
||||||
)
|
)}
|
||||||
},
|
</View>
|
||||||
)
|
) : null}
|
||||||
|
</Animated.View>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
|
|
|
@ -15,120 +15,118 @@ import {AtUri} from '@atproto/api'
|
||||||
import * as Toast from 'view/com/util/Toast'
|
import * as Toast from 'view/com/util/Toast'
|
||||||
import {sanitizeHandle} from 'lib/strings/handles'
|
import {sanitizeHandle} from 'lib/strings/handles'
|
||||||
|
|
||||||
export const CustomFeed = observer(
|
export const CustomFeed = observer(function CustomFeedImpl({
|
||||||
({
|
item,
|
||||||
item,
|
style,
|
||||||
style,
|
showSaveBtn = false,
|
||||||
showSaveBtn = false,
|
showDescription = false,
|
||||||
showDescription = false,
|
showLikes = false,
|
||||||
showLikes = false,
|
}: {
|
||||||
}: {
|
item: CustomFeedModel
|
||||||
item: CustomFeedModel
|
style?: StyleProp<ViewStyle>
|
||||||
style?: StyleProp<ViewStyle>
|
showSaveBtn?: boolean
|
||||||
showSaveBtn?: boolean
|
showDescription?: boolean
|
||||||
showDescription?: boolean
|
showLikes?: boolean
|
||||||
showLikes?: boolean
|
}) {
|
||||||
}) => {
|
const store = useStores()
|
||||||
const store = useStores()
|
const pal = usePalette('default')
|
||||||
const pal = usePalette('default')
|
const navigation = useNavigation<NavigationProp>()
|
||||||
const navigation = useNavigation<NavigationProp>()
|
|
||||||
|
|
||||||
const onToggleSaved = React.useCallback(async () => {
|
const onToggleSaved = React.useCallback(async () => {
|
||||||
if (item.isSaved) {
|
if (item.isSaved) {
|
||||||
store.shell.openModal({
|
store.shell.openModal({
|
||||||
name: 'confirm',
|
name: 'confirm',
|
||||||
title: 'Remove from my feeds',
|
title: 'Remove from my feeds',
|
||||||
message: `Remove ${item.displayName} from my feeds?`,
|
message: `Remove ${item.displayName} from my feeds?`,
|
||||||
onPressConfirm: async () => {
|
onPressConfirm: async () => {
|
||||||
try {
|
try {
|
||||||
await store.me.savedFeeds.unsave(item)
|
await store.me.savedFeeds.unsave(item)
|
||||||
Toast.show('Removed from my feeds')
|
Toast.show('Removed from my feeds')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Toast.show('There was an issue contacting your server')
|
Toast.show('There was an issue contacting your server')
|
||||||
store.log.error('Failed to unsave feed', {e})
|
store.log.error('Failed to unsave feed', {e})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
await store.me.savedFeeds.save(item)
|
await store.me.savedFeeds.save(item)
|
||||||
Toast.show('Added to my feeds')
|
Toast.show('Added to my feeds')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Toast.show('There was an issue contacting your server')
|
Toast.show('There was an issue contacting your server')
|
||||||
store.log.error('Failed to save feed', {e})
|
store.log.error('Failed to save feed', {e})
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [store, item])
|
}
|
||||||
|
}, [store, item])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
testID={`feed-${item.displayName}`}
|
testID={`feed-${item.displayName}`}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
style={[styles.container, pal.border, style]}
|
style={[styles.container, pal.border, style]}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
navigation.push('CustomFeed', {
|
navigation.push('CustomFeed', {
|
||||||
name: item.data.creator.did,
|
name: item.data.creator.did,
|
||||||
rkey: new AtUri(item.data.uri).rkey,
|
rkey: new AtUri(item.data.uri).rkey,
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
key={item.data.uri}>
|
key={item.data.uri}>
|
||||||
<View style={[styles.headerContainer]}>
|
<View style={[styles.headerContainer]}>
|
||||||
<View style={[s.mr10]}>
|
<View style={[s.mr10]}>
|
||||||
<UserAvatar type="algo" size={36} avatar={item.data.avatar} />
|
<UserAvatar type="algo" size={36} avatar={item.data.avatar} />
|
||||||
</View>
|
|
||||||
<View style={[styles.headerTextContainer]}>
|
|
||||||
<Text style={[pal.text, s.bold]} numberOfLines={3}>
|
|
||||||
{item.displayName}
|
|
||||||
</Text>
|
|
||||||
<Text style={[pal.textLight]} numberOfLines={3}>
|
|
||||||
by {sanitizeHandle(item.data.creator.handle, '@')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
{showSaveBtn && (
|
|
||||||
<View>
|
|
||||||
<Pressable
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={
|
|
||||||
item.isSaved ? 'Remove from my feeds' : 'Add to my feeds'
|
|
||||||
}
|
|
||||||
accessibilityHint=""
|
|
||||||
onPress={onToggleSaved}
|
|
||||||
hitSlop={15}
|
|
||||||
style={styles.btn}>
|
|
||||||
{item.isSaved ? (
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={['far', 'trash-can']}
|
|
||||||
size={19}
|
|
||||||
color={pal.colors.icon}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon="plus"
|
|
||||||
size={18}
|
|
||||||
color={pal.colors.link}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
|
<View style={[styles.headerTextContainer]}>
|
||||||
{showDescription && item.data.description ? (
|
<Text style={[pal.text, s.bold]} numberOfLines={3}>
|
||||||
<Text style={[pal.textLight, styles.description]} numberOfLines={3}>
|
{item.displayName}
|
||||||
{item.data.description}
|
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
<Text style={[pal.textLight]} numberOfLines={3}>
|
||||||
|
by {sanitizeHandle(item.data.creator.handle, '@')}
|
||||||
{showLikes ? (
|
|
||||||
<Text type="sm-medium" style={[pal.text, pal.textLight]}>
|
|
||||||
Liked by {item.data.likeCount || 0}{' '}
|
|
||||||
{pluralize(item.data.likeCount || 0, 'user')}
|
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
</View>
|
||||||
</Pressable>
|
{showSaveBtn && (
|
||||||
)
|
<View>
|
||||||
},
|
<Pressable
|
||||||
)
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={
|
||||||
|
item.isSaved ? 'Remove from my feeds' : 'Add to my feeds'
|
||||||
|
}
|
||||||
|
accessibilityHint=""
|
||||||
|
onPress={onToggleSaved}
|
||||||
|
hitSlop={15}
|
||||||
|
style={styles.btn}>
|
||||||
|
{item.isSaved ? (
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={['far', 'trash-can']}
|
||||||
|
size={19}
|
||||||
|
color={pal.colors.icon}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon="plus"
|
||||||
|
size={18}
|
||||||
|
color={pal.colors.link}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{showDescription && item.data.description ? (
|
||||||
|
<Text style={[pal.textLight, styles.description]} numberOfLines={3}>
|
||||||
|
{item.data.description}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showLikes ? (
|
||||||
|
<Text type="sm-medium" style={[pal.text, pal.textLight]}>
|
||||||
|
Liked by {item.data.likeCount || 0}{' '}
|
||||||
|
{pluralize(item.data.likeCount || 0, 'user')}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Pressable>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
|
|
|
@ -35,319 +35,314 @@ const EMPTY_ITEM = {_reactKey: '__empty__'}
|
||||||
const ERROR_ITEM = {_reactKey: '__error__'}
|
const ERROR_ITEM = {_reactKey: '__error__'}
|
||||||
const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
|
const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
|
||||||
|
|
||||||
export const ListItems = observer(
|
export const ListItems = observer(function ListItemsImpl({
|
||||||
({
|
list,
|
||||||
list,
|
style,
|
||||||
style,
|
scrollElRef,
|
||||||
scrollElRef,
|
onPressTryAgain,
|
||||||
onPressTryAgain,
|
onToggleSubscribed,
|
||||||
onToggleSubscribed,
|
onPressEditList,
|
||||||
onPressEditList,
|
onPressDeleteList,
|
||||||
onPressDeleteList,
|
onPressShareList,
|
||||||
onPressShareList,
|
onPressReportList,
|
||||||
onPressReportList,
|
renderEmptyState,
|
||||||
renderEmptyState,
|
testID,
|
||||||
testID,
|
headerOffset = 0,
|
||||||
headerOffset = 0,
|
}: {
|
||||||
}: {
|
list: ListModel
|
||||||
list: ListModel
|
style?: StyleProp<ViewStyle>
|
||||||
style?: StyleProp<ViewStyle>
|
scrollElRef?: MutableRefObject<FlatList<any> | null>
|
||||||
scrollElRef?: MutableRefObject<FlatList<any> | null>
|
onPressTryAgain?: () => void
|
||||||
onPressTryAgain?: () => void
|
onToggleSubscribed: () => void
|
||||||
onToggleSubscribed: () => void
|
onPressEditList: () => void
|
||||||
onPressEditList: () => void
|
onPressDeleteList: () => void
|
||||||
onPressDeleteList: () => void
|
onPressShareList: () => void
|
||||||
onPressShareList: () => void
|
onPressReportList: () => void
|
||||||
onPressReportList: () => void
|
renderEmptyState?: () => JSX.Element
|
||||||
renderEmptyState?: () => JSX.Element
|
testID?: string
|
||||||
testID?: string
|
headerOffset?: number
|
||||||
headerOffset?: number
|
}) {
|
||||||
}) => {
|
const pal = usePalette('default')
|
||||||
const pal = usePalette('default')
|
const store = useStores()
|
||||||
const store = useStores()
|
const {track} = useAnalytics()
|
||||||
const {track} = useAnalytics()
|
const [isRefreshing, setIsRefreshing] = React.useState(false)
|
||||||
const [isRefreshing, setIsRefreshing] = React.useState(false)
|
|
||||||
|
|
||||||
const data = React.useMemo(() => {
|
const data = React.useMemo(() => {
|
||||||
let items: any[] = [HEADER_ITEM]
|
let items: any[] = [HEADER_ITEM]
|
||||||
if (list.hasLoaded) {
|
if (list.hasLoaded) {
|
||||||
if (list.hasError) {
|
if (list.hasError) {
|
||||||
items = items.concat([ERROR_ITEM])
|
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
|
if (list.isEmpty) {
|
||||||
}, [
|
items = items.concat([EMPTY_ITEM])
|
||||||
list.hasError,
|
} else {
|
||||||
list.hasLoaded,
|
items = items.concat(list.items)
|
||||||
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)
|
if (list.loadMoreError) {
|
||||||
}, [list, track, setIsRefreshing])
|
items = items.concat([LOAD_MORE_ERROR_ITEM])
|
||||||
|
|
||||||
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])
|
} else if (list.isLoading) {
|
||||||
|
items = items.concat([LOADING_ITEM])
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}, [
|
||||||
|
list.hasError,
|
||||||
|
list.hasLoaded,
|
||||||
|
list.isLoading,
|
||||||
|
list.isEmpty,
|
||||||
|
list.items,
|
||||||
|
list.loadMoreError,
|
||||||
|
])
|
||||||
|
|
||||||
const onPressRetryLoadMore = React.useCallback(() => {
|
// events
|
||||||
list.retryLoadMore()
|
// =
|
||||||
}, [list])
|
|
||||||
|
|
||||||
const onPressEditMembership = React.useCallback(
|
const onRefresh = React.useCallback(async () => {
|
||||||
(profile: AppBskyActorDefs.ProfileViewBasic) => {
|
track('Lists:onRefresh')
|
||||||
store.shell.openModal({
|
setIsRefreshing(true)
|
||||||
name: 'list-add-remove-user',
|
try {
|
||||||
subject: profile.did,
|
await list.refresh()
|
||||||
displayName: profile.displayName || profile.handle,
|
} catch (err) {
|
||||||
onUpdate() {
|
list.rootStore.log.error('Failed to refresh lists', err)
|
||||||
list.refresh()
|
}
|
||||||
},
|
setIsRefreshing(false)
|
||||||
})
|
}, [list, track, setIsRefreshing])
|
||||||
},
|
|
||||||
[store, list],
|
|
||||||
)
|
|
||||||
|
|
||||||
// rendering
|
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 renderMemberButton = React.useCallback(
|
const onPressRetryLoadMore = React.useCallback(() => {
|
||||||
(profile: AppBskyActorDefs.ProfileViewBasic) => {
|
list.retryLoadMore()
|
||||||
if (!list.isOwner) {
|
}, [list])
|
||||||
return null
|
|
||||||
|
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}
|
||||||
|
onPressShareList={onPressShareList}
|
||||||
|
onPressReportList={onPressReportList}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
} else if (item === ERROR_ITEM) {
|
||||||
return (
|
return (
|
||||||
<Button
|
<ErrorMessage
|
||||||
type="default"
|
message={list.error}
|
||||||
label="Edit"
|
onPressTryAgain={onPressTryAgain}
|
||||||
onPress={() => onPressEditMembership(profile)}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
},
|
} else if (item === LOAD_MORE_ERROR_ITEM) {
|
||||||
[list, onPressEditMembership],
|
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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[
|
||||||
|
renderMemberButton,
|
||||||
|
renderEmptyState,
|
||||||
|
list.list,
|
||||||
|
list.isOwner,
|
||||||
|
list.error,
|
||||||
|
onToggleSubscribed,
|
||||||
|
onPressEditList,
|
||||||
|
onPressDeleteList,
|
||||||
|
onPressShareList,
|
||||||
|
onPressReportList,
|
||||||
|
onPressTryAgain,
|
||||||
|
onPressRetryLoadMore,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
const renderItem = React.useCallback(
|
const Footer = React.useCallback(
|
||||||
({item}: {item: any}) => {
|
() =>
|
||||||
if (item === EMPTY_ITEM) {
|
list.isLoading ? (
|
||||||
if (renderEmptyState) {
|
<View style={styles.feedFooter}>
|
||||||
return renderEmptyState()
|
<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}
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
return <View />
|
contentContainerStyle={s.contentContainer}
|
||||||
} else if (item === HEADER_ITEM) {
|
style={{paddingTop: headerOffset}}
|
||||||
return list.list ? (
|
onEndReached={onEndReached}
|
||||||
<ListHeader
|
onEndReachedThreshold={0.6}
|
||||||
list={list.list}
|
removeClippedSubviews={true}
|
||||||
isOwner={list.isOwner}
|
contentOffset={{x: 0, y: headerOffset * -1}}
|
||||||
onToggleSubscribed={onToggleSubscribed}
|
// @ts-ignore our .web version only -prf
|
||||||
onPressEditList={onPressEditList}
|
desktopFixedHeight
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const ListHeader = observer(function ListHeaderImpl({
|
||||||
|
list,
|
||||||
|
isOwner,
|
||||||
|
onToggleSubscribed,
|
||||||
|
onPressEditList,
|
||||||
|
onPressDeleteList,
|
||||||
|
onPressShareList,
|
||||||
|
onPressReportList,
|
||||||
|
}: {
|
||||||
|
list: AppBskyGraphDefs.ListView
|
||||||
|
isOwner: boolean
|
||||||
|
onToggleSubscribed: () => void
|
||||||
|
onPressEditList: () => void
|
||||||
|
onPressDeleteList: () => void
|
||||||
|
onPressShareList: () => void
|
||||||
|
onPressReportList: () => void
|
||||||
|
}) {
|
||||||
|
const pal = usePalette('default')
|
||||||
|
const store = useStores()
|
||||||
|
const {isDesktop} = useWebMediaQueries()
|
||||||
|
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={sanitizeHandle(list.creator.handle, '@')}
|
||||||
|
href={makeProfileLink(list.creator)}
|
||||||
|
style={pal.textLight}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{descriptionRT && (
|
||||||
|
<RichTextCom
|
||||||
|
testID="listDescription"
|
||||||
|
style={[pal.text, styles.headerDescription]}
|
||||||
|
richText={descriptionRT}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isDesktop && (
|
||||||
|
<ListActions
|
||||||
|
isOwner={isOwner}
|
||||||
|
muted={list.viewer?.muted}
|
||||||
onPressDeleteList={onPressDeleteList}
|
onPressDeleteList={onPressDeleteList}
|
||||||
|
onPressEditList={onPressEditList}
|
||||||
|
onToggleSubscribed={onToggleSubscribed}
|
||||||
onPressShareList={onPressShareList}
|
onPressShareList={onPressShareList}
|
||||||
onPressReportList={onPressReportList}
|
onPressReportList={onPressReportList}
|
||||||
/>
|
/>
|
||||||
) : null
|
)}
|
||||||
} else if (item === ERROR_ITEM) {
|
</View>
|
||||||
return (
|
<View>
|
||||||
<ErrorMessage
|
<UserAvatar type="list" avatar={list.avatar} size={64} />
|
||||||
message={list.error}
|
</View>
|
||||||
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}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
[
|
|
||||||
renderMemberButton,
|
|
||||||
renderEmptyState,
|
|
||||||
list.list,
|
|
||||||
list.isOwner,
|
|
||||||
list.error,
|
|
||||||
onToggleSubscribed,
|
|
||||||
onPressEditList,
|
|
||||||
onPressDeleteList,
|
|
||||||
onPressShareList,
|
|
||||||
onPressReportList,
|
|
||||||
onPressTryAgain,
|
|
||||||
onPressRetryLoadMore,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
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>
|
</View>
|
||||||
)
|
<View
|
||||||
},
|
style={{flexDirection: 'row', paddingHorizontal: isDesktop ? 16 : 6}}>
|
||||||
)
|
<View style={[styles.fakeSelectorItem, {borderColor: pal.colors.link}]}>
|
||||||
|
<Text type="md-medium" style={[pal.text]}>
|
||||||
const ListHeader = observer(
|
Muted users
|
||||||
({
|
</Text>
|
||||||
list,
|
|
||||||
isOwner,
|
|
||||||
onToggleSubscribed,
|
|
||||||
onPressEditList,
|
|
||||||
onPressDeleteList,
|
|
||||||
onPressShareList,
|
|
||||||
onPressReportList,
|
|
||||||
}: {
|
|
||||||
list: AppBskyGraphDefs.ListView
|
|
||||||
isOwner: boolean
|
|
||||||
onToggleSubscribed: () => void
|
|
||||||
onPressEditList: () => void
|
|
||||||
onPressDeleteList: () => void
|
|
||||||
onPressShareList: () => void
|
|
||||||
onPressReportList: () => void
|
|
||||||
}) => {
|
|
||||||
const pal = usePalette('default')
|
|
||||||
const store = useStores()
|
|
||||||
const {isDesktop} = useWebMediaQueries()
|
|
||||||
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={sanitizeHandle(list.creator.handle, '@')}
|
|
||||||
href={makeProfileLink(list.creator)}
|
|
||||||
style={pal.textLight}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{descriptionRT && (
|
|
||||||
<RichTextCom
|
|
||||||
testID="listDescription"
|
|
||||||
style={[pal.text, styles.headerDescription]}
|
|
||||||
richText={descriptionRT}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{isDesktop && (
|
|
||||||
<ListActions
|
|
||||||
isOwner={isOwner}
|
|
||||||
muted={list.viewer?.muted}
|
|
||||||
onPressDeleteList={onPressDeleteList}
|
|
||||||
onPressEditList={onPressEditList}
|
|
||||||
onToggleSubscribed={onToggleSubscribed}
|
|
||||||
onPressShareList={onPressShareList}
|
|
||||||
onPressReportList={onPressReportList}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
<View>
|
|
||||||
<UserAvatar type="list" avatar={list.avatar} size={64} />
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
<View
|
</View>
|
||||||
style={{flexDirection: 'row', paddingHorizontal: isDesktop ? 16 : 6}}>
|
</>
|
||||||
<View
|
)
|
||||||
style={[styles.fakeSelectorItem, {borderColor: pal.colors.link}]}>
|
})
|
||||||
<Text type="md-medium" style={[pal.text]}>
|
|
||||||
Muted users
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
header: {
|
header: {
|
||||||
|
|
|
@ -30,173 +30,171 @@ const EMPTY_ITEM = {_reactKey: '__empty__'}
|
||||||
const ERROR_ITEM = {_reactKey: '__error__'}
|
const ERROR_ITEM = {_reactKey: '__error__'}
|
||||||
const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
|
const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
|
||||||
|
|
||||||
export const ListsList = observer(
|
export const ListsList = observer(function ListsListImpl({
|
||||||
({
|
listsList,
|
||||||
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,
|
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(() => {
|
// events
|
||||||
let items: any[] = []
|
// =
|
||||||
if (listsList.hasLoaded) {
|
|
||||||
if (listsList.hasError) {
|
const onRefresh = React.useCallback(async () => {
|
||||||
items = items.concat([ERROR_ITEM])
|
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()
|
||||||
}
|
}
|
||||||
if (listsList.isEmpty) {
|
return <View />
|
||||||
items = items.concat([EMPTY_ITEM])
|
} else if (item === CREATENEW_ITEM) {
|
||||||
} else {
|
return <CreateNewItem onPress={onPressCreateNew} />
|
||||||
if (showAddBtns) {
|
} else if (item === ERROR_ITEM) {
|
||||||
items = items.concat([CREATENEW_ITEM])
|
return (
|
||||||
}
|
<ErrorMessage
|
||||||
items = items.concat(listsList.lists)
|
message={listsList.error}
|
||||||
}
|
onPressTryAgain={onPressTryAgain}
|
||||||
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}`}
|
|
||||||
style={styles.item}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
},
|
} else if (item === LOAD_MORE_ERROR_ITEM) {
|
||||||
[
|
return (
|
||||||
listsList,
|
<LoadMoreRetryBtn
|
||||||
onPressTryAgain,
|
label="There was an issue fetching your lists. Tap here to try again."
|
||||||
onPressRetryLoadMore,
|
onPress={onPressRetryLoadMore}
|
||||||
onPressCreateNew,
|
|
||||||
renderItem,
|
|
||||||
renderEmptyState,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
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}
|
|
||||||
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>
|
} else if (item === LOADING_ITEM) {
|
||||||
)
|
return <ProfileCardFeedLoadingPlaceholder />
|
||||||
},
|
}
|
||||||
)
|
return renderItem ? (
|
||||||
|
renderItem(item)
|
||||||
|
) : (
|
||||||
|
<ListCard
|
||||||
|
list={item}
|
||||||
|
testID={`list-${item.name}`}
|
||||||
|
style={styles.item}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[
|
||||||
|
listsList,
|
||||||
|
onPressTryAgain,
|
||||||
|
onPressRetryLoadMore,
|
||||||
|
onPressCreateNew,
|
||||||
|
renderItem,
|
||||||
|
renderEmptyState,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
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}
|
||||||
|
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}) {
|
function CreateNewItem({onPress}: {onPress: () => void}) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
|
|
|
@ -17,160 +17,162 @@ import * as Toast from '../util/Toast'
|
||||||
|
|
||||||
export const snapPoints = ['90%']
|
export const snapPoints = ['90%']
|
||||||
|
|
||||||
export const Component = observer(({}: {}) => {
|
export const Component = observer(
|
||||||
const store = useStores()
|
function ContentFilteringSettingsImpl({}: {}) {
|
||||||
const {isMobile} = useWebMediaQueries()
|
|
||||||
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="contentFilteringModal" style={[pal.view, styles.container]}>
|
|
||||||
<Text style={[pal.text, styles.title]}>Content Filtering</Text>
|
|
||||||
<ScrollView style={styles.scrollContainer}>
|
|
||||||
<View style={s.mb10}>
|
|
||||||
{isIOS ? (
|
|
||||||
store.preferences.adultContentEnabled ? null : (
|
|
||||||
<Text type="md" style={pal.textLight}>
|
|
||||||
Adult content can only be enabled via the Web at{' '}
|
|
||||||
<TextLink
|
|
||||||
style={pal.link}
|
|
||||||
href="https://bsky.app"
|
|
||||||
text="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}
|
|
||||||
/>
|
|
||||||
<ContentLabelPref
|
|
||||||
group="nudity"
|
|
||||||
disabled={!store.preferences.adultContentEnabled}
|
|
||||||
/>
|
|
||||||
<ContentLabelPref
|
|
||||||
group="suggestive"
|
|
||||||
disabled={!store.preferences.adultContentEnabled}
|
|
||||||
/>
|
|
||||||
<ContentLabelPref
|
|
||||||
group="gore"
|
|
||||||
disabled={!store.preferences.adultContentEnabled}
|
|
||||||
/>
|
|
||||||
<ContentLabelPref group="hate" />
|
|
||||||
<ContentLabelPref group="spam" />
|
|
||||||
<ContentLabelPref group="impersonation" />
|
|
||||||
<View style={{height: isMobile ? 60 : 0}} />
|
|
||||||
</ScrollView>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.btnContainer,
|
|
||||||
isMobile && styles.btnContainerMobile,
|
|
||||||
pal.borderDark,
|
|
||||||
]}>
|
|
||||||
<Pressable
|
|
||||||
testID="sendReportBtn"
|
|
||||||
onPress={onPressDone}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel="Done"
|
|
||||||
accessibilityHint="">
|
|
||||||
<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, s.f18]}>Done</Text>
|
|
||||||
</LinearGradient>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
// TODO: Refactor this component to pass labels down to each tab
|
|
||||||
const ContentLabelPref = observer(
|
|
||||||
({
|
|
||||||
group,
|
|
||||||
disabled,
|
|
||||||
}: {
|
|
||||||
group: keyof typeof CONFIGURABLE_LABEL_GROUPS
|
|
||||||
disabled?: boolean
|
|
||||||
}) => {
|
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
|
const {isMobile} = useWebMediaQueries()
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
|
|
||||||
const onChange = React.useCallback(
|
React.useEffect(() => {
|
||||||
async (v: LabelPreference) => {
|
store.preferences.sync()
|
||||||
try {
|
}, [store])
|
||||||
await store.preferences.setContentLabelPref(group, v)
|
|
||||||
} catch (e) {
|
const onToggleAdultContent = React.useCallback(async () => {
|
||||||
Toast.show(
|
if (isIOS) {
|
||||||
'There was an issue syncing your preferences with the server',
|
return
|
||||||
)
|
}
|
||||||
store.log.error('Failed to update preferences with server', {e})
|
try {
|
||||||
}
|
await store.preferences.setAdultContentEnabled(
|
||||||
},
|
!store.preferences.adultContentEnabled,
|
||||||
[store, group],
|
)
|
||||||
)
|
} 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 (
|
return (
|
||||||
<View style={[styles.contentLabelPref, pal.border]}>
|
<View testID="contentFilteringModal" style={[pal.view, styles.container]}>
|
||||||
<View style={s.flex1}>
|
<Text style={[pal.text, styles.title]}>Content Filtering</Text>
|
||||||
<Text type="md-medium" style={[pal.text]}>
|
<ScrollView style={styles.scrollContainer}>
|
||||||
{CONFIGURABLE_LABEL_GROUPS[group].title}
|
<View style={s.mb10}>
|
||||||
</Text>
|
{isIOS ? (
|
||||||
{typeof CONFIGURABLE_LABEL_GROUPS[group].subtitle === 'string' && (
|
store.preferences.adultContentEnabled ? null : (
|
||||||
<Text type="sm" style={[pal.textLight]}>
|
<Text type="md" style={pal.textLight}>
|
||||||
{CONFIGURABLE_LABEL_GROUPS[group].subtitle}
|
Adult content can only be enabled via the Web at{' '}
|
||||||
</Text>
|
<TextLink
|
||||||
)}
|
style={pal.link}
|
||||||
</View>
|
href="https://bsky.app"
|
||||||
{disabled ? (
|
text="bsky.app"
|
||||||
<Text type="sm-bold" style={pal.textLight}>
|
/>
|
||||||
Hide
|
.
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
)
|
||||||
<SelectGroup
|
) : (
|
||||||
current={store.preferences.contentLabels[group]}
|
<ToggleButton
|
||||||
onChange={onChange}
|
type="default-light"
|
||||||
group={group}
|
label="Enable Adult Content"
|
||||||
|
isSelected={store.preferences.adultContentEnabled}
|
||||||
|
onPress={onToggleAdultContent}
|
||||||
|
style={styles.toggleBtn}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<ContentLabelPref
|
||||||
|
group="nsfw"
|
||||||
|
disabled={!store.preferences.adultContentEnabled}
|
||||||
/>
|
/>
|
||||||
)}
|
<ContentLabelPref
|
||||||
|
group="nudity"
|
||||||
|
disabled={!store.preferences.adultContentEnabled}
|
||||||
|
/>
|
||||||
|
<ContentLabelPref
|
||||||
|
group="suggestive"
|
||||||
|
disabled={!store.preferences.adultContentEnabled}
|
||||||
|
/>
|
||||||
|
<ContentLabelPref
|
||||||
|
group="gore"
|
||||||
|
disabled={!store.preferences.adultContentEnabled}
|
||||||
|
/>
|
||||||
|
<ContentLabelPref group="hate" />
|
||||||
|
<ContentLabelPref group="spam" />
|
||||||
|
<ContentLabelPref group="impersonation" />
|
||||||
|
<View style={{height: isMobile ? 60 : 0}} />
|
||||||
|
</ScrollView>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.btnContainer,
|
||||||
|
isMobile && styles.btnContainerMobile,
|
||||||
|
pal.borderDark,
|
||||||
|
]}>
|
||||||
|
<Pressable
|
||||||
|
testID="sendReportBtn"
|
||||||
|
onPress={onPressDone}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Done"
|
||||||
|
accessibilityHint="">
|
||||||
|
<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, s.f18]}>Done</Text>
|
||||||
|
</LinearGradient>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TODO: Refactor this component to pass labels down to each tab
|
||||||
|
const ContentLabelPref = observer(function ContentLabelPrefImpl({
|
||||||
|
group,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
group: keyof typeof CONFIGURABLE_LABEL_GROUPS
|
||||||
|
disabled?: boolean
|
||||||
|
}) {
|
||||||
|
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}>
|
||||||
|
<Text type="md-medium" style={[pal.text]}>
|
||||||
|
{CONFIGURABLE_LABEL_GROUPS[group].title}
|
||||||
|
</Text>
|
||||||
|
{typeof CONFIGURABLE_LABEL_GROUPS[group].subtitle === 'string' && (
|
||||||
|
<Text type="sm" style={[pal.textLight]}>
|
||||||
|
{CONFIGURABLE_LABEL_GROUPS[group].subtitle}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{disabled ? (
|
||||||
|
<Text type="sm-bold" style={pal.textLight}>
|
||||||
|
Hide
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<SelectGroup
|
||||||
|
current={store.preferences.contentLabels[group]}
|
||||||
|
onChange={onChange}
|
||||||
|
group={group}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
interface SelectGroupProps {
|
interface SelectGroupProps {
|
||||||
current: LabelPreference
|
current: LabelPreference
|
||||||
onChange: (v: LabelPreference) => void
|
onChange: (v: LabelPreference) => void
|
||||||
|
|
|
@ -46,7 +46,10 @@ interface Props {
|
||||||
gallery: GalleryModel
|
gallery: GalleryModel
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Component = observer(function ({image, gallery}: Props) {
|
export const Component = observer(function EditImageImpl({
|
||||||
|
image,
|
||||||
|
gallery,
|
||||||
|
}: Props) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
|
|
|
@ -79,50 +79,56 @@ export function Component({}: {}) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const InviteCode = observer(
|
const InviteCode = observer(function InviteCodeImpl({
|
||||||
({testID, code, used}: {testID: string; code: string; used?: boolean}) => {
|
testID,
|
||||||
const pal = usePalette('default')
|
code,
|
||||||
const store = useStores()
|
used,
|
||||||
const {invitesAvailable} = store.me
|
}: {
|
||||||
|
testID: string
|
||||||
|
code: string
|
||||||
|
used?: boolean
|
||||||
|
}) {
|
||||||
|
const pal = usePalette('default')
|
||||||
|
const store = useStores()
|
||||||
|
const {invitesAvailable} = store.me
|
||||||
|
|
||||||
const onPress = React.useCallback(() => {
|
const onPress = React.useCallback(() => {
|
||||||
Clipboard.setString(code)
|
Clipboard.setString(code)
|
||||||
Toast.show('Copied to clipboard')
|
Toast.show('Copied to clipboard')
|
||||||
store.invitedUsers.setInviteCopied(code)
|
store.invitedUsers.setInviteCopied(code)
|
||||||
}, [store, code])
|
}, [store, code])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
testID={testID}
|
testID={testID}
|
||||||
style={[styles.inviteCode, pal.border]}
|
style={[styles.inviteCode, pal.border]}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={
|
accessibilityLabel={
|
||||||
invitesAvailable === 1
|
invitesAvailable === 1
|
||||||
? 'Invite codes: 1 available'
|
? 'Invite codes: 1 available'
|
||||||
: `Invite codes: ${invitesAvailable} available`
|
: `Invite codes: ${invitesAvailable} available`
|
||||||
}
|
}
|
||||||
accessibilityHint="Opens list of invite codes">
|
accessibilityHint="Opens list of invite codes">
|
||||||
<Text
|
<Text
|
||||||
testID={`${testID}-code`}
|
testID={`${testID}-code`}
|
||||||
type={used ? 'md' : 'md-bold'}
|
type={used ? 'md' : 'md-bold'}
|
||||||
style={used ? [pal.textLight, styles.strikeThrough] : pal.text}>
|
style={used ? [pal.textLight, styles.strikeThrough] : pal.text}>
|
||||||
{code}
|
{code}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={styles.flex1} />
|
<View style={styles.flex1} />
|
||||||
{!used && store.invitedUsers.isInviteCopied(code) && (
|
{!used && store.invitedUsers.isInviteCopied(code) && (
|
||||||
<Text style={[pal.textLight, styles.codeCopied]}>Copied</Text>
|
<Text style={[pal.textLight, styles.codeCopied]}>Copied</Text>
|
||||||
)}
|
)}
|
||||||
{!used && (
|
{!used && (
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon={['far', 'clone']}
|
icon={['far', 'clone']}
|
||||||
style={pal.text as FontAwesomeIconStyle}
|
style={pal.text as FontAwesomeIconStyle}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)
|
)
|
||||||
},
|
})
|
||||||
)
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
|
|
|
@ -24,210 +24,207 @@ import isEqual from 'lodash.isequal'
|
||||||
|
|
||||||
export const snapPoints = ['fullscreen']
|
export const snapPoints = ['fullscreen']
|
||||||
|
|
||||||
export const Component = observer(
|
export const Component = observer(function ListAddRemoveUserImpl({
|
||||||
({
|
subject,
|
||||||
subject,
|
displayName,
|
||||||
displayName,
|
onUpdate,
|
||||||
onUpdate,
|
}: {
|
||||||
}: {
|
subject: string
|
||||||
subject: string
|
displayName: string
|
||||||
displayName: string
|
onUpdate?: () => void
|
||||||
onUpdate?: () => void
|
}) {
|
||||||
}) => {
|
const store = useStores()
|
||||||
const store = useStores()
|
const pal = usePalette('default')
|
||||||
const pal = usePalette('default')
|
const palPrimary = usePalette('primary')
|
||||||
const palPrimary = usePalette('primary')
|
const palInverted = usePalette('inverted')
|
||||||
const palInverted = usePalette('inverted')
|
const [originalSelections, setOriginalSelections] = React.useState<string[]>(
|
||||||
const [originalSelections, setOriginalSelections] = React.useState<
|
[],
|
||||||
string[]
|
)
|
||||||
>([])
|
const [selected, setSelected] = React.useState<string[]>([])
|
||||||
const [selected, setSelected] = React.useState<string[]>([])
|
const [membershipsLoaded, setMembershipsLoaded] = React.useState(false)
|
||||||
const [membershipsLoaded, setMembershipsLoaded] = React.useState(false)
|
|
||||||
|
|
||||||
const listsList: ListsListModel = React.useMemo(
|
const listsList: ListsListModel = React.useMemo(
|
||||||
() => new ListsListModel(store, store.me.did),
|
() => new ListsListModel(store, store.me.did),
|
||||||
[store],
|
[store],
|
||||||
|
)
|
||||||
|
const memberships: ListMembershipModel = React.useMemo(
|
||||||
|
() => new ListMembershipModel(store, subject),
|
||||||
|
[store, subject],
|
||||||
|
)
|
||||||
|
React.useEffect(() => {
|
||||||
|
listsList.refresh()
|
||||||
|
memberships.fetch().then(
|
||||||
|
() => {
|
||||||
|
const ids = memberships.memberships.map(m => m.value.list)
|
||||||
|
setOriginalSelections(ids)
|
||||||
|
setSelected(ids)
|
||||||
|
setMembershipsLoaded(true)
|
||||||
|
},
|
||||||
|
err => {
|
||||||
|
store.log.error('Failed to fetch memberships', {err})
|
||||||
|
},
|
||||||
)
|
)
|
||||||
const memberships: ListMembershipModel = React.useMemo(
|
}, [memberships, listsList, store, setSelected, setMembershipsLoaded])
|
||||||
() => new ListMembershipModel(store, subject),
|
|
||||||
[store, subject],
|
|
||||||
)
|
|
||||||
React.useEffect(() => {
|
|
||||||
listsList.refresh()
|
|
||||||
memberships.fetch().then(
|
|
||||||
() => {
|
|
||||||
const ids = memberships.memberships.map(m => m.value.list)
|
|
||||||
setOriginalSelections(ids)
|
|
||||||
setSelected(ids)
|
|
||||||
setMembershipsLoaded(true)
|
|
||||||
},
|
|
||||||
err => {
|
|
||||||
store.log.error('Failed to fetch memberships', {err})
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}, [memberships, listsList, store, setSelected, setMembershipsLoaded])
|
|
||||||
|
|
||||||
const onPressCancel = useCallback(() => {
|
const onPressCancel = useCallback(() => {
|
||||||
store.shell.closeModal()
|
store.shell.closeModal()
|
||||||
}, [store])
|
}, [store])
|
||||||
|
|
||||||
const onPressSave = useCallback(async () => {
|
const onPressSave = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await memberships.updateTo(selected)
|
await memberships.updateTo(selected)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
store.log.error('Failed to update memberships', {err})
|
store.log.error('Failed to update memberships', {err})
|
||||||
return
|
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])
|
||||||
}
|
}
|
||||||
Toast.show('Lists updated')
|
},
|
||||||
onUpdate?.()
|
[selected, setSelected],
|
||||||
store.shell.closeModal()
|
)
|
||||||
}, [store, selected, memberships, onUpdate])
|
|
||||||
|
|
||||||
const onPressNewMuteList = useCallback(() => {
|
const renderItem = useCallback(
|
||||||
store.shell.openModal({
|
(list: GraphDefs.ListView) => {
|
||||||
name: 'create-or-edit-mute-list',
|
const isSelected = selected.includes(list.uri)
|
||||||
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,
|
|
||||||
{opacity: membershipsLoaded ? 1 : 0.5},
|
|
||||||
]}
|
|
||||||
accessibilityLabel={`${isSelected ? 'Remove from' : 'Add to'} ${
|
|
||||||
list.name
|
|
||||||
}`}
|
|
||||||
accessibilityHint=""
|
|
||||||
disabled={!membershipsLoaded}
|
|
||||||
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'
|
|
||||||
: sanitizeHandle(list.creator.handle, '@')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
{membershipsLoaded && (
|
|
||||||
<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,
|
|
||||||
membershipsLoaded,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
const renderEmptyState = React.useCallback(() => {
|
|
||||||
return (
|
return (
|
||||||
<EmptyStateWithButton
|
<Pressable
|
||||||
icon="users-slash"
|
testID={`toggleBtn-${list.name}`}
|
||||||
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."
|
style={[
|
||||||
buttonLabel="New Mute List"
|
styles.listItem,
|
||||||
onPress={onPressNewMuteList}
|
pal.border,
|
||||||
/>
|
{opacity: membershipsLoaded ? 1 : 0.5},
|
||||||
)
|
]}
|
||||||
}, [onPressNewMuteList])
|
accessibilityLabel={`${isSelected ? 'Remove from' : 'Add to'} ${
|
||||||
|
list.name
|
||||||
// Only show changes button if there are some items on the list to choose from AND user has made changes in selection
|
}`}
|
||||||
const canSaveChanges =
|
accessibilityHint=""
|
||||||
!listsList.isEmpty && !isEqual(selected, originalSelections)
|
disabled={!membershipsLoaded}
|
||||||
|
onPress={() => onToggleSelected(list.uri)}>
|
||||||
return (
|
<View style={styles.listItemAvi}>
|
||||||
<View testID="listAddRemoveUserModal" style={s.hContentRegion}>
|
<UserAvatar size={40} avatar={list.avatar} />
|
||||||
<Text style={[styles.title, pal.text]}>Add {displayName} to Lists</Text>
|
</View>
|
||||||
<ListsList
|
<View style={styles.listItemContent}>
|
||||||
listsList={listsList}
|
<Text
|
||||||
showAddBtns
|
type="lg"
|
||||||
onPressCreateNew={onPressNewMuteList}
|
style={[s.bold, pal.text]}
|
||||||
renderItem={renderItem}
|
numberOfLines={1}
|
||||||
renderEmptyState={renderEmptyState}
|
lineHeight={1.2}>
|
||||||
style={[styles.list, pal.border]}
|
{sanitizeDisplayName(list.name)}
|
||||||
/>
|
</Text>
|
||||||
<View style={[styles.btns, pal.border]}>
|
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
|
||||||
<Button
|
{list.purpose === 'app.bsky.graph.defs#modlist' && 'Mute list'} by{' '}
|
||||||
testID="cancelBtn"
|
{list.creator.did === store.me.did
|
||||||
type="default"
|
? 'you'
|
||||||
onPress={onPressCancel}
|
: sanitizeHandle(list.creator.handle, '@')}
|
||||||
style={styles.footerBtn}
|
</Text>
|
||||||
accessibilityLabel="Cancel"
|
</View>
|
||||||
accessibilityHint=""
|
{membershipsLoaded && (
|
||||||
onAccessibilityEscape={onPressCancel}
|
<View
|
||||||
label="Cancel"
|
style={
|
||||||
/>
|
isSelected
|
||||||
{canSaveChanges && (
|
? [styles.checkbox, palPrimary.border, palPrimary.view]
|
||||||
<Button
|
: [styles.checkbox, pal.borderDark]
|
||||||
testID="saveBtn"
|
}>
|
||||||
type="primary"
|
{isSelected && (
|
||||||
onPress={onPressSave}
|
<FontAwesomeIcon
|
||||||
style={styles.footerBtn}
|
icon="check"
|
||||||
accessibilityLabel="Save changes"
|
style={palInverted.text as FontAwesomeIconStyle}
|
||||||
accessibilityHint=""
|
/>
|
||||||
onAccessibilityEscape={onPressSave}
|
)}
|
||||||
label="Save Changes"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(listsList.isLoading || !membershipsLoaded) && (
|
|
||||||
<View style={styles.loadingContainer}>
|
|
||||||
<ActivityIndicator />
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</Pressable>
|
||||||
</View>
|
)
|
||||||
|
},
|
||||||
|
[
|
||||||
|
pal,
|
||||||
|
palPrimary,
|
||||||
|
palInverted,
|
||||||
|
onToggleSelected,
|
||||||
|
selected,
|
||||||
|
store.me.did,
|
||||||
|
membershipsLoaded,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
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])
|
||||||
)
|
|
||||||
|
// Only show changes button if there are some items on the list to choose from AND user has made changes in selection
|
||||||
|
const canSaveChanges =
|
||||||
|
!listsList.isEmpty && !isEqual(selected, originalSelections)
|
||||||
|
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
{canSaveChanges && (
|
||||||
|
<Button
|
||||||
|
testID="saveBtn"
|
||||||
|
type="primary"
|
||||||
|
onPress={onPressSave}
|
||||||
|
style={styles.footerBtn}
|
||||||
|
accessibilityLabel="Save changes"
|
||||||
|
accessibilityHint=""
|
||||||
|
onAccessibilityEscape={onPressSave}
|
||||||
|
label="Save Changes"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(listsList.isLoading || !membershipsLoaded) && (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
|
|
|
@ -14,7 +14,11 @@ import {s} from 'lib/styles'
|
||||||
|
|
||||||
export const snapPoints = [520, '100%']
|
export const snapPoints = [520, '100%']
|
||||||
|
|
||||||
export const Component = observer(({did}: {did: string}) => {
|
export const Component = observer(function ProfilePreviewImpl({
|
||||||
|
did,
|
||||||
|
}: {
|
||||||
|
did: string
|
||||||
|
}) {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const [model] = useState(new ProfileModel(store, {actor: did}))
|
const [model] = useState(new ProfileModel(store, {actor: did}))
|
||||||
|
|
|
@ -5,43 +5,41 @@ import {observer} from 'mobx-react-lite'
|
||||||
import {ToggleButton} from 'view/com/util/forms/ToggleButton'
|
import {ToggleButton} from 'view/com/util/forms/ToggleButton'
|
||||||
import {useStores} from 'state/index'
|
import {useStores} from 'state/index'
|
||||||
|
|
||||||
export const LanguageToggle = observer(
|
export const LanguageToggle = observer(function LanguageToggleImpl({
|
||||||
({
|
code2,
|
||||||
code2,
|
name,
|
||||||
name,
|
onPress,
|
||||||
onPress,
|
langType,
|
||||||
langType,
|
}: {
|
||||||
}: {
|
code2: string
|
||||||
code2: string
|
name: string
|
||||||
name: string
|
onPress: () => void
|
||||||
onPress: () => void
|
langType: 'contentLanguages' | 'postLanguages'
|
||||||
langType: 'contentLanguages' | 'postLanguages'
|
}) {
|
||||||
}) => {
|
const pal = usePalette('default')
|
||||||
const pal = usePalette('default')
|
const store = useStores()
|
||||||
const store = useStores()
|
|
||||||
|
|
||||||
const isSelected = store.preferences[langType].includes(code2)
|
const isSelected = store.preferences[langType].includes(code2)
|
||||||
|
|
||||||
// enforce a max of 3 selections for post languages
|
// enforce a max of 3 selections for post languages
|
||||||
let isDisabled = false
|
let isDisabled = false
|
||||||
if (
|
if (
|
||||||
langType === 'postLanguages' &&
|
langType === 'postLanguages' &&
|
||||||
store.preferences[langType].length >= 3 &&
|
store.preferences[langType].length >= 3 &&
|
||||||
!isSelected
|
!isSelected
|
||||||
) {
|
) {
|
||||||
isDisabled = true
|
isDisabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToggleButton
|
<ToggleButton
|
||||||
label={name}
|
label={name}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
onPress={isDisabled ? undefined : onPress}
|
onPress={isDisabled ? undefined : onPress}
|
||||||
style={[pal.border, styles.languageToggle, isDisabled && styles.dimmed]}
|
style={[pal.border, styles.languageToggle, isDisabled && styles.dimmed]}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
},
|
})
|
||||||
)
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
languageToggle: {
|
languageToggle: {
|
||||||
|
|
|
@ -13,7 +13,7 @@ import {ToggleButton} from 'view/com/util/forms/ToggleButton'
|
||||||
|
|
||||||
export const snapPoints = ['100%']
|
export const snapPoints = ['100%']
|
||||||
|
|
||||||
export const Component = observer(() => {
|
export const Component = observer(function PostLanguagesSettingsImpl() {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const {isMobile} = useWebMediaQueries()
|
const {isMobile} = useWebMediaQueries()
|
||||||
|
|
|
@ -52,7 +52,7 @@ interface Author {
|
||||||
moderation: ProfileModeration
|
moderation: ProfileModeration
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FeedItem = observer(function ({
|
export const FeedItem = observer(function FeedItemImpl({
|
||||||
item,
|
item,
|
||||||
}: {
|
}: {
|
||||||
item: NotificationsFeedItemModel
|
item: NotificationsFeedItemModel
|
||||||
|
|
|
@ -18,7 +18,7 @@ import {s} from 'lib/styles'
|
||||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||||
import {makeProfileLink} from 'lib/routes/links'
|
import {makeProfileLink} from 'lib/routes/links'
|
||||||
|
|
||||||
export const InvitedUsers = observer(() => {
|
export const InvitedUsers = observer(function InvitedUsersImpl() {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
return (
|
return (
|
||||||
<CenteredView>
|
<CenteredView>
|
||||||
|
|
|
@ -9,59 +9,55 @@ import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
|
||||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||||
import {FeedsTabBar as FeedsTabBarMobile} from './FeedsTabBarMobile'
|
import {FeedsTabBar as FeedsTabBarMobile} from './FeedsTabBarMobile'
|
||||||
|
|
||||||
export const FeedsTabBar = observer(
|
export const FeedsTabBar = observer(function FeedsTabBarImpl(
|
||||||
(
|
props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
|
||||||
props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
|
) {
|
||||||
) => {
|
const {isMobile} = useWebMediaQueries()
|
||||||
const {isMobile} = useWebMediaQueries()
|
if (isMobile) {
|
||||||
if (isMobile) {
|
return <FeedsTabBarMobile {...props} />
|
||||||
return <FeedsTabBarMobile {...props} />
|
} else {
|
||||||
} else {
|
return <FeedsTabBarDesktop {...props} />
|
||||||
return <FeedsTabBarDesktop {...props} />
|
}
|
||||||
}
|
})
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const FeedsTabBarDesktop = observer(
|
const FeedsTabBarDesktop = observer(function FeedsTabBarDesktopImpl(
|
||||||
(
|
props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
|
||||||
props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
|
) {
|
||||||
) => {
|
const store = useStores()
|
||||||
const store = useStores()
|
const items = useMemo(
|
||||||
const items = useMemo(
|
() => ['Following', ...store.me.savedFeeds.pinnedFeedNames],
|
||||||
() => ['Following', ...store.me.savedFeeds.pinnedFeedNames],
|
[store.me.savedFeeds.pinnedFeedNames],
|
||||||
[store.me.savedFeeds.pinnedFeedNames],
|
)
|
||||||
)
|
const pal = usePalette('default')
|
||||||
const pal = usePalette('default')
|
const interp = useAnimatedValue(0)
|
||||||
const interp = useAnimatedValue(0)
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
Animated.timing(interp, {
|
Animated.timing(interp, {
|
||||||
toValue: store.shell.minimalShellMode ? 1 : 0,
|
toValue: store.shell.minimalShellMode ? 1 : 0,
|
||||||
duration: 100,
|
duration: 100,
|
||||||
useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
isInteraction: false,
|
isInteraction: false,
|
||||||
}).start()
|
}).start()
|
||||||
}, [interp, store.shell.minimalShellMode])
|
}, [interp, store.shell.minimalShellMode])
|
||||||
const transform = {
|
const transform = {
|
||||||
transform: [
|
transform: [
|
||||||
{translateX: '-50%'},
|
{translateX: '-50%'},
|
||||||
{translateY: Animated.multiply(interp, -100)},
|
{translateY: Animated.multiply(interp, -100)},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// @ts-ignore the type signature for transform wrong here, translateX and translateY need to be in separate objects -prf
|
// @ts-ignore the type signature for transform wrong here, translateX and translateY need to be in separate objects -prf
|
||||||
<Animated.View style={[pal.view, styles.tabBar, transform]}>
|
<Animated.View style={[pal.view, styles.tabBar, transform]}>
|
||||||
<TabBar
|
<TabBar
|
||||||
key={items.join(',')}
|
key={items.join(',')}
|
||||||
{...props}
|
{...props}
|
||||||
items={items}
|
items={items}
|
||||||
indicatorColor={pal.colors.link}
|
indicatorColor={pal.colors.link}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
)
|
)
|
||||||
},
|
})
|
||||||
)
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
tabBar: {
|
tabBar: {
|
||||||
|
|
|
@ -14,79 +14,77 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||||
import {s} from 'lib/styles'
|
import {s} from 'lib/styles'
|
||||||
import {HITSLOP_10} from 'lib/constants'
|
import {HITSLOP_10} from 'lib/constants'
|
||||||
|
|
||||||
export const FeedsTabBar = observer(
|
export const FeedsTabBar = observer(function FeedsTabBarImpl(
|
||||||
(
|
props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
|
||||||
props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
|
) {
|
||||||
) => {
|
const store = useStores()
|
||||||
const store = useStores()
|
const pal = usePalette('default')
|
||||||
const pal = usePalette('default')
|
const interp = useAnimatedValue(0)
|
||||||
const interp = useAnimatedValue(0)
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
Animated.timing(interp, {
|
Animated.timing(interp, {
|
||||||
toValue: store.shell.minimalShellMode ? 1 : 0,
|
toValue: store.shell.minimalShellMode ? 1 : 0,
|
||||||
duration: 100,
|
duration: 100,
|
||||||
useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
isInteraction: false,
|
isInteraction: false,
|
||||||
}).start()
|
}).start()
|
||||||
}, [interp, store.shell.minimalShellMode])
|
}, [interp, store.shell.minimalShellMode])
|
||||||
const transform = {
|
const transform = {
|
||||||
transform: [{translateY: Animated.multiply(interp, -100)}],
|
transform: [{translateY: Animated.multiply(interp, -100)}],
|
||||||
}
|
}
|
||||||
|
|
||||||
const brandBlue = useColorSchemeStyle(s.brandBlue, s.blue3)
|
const brandBlue = useColorSchemeStyle(s.brandBlue, s.blue3)
|
||||||
|
|
||||||
const onPressAvi = React.useCallback(() => {
|
const onPressAvi = React.useCallback(() => {
|
||||||
store.shell.openDrawer()
|
store.shell.openDrawer()
|
||||||
}, [store])
|
}, [store])
|
||||||
|
|
||||||
const items = useMemo(
|
const items = useMemo(
|
||||||
() => ['Following', ...store.me.savedFeeds.pinnedFeedNames],
|
() => ['Following', ...store.me.savedFeeds.pinnedFeedNames],
|
||||||
[store.me.savedFeeds.pinnedFeedNames],
|
[store.me.savedFeeds.pinnedFeedNames],
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View style={[pal.view, pal.border, styles.tabBar, transform]}>
|
<Animated.View style={[pal.view, pal.border, styles.tabBar, transform]}>
|
||||||
<View style={[pal.view, styles.topBar]}>
|
<View style={[pal.view, styles.topBar]}>
|
||||||
<View style={[pal.view]}>
|
<View style={[pal.view]}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
testID="viewHeaderDrawerBtn"
|
testID="viewHeaderDrawerBtn"
|
||||||
onPress={onPressAvi}
|
onPress={onPressAvi}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Open navigation"
|
accessibilityLabel="Open navigation"
|
||||||
accessibilityHint="Access profile and other navigation links"
|
accessibilityHint="Access profile and other navigation links"
|
||||||
hitSlop={HITSLOP_10}>
|
hitSlop={HITSLOP_10}>
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon="bars"
|
icon="bars"
|
||||||
size={18}
|
size={18}
|
||||||
color={pal.colors.textLight}
|
color={pal.colors.textLight}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
|
||||||
<Text style={[brandBlue, s.bold, styles.title]}>
|
|
||||||
{store.session.isSandbox ? 'SANDBOX' : 'Bluesky'}
|
|
||||||
</Text>
|
|
||||||
<View style={[pal.view]}>
|
|
||||||
<Link
|
|
||||||
href="/settings/saved-feeds"
|
|
||||||
hitSlop={HITSLOP_10}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel="Edit Saved Feeds"
|
|
||||||
accessibilityHint="Opens screen to edit Saved Feeds">
|
|
||||||
<CogIcon size={21} strokeWidth={2} style={pal.textLight} />
|
|
||||||
</Link>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
<TabBar
|
<Text style={[brandBlue, s.bold, styles.title]}>
|
||||||
key={items.join(',')}
|
{store.session.isSandbox ? 'SANDBOX' : 'Bluesky'}
|
||||||
{...props}
|
</Text>
|
||||||
items={items}
|
<View style={[pal.view]}>
|
||||||
indicatorColor={pal.colors.link}
|
<Link
|
||||||
/>
|
href="/settings/saved-feeds"
|
||||||
</Animated.View>
|
hitSlop={HITSLOP_10}
|
||||||
)
|
accessibilityRole="button"
|
||||||
},
|
accessibilityLabel="Edit Saved Feeds"
|
||||||
)
|
accessibilityHint="Opens screen to edit Saved Feeds">
|
||||||
|
<CogIcon size={21} strokeWidth={2} style={pal.textLight} />
|
||||||
|
</Link>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<TabBar
|
||||||
|
key={items.join(',')}
|
||||||
|
{...props}
|
||||||
|
items={items}
|
||||||
|
indicatorColor={pal.colors.link}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
tabBar: {
|
tabBar: {
|
||||||
|
|
|
@ -8,7 +8,11 @@ import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
|
||||||
import {useStores} from 'state/index'
|
import {useStores} from 'state/index'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
|
|
||||||
export const PostLikedBy = observer(function ({uri}: {uri: string}) {
|
export const PostLikedBy = observer(function PostLikedByImpl({
|
||||||
|
uri,
|
||||||
|
}: {
|
||||||
|
uri: string
|
||||||
|
}) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const view = React.useMemo(() => new LikesModel(store, {uri}), [store, uri])
|
const view = React.useMemo(() => new LikesModel(store, {uri}), [store, uri])
|
||||||
|
@ -64,6 +68,8 @@ export const PostLikedBy = observer(function ({uri}: {uri: string}) {
|
||||||
onEndReached={onEndReached}
|
onEndReached={onEndReached}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
initialNumToRender={15}
|
initialNumToRender={15}
|
||||||
|
// FIXME(dan)
|
||||||
|
// eslint-disable-next-line react/no-unstable-nested-components
|
||||||
ListFooterComponent={() => (
|
ListFooterComponent={() => (
|
||||||
<View style={styles.footer}>
|
<View style={styles.footer}>
|
||||||
{view.isLoading && <ActivityIndicator />}
|
{view.isLoading && <ActivityIndicator />}
|
||||||
|
|
|
@ -8,7 +8,7 @@ import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||||
import {useStores} from 'state/index'
|
import {useStores} from 'state/index'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
|
|
||||||
export const PostRepostedBy = observer(function PostRepostedBy({
|
export const PostRepostedBy = observer(function PostRepostedByImpl({
|
||||||
uri,
|
uri,
|
||||||
}: {
|
}: {
|
||||||
uri: string
|
uri: string
|
||||||
|
@ -75,6 +75,8 @@ export const PostRepostedBy = observer(function PostRepostedBy({
|
||||||
onEndReached={onEndReached}
|
onEndReached={onEndReached}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
initialNumToRender={15}
|
initialNumToRender={15}
|
||||||
|
// FIXME(dan)
|
||||||
|
// eslint-disable-next-line react/no-unstable-nested-components
|
||||||
ListFooterComponent={() => (
|
ListFooterComponent={() => (
|
||||||
<View style={styles.footer}>
|
<View style={styles.footer}>
|
||||||
{view.isLoading && <ActivityIndicator />}
|
{view.isLoading && <ActivityIndicator />}
|
||||||
|
|
|
@ -31,7 +31,7 @@ import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers'
|
import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers'
|
||||||
import {makeProfileLink} from 'lib/routes/links'
|
import {makeProfileLink} from 'lib/routes/links'
|
||||||
|
|
||||||
export const Post = observer(function Post({
|
export const Post = observer(function PostImpl({
|
||||||
view,
|
view,
|
||||||
showReplyLine,
|
showReplyLine,
|
||||||
hideError,
|
hideError,
|
||||||
|
@ -88,214 +88,212 @@ export const Post = observer(function Post({
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const PostLoaded = observer(
|
const PostLoaded = observer(function PostLoadedImpl({
|
||||||
({
|
item,
|
||||||
item,
|
record,
|
||||||
record,
|
setDeleted,
|
||||||
setDeleted,
|
showReplyLine,
|
||||||
showReplyLine,
|
style,
|
||||||
style,
|
}: {
|
||||||
}: {
|
item: PostThreadItemModel
|
||||||
item: PostThreadItemModel
|
record: FeedPost.Record
|
||||||
record: FeedPost.Record
|
setDeleted: (v: boolean) => void
|
||||||
setDeleted: (v: boolean) => void
|
showReplyLine?: boolean
|
||||||
showReplyLine?: boolean
|
style?: StyleProp<ViewStyle>
|
||||||
style?: StyleProp<ViewStyle>
|
}) {
|
||||||
}) => {
|
const pal = usePalette('default')
|
||||||
const pal = usePalette('default')
|
const store = useStores()
|
||||||
const store = useStores()
|
|
||||||
|
|
||||||
const itemUri = item.post.uri
|
const itemUri = item.post.uri
|
||||||
const itemCid = item.post.cid
|
const itemCid = item.post.cid
|
||||||
const itemUrip = new AtUri(item.post.uri)
|
const itemUrip = new AtUri(item.post.uri)
|
||||||
const itemHref = makeProfileLink(item.post.author, 'post', itemUrip.rkey)
|
const itemHref = makeProfileLink(item.post.author, 'post', itemUrip.rkey)
|
||||||
const itemTitle = `Post by ${item.post.author.handle}`
|
const itemTitle = `Post by ${item.post.author.handle}`
|
||||||
let replyAuthorDid = ''
|
let replyAuthorDid = ''
|
||||||
if (record.reply) {
|
if (record.reply) {
|
||||||
const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri)
|
const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri)
|
||||||
replyAuthorDid = urip.hostname
|
replyAuthorDid = urip.hostname
|
||||||
}
|
}
|
||||||
|
|
||||||
const translatorUrl = getTranslatorLink(record?.text || '')
|
const translatorUrl = getTranslatorLink(record?.text || '')
|
||||||
const needsTranslation = useMemo(
|
const needsTranslation = useMemo(
|
||||||
() =>
|
() =>
|
||||||
store.preferences.contentLanguages.length > 0 &&
|
store.preferences.contentLanguages.length > 0 &&
|
||||||
!isPostInLanguage(item.post, store.preferences.contentLanguages),
|
!isPostInLanguage(item.post, store.preferences.contentLanguages),
|
||||||
[item.post, store.preferences.contentLanguages],
|
[item.post, store.preferences.contentLanguages],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onPressReply = React.useCallback(() => {
|
const onPressReply = React.useCallback(() => {
|
||||||
store.shell.openComposer({
|
store.shell.openComposer({
|
||||||
replyTo: {
|
replyTo: {
|
||||||
uri: item.post.uri,
|
uri: item.post.uri,
|
||||||
cid: item.post.cid,
|
cid: item.post.cid,
|
||||||
text: record.text as string,
|
text: record.text as string,
|
||||||
author: {
|
author: {
|
||||||
handle: item.post.author.handle,
|
handle: item.post.author.handle,
|
||||||
displayName: item.post.author.displayName,
|
displayName: item.post.author.displayName,
|
||||||
avatar: item.post.author.avatar,
|
avatar: item.post.author.avatar,
|
||||||
},
|
|
||||||
},
|
},
|
||||||
})
|
},
|
||||||
}, [store, item, record])
|
})
|
||||||
|
}, [store, item, record])
|
||||||
|
|
||||||
const onPressToggleRepost = React.useCallback(() => {
|
const onPressToggleRepost = React.useCallback(() => {
|
||||||
return item
|
return item
|
||||||
.toggleRepost()
|
.toggleRepost()
|
||||||
.catch(e => store.log.error('Failed to toggle repost', e))
|
.catch(e => store.log.error('Failed to toggle repost', e))
|
||||||
}, [item, store])
|
}, [item, store])
|
||||||
|
|
||||||
const onPressToggleLike = React.useCallback(() => {
|
const onPressToggleLike = React.useCallback(() => {
|
||||||
return item
|
return item
|
||||||
.toggleLike()
|
.toggleLike()
|
||||||
.catch(e => store.log.error('Failed to toggle like', e))
|
.catch(e => store.log.error('Failed to toggle like', e))
|
||||||
}, [item, store])
|
}, [item, store])
|
||||||
|
|
||||||
const onCopyPostText = React.useCallback(() => {
|
const onCopyPostText = React.useCallback(() => {
|
||||||
Clipboard.setString(record.text)
|
Clipboard.setString(record.text)
|
||||||
Toast.show('Copied to clipboard')
|
Toast.show('Copied to clipboard')
|
||||||
}, [record])
|
}, [record])
|
||||||
|
|
||||||
const onOpenTranslate = React.useCallback(() => {
|
const onOpenTranslate = React.useCallback(() => {
|
||||||
Linking.openURL(translatorUrl)
|
Linking.openURL(translatorUrl)
|
||||||
}, [translatorUrl])
|
}, [translatorUrl])
|
||||||
|
|
||||||
const onToggleThreadMute = React.useCallback(async () => {
|
const onToggleThreadMute = React.useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await item.toggleThreadMute()
|
await item.toggleThreadMute()
|
||||||
if (item.isThreadMuted) {
|
if (item.isThreadMuted) {
|
||||||
Toast.show('You will no longer receive notifications for this thread')
|
Toast.show('You will no longer receive notifications for this thread')
|
||||||
} else {
|
} else {
|
||||||
Toast.show('You will now receive notifications for this thread')
|
Toast.show('You will now receive notifications for this thread')
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
store.log.error('Failed to toggle thread mute', e)
|
|
||||||
}
|
}
|
||||||
}, [item, store])
|
} catch (e) {
|
||||||
|
store.log.error('Failed to toggle thread mute', e)
|
||||||
|
}
|
||||||
|
}, [item, store])
|
||||||
|
|
||||||
const onDeletePost = React.useCallback(() => {
|
const onDeletePost = React.useCallback(() => {
|
||||||
item.delete().then(
|
item.delete().then(
|
||||||
() => {
|
() => {
|
||||||
setDeleted(true)
|
setDeleted(true)
|
||||||
Toast.show('Post deleted')
|
Toast.show('Post deleted')
|
||||||
},
|
},
|
||||||
e => {
|
e => {
|
||||||
store.log.error('Failed to delete post', e)
|
store.log.error('Failed to delete post', e)
|
||||||
Toast.show('Failed to delete post, please try again')
|
Toast.show('Failed to delete post, please try again')
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}, [item, setDeleted, store])
|
}, [item, setDeleted, store])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={itemHref} style={[styles.outer, pal.view, pal.border, style]}>
|
<Link href={itemHref} style={[styles.outer, pal.view, pal.border, style]}>
|
||||||
{showReplyLine && <View style={styles.replyLine} />}
|
{showReplyLine && <View style={styles.replyLine} />}
|
||||||
<View style={styles.layout}>
|
<View style={styles.layout}>
|
||||||
<View style={styles.layoutAvi}>
|
<View style={styles.layoutAvi}>
|
||||||
<PreviewableUserAvatar
|
<PreviewableUserAvatar
|
||||||
size={52}
|
size={52}
|
||||||
did={item.post.author.did}
|
did={item.post.author.did}
|
||||||
handle={item.post.author.handle}
|
handle={item.post.author.handle}
|
||||||
avatar={item.post.author.avatar}
|
avatar={item.post.author.avatar}
|
||||||
moderation={item.moderation.avatar}
|
moderation={item.moderation.avatar}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.layoutContent}>
|
<View style={styles.layoutContent}>
|
||||||
<PostMeta
|
<PostMeta
|
||||||
author={item.post.author}
|
author={item.post.author}
|
||||||
authorHasWarning={!!item.post.author.labels?.length}
|
authorHasWarning={!!item.post.author.labels?.length}
|
||||||
timestamp={item.post.indexedAt}
|
timestamp={item.post.indexedAt}
|
||||||
postHref={itemHref}
|
postHref={itemHref}
|
||||||
/>
|
/>
|
||||||
{replyAuthorDid !== '' && (
|
{replyAuthorDid !== '' && (
|
||||||
<View style={[s.flexRow, s.mb2, s.alignCenter]}>
|
<View style={[s.flexRow, s.mb2, s.alignCenter]}>
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon="reply"
|
icon="reply"
|
||||||
size={9}
|
size={9}
|
||||||
style={[pal.textLight, s.mr5]}
|
style={[pal.textLight, s.mr5]}
|
||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
|
type="sm"
|
||||||
|
style={[pal.textLight, s.mr2]}
|
||||||
|
lineHeight={1.2}
|
||||||
|
numberOfLines={1}>
|
||||||
|
Reply to{' '}
|
||||||
|
<UserInfoText
|
||||||
type="sm"
|
type="sm"
|
||||||
style={[pal.textLight, s.mr2]}
|
did={replyAuthorDid}
|
||||||
lineHeight={1.2}
|
attr="displayName"
|
||||||
numberOfLines={1}>
|
style={[pal.textLight]}
|
||||||
Reply to{' '}
|
/>
|
||||||
<UserInfoText
|
</Text>
|
||||||
type="sm"
|
</View>
|
||||||
did={replyAuthorDid}
|
)}
|
||||||
attr="displayName"
|
<ContentHider
|
||||||
style={[pal.textLight]}
|
moderation={item.moderation.content}
|
||||||
/>
|
style={styles.contentHider}
|
||||||
</Text>
|
childContainerStyle={styles.contentHiderChild}>
|
||||||
|
<PostAlerts
|
||||||
|
moderation={item.moderation.content}
|
||||||
|
style={styles.alert}
|
||||||
|
/>
|
||||||
|
{item.richText?.text ? (
|
||||||
|
<View style={styles.postTextContainer}>
|
||||||
|
<RichText
|
||||||
|
testID="postText"
|
||||||
|
type="post-text"
|
||||||
|
richText={item.richText}
|
||||||
|
lineHeight={1.3}
|
||||||
|
style={s.flex1}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : undefined}
|
||||||
|
{item.post.embed ? (
|
||||||
|
<ContentHider
|
||||||
|
moderation={item.moderation.embed}
|
||||||
|
style={styles.contentHider}>
|
||||||
|
<PostEmbeds
|
||||||
|
embed={item.post.embed}
|
||||||
|
moderation={item.moderation.embed}
|
||||||
|
/>
|
||||||
|
</ContentHider>
|
||||||
|
) : null}
|
||||||
|
{needsTranslation && (
|
||||||
|
<View style={[pal.borderDark, styles.translateLink]}>
|
||||||
|
<Link href={translatorUrl} title="Translate">
|
||||||
|
<Text type="sm" style={pal.link}>
|
||||||
|
Translate this post
|
||||||
|
</Text>
|
||||||
|
</Link>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<ContentHider
|
</ContentHider>
|
||||||
moderation={item.moderation.content}
|
<PostCtrls
|
||||||
style={styles.contentHider}
|
itemUri={itemUri}
|
||||||
childContainerStyle={styles.contentHiderChild}>
|
itemCid={itemCid}
|
||||||
<PostAlerts
|
itemHref={itemHref}
|
||||||
moderation={item.moderation.content}
|
itemTitle={itemTitle}
|
||||||
style={styles.alert}
|
author={item.post.author}
|
||||||
/>
|
indexedAt={item.post.indexedAt}
|
||||||
{item.richText?.text ? (
|
text={item.richText?.text || record.text}
|
||||||
<View style={styles.postTextContainer}>
|
isAuthor={item.post.author.did === store.me.did}
|
||||||
<RichText
|
replyCount={item.post.replyCount}
|
||||||
testID="postText"
|
repostCount={item.post.repostCount}
|
||||||
type="post-text"
|
likeCount={item.post.likeCount}
|
||||||
richText={item.richText}
|
isReposted={!!item.post.viewer?.repost}
|
||||||
lineHeight={1.3}
|
isLiked={!!item.post.viewer?.like}
|
||||||
style={s.flex1}
|
isThreadMuted={item.isThreadMuted}
|
||||||
/>
|
onPressReply={onPressReply}
|
||||||
</View>
|
onPressToggleRepost={onPressToggleRepost}
|
||||||
) : undefined}
|
onPressToggleLike={onPressToggleLike}
|
||||||
{item.post.embed ? (
|
onCopyPostText={onCopyPostText}
|
||||||
<ContentHider
|
onOpenTranslate={onOpenTranslate}
|
||||||
moderation={item.moderation.embed}
|
onToggleThreadMute={onToggleThreadMute}
|
||||||
style={styles.contentHider}>
|
onDeletePost={onDeletePost}
|
||||||
<PostEmbeds
|
/>
|
||||||
embed={item.post.embed}
|
|
||||||
moderation={item.moderation.embed}
|
|
||||||
/>
|
|
||||||
</ContentHider>
|
|
||||||
) : null}
|
|
||||||
{needsTranslation && (
|
|
||||||
<View style={[pal.borderDark, styles.translateLink]}>
|
|
||||||
<Link href={translatorUrl} title="Translate">
|
|
||||||
<Text type="sm" style={pal.link}>
|
|
||||||
Translate this post
|
|
||||||
</Text>
|
|
||||||
</Link>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</ContentHider>
|
|
||||||
<PostCtrls
|
|
||||||
itemUri={itemUri}
|
|
||||||
itemCid={itemCid}
|
|
||||||
itemHref={itemHref}
|
|
||||||
itemTitle={itemTitle}
|
|
||||||
author={item.post.author}
|
|
||||||
indexedAt={item.post.indexedAt}
|
|
||||||
text={item.richText?.text || record.text}
|
|
||||||
isAuthor={item.post.author.did === store.me.did}
|
|
||||||
replyCount={item.post.replyCount}
|
|
||||||
repostCount={item.post.repostCount}
|
|
||||||
likeCount={item.post.likeCount}
|
|
||||||
isReposted={!!item.post.viewer?.repost}
|
|
||||||
isLiked={!!item.post.viewer?.like}
|
|
||||||
isThreadMuted={item.isThreadMuted}
|
|
||||||
onPressReply={onPressReply}
|
|
||||||
onPressToggleRepost={onPressToggleRepost}
|
|
||||||
onPressToggleLike={onPressToggleLike}
|
|
||||||
onCopyPostText={onCopyPostText}
|
|
||||||
onOpenTranslate={onOpenTranslate}
|
|
||||||
onToggleThreadMute={onToggleThreadMute}
|
|
||||||
onDeletePost={onDeletePost}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
</Link>
|
</View>
|
||||||
)
|
</Link>
|
||||||
},
|
)
|
||||||
)
|
})
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
outer: {
|
outer: {
|
||||||
|
|
|
@ -30,7 +30,7 @@ import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers'
|
||||||
import {makeProfileLink} from 'lib/routes/links'
|
import {makeProfileLink} from 'lib/routes/links'
|
||||||
import {isEmbedByEmbedder} from 'lib/embeds'
|
import {isEmbedByEmbedder} from 'lib/embeds'
|
||||||
|
|
||||||
export const FeedItem = observer(function ({
|
export const FeedItem = observer(function FeedItemImpl({
|
||||||
item,
|
item,
|
||||||
isThreadChild,
|
isThreadChild,
|
||||||
isThreadLastChild,
|
isThreadLastChild,
|
||||||
|
|
|
@ -10,63 +10,61 @@ import {FeedItem} from './FeedItem'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {makeProfileLink} from 'lib/routes/links'
|
import {makeProfileLink} from 'lib/routes/links'
|
||||||
|
|
||||||
export const FeedSlice = observer(
|
export const FeedSlice = observer(function FeedSliceImpl({
|
||||||
({
|
slice,
|
||||||
slice,
|
ignoreFilterFor,
|
||||||
ignoreFilterFor,
|
}: {
|
||||||
}: {
|
slice: PostsFeedSliceModel
|
||||||
slice: PostsFeedSliceModel
|
ignoreFilterFor?: string
|
||||||
ignoreFilterFor?: string
|
}) {
|
||||||
}) => {
|
if (slice.shouldFilter(ignoreFilterFor)) {
|
||||||
if (slice.shouldFilter(ignoreFilterFor)) {
|
return null
|
||||||
return null
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (slice.isThread && slice.items.length > 3) {
|
|
||||||
const last = slice.items.length - 1
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<FeedItem
|
|
||||||
key={slice.items[0]._reactKey}
|
|
||||||
item={slice.items[0]}
|
|
||||||
isThreadParent={slice.isThreadParentAt(0)}
|
|
||||||
isThreadChild={slice.isThreadChildAt(0)}
|
|
||||||
/>
|
|
||||||
<FeedItem
|
|
||||||
key={slice.items[1]._reactKey}
|
|
||||||
item={slice.items[1]}
|
|
||||||
isThreadParent={slice.isThreadParentAt(1)}
|
|
||||||
isThreadChild={slice.isThreadChildAt(1)}
|
|
||||||
/>
|
|
||||||
<ViewFullThread slice={slice} />
|
|
||||||
<FeedItem
|
|
||||||
key={slice.items[last]._reactKey}
|
|
||||||
item={slice.items[last]}
|
|
||||||
isThreadParent={slice.isThreadParentAt(last)}
|
|
||||||
isThreadChild={slice.isThreadChildAt(last)}
|
|
||||||
isThreadLastChild
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (slice.isThread && slice.items.length > 3) {
|
||||||
|
const last = slice.items.length - 1
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{slice.items.map((item, i) => (
|
<FeedItem
|
||||||
<FeedItem
|
key={slice.items[0]._reactKey}
|
||||||
key={item._reactKey}
|
item={slice.items[0]}
|
||||||
item={item}
|
isThreadParent={slice.isThreadParentAt(0)}
|
||||||
isThreadParent={slice.isThreadParentAt(i)}
|
isThreadChild={slice.isThreadChildAt(0)}
|
||||||
isThreadChild={slice.isThreadChildAt(i)}
|
/>
|
||||||
isThreadLastChild={
|
<FeedItem
|
||||||
slice.isThreadChildAt(i) && slice.items.length === i + 1
|
key={slice.items[1]._reactKey}
|
||||||
}
|
item={slice.items[1]}
|
||||||
/>
|
isThreadParent={slice.isThreadParentAt(1)}
|
||||||
))}
|
isThreadChild={slice.isThreadChildAt(1)}
|
||||||
|
/>
|
||||||
|
<ViewFullThread slice={slice} />
|
||||||
|
<FeedItem
|
||||||
|
key={slice.items[last]._reactKey}
|
||||||
|
item={slice.items[last]}
|
||||||
|
isThreadParent={slice.isThreadParentAt(last)}
|
||||||
|
isThreadChild={slice.isThreadChildAt(last)}
|
||||||
|
isThreadLastChild
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
)
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{slice.items.map((item, i) => (
|
||||||
|
<FeedItem
|
||||||
|
key={item._reactKey}
|
||||||
|
item={item}
|
||||||
|
isThreadParent={slice.isThreadParentAt(i)}
|
||||||
|
isThreadChild={slice.isThreadChildAt(i)}
|
||||||
|
isThreadLastChild={
|
||||||
|
slice.isThreadChildAt(i) && slice.items.length === i + 1
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
function ViewFullThread({slice}: {slice: PostsFeedSliceModel}) {
|
function ViewFullThread({slice}: {slice: PostsFeedSliceModel}) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
|
|
|
@ -6,56 +6,54 @@ import {useStores} from 'state/index'
|
||||||
import * as Toast from '../util/Toast'
|
import * as Toast from '../util/Toast'
|
||||||
import {FollowState} from 'state/models/cache/my-follows'
|
import {FollowState} from 'state/models/cache/my-follows'
|
||||||
|
|
||||||
export const FollowButton = observer(
|
export const FollowButton = observer(function FollowButtonImpl({
|
||||||
({
|
unfollowedType = 'inverted',
|
||||||
unfollowedType = 'inverted',
|
followedType = 'default',
|
||||||
followedType = 'default',
|
did,
|
||||||
did,
|
onToggleFollow,
|
||||||
onToggleFollow,
|
}: {
|
||||||
}: {
|
unfollowedType?: ButtonType
|
||||||
unfollowedType?: ButtonType
|
followedType?: ButtonType
|
||||||
followedType?: ButtonType
|
did: string
|
||||||
did: string
|
onToggleFollow?: (v: boolean) => void
|
||||||
onToggleFollow?: (v: boolean) => void
|
}) {
|
||||||
}) => {
|
const store = useStores()
|
||||||
const store = useStores()
|
const followState = store.me.follows.getFollowState(did)
|
||||||
const followState = store.me.follows.getFollowState(did)
|
|
||||||
|
|
||||||
if (followState === FollowState.Unknown) {
|
if (followState === FollowState.Unknown) {
|
||||||
return <View />
|
return <View />
|
||||||
}
|
}
|
||||||
|
|
||||||
const onToggleFollowInner = async () => {
|
const onToggleFollowInner = async () => {
|
||||||
const updatedFollowState = await store.me.follows.fetchFollowState(did)
|
const updatedFollowState = await store.me.follows.fetchFollowState(did)
|
||||||
if (updatedFollowState === FollowState.Following) {
|
if (updatedFollowState === FollowState.Following) {
|
||||||
try {
|
try {
|
||||||
await store.agent.deleteFollow(store.me.follows.getFollowUri(did))
|
await store.agent.deleteFollow(store.me.follows.getFollowUri(did))
|
||||||
store.me.follows.removeFollow(did)
|
store.me.follows.removeFollow(did)
|
||||||
onToggleFollow?.(false)
|
onToggleFollow?.(false)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
store.log.error('Failed to delete follow', e)
|
store.log.error('Failed to delete follow', e)
|
||||||
Toast.show('An issue occurred, please try again.')
|
Toast.show('An issue occurred, please try again.')
|
||||||
}
|
}
|
||||||
} else if (updatedFollowState === FollowState.NotFollowing) {
|
} else if (updatedFollowState === FollowState.NotFollowing) {
|
||||||
try {
|
try {
|
||||||
const res = await store.agent.follow(did)
|
const res = await store.agent.follow(did)
|
||||||
store.me.follows.addFollow(did, res.uri)
|
store.me.follows.addFollow(did, res.uri)
|
||||||
onToggleFollow?.(true)
|
onToggleFollow?.(true)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
store.log.error('Failed to create follow', e)
|
store.log.error('Failed to create follow', e)
|
||||||
Toast.show('An issue occurred, please try again.')
|
Toast.show('An issue occurred, please try again.')
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
type={
|
type={
|
||||||
followState === FollowState.Following ? followedType : unfollowedType
|
followState === FollowState.Following ? followedType : unfollowedType
|
||||||
}
|
}
|
||||||
onPress={onToggleFollowInner}
|
onPress={onToggleFollowInner}
|
||||||
label={followState === FollowState.Following ? 'Unfollow' : 'Follow'}
|
label={followState === FollowState.Following ? 'Unfollow' : 'Follow'}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
},
|
})
|
||||||
)
|
|
||||||
|
|
|
@ -22,89 +22,82 @@ import {
|
||||||
getModerationCauseKey,
|
getModerationCauseKey,
|
||||||
} from 'lib/moderation'
|
} from 'lib/moderation'
|
||||||
|
|
||||||
export const ProfileCard = observer(
|
export const ProfileCard = observer(function ProfileCardImpl({
|
||||||
({
|
testID,
|
||||||
testID,
|
profile,
|
||||||
profile,
|
noBg,
|
||||||
noBg,
|
noBorder,
|
||||||
noBorder,
|
followers,
|
||||||
followers,
|
renderButton,
|
||||||
renderButton,
|
}: {
|
||||||
}: {
|
testID?: string
|
||||||
testID?: string
|
profile: AppBskyActorDefs.ProfileViewBasic
|
||||||
profile: AppBskyActorDefs.ProfileViewBasic
|
noBg?: boolean
|
||||||
noBg?: boolean
|
noBorder?: boolean
|
||||||
noBorder?: boolean
|
followers?: AppBskyActorDefs.ProfileView[] | undefined
|
||||||
followers?: AppBskyActorDefs.ProfileView[] | undefined
|
renderButton?: (profile: AppBskyActorDefs.ProfileViewBasic) => React.ReactNode
|
||||||
renderButton?: (
|
}) {
|
||||||
profile: AppBskyActorDefs.ProfileViewBasic,
|
const store = useStores()
|
||||||
) => React.ReactNode
|
const pal = usePalette('default')
|
||||||
}) => {
|
|
||||||
const store = useStores()
|
|
||||||
const pal = usePalette('default')
|
|
||||||
|
|
||||||
const moderation = moderateProfile(
|
const moderation = moderateProfile(profile, store.preferences.moderationOpts)
|
||||||
profile,
|
|
||||||
store.preferences.moderationOpts,
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
testID={testID}
|
testID={testID}
|
||||||
style={[
|
style={[
|
||||||
styles.outer,
|
styles.outer,
|
||||||
pal.border,
|
pal.border,
|
||||||
noBorder && styles.outerNoBorder,
|
noBorder && styles.outerNoBorder,
|
||||||
!noBg && pal.view,
|
!noBg && pal.view,
|
||||||
]}
|
]}
|
||||||
href={makeProfileLink(profile)}
|
href={makeProfileLink(profile)}
|
||||||
title={profile.handle}
|
title={profile.handle}
|
||||||
asAnchor
|
asAnchor
|
||||||
anchorNoUnderline>
|
anchorNoUnderline>
|
||||||
<View style={styles.layout}>
|
<View style={styles.layout}>
|
||||||
<View style={styles.layoutAvi}>
|
<View style={styles.layoutAvi}>
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
size={40}
|
size={40}
|
||||||
avatar={profile.avatar}
|
avatar={profile.avatar}
|
||||||
moderation={moderation.avatar}
|
moderation={moderation.avatar}
|
||||||
/>
|
/>
|
||||||
</View>
|
|
||||||
<View style={styles.layoutContent}>
|
|
||||||
<Text
|
|
||||||
type="lg"
|
|
||||||
style={[s.bold, pal.text]}
|
|
||||||
numberOfLines={1}
|
|
||||||
lineHeight={1.2}>
|
|
||||||
{sanitizeDisplayName(
|
|
||||||
profile.displayName || sanitizeHandle(profile.handle),
|
|
||||||
moderation.profile,
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
|
|
||||||
{sanitizeHandle(profile.handle, '@')}
|
|
||||||
</Text>
|
|
||||||
<ProfileCardPills
|
|
||||||
followedBy={!!profile.viewer?.followedBy}
|
|
||||||
moderation={moderation}
|
|
||||||
/>
|
|
||||||
{!!profile.viewer?.followedBy && <View style={s.flexRow} />}
|
|
||||||
</View>
|
|
||||||
{renderButton ? (
|
|
||||||
<View style={styles.layoutButton}>{renderButton(profile)}</View>
|
|
||||||
) : undefined}
|
|
||||||
</View>
|
</View>
|
||||||
{profile.description ? (
|
<View style={styles.layoutContent}>
|
||||||
<View style={styles.details}>
|
<Text
|
||||||
<Text style={pal.text} numberOfLines={4}>
|
type="lg"
|
||||||
{profile.description as string}
|
style={[s.bold, pal.text]}
|
||||||
</Text>
|
numberOfLines={1}
|
||||||
</View>
|
lineHeight={1.2}>
|
||||||
|
{sanitizeDisplayName(
|
||||||
|
profile.displayName || sanitizeHandle(profile.handle),
|
||||||
|
moderation.profile,
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
|
||||||
|
{sanitizeHandle(profile.handle, '@')}
|
||||||
|
</Text>
|
||||||
|
<ProfileCardPills
|
||||||
|
followedBy={!!profile.viewer?.followedBy}
|
||||||
|
moderation={moderation}
|
||||||
|
/>
|
||||||
|
{!!profile.viewer?.followedBy && <View style={s.flexRow} />}
|
||||||
|
</View>
|
||||||
|
{renderButton ? (
|
||||||
|
<View style={styles.layoutButton}>{renderButton(profile)}</View>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
<FollowersList followers={followers} />
|
</View>
|
||||||
</Link>
|
{profile.description ? (
|
||||||
)
|
<View style={styles.details}>
|
||||||
},
|
<Text style={pal.text} numberOfLines={4}>
|
||||||
)
|
{profile.description as string}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : undefined}
|
||||||
|
<FollowersList followers={followers} />
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
function ProfileCardPills({
|
function ProfileCardPills({
|
||||||
followedBy,
|
followedBy,
|
||||||
|
@ -146,45 +139,47 @@ function ProfileCardPills({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const FollowersList = observer(
|
const FollowersList = observer(function FollowersListImpl({
|
||||||
({followers}: {followers?: AppBskyActorDefs.ProfileView[] | undefined}) => {
|
followers,
|
||||||
const store = useStores()
|
}: {
|
||||||
const pal = usePalette('default')
|
followers?: AppBskyActorDefs.ProfileView[] | undefined
|
||||||
if (!followers?.length) {
|
}) {
|
||||||
return null
|
const store = useStores()
|
||||||
}
|
const pal = usePalette('default')
|
||||||
|
if (!followers?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const followersWithMods = followers
|
const followersWithMods = followers
|
||||||
.map(f => ({
|
.map(f => ({
|
||||||
f,
|
f,
|
||||||
mod: moderateProfile(f, store.preferences.moderationOpts),
|
mod: moderateProfile(f, store.preferences.moderationOpts),
|
||||||
}))
|
}))
|
||||||
.filter(({mod}) => !mod.account.filter)
|
.filter(({mod}) => !mod.account.filter)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.followedBy}>
|
<View style={styles.followedBy}>
|
||||||
<Text
|
<Text
|
||||||
type="sm"
|
type="sm"
|
||||||
style={[styles.followsByDesc, pal.textLight]}
|
style={[styles.followsByDesc, pal.textLight]}
|
||||||
numberOfLines={2}
|
numberOfLines={2}
|
||||||
lineHeight={1.2}>
|
lineHeight={1.2}>
|
||||||
Followed by{' '}
|
Followed by{' '}
|
||||||
{followersWithMods.map(({f}) => f.displayName || f.handle).join(', ')}
|
{followersWithMods.map(({f}) => f.displayName || f.handle).join(', ')}
|
||||||
</Text>
|
</Text>
|
||||||
{followersWithMods.slice(0, 3).map(({f, mod}) => (
|
{followersWithMods.slice(0, 3).map(({f, mod}) => (
|
||||||
<View key={f.did} style={styles.followedByAviContainer}>
|
<View key={f.did} style={styles.followedByAviContainer}>
|
||||||
<View style={[styles.followedByAvi, pal.view]}>
|
<View style={[styles.followedByAvi, pal.view]}>
|
||||||
<UserAvatar avatar={f.avatar} size={32} moderation={mod.avatar} />
|
<UserAvatar avatar={f.avatar} size={32} moderation={mod.avatar} />
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
))}
|
</View>
|
||||||
</View>
|
))}
|
||||||
)
|
</View>
|
||||||
},
|
)
|
||||||
)
|
})
|
||||||
|
|
||||||
export const ProfileCardWithFollowBtn = observer(
|
export const ProfileCardWithFollowBtn = observer(
|
||||||
({
|
function ProfileCardWithFollowBtnImpl({
|
||||||
profile,
|
profile,
|
||||||
noBg,
|
noBg,
|
||||||
noBorder,
|
noBorder,
|
||||||
|
@ -194,7 +189,7 @@ export const ProfileCardWithFollowBtn = observer(
|
||||||
noBg?: boolean
|
noBg?: boolean
|
||||||
noBorder?: boolean
|
noBorder?: boolean
|
||||||
followers?: AppBskyActorDefs.ProfileView[] | undefined
|
followers?: AppBskyActorDefs.ProfileView[] | undefined
|
||||||
}) => {
|
}) {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const isMe = store.me.did === profile.did
|
const isMe = store.me.did === profile.did
|
||||||
|
|
||||||
|
|
|
@ -78,6 +78,8 @@ export const ProfileFollowers = observer(function ProfileFollowers({
|
||||||
onEndReached={onEndReached}
|
onEndReached={onEndReached}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
initialNumToRender={15}
|
initialNumToRender={15}
|
||||||
|
// FIXME(dan)
|
||||||
|
// eslint-disable-next-line react/no-unstable-nested-components
|
||||||
ListFooterComponent={() => (
|
ListFooterComponent={() => (
|
||||||
<View style={styles.footer}>
|
<View style={styles.footer}>
|
||||||
{view.isLoading && <ActivityIndicator />}
|
{view.isLoading && <ActivityIndicator />}
|
||||||
|
|
|
@ -75,6 +75,8 @@ export const ProfileFollows = observer(function ProfileFollows({
|
||||||
onEndReached={onEndReached}
|
onEndReached={onEndReached}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
initialNumToRender={15}
|
initialNumToRender={15}
|
||||||
|
// FIXME(dan)
|
||||||
|
// eslint-disable-next-line react/no-unstable-nested-components
|
||||||
ListFooterComponent={() => (
|
ListFooterComponent={() => (
|
||||||
<View style={styles.footer}>
|
<View style={styles.footer}>
|
||||||
{view.isLoading && <ActivityIndicator />}
|
{view.isLoading && <ActivityIndicator />}
|
||||||
|
|
|
@ -45,510 +45,502 @@ interface Props {
|
||||||
hideBackButton?: boolean
|
hideBackButton?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProfileHeader = observer(
|
export const ProfileHeader = observer(function ProfileHeaderImpl({
|
||||||
({view, onRefreshAll, hideBackButton = false}: Props) => {
|
view,
|
||||||
const pal = usePalette('default')
|
onRefreshAll,
|
||||||
|
hideBackButton = false,
|
||||||
// loading
|
}: Props) {
|
||||||
// =
|
const pal = usePalette('default')
|
||||||
if (!view || !view.hasLoaded) {
|
|
||||||
return (
|
|
||||||
<View style={pal.view}>
|
|
||||||
<LoadingPlaceholder width="100%" height={120} />
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
pal.view,
|
|
||||||
{borderColor: pal.colors.background},
|
|
||||||
styles.avi,
|
|
||||||
]}>
|
|
||||||
<LoadingPlaceholder width={80} height={80} style={styles.br40} />
|
|
||||||
</View>
|
|
||||||
<View style={styles.content}>
|
|
||||||
<View style={[styles.buttonsLine]}>
|
|
||||||
<LoadingPlaceholder width={100} height={31} style={styles.br50} />
|
|
||||||
</View>
|
|
||||||
<View>
|
|
||||||
<Text type="title-2xl" style={[pal.text, styles.title]}>
|
|
||||||
{sanitizeDisplayName(
|
|
||||||
view.displayName || sanitizeHandle(view.handle),
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// error
|
|
||||||
// =
|
|
||||||
if (view.hasError) {
|
|
||||||
return (
|
|
||||||
<View testID="profileHeaderHasError">
|
|
||||||
<Text>{view.error}</Text>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// loaded
|
|
||||||
// =
|
|
||||||
return (
|
|
||||||
<ProfileHeaderLoaded
|
|
||||||
view={view}
|
|
||||||
onRefreshAll={onRefreshAll}
|
|
||||||
hideBackButton={hideBackButton}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const ProfileHeaderLoaded = observer(
|
|
||||||
({view, onRefreshAll, hideBackButton = false}: Props) => {
|
|
||||||
const pal = usePalette('default')
|
|
||||||
const palInverted = usePalette('inverted')
|
|
||||||
const store = useStores()
|
|
||||||
const navigation = useNavigation<NavigationProp>()
|
|
||||||
const {track} = useAnalytics()
|
|
||||||
const invalidHandle = isInvalidHandle(view.handle)
|
|
||||||
const {isDesktop} = useWebMediaQueries()
|
|
||||||
|
|
||||||
const onPressBack = React.useCallback(() => {
|
|
||||||
navigation.goBack()
|
|
||||||
}, [navigation])
|
|
||||||
|
|
||||||
const onPressAvi = React.useCallback(() => {
|
|
||||||
if (
|
|
||||||
view.avatar &&
|
|
||||||
!(view.moderation.avatar.blur && view.moderation.avatar.noOverride)
|
|
||||||
) {
|
|
||||||
store.shell.openLightbox(new ProfileImageLightbox(view))
|
|
||||||
}
|
|
||||||
}, [store, view])
|
|
||||||
|
|
||||||
const onPressToggleFollow = React.useCallback(() => {
|
|
||||||
track(
|
|
||||||
view.viewer.following
|
|
||||||
? 'ProfileHeader:FollowButtonClicked'
|
|
||||||
: 'ProfileHeader:UnfollowButtonClicked',
|
|
||||||
)
|
|
||||||
view?.toggleFollowing().then(
|
|
||||||
() => {
|
|
||||||
Toast.show(
|
|
||||||
`${
|
|
||||||
view.viewer.following ? 'Following' : 'No longer following'
|
|
||||||
} ${sanitizeDisplayName(view.displayName || view.handle)}`,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
err => store.log.error('Failed to toggle follow', err),
|
|
||||||
)
|
|
||||||
}, [track, view, store.log])
|
|
||||||
|
|
||||||
const onPressEditProfile = React.useCallback(() => {
|
|
||||||
track('ProfileHeader:EditProfileButtonClicked')
|
|
||||||
store.shell.openModal({
|
|
||||||
name: 'edit-profile',
|
|
||||||
profileView: view,
|
|
||||||
onUpdate: onRefreshAll,
|
|
||||||
})
|
|
||||||
}, [track, store, view, onRefreshAll])
|
|
||||||
|
|
||||||
const onPressFollowers = React.useCallback(() => {
|
|
||||||
track('ProfileHeader:FollowersButtonClicked')
|
|
||||||
navigate('ProfileFollowers', {
|
|
||||||
name: isInvalidHandle(view.handle) ? view.did : view.handle,
|
|
||||||
})
|
|
||||||
store.shell.closeAllActiveElements() // for when used in the profile preview modal
|
|
||||||
}, [track, view, store.shell])
|
|
||||||
|
|
||||||
const onPressFollows = React.useCallback(() => {
|
|
||||||
track('ProfileHeader:FollowsButtonClicked')
|
|
||||||
navigate('ProfileFollows', {
|
|
||||||
name: isInvalidHandle(view.handle) ? view.did : view.handle,
|
|
||||||
})
|
|
||||||
store.shell.closeAllActiveElements() // for when used in the profile preview modal
|
|
||||||
}, [track, view, store.shell])
|
|
||||||
|
|
||||||
const onPressShare = React.useCallback(() => {
|
|
||||||
track('ProfileHeader:ShareButtonClicked')
|
|
||||||
const url = toShareUrl(makeProfileLink(view))
|
|
||||||
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 {
|
|
||||||
await view.muteAccount()
|
|
||||||
Toast.show('Account muted')
|
|
||||||
} catch (e: any) {
|
|
||||||
store.log.error('Failed to mute account', e)
|
|
||||||
Toast.show(`There was an issue! ${e.toString()}`)
|
|
||||||
}
|
|
||||||
}, [track, view, store])
|
|
||||||
|
|
||||||
const onPressUnmuteAccount = React.useCallback(async () => {
|
|
||||||
track('ProfileHeader:UnmuteAccountButtonClicked')
|
|
||||||
try {
|
|
||||||
await view.unmuteAccount()
|
|
||||||
Toast.show('Account unmuted')
|
|
||||||
} catch (e: any) {
|
|
||||||
store.log.error('Failed to unmute account', e)
|
|
||||||
Toast.show(`There was an issue! ${e.toString()}`)
|
|
||||||
}
|
|
||||||
}, [track, view, store])
|
|
||||||
|
|
||||||
const onPressBlockAccount = React.useCallback(async () => {
|
|
||||||
track('ProfileHeader:BlockAccountButtonClicked')
|
|
||||||
store.shell.openModal({
|
|
||||||
name: 'confirm',
|
|
||||||
title: 'Block Account',
|
|
||||||
message:
|
|
||||||
'Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.',
|
|
||||||
onPressConfirm: async () => {
|
|
||||||
try {
|
|
||||||
await view.blockAccount()
|
|
||||||
onRefreshAll()
|
|
||||||
Toast.show('Account blocked')
|
|
||||||
} catch (e: any) {
|
|
||||||
store.log.error('Failed to block account', e)
|
|
||||||
Toast.show(`There was an issue! ${e.toString()}`)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}, [track, view, store, onRefreshAll])
|
|
||||||
|
|
||||||
const onPressUnblockAccount = React.useCallback(async () => {
|
|
||||||
track('ProfileHeader:UnblockAccountButtonClicked')
|
|
||||||
store.shell.openModal({
|
|
||||||
name: 'confirm',
|
|
||||||
title: 'Unblock Account',
|
|
||||||
message:
|
|
||||||
'The account will be able to interact with you after unblocking.',
|
|
||||||
onPressConfirm: async () => {
|
|
||||||
try {
|
|
||||||
await view.unblockAccount()
|
|
||||||
onRefreshAll()
|
|
||||||
Toast.show('Account unblocked')
|
|
||||||
} catch (e: any) {
|
|
||||||
store.log.error('Failed to unblock account', e)
|
|
||||||
Toast.show(`There was an issue! ${e.toString()}`)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}, [track, view, store, onRefreshAll])
|
|
||||||
|
|
||||||
const onPressReportAccount = React.useCallback(() => {
|
|
||||||
track('ProfileHeader:ReportAccountButtonClicked')
|
|
||||||
store.shell.openModal({
|
|
||||||
name: 'report',
|
|
||||||
did: view.did,
|
|
||||||
})
|
|
||||||
}, [track, store, view])
|
|
||||||
|
|
||||||
const isMe = React.useMemo(
|
|
||||||
() => store.me.did === view.did,
|
|
||||||
[store.me.did, view.did],
|
|
||||||
)
|
|
||||||
const dropdownItems: DropdownItem[] = React.useMemo(() => {
|
|
||||||
let items: DropdownItem[] = [
|
|
||||||
{
|
|
||||||
testID: 'profileHeaderDropdownShareBtn',
|
|
||||||
label: 'Share',
|
|
||||||
onPress: onPressShare,
|
|
||||||
icon: {
|
|
||||||
ios: {
|
|
||||||
name: 'square.and.arrow.up',
|
|
||||||
},
|
|
||||||
android: 'ic_menu_share',
|
|
||||||
web: 'share',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
if (!isMe) {
|
|
||||||
items.push({label: 'separator'})
|
|
||||||
// Only add "Add to Lists" on other user's profiles, doesn't make sense to mute my own self!
|
|
||||||
items.push({
|
|
||||||
testID: 'profileHeaderDropdownListAddRemoveBtn',
|
|
||||||
label: 'Add to Lists',
|
|
||||||
onPress: onPressAddRemoveLists,
|
|
||||||
icon: {
|
|
||||||
ios: {
|
|
||||||
name: 'list.bullet',
|
|
||||||
},
|
|
||||||
android: 'ic_menu_add',
|
|
||||||
web: 'list',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if (!view.viewer.blocking) {
|
|
||||||
items.push({
|
|
||||||
testID: 'profileHeaderDropdownMuteBtn',
|
|
||||||
label: view.viewer.muted ? 'Unmute Account' : 'Mute Account',
|
|
||||||
onPress: view.viewer.muted
|
|
||||||
? onPressUnmuteAccount
|
|
||||||
: onPressMuteAccount,
|
|
||||||
icon: {
|
|
||||||
ios: {
|
|
||||||
name: 'speaker.slash',
|
|
||||||
},
|
|
||||||
android: 'ic_lock_silent_mode',
|
|
||||||
web: 'comment-slash',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
items.push({
|
|
||||||
testID: 'profileHeaderDropdownBlockBtn',
|
|
||||||
label: view.viewer.blocking ? 'Unblock Account' : 'Block Account',
|
|
||||||
onPress: view.viewer.blocking
|
|
||||||
? onPressUnblockAccount
|
|
||||||
: onPressBlockAccount,
|
|
||||||
icon: {
|
|
||||||
ios: {
|
|
||||||
name: 'person.fill.xmark',
|
|
||||||
},
|
|
||||||
android: 'ic_menu_close_clear_cancel',
|
|
||||||
web: 'user-slash',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
items.push({
|
|
||||||
testID: 'profileHeaderDropdownReportBtn',
|
|
||||||
label: 'Report Account',
|
|
||||||
onPress: onPressReportAccount,
|
|
||||||
icon: {
|
|
||||||
ios: {
|
|
||||||
name: 'exclamationmark.triangle',
|
|
||||||
},
|
|
||||||
android: 'ic_menu_report_image',
|
|
||||||
web: 'circle-exclamation',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}, [
|
|
||||||
isMe,
|
|
||||||
view.viewer.muted,
|
|
||||||
view.viewer.blocking,
|
|
||||||
onPressShare,
|
|
||||||
onPressUnmuteAccount,
|
|
||||||
onPressMuteAccount,
|
|
||||||
onPressUnblockAccount,
|
|
||||||
onPressBlockAccount,
|
|
||||||
onPressReportAccount,
|
|
||||||
onPressAddRemoveLists,
|
|
||||||
])
|
|
||||||
|
|
||||||
const blockHide = !isMe && (view.viewer.blocking || view.viewer.blockedBy)
|
|
||||||
const following = formatCount(view.followsCount)
|
|
||||||
const followers = formatCount(view.followersCount)
|
|
||||||
const pluralizedFollowers = pluralize(view.followersCount, 'follower')
|
|
||||||
|
|
||||||
|
// loading
|
||||||
|
// =
|
||||||
|
if (!view || !view.hasLoaded) {
|
||||||
return (
|
return (
|
||||||
<View style={pal.view}>
|
<View style={pal.view}>
|
||||||
<UserBanner banner={view.banner} moderation={view.moderation.avatar} />
|
<LoadingPlaceholder width="100%" height={120} />
|
||||||
|
<View
|
||||||
|
style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}>
|
||||||
|
<LoadingPlaceholder width={80} height={80} style={styles.br40} />
|
||||||
|
</View>
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
<View style={[styles.buttonsLine]}>
|
<View style={[styles.buttonsLine]}>
|
||||||
{isMe ? (
|
<LoadingPlaceholder width={100} height={31} style={styles.br50} />
|
||||||
<TouchableOpacity
|
|
||||||
testID="profileHeaderEditProfileButton"
|
|
||||||
onPress={onPressEditProfile}
|
|
||||||
style={[styles.btn, styles.mainBtn, pal.btn]}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel="Edit profile"
|
|
||||||
accessibilityHint="Opens editor for profile display name, avatar, background image, and description">
|
|
||||||
<Text type="button" style={pal.text}>
|
|
||||||
Edit Profile
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
) : view.viewer.blocking ? (
|
|
||||||
<TouchableOpacity
|
|
||||||
testID="unblockBtn"
|
|
||||||
onPress={onPressUnblockAccount}
|
|
||||||
style={[styles.btn, styles.mainBtn, pal.btn]}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel="Unblock"
|
|
||||||
accessibilityHint="">
|
|
||||||
<Text type="button" style={[pal.text, s.bold]}>
|
|
||||||
Unblock
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
) : !view.viewer.blockedBy ? (
|
|
||||||
<>
|
|
||||||
{store.me.follows.getFollowState(view.did) ===
|
|
||||||
FollowState.Following ? (
|
|
||||||
<TouchableOpacity
|
|
||||||
testID="unfollowBtn"
|
|
||||||
onPress={onPressToggleFollow}
|
|
||||||
style={[styles.btn, styles.mainBtn, pal.btn]}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={`Unfollow ${view.handle}`}
|
|
||||||
accessibilityHint={`Hides posts from ${view.handle} in your feed`}>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon="check"
|
|
||||||
style={[pal.text, s.mr5]}
|
|
||||||
size={14}
|
|
||||||
/>
|
|
||||||
<Text type="button" style={pal.text}>
|
|
||||||
Following
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
) : (
|
|
||||||
<TouchableOpacity
|
|
||||||
testID="followBtn"
|
|
||||||
onPress={onPressToggleFollow}
|
|
||||||
style={[styles.btn, styles.mainBtn, palInverted.view]}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={`Follow ${view.handle}`}
|
|
||||||
accessibilityHint={`Shows posts from ${view.handle} in your feed`}>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon="plus"
|
|
||||||
style={[palInverted.text, s.mr5]}
|
|
||||||
/>
|
|
||||||
<Text type="button" style={[palInverted.text, s.bold]}>
|
|
||||||
Follow
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{dropdownItems?.length ? (
|
|
||||||
<NativeDropdown
|
|
||||||
testID="profileHeaderDropdownBtn"
|
|
||||||
items={dropdownItems}>
|
|
||||||
<View style={[styles.btn, styles.secondaryBtn, pal.btn]}>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon="ellipsis"
|
|
||||||
size={20}
|
|
||||||
style={[pal.text]}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</NativeDropdown>
|
|
||||||
) : undefined}
|
|
||||||
</View>
|
</View>
|
||||||
<View>
|
<View>
|
||||||
<Text
|
<Text type="title-2xl" style={[pal.text, styles.title]}>
|
||||||
testID="profileHeaderDisplayName"
|
|
||||||
type="title-2xl"
|
|
||||||
style={[pal.text, styles.title]}>
|
|
||||||
{sanitizeDisplayName(
|
{sanitizeDisplayName(
|
||||||
view.displayName || sanitizeHandle(view.handle),
|
view.displayName || sanitizeHandle(view.handle),
|
||||||
view.moderation.profile,
|
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.handleLine}>
|
|
||||||
{view.viewer.followedBy && !blockHide ? (
|
|
||||||
<View style={[styles.pill, pal.btn, s.mr5]}>
|
|
||||||
<Text type="xs" style={[pal.text]}>
|
|
||||||
Follows you
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : undefined}
|
|
||||||
<ThemedText
|
|
||||||
type={invalidHandle ? 'xs' : 'md'}
|
|
||||||
fg={invalidHandle ? 'error' : 'light'}
|
|
||||||
border={invalidHandle ? 'error' : undefined}
|
|
||||||
style={[
|
|
||||||
invalidHandle ? styles.invalidHandle : undefined,
|
|
||||||
styles.handle,
|
|
||||||
]}>
|
|
||||||
{invalidHandle ? '⚠Invalid Handle' : `@${view.handle}`}
|
|
||||||
</ThemedText>
|
|
||||||
</View>
|
|
||||||
{!blockHide && (
|
|
||||||
<>
|
|
||||||
<View style={styles.metricsLine}>
|
|
||||||
<TouchableOpacity
|
|
||||||
testID="profileHeaderFollowersButton"
|
|
||||||
style={[s.flexRow, s.mr10]}
|
|
||||||
onPress={onPressFollowers}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={`${followers} ${pluralizedFollowers}`}
|
|
||||||
accessibilityHint={'Opens followers list'}>
|
|
||||||
<Text type="md" style={[s.bold, pal.text]}>
|
|
||||||
{followers}{' '}
|
|
||||||
</Text>
|
|
||||||
<Text type="md" style={[pal.textLight]}>
|
|
||||||
{pluralizedFollowers}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity
|
|
||||||
testID="profileHeaderFollowsButton"
|
|
||||||
style={[s.flexRow, s.mr10]}
|
|
||||||
onPress={onPressFollows}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={`${following} following`}
|
|
||||||
accessibilityHint={'Opens following list'}>
|
|
||||||
<Text type="md" style={[s.bold, pal.text]}>
|
|
||||||
{following}{' '}
|
|
||||||
</Text>
|
|
||||||
<Text type="md" style={[pal.textLight]}>
|
|
||||||
following
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<Text type="md" style={[s.bold, pal.text]}>
|
|
||||||
{formatCount(view.postsCount)}{' '}
|
|
||||||
<Text type="md" style={[pal.textLight]}>
|
|
||||||
{pluralize(view.postsCount, 'post')}
|
|
||||||
</Text>
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
{view.description &&
|
|
||||||
view.descriptionRichText &&
|
|
||||||
!view.moderation.profile.blur ? (
|
|
||||||
<RichText
|
|
||||||
testID="profileHeaderDescription"
|
|
||||||
style={[styles.description, pal.text]}
|
|
||||||
numberOfLines={15}
|
|
||||||
richText={view.descriptionRichText}
|
|
||||||
/>
|
|
||||||
) : undefined}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<ProfileHeaderAlerts moderation={view.moderation} />
|
|
||||||
</View>
|
</View>
|
||||||
{!isDesktop && !hideBackButton && (
|
|
||||||
<TouchableWithoutFeedback
|
|
||||||
onPress={onPressBack}
|
|
||||||
hitSlop={BACK_HITSLOP}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel="Back"
|
|
||||||
accessibilityHint="">
|
|
||||||
<View style={styles.backBtnWrapper}>
|
|
||||||
<BlurView style={styles.backBtn} blurType="dark">
|
|
||||||
<FontAwesomeIcon size={18} icon="angle-left" style={s.white} />
|
|
||||||
</BlurView>
|
|
||||||
</View>
|
|
||||||
</TouchableWithoutFeedback>
|
|
||||||
)}
|
|
||||||
<TouchableWithoutFeedback
|
|
||||||
testID="profileHeaderAviButton"
|
|
||||||
onPress={onPressAvi}
|
|
||||||
accessibilityRole="image"
|
|
||||||
accessibilityLabel={`View ${view.handle}'s avatar`}
|
|
||||||
accessibilityHint="">
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
pal.view,
|
|
||||||
{borderColor: pal.colors.background},
|
|
||||||
styles.avi,
|
|
||||||
]}>
|
|
||||||
<UserAvatar
|
|
||||||
size={80}
|
|
||||||
avatar={view.avatar}
|
|
||||||
moderation={view.moderation.avatar}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</TouchableWithoutFeedback>
|
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
)
|
|
||||||
|
// error
|
||||||
|
// =
|
||||||
|
if (view.hasError) {
|
||||||
|
return (
|
||||||
|
<View testID="profileHeaderHasError">
|
||||||
|
<Text>{view.error}</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loaded
|
||||||
|
// =
|
||||||
|
return (
|
||||||
|
<ProfileHeaderLoaded
|
||||||
|
view={view}
|
||||||
|
onRefreshAll={onRefreshAll}
|
||||||
|
hideBackButton={hideBackButton}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
|
||||||
|
view,
|
||||||
|
onRefreshAll,
|
||||||
|
hideBackButton = false,
|
||||||
|
}: Props) {
|
||||||
|
const pal = usePalette('default')
|
||||||
|
const palInverted = usePalette('inverted')
|
||||||
|
const store = useStores()
|
||||||
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
const {track} = useAnalytics()
|
||||||
|
const invalidHandle = isInvalidHandle(view.handle)
|
||||||
|
const {isDesktop} = useWebMediaQueries()
|
||||||
|
|
||||||
|
const onPressBack = React.useCallback(() => {
|
||||||
|
navigation.goBack()
|
||||||
|
}, [navigation])
|
||||||
|
|
||||||
|
const onPressAvi = React.useCallback(() => {
|
||||||
|
if (
|
||||||
|
view.avatar &&
|
||||||
|
!(view.moderation.avatar.blur && view.moderation.avatar.noOverride)
|
||||||
|
) {
|
||||||
|
store.shell.openLightbox(new ProfileImageLightbox(view))
|
||||||
|
}
|
||||||
|
}, [store, view])
|
||||||
|
|
||||||
|
const onPressToggleFollow = React.useCallback(() => {
|
||||||
|
track(
|
||||||
|
view.viewer.following
|
||||||
|
? 'ProfileHeader:FollowButtonClicked'
|
||||||
|
: 'ProfileHeader:UnfollowButtonClicked',
|
||||||
|
)
|
||||||
|
view?.toggleFollowing().then(
|
||||||
|
() => {
|
||||||
|
Toast.show(
|
||||||
|
`${
|
||||||
|
view.viewer.following ? 'Following' : 'No longer following'
|
||||||
|
} ${sanitizeDisplayName(view.displayName || view.handle)}`,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
err => store.log.error('Failed to toggle follow', err),
|
||||||
|
)
|
||||||
|
}, [track, view, store.log])
|
||||||
|
|
||||||
|
const onPressEditProfile = React.useCallback(() => {
|
||||||
|
track('ProfileHeader:EditProfileButtonClicked')
|
||||||
|
store.shell.openModal({
|
||||||
|
name: 'edit-profile',
|
||||||
|
profileView: view,
|
||||||
|
onUpdate: onRefreshAll,
|
||||||
|
})
|
||||||
|
}, [track, store, view, onRefreshAll])
|
||||||
|
|
||||||
|
const onPressFollowers = React.useCallback(() => {
|
||||||
|
track('ProfileHeader:FollowersButtonClicked')
|
||||||
|
navigate('ProfileFollowers', {
|
||||||
|
name: isInvalidHandle(view.handle) ? view.did : view.handle,
|
||||||
|
})
|
||||||
|
store.shell.closeAllActiveElements() // for when used in the profile preview modal
|
||||||
|
}, [track, view, store.shell])
|
||||||
|
|
||||||
|
const onPressFollows = React.useCallback(() => {
|
||||||
|
track('ProfileHeader:FollowsButtonClicked')
|
||||||
|
navigate('ProfileFollows', {
|
||||||
|
name: isInvalidHandle(view.handle) ? view.did : view.handle,
|
||||||
|
})
|
||||||
|
store.shell.closeAllActiveElements() // for when used in the profile preview modal
|
||||||
|
}, [track, view, store.shell])
|
||||||
|
|
||||||
|
const onPressShare = React.useCallback(() => {
|
||||||
|
track('ProfileHeader:ShareButtonClicked')
|
||||||
|
const url = toShareUrl(makeProfileLink(view))
|
||||||
|
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 {
|
||||||
|
await view.muteAccount()
|
||||||
|
Toast.show('Account muted')
|
||||||
|
} catch (e: any) {
|
||||||
|
store.log.error('Failed to mute account', e)
|
||||||
|
Toast.show(`There was an issue! ${e.toString()}`)
|
||||||
|
}
|
||||||
|
}, [track, view, store])
|
||||||
|
|
||||||
|
const onPressUnmuteAccount = React.useCallback(async () => {
|
||||||
|
track('ProfileHeader:UnmuteAccountButtonClicked')
|
||||||
|
try {
|
||||||
|
await view.unmuteAccount()
|
||||||
|
Toast.show('Account unmuted')
|
||||||
|
} catch (e: any) {
|
||||||
|
store.log.error('Failed to unmute account', e)
|
||||||
|
Toast.show(`There was an issue! ${e.toString()}`)
|
||||||
|
}
|
||||||
|
}, [track, view, store])
|
||||||
|
|
||||||
|
const onPressBlockAccount = React.useCallback(async () => {
|
||||||
|
track('ProfileHeader:BlockAccountButtonClicked')
|
||||||
|
store.shell.openModal({
|
||||||
|
name: 'confirm',
|
||||||
|
title: 'Block Account',
|
||||||
|
message:
|
||||||
|
'Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.',
|
||||||
|
onPressConfirm: async () => {
|
||||||
|
try {
|
||||||
|
await view.blockAccount()
|
||||||
|
onRefreshAll()
|
||||||
|
Toast.show('Account blocked')
|
||||||
|
} catch (e: any) {
|
||||||
|
store.log.error('Failed to block account', e)
|
||||||
|
Toast.show(`There was an issue! ${e.toString()}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, [track, view, store, onRefreshAll])
|
||||||
|
|
||||||
|
const onPressUnblockAccount = React.useCallback(async () => {
|
||||||
|
track('ProfileHeader:UnblockAccountButtonClicked')
|
||||||
|
store.shell.openModal({
|
||||||
|
name: 'confirm',
|
||||||
|
title: 'Unblock Account',
|
||||||
|
message:
|
||||||
|
'The account will be able to interact with you after unblocking.',
|
||||||
|
onPressConfirm: async () => {
|
||||||
|
try {
|
||||||
|
await view.unblockAccount()
|
||||||
|
onRefreshAll()
|
||||||
|
Toast.show('Account unblocked')
|
||||||
|
} catch (e: any) {
|
||||||
|
store.log.error('Failed to unblock account', e)
|
||||||
|
Toast.show(`There was an issue! ${e.toString()}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, [track, view, store, onRefreshAll])
|
||||||
|
|
||||||
|
const onPressReportAccount = React.useCallback(() => {
|
||||||
|
track('ProfileHeader:ReportAccountButtonClicked')
|
||||||
|
store.shell.openModal({
|
||||||
|
name: 'report',
|
||||||
|
did: view.did,
|
||||||
|
})
|
||||||
|
}, [track, store, view])
|
||||||
|
|
||||||
|
const isMe = React.useMemo(
|
||||||
|
() => store.me.did === view.did,
|
||||||
|
[store.me.did, view.did],
|
||||||
|
)
|
||||||
|
const dropdownItems: DropdownItem[] = React.useMemo(() => {
|
||||||
|
let items: DropdownItem[] = [
|
||||||
|
{
|
||||||
|
testID: 'profileHeaderDropdownShareBtn',
|
||||||
|
label: 'Share',
|
||||||
|
onPress: onPressShare,
|
||||||
|
icon: {
|
||||||
|
ios: {
|
||||||
|
name: 'square.and.arrow.up',
|
||||||
|
},
|
||||||
|
android: 'ic_menu_share',
|
||||||
|
web: 'share',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
if (!isMe) {
|
||||||
|
items.push({label: 'separator'})
|
||||||
|
// Only add "Add to Lists" on other user's profiles, doesn't make sense to mute my own self!
|
||||||
|
items.push({
|
||||||
|
testID: 'profileHeaderDropdownListAddRemoveBtn',
|
||||||
|
label: 'Add to Lists',
|
||||||
|
onPress: onPressAddRemoveLists,
|
||||||
|
icon: {
|
||||||
|
ios: {
|
||||||
|
name: 'list.bullet',
|
||||||
|
},
|
||||||
|
android: 'ic_menu_add',
|
||||||
|
web: 'list',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!view.viewer.blocking) {
|
||||||
|
items.push({
|
||||||
|
testID: 'profileHeaderDropdownMuteBtn',
|
||||||
|
label: view.viewer.muted ? 'Unmute Account' : 'Mute Account',
|
||||||
|
onPress: view.viewer.muted
|
||||||
|
? onPressUnmuteAccount
|
||||||
|
: onPressMuteAccount,
|
||||||
|
icon: {
|
||||||
|
ios: {
|
||||||
|
name: 'speaker.slash',
|
||||||
|
},
|
||||||
|
android: 'ic_lock_silent_mode',
|
||||||
|
web: 'comment-slash',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
items.push({
|
||||||
|
testID: 'profileHeaderDropdownBlockBtn',
|
||||||
|
label: view.viewer.blocking ? 'Unblock Account' : 'Block Account',
|
||||||
|
onPress: view.viewer.blocking
|
||||||
|
? onPressUnblockAccount
|
||||||
|
: onPressBlockAccount,
|
||||||
|
icon: {
|
||||||
|
ios: {
|
||||||
|
name: 'person.fill.xmark',
|
||||||
|
},
|
||||||
|
android: 'ic_menu_close_clear_cancel',
|
||||||
|
web: 'user-slash',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
items.push({
|
||||||
|
testID: 'profileHeaderDropdownReportBtn',
|
||||||
|
label: 'Report Account',
|
||||||
|
onPress: onPressReportAccount,
|
||||||
|
icon: {
|
||||||
|
ios: {
|
||||||
|
name: 'exclamationmark.triangle',
|
||||||
|
},
|
||||||
|
android: 'ic_menu_report_image',
|
||||||
|
web: 'circle-exclamation',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}, [
|
||||||
|
isMe,
|
||||||
|
view.viewer.muted,
|
||||||
|
view.viewer.blocking,
|
||||||
|
onPressShare,
|
||||||
|
onPressUnmuteAccount,
|
||||||
|
onPressMuteAccount,
|
||||||
|
onPressUnblockAccount,
|
||||||
|
onPressBlockAccount,
|
||||||
|
onPressReportAccount,
|
||||||
|
onPressAddRemoveLists,
|
||||||
|
])
|
||||||
|
|
||||||
|
const blockHide = !isMe && (view.viewer.blocking || view.viewer.blockedBy)
|
||||||
|
const following = formatCount(view.followsCount)
|
||||||
|
const followers = formatCount(view.followersCount)
|
||||||
|
const pluralizedFollowers = pluralize(view.followersCount, 'follower')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={pal.view}>
|
||||||
|
<UserBanner banner={view.banner} moderation={view.moderation.avatar} />
|
||||||
|
<View style={styles.content}>
|
||||||
|
<View style={[styles.buttonsLine]}>
|
||||||
|
{isMe ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
testID="profileHeaderEditProfileButton"
|
||||||
|
onPress={onPressEditProfile}
|
||||||
|
style={[styles.btn, styles.mainBtn, pal.btn]}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Edit profile"
|
||||||
|
accessibilityHint="Opens editor for profile display name, avatar, background image, and description">
|
||||||
|
<Text type="button" style={pal.text}>
|
||||||
|
Edit Profile
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : view.viewer.blocking ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
testID="unblockBtn"
|
||||||
|
onPress={onPressUnblockAccount}
|
||||||
|
style={[styles.btn, styles.mainBtn, pal.btn]}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Unblock"
|
||||||
|
accessibilityHint="">
|
||||||
|
<Text type="button" style={[pal.text, s.bold]}>
|
||||||
|
Unblock
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : !view.viewer.blockedBy ? (
|
||||||
|
<>
|
||||||
|
{store.me.follows.getFollowState(view.did) ===
|
||||||
|
FollowState.Following ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
testID="unfollowBtn"
|
||||||
|
onPress={onPressToggleFollow}
|
||||||
|
style={[styles.btn, styles.mainBtn, pal.btn]}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Unfollow ${view.handle}`}
|
||||||
|
accessibilityHint={`Hides posts from ${view.handle} in your feed`}>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon="check"
|
||||||
|
style={[pal.text, s.mr5]}
|
||||||
|
size={14}
|
||||||
|
/>
|
||||||
|
<Text type="button" style={pal.text}>
|
||||||
|
Following
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : (
|
||||||
|
<TouchableOpacity
|
||||||
|
testID="followBtn"
|
||||||
|
onPress={onPressToggleFollow}
|
||||||
|
style={[styles.btn, styles.mainBtn, palInverted.view]}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Follow ${view.handle}`}
|
||||||
|
accessibilityHint={`Shows posts from ${view.handle} in your feed`}>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon="plus"
|
||||||
|
style={[palInverted.text, s.mr5]}
|
||||||
|
/>
|
||||||
|
<Text type="button" style={[palInverted.text, s.bold]}>
|
||||||
|
Follow
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{dropdownItems?.length ? (
|
||||||
|
<NativeDropdown
|
||||||
|
testID="profileHeaderDropdownBtn"
|
||||||
|
items={dropdownItems}>
|
||||||
|
<View style={[styles.btn, styles.secondaryBtn, pal.btn]}>
|
||||||
|
<FontAwesomeIcon icon="ellipsis" size={20} style={[pal.text]} />
|
||||||
|
</View>
|
||||||
|
</NativeDropdown>
|
||||||
|
) : undefined}
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<Text
|
||||||
|
testID="profileHeaderDisplayName"
|
||||||
|
type="title-2xl"
|
||||||
|
style={[pal.text, styles.title]}>
|
||||||
|
{sanitizeDisplayName(
|
||||||
|
view.displayName || sanitizeHandle(view.handle),
|
||||||
|
view.moderation.profile,
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.handleLine}>
|
||||||
|
{view.viewer.followedBy && !blockHide ? (
|
||||||
|
<View style={[styles.pill, pal.btn, s.mr5]}>
|
||||||
|
<Text type="xs" style={[pal.text]}>
|
||||||
|
Follows you
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : undefined}
|
||||||
|
<ThemedText
|
||||||
|
type={invalidHandle ? 'xs' : 'md'}
|
||||||
|
fg={invalidHandle ? 'error' : 'light'}
|
||||||
|
border={invalidHandle ? 'error' : undefined}
|
||||||
|
style={[
|
||||||
|
invalidHandle ? styles.invalidHandle : undefined,
|
||||||
|
styles.handle,
|
||||||
|
]}>
|
||||||
|
{invalidHandle ? '⚠Invalid Handle' : `@${view.handle}`}
|
||||||
|
</ThemedText>
|
||||||
|
</View>
|
||||||
|
{!blockHide && (
|
||||||
|
<>
|
||||||
|
<View style={styles.metricsLine}>
|
||||||
|
<TouchableOpacity
|
||||||
|
testID="profileHeaderFollowersButton"
|
||||||
|
style={[s.flexRow, s.mr10]}
|
||||||
|
onPress={onPressFollowers}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`${followers} ${pluralizedFollowers}`}
|
||||||
|
accessibilityHint={'Opens followers list'}>
|
||||||
|
<Text type="md" style={[s.bold, pal.text]}>
|
||||||
|
{followers}{' '}
|
||||||
|
</Text>
|
||||||
|
<Text type="md" style={[pal.textLight]}>
|
||||||
|
{pluralizedFollowers}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
testID="profileHeaderFollowsButton"
|
||||||
|
style={[s.flexRow, s.mr10]}
|
||||||
|
onPress={onPressFollows}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`${following} following`}
|
||||||
|
accessibilityHint={'Opens following list'}>
|
||||||
|
<Text type="md" style={[s.bold, pal.text]}>
|
||||||
|
{following}{' '}
|
||||||
|
</Text>
|
||||||
|
<Text type="md" style={[pal.textLight]}>
|
||||||
|
following
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text type="md" style={[s.bold, pal.text]}>
|
||||||
|
{formatCount(view.postsCount)}{' '}
|
||||||
|
<Text type="md" style={[pal.textLight]}>
|
||||||
|
{pluralize(view.postsCount, 'post')}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{view.description &&
|
||||||
|
view.descriptionRichText &&
|
||||||
|
!view.moderation.profile.blur ? (
|
||||||
|
<RichText
|
||||||
|
testID="profileHeaderDescription"
|
||||||
|
style={[styles.description, pal.text]}
|
||||||
|
numberOfLines={15}
|
||||||
|
richText={view.descriptionRichText}
|
||||||
|
/>
|
||||||
|
) : undefined}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<ProfileHeaderAlerts moderation={view.moderation} />
|
||||||
|
</View>
|
||||||
|
{!isDesktop && !hideBackButton && (
|
||||||
|
<TouchableWithoutFeedback
|
||||||
|
onPress={onPressBack}
|
||||||
|
hitSlop={BACK_HITSLOP}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Back"
|
||||||
|
accessibilityHint="">
|
||||||
|
<View style={styles.backBtnWrapper}>
|
||||||
|
<BlurView style={styles.backBtn} blurType="dark">
|
||||||
|
<FontAwesomeIcon size={18} icon="angle-left" style={s.white} />
|
||||||
|
</BlurView>
|
||||||
|
</View>
|
||||||
|
</TouchableWithoutFeedback>
|
||||||
|
)}
|
||||||
|
<TouchableWithoutFeedback
|
||||||
|
testID="profileHeaderAviButton"
|
||||||
|
onPress={onPressAvi}
|
||||||
|
accessibilityRole="image"
|
||||||
|
accessibilityLabel={`View ${view.handle}'s avatar`}
|
||||||
|
accessibilityHint="">
|
||||||
|
<View
|
||||||
|
style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}>
|
||||||
|
<UserAvatar
|
||||||
|
size={80}
|
||||||
|
avatar={view.avatar}
|
||||||
|
moderation={view.moderation.avatar}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</TouchableWithoutFeedback>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
banner: {
|
banner: {
|
||||||
|
|
|
@ -18,7 +18,11 @@ import {s} from 'lib/styles'
|
||||||
|
|
||||||
const SECTIONS = ['Posts', 'Users']
|
const SECTIONS = ['Posts', 'Users']
|
||||||
|
|
||||||
export const SearchResults = observer(({model}: {model: SearchUIModel}) => {
|
export const SearchResults = observer(function SearchResultsImpl({
|
||||||
|
model,
|
||||||
|
}: {
|
||||||
|
model: SearchUIModel
|
||||||
|
}) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const {isMobile} = useWebMediaQueries()
|
const {isMobile} = useWebMediaQueries()
|
||||||
|
|
||||||
|
@ -56,7 +60,11 @@ export const SearchResults = observer(({model}: {model: SearchUIModel}) => {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const PostResults = observer(({model}: {model: SearchUIModel}) => {
|
const PostResults = observer(function PostResultsImpl({
|
||||||
|
model,
|
||||||
|
}: {
|
||||||
|
model: SearchUIModel
|
||||||
|
}) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
if (model.isPostsLoading) {
|
if (model.isPostsLoading) {
|
||||||
return (
|
return (
|
||||||
|
@ -88,7 +96,11 @@ const PostResults = observer(({model}: {model: SearchUIModel}) => {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const Profiles = observer(({model}: {model: SearchUIModel}) => {
|
const Profiles = observer(function ProfilesImpl({
|
||||||
|
model,
|
||||||
|
}: {
|
||||||
|
model: SearchUIModel
|
||||||
|
}) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
if (model.isProfilesLoading) {
|
if (model.isProfilesLoading) {
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -38,6 +38,9 @@ interface ProfileView {
|
||||||
}
|
}
|
||||||
type Item = Heading | RefWrapper | SuggestWrapper | ProfileView
|
type Item = Heading | RefWrapper | SuggestWrapper | ProfileView
|
||||||
|
|
||||||
|
// FIXME(dan): Figure out why the false positives
|
||||||
|
/* eslint-disable react/prop-types */
|
||||||
|
|
||||||
export const Suggestions = observer(
|
export const Suggestions = observer(
|
||||||
forwardRef(function SuggestionsImpl(
|
forwardRef(function SuggestionsImpl(
|
||||||
{
|
{
|
||||||
|
|
|
@ -25,7 +25,7 @@ interface PostMetaOpts {
|
||||||
timestamp: string
|
timestamp: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PostMeta = observer(function (opts: PostMetaOpts) {
|
export const PostMeta = observer(function PostMetaImpl(opts: PostMetaOpts) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const displayName = opts.author.displayName || opts.author.handle
|
const displayName = opts.author.displayName || opts.author.handle
|
||||||
const handle = opts.author.handle
|
const handle = opts.author.handle
|
||||||
|
|
|
@ -3,6 +3,9 @@ import {observer} from 'mobx-react-lite'
|
||||||
import {ago} from 'lib/strings/time'
|
import {ago} from 'lib/strings/time'
|
||||||
import {useStores} from 'state/index'
|
import {useStores} from 'state/index'
|
||||||
|
|
||||||
|
// FIXME(dan): Figure out why the false positives
|
||||||
|
/* eslint-disable react/prop-types */
|
||||||
|
|
||||||
export const TimeElapsed = observer(function TimeElapsed({
|
export const TimeElapsed = observer(function TimeElapsed({
|
||||||
timestamp,
|
timestamp,
|
||||||
children,
|
children,
|
||||||
|
|
|
@ -14,7 +14,7 @@ import {NavigationProp} from 'lib/routes/types'
|
||||||
|
|
||||||
const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20}
|
const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20}
|
||||||
|
|
||||||
export const ViewHeader = observer(function ({
|
export const ViewHeader = observer(function ViewHeaderImpl({
|
||||||
title,
|
title,
|
||||||
canGoBack,
|
canGoBack,
|
||||||
showBackButton = true,
|
showBackButton = true,
|
||||||
|
@ -140,70 +140,68 @@ function DesktopWebHeader({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const Container = observer(
|
const Container = observer(function ContainerImpl({
|
||||||
({
|
children,
|
||||||
children,
|
hideOnScroll,
|
||||||
hideOnScroll,
|
showBorder,
|
||||||
showBorder,
|
}: {
|
||||||
}: {
|
children: React.ReactNode
|
||||||
children: React.ReactNode
|
hideOnScroll: boolean
|
||||||
hideOnScroll: boolean
|
showBorder?: boolean
|
||||||
showBorder?: boolean
|
}) {
|
||||||
}) => {
|
const store = useStores()
|
||||||
const store = useStores()
|
const pal = usePalette('default')
|
||||||
const pal = usePalette('default')
|
const interp = useAnimatedValue(0)
|
||||||
const interp = useAnimatedValue(0)
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (store.shell.minimalShellMode) {
|
if (store.shell.minimalShellMode) {
|
||||||
Animated.timing(interp, {
|
Animated.timing(interp, {
|
||||||
toValue: 1,
|
toValue: 1,
|
||||||
duration: 100,
|
duration: 100,
|
||||||
useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
isInteraction: false,
|
isInteraction: false,
|
||||||
}).start()
|
}).start()
|
||||||
} else {
|
} else {
|
||||||
Animated.timing(interp, {
|
Animated.timing(interp, {
|
||||||
toValue: 0,
|
toValue: 0,
|
||||||
duration: 100,
|
duration: 100,
|
||||||
useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
isInteraction: false,
|
isInteraction: false,
|
||||||
}).start()
|
}).start()
|
||||||
}
|
|
||||||
}, [interp, store.shell.minimalShellMode])
|
|
||||||
const transform = {
|
|
||||||
transform: [{translateY: Animated.multiply(interp, -100)}],
|
|
||||||
}
|
}
|
||||||
|
}, [interp, store.shell.minimalShellMode])
|
||||||
|
const transform = {
|
||||||
|
transform: [{translateY: Animated.multiply(interp, -100)}],
|
||||||
|
}
|
||||||
|
|
||||||
if (!hideOnScroll) {
|
if (!hideOnScroll) {
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.header,
|
|
||||||
styles.headerFixed,
|
|
||||||
pal.view,
|
|
||||||
pal.border,
|
|
||||||
showBorder && styles.border,
|
|
||||||
]}>
|
|
||||||
{children}
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<Animated.View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.header,
|
styles.header,
|
||||||
styles.headerFloating,
|
styles.headerFixed,
|
||||||
pal.view,
|
pal.view,
|
||||||
pal.border,
|
pal.border,
|
||||||
transform,
|
|
||||||
showBorder && styles.border,
|
showBorder && styles.border,
|
||||||
]}>
|
]}>
|
||||||
{children}
|
{children}
|
||||||
</Animated.View>
|
</View>
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
)
|
return (
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.header,
|
||||||
|
styles.headerFloating,
|
||||||
|
pal.view,
|
||||||
|
pal.border,
|
||||||
|
transform,
|
||||||
|
showBorder && styles.border,
|
||||||
|
]}>
|
||||||
|
{children}
|
||||||
|
</Animated.View>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
header: {
|
header: {
|
||||||
|
|
|
@ -14,7 +14,11 @@ export interface FABProps
|
||||||
icon: JSX.Element
|
icon: JSX.Element
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FABInner = observer(({testID, icon, ...props}: FABProps) => {
|
export const FABInner = observer(function FABInnerImpl({
|
||||||
|
testID,
|
||||||
|
icon,
|
||||||
|
...props
|
||||||
|
}: FABProps) {
|
||||||
const {isTablet} = useWebMediaQueries()
|
const {isTablet} = useWebMediaQueries()
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const interp = useAnimatedValue(0)
|
const interp = useAnimatedValue(0)
|
||||||
|
|
|
@ -9,41 +9,39 @@ import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {colors} from 'lib/styles'
|
import {colors} from 'lib/styles'
|
||||||
import {HITSLOP_20} from 'lib/constants'
|
import {HITSLOP_20} from 'lib/constants'
|
||||||
|
|
||||||
export const LoadLatestBtn = observer(
|
export const LoadLatestBtn = observer(function LoadLatestBtnImpl({
|
||||||
({
|
onPress,
|
||||||
onPress,
|
label,
|
||||||
label,
|
showIndicator,
|
||||||
showIndicator,
|
}: {
|
||||||
}: {
|
onPress: () => void
|
||||||
onPress: () => void
|
label: string
|
||||||
label: string
|
showIndicator: boolean
|
||||||
showIndicator: boolean
|
minimalShellMode?: boolean // NOTE not used on mobile -prf
|
||||||
minimalShellMode?: boolean // NOTE not used on mobile -prf
|
}) {
|
||||||
}) => {
|
const store = useStores()
|
||||||
const store = useStores()
|
const pal = usePalette('default')
|
||||||
const pal = usePalette('default')
|
const safeAreaInsets = useSafeAreaInsets()
|
||||||
const safeAreaInsets = useSafeAreaInsets()
|
return (
|
||||||
return (
|
<TouchableOpacity
|
||||||
<TouchableOpacity
|
style={[
|
||||||
style={[
|
styles.loadLatest,
|
||||||
styles.loadLatest,
|
pal.borderDark,
|
||||||
pal.borderDark,
|
pal.view,
|
||||||
pal.view,
|
!store.shell.minimalShellMode && {
|
||||||
!store.shell.minimalShellMode && {
|
bottom: 60 + clamp(safeAreaInsets.bottom, 15, 30),
|
||||||
bottom: 60 + clamp(safeAreaInsets.bottom, 15, 30),
|
},
|
||||||
},
|
]}
|
||||||
]}
|
onPress={onPress}
|
||||||
onPress={onPress}
|
hitSlop={HITSLOP_20}
|
||||||
hitSlop={HITSLOP_20}
|
accessibilityRole="button"
|
||||||
accessibilityRole="button"
|
accessibilityLabel={label}
|
||||||
accessibilityLabel={label}
|
accessibilityHint="">
|
||||||
accessibilityHint="">
|
<FontAwesomeIcon icon="angle-up" color={pal.colors.text} size={19} />
|
||||||
<FontAwesomeIcon icon="angle-up" color={pal.colors.text} size={19} />
|
{showIndicator && <View style={[styles.indicator, pal.borderDark]} />}
|
||||||
{showIndicator && <View style={[styles.indicator, pal.borderDark]} />}
|
</TouchableOpacity>
|
||||||
</TouchableOpacity>
|
)
|
||||||
)
|
})
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
loadLatest: {
|
loadLatest: {
|
||||||
|
|
|
@ -6,23 +6,21 @@ import {ListCard} from 'view/com/lists/ListCard'
|
||||||
import {AppBskyGraphDefs} from '@atproto/api'
|
import {AppBskyGraphDefs} from '@atproto/api'
|
||||||
import {s} from 'lib/styles'
|
import {s} from 'lib/styles'
|
||||||
|
|
||||||
export const ListEmbed = observer(
|
export const ListEmbed = observer(function ListEmbedImpl({
|
||||||
({
|
item,
|
||||||
item,
|
style,
|
||||||
style,
|
}: {
|
||||||
}: {
|
item: AppBskyGraphDefs.ListView
|
||||||
item: AppBskyGraphDefs.ListView
|
style?: StyleProp<ViewStyle>
|
||||||
style?: StyleProp<ViewStyle>
|
}) {
|
||||||
}) => {
|
const pal = usePalette('default')
|
||||||
const pal = usePalette('default')
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[pal.view, pal.border, s.border1, styles.container]}>
|
<View style={[pal.view, pal.border, s.border1, styles.container]}>
|
||||||
<ListCard list={item} style={[style, styles.card]} />
|
<ListCard list={item} style={[style, styles.card]} />
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
},
|
})
|
||||||
)
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
|
|
|
@ -19,7 +19,7 @@ import {CenteredView} from 'view/com/util/Views'
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'AppPasswords'>
|
type Props = NativeStackScreenProps<CommonNavigatorParams, 'AppPasswords'>
|
||||||
export const AppPasswords = withAuthRequired(
|
export const AppPasswords = withAuthRequired(
|
||||||
observer(({}: Props) => {
|
observer(function AppPasswordsImpl({}: Props) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const {screen} = useAnalytics()
|
const {screen} = useAnalytics()
|
||||||
|
|
|
@ -42,7 +42,7 @@ import {NavigationProp} from 'lib/routes/types'
|
||||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'CustomFeed'>
|
type Props = NativeStackScreenProps<CommonNavigatorParams, 'CustomFeed'>
|
||||||
|
|
||||||
export const CustomFeedScreen = withAuthRequired(
|
export const CustomFeedScreen = withAuthRequired(
|
||||||
observer((props: Props) => {
|
observer(function CustomFeedScreenImpl(props: Props) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const navigation = useNavigation<NavigationProp>()
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
@ -119,7 +119,10 @@ export const CustomFeedScreen = withAuthRequired(
|
||||||
)
|
)
|
||||||
|
|
||||||
export const CustomFeedScreenInner = observer(
|
export const CustomFeedScreenInner = observer(
|
||||||
({route, feedOwnerDid}: Props & {feedOwnerDid: string}) => {
|
function CustomFeedScreenInnerImpl({
|
||||||
|
route,
|
||||||
|
feedOwnerDid,
|
||||||
|
}: Props & {feedOwnerDid: string}) {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const {isTabletOrDesktop} = useWebMediaQueries()
|
const {isTabletOrDesktop} = useWebMediaQueries()
|
||||||
|
|
|
@ -19,7 +19,7 @@ import debounce from 'lodash.debounce'
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'DiscoverFeeds'>
|
type Props = NativeStackScreenProps<CommonNavigatorParams, 'DiscoverFeeds'>
|
||||||
export const DiscoverFeedsScreen = withAuthRequired(
|
export const DiscoverFeedsScreen = withAuthRequired(
|
||||||
observer(({}: Props) => {
|
observer(function DiscoverFeedsScreenImpl({}: Props) {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const feeds = React.useMemo(() => new FeedsDiscoveryModel(store), [store])
|
const feeds = React.useMemo(() => new FeedsDiscoveryModel(store), [store])
|
||||||
|
|
|
@ -25,7 +25,7 @@ const MOBILE_HEADER_OFFSET = 40
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<FeedsTabNavigatorParams, 'Feeds'>
|
type Props = NativeStackScreenProps<FeedsTabNavigatorParams, 'Feeds'>
|
||||||
export const FeedsScreen = withAuthRequired(
|
export const FeedsScreen = withAuthRequired(
|
||||||
observer<Props>(({}: Props) => {
|
observer<Props>(function FeedsScreenImpl({}: Props) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const {isMobile} = useWebMediaQueries()
|
const {isMobile} = useWebMediaQueries()
|
||||||
|
|
|
@ -28,7 +28,7 @@ const POLL_FREQ = 30e3 // 30sec
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'>
|
type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'>
|
||||||
export const HomeScreen = withAuthRequired(
|
export const HomeScreen = withAuthRequired(
|
||||||
observer(({}: Props) => {
|
observer(function HomeScreenImpl({}: Props) {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const pagerRef = React.useRef<PagerRef>(null)
|
const pagerRef = React.useRef<PagerRef>(null)
|
||||||
const [selectedPage, setSelectedPage] = React.useState(0)
|
const [selectedPage, setSelectedPage] = React.useState(0)
|
||||||
|
@ -142,152 +142,141 @@ export const HomeScreen = withAuthRequired(
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const FeedPage = observer(
|
const FeedPage = observer(function FeedPageImpl({
|
||||||
({
|
testID,
|
||||||
testID,
|
isPageFocused,
|
||||||
isPageFocused,
|
feed,
|
||||||
feed,
|
renderEmptyState,
|
||||||
renderEmptyState,
|
}: {
|
||||||
}: {
|
testID?: string
|
||||||
testID?: string
|
feed: PostsFeedModel
|
||||||
feed: PostsFeedModel
|
isPageFocused: boolean
|
||||||
isPageFocused: boolean
|
renderEmptyState?: () => JSX.Element
|
||||||
renderEmptyState?: () => JSX.Element
|
}) {
|
||||||
}) => {
|
const store = useStores()
|
||||||
const store = useStores()
|
const {isMobile} = useWebMediaQueries()
|
||||||
const {isMobile} = useWebMediaQueries()
|
const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll(store)
|
||||||
const [onMainScroll, isScrolledDown, resetMainScroll] =
|
const {screen, track} = useAnalytics()
|
||||||
useOnMainScroll(store)
|
const [headerOffset, setHeaderOffset] = React.useState(
|
||||||
const {screen, track} = useAnalytics()
|
isMobile ? HEADER_OFFSET_MOBILE : HEADER_OFFSET_DESKTOP,
|
||||||
const [headerOffset, setHeaderOffset] = React.useState(
|
)
|
||||||
isMobile ? HEADER_OFFSET_MOBILE : HEADER_OFFSET_DESKTOP,
|
const scrollElRef = React.useRef<FlatList>(null)
|
||||||
)
|
const {appState} = useAppState({
|
||||||
const scrollElRef = React.useRef<FlatList>(null)
|
onForeground: () => doPoll(true),
|
||||||
const {appState} = useAppState({
|
})
|
||||||
onForeground: () => doPoll(true),
|
const isScreenFocused = useIsFocused()
|
||||||
})
|
|
||||||
const isScreenFocused = useIsFocused()
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
// called on first load
|
// called on first load
|
||||||
if (!feed.hasLoaded && isPageFocused) {
|
if (!feed.hasLoaded && isPageFocused) {
|
||||||
feed.setup()
|
feed.setup()
|
||||||
}
|
}
|
||||||
}, [isPageFocused, feed])
|
}, [isPageFocused, feed])
|
||||||
|
|
||||||
const doPoll = React.useCallback(
|
const doPoll = React.useCallback(
|
||||||
(knownActive = false) => {
|
(knownActive = false) => {
|
||||||
if (
|
if (
|
||||||
(!knownActive && appState !== 'active') ||
|
(!knownActive && appState !== 'active') ||
|
||||||
!isScreenFocused ||
|
!isScreenFocused ||
|
||||||
!isPageFocused
|
!isPageFocused
|
||||||
) {
|
) {
|
||||||
return
|
|
||||||
}
|
|
||||||
if (feed.isLoading) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
store.log.debug('HomeScreen: Polling for new posts')
|
|
||||||
feed.checkForLatest()
|
|
||||||
},
|
|
||||||
[appState, isScreenFocused, isPageFocused, store, feed],
|
|
||||||
)
|
|
||||||
|
|
||||||
const scrollToTop = React.useCallback(() => {
|
|
||||||
scrollElRef.current?.scrollToOffset({offset: -headerOffset})
|
|
||||||
resetMainScroll()
|
|
||||||
}, [headerOffset, resetMainScroll])
|
|
||||||
|
|
||||||
const onSoftReset = React.useCallback(() => {
|
|
||||||
if (isPageFocused) {
|
|
||||||
scrollToTop()
|
|
||||||
feed.refresh()
|
|
||||||
}
|
|
||||||
}, [isPageFocused, scrollToTop, feed])
|
|
||||||
|
|
||||||
// listens for resize events
|
|
||||||
React.useEffect(() => {
|
|
||||||
setHeaderOffset(isMobile ? HEADER_OFFSET_MOBILE : HEADER_OFFSET_DESKTOP)
|
|
||||||
}, [isMobile])
|
|
||||||
|
|
||||||
// fires when page within screen is activated/deactivated
|
|
||||||
// - check for latest
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!isPageFocused || !isScreenFocused) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (feed.isLoading) {
|
||||||
const softResetSub = store.onScreenSoftReset(onSoftReset)
|
return
|
||||||
const feedCleanup = feed.registerListeners()
|
}
|
||||||
const pollInterval = setInterval(doPoll, POLL_FREQ)
|
store.log.debug('HomeScreen: Polling for new posts')
|
||||||
|
|
||||||
screen('Feed')
|
|
||||||
store.log.debug('HomeScreen: Updating feed')
|
|
||||||
feed.checkForLatest()
|
feed.checkForLatest()
|
||||||
if (feed.hasContent) {
|
},
|
||||||
feed.update()
|
[appState, isScreenFocused, isPageFocused, store, feed],
|
||||||
}
|
)
|
||||||
|
|
||||||
return () => {
|
const scrollToTop = React.useCallback(() => {
|
||||||
clearInterval(pollInterval)
|
scrollElRef.current?.scrollToOffset({offset: -headerOffset})
|
||||||
softResetSub.remove()
|
resetMainScroll()
|
||||||
feedCleanup()
|
}, [headerOffset, resetMainScroll])
|
||||||
}
|
|
||||||
}, [
|
|
||||||
store,
|
|
||||||
doPoll,
|
|
||||||
onSoftReset,
|
|
||||||
screen,
|
|
||||||
feed,
|
|
||||||
isPageFocused,
|
|
||||||
isScreenFocused,
|
|
||||||
])
|
|
||||||
|
|
||||||
const onPressCompose = React.useCallback(() => {
|
const onSoftReset = React.useCallback(() => {
|
||||||
track('HomeScreen:PressCompose')
|
if (isPageFocused) {
|
||||||
store.shell.openComposer({})
|
|
||||||
}, [store, track])
|
|
||||||
|
|
||||||
const onPressTryAgain = React.useCallback(() => {
|
|
||||||
feed.refresh()
|
|
||||||
}, [feed])
|
|
||||||
|
|
||||||
const onPressLoadLatest = React.useCallback(() => {
|
|
||||||
scrollToTop()
|
scrollToTop()
|
||||||
feed.refresh()
|
feed.refresh()
|
||||||
}, [feed, scrollToTop])
|
}
|
||||||
|
}, [isPageFocused, scrollToTop, feed])
|
||||||
|
|
||||||
const hasNew = feed.hasNewLatest && !feed.isRefreshing
|
// listens for resize events
|
||||||
return (
|
React.useEffect(() => {
|
||||||
<View testID={testID} style={s.h100pct}>
|
setHeaderOffset(isMobile ? HEADER_OFFSET_MOBILE : HEADER_OFFSET_DESKTOP)
|
||||||
<Feed
|
}, [isMobile])
|
||||||
testID={testID ? `${testID}-feed` : undefined}
|
|
||||||
key="default"
|
// fires when page within screen is activated/deactivated
|
||||||
feed={feed}
|
// - check for latest
|
||||||
scrollElRef={scrollElRef}
|
React.useEffect(() => {
|
||||||
onPressTryAgain={onPressTryAgain}
|
if (!isPageFocused || !isScreenFocused) {
|
||||||
onScroll={onMainScroll}
|
return
|
||||||
scrollEventThrottle={100}
|
}
|
||||||
renderEmptyState={renderEmptyState}
|
|
||||||
headerOffset={headerOffset}
|
const softResetSub = store.onScreenSoftReset(onSoftReset)
|
||||||
|
const feedCleanup = feed.registerListeners()
|
||||||
|
const pollInterval = setInterval(doPoll, POLL_FREQ)
|
||||||
|
|
||||||
|
screen('Feed')
|
||||||
|
store.log.debug('HomeScreen: Updating feed')
|
||||||
|
feed.checkForLatest()
|
||||||
|
if (feed.hasContent) {
|
||||||
|
feed.update()
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(pollInterval)
|
||||||
|
softResetSub.remove()
|
||||||
|
feedCleanup()
|
||||||
|
}
|
||||||
|
}, [store, doPoll, onSoftReset, screen, feed, isPageFocused, isScreenFocused])
|
||||||
|
|
||||||
|
const onPressCompose = React.useCallback(() => {
|
||||||
|
track('HomeScreen:PressCompose')
|
||||||
|
store.shell.openComposer({})
|
||||||
|
}, [store, track])
|
||||||
|
|
||||||
|
const onPressTryAgain = React.useCallback(() => {
|
||||||
|
feed.refresh()
|
||||||
|
}, [feed])
|
||||||
|
|
||||||
|
const onPressLoadLatest = React.useCallback(() => {
|
||||||
|
scrollToTop()
|
||||||
|
feed.refresh()
|
||||||
|
}, [feed, scrollToTop])
|
||||||
|
|
||||||
|
const hasNew = feed.hasNewLatest && !feed.isRefreshing
|
||||||
|
return (
|
||||||
|
<View testID={testID} style={s.h100pct}>
|
||||||
|
<Feed
|
||||||
|
testID={testID ? `${testID}-feed` : undefined}
|
||||||
|
key="default"
|
||||||
|
feed={feed}
|
||||||
|
scrollElRef={scrollElRef}
|
||||||
|
onPressTryAgain={onPressTryAgain}
|
||||||
|
onScroll={onMainScroll}
|
||||||
|
scrollEventThrottle={100}
|
||||||
|
renderEmptyState={renderEmptyState}
|
||||||
|
headerOffset={headerOffset}
|
||||||
|
/>
|
||||||
|
{(isScrolledDown || hasNew) && (
|
||||||
|
<LoadLatestBtn
|
||||||
|
onPress={onPressLoadLatest}
|
||||||
|
label="Load new posts"
|
||||||
|
showIndicator={hasNew}
|
||||||
|
minimalShellMode={store.shell.minimalShellMode}
|
||||||
/>
|
/>
|
||||||
{(isScrolledDown || hasNew) && (
|
)}
|
||||||
<LoadLatestBtn
|
<FAB
|
||||||
onPress={onPressLoadLatest}
|
testID="composeFAB"
|
||||||
label="Load new posts"
|
onPress={onPressCompose}
|
||||||
showIndicator={hasNew}
|
icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
|
||||||
minimalShellMode={store.shell.minimalShellMode}
|
accessibilityRole="button"
|
||||||
/>
|
accessibilityLabel="New post"
|
||||||
)}
|
accessibilityHint=""
|
||||||
<FAB
|
/>
|
||||||
testID="composeFAB"
|
</View>
|
||||||
onPress={onPressCompose}
|
)
|
||||||
icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
|
})
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel="New post"
|
|
||||||
accessibilityHint=""
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
|
@ -27,7 +27,7 @@ type Props = NativeStackScreenProps<
|
||||||
'ModerationBlockedAccounts'
|
'ModerationBlockedAccounts'
|
||||||
>
|
>
|
||||||
export const ModerationBlockedAccounts = withAuthRequired(
|
export const ModerationBlockedAccounts = withAuthRequired(
|
||||||
observer(({}: Props) => {
|
observer(function ModerationBlockedAccountsImpl({}: Props) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const {isTabletOrDesktop} = useWebMediaQueries()
|
const {isTabletOrDesktop} = useWebMediaQueries()
|
||||||
|
@ -116,6 +116,8 @@ export const ModerationBlockedAccounts = withAuthRequired(
|
||||||
onEndReached={onEndReached}
|
onEndReached={onEndReached}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
initialNumToRender={15}
|
initialNumToRender={15}
|
||||||
|
// FIXME(dan)
|
||||||
|
// eslint-disable-next-line react/no-unstable-nested-components
|
||||||
ListFooterComponent={() => (
|
ListFooterComponent={() => (
|
||||||
<View style={styles.footer}>
|
<View style={styles.footer}>
|
||||||
{blockedAccounts.isLoading && <ActivityIndicator />}
|
{blockedAccounts.isLoading && <ActivityIndicator />}
|
||||||
|
|
|
@ -27,7 +27,7 @@ type Props = NativeStackScreenProps<
|
||||||
'ModerationMutedAccounts'
|
'ModerationMutedAccounts'
|
||||||
>
|
>
|
||||||
export const ModerationMutedAccounts = withAuthRequired(
|
export const ModerationMutedAccounts = withAuthRequired(
|
||||||
observer(({}: Props) => {
|
observer(function ModerationMutedAccountsImpl({}: Props) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const {isTabletOrDesktop} = useWebMediaQueries()
|
const {isTabletOrDesktop} = useWebMediaQueries()
|
||||||
|
@ -112,6 +112,8 @@ export const ModerationMutedAccounts = withAuthRequired(
|
||||||
onEndReached={onEndReached}
|
onEndReached={onEndReached}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
initialNumToRender={15}
|
initialNumToRender={15}
|
||||||
|
// FIXME(dan)
|
||||||
|
// eslint-disable-next-line react/no-unstable-nested-components
|
||||||
ListFooterComponent={() => (
|
ListFooterComponent={() => (
|
||||||
<View style={styles.footer}>
|
<View style={styles.footer}>
|
||||||
{mutedAccounts.isLoading && <ActivityIndicator />}
|
{mutedAccounts.isLoading && <ActivityIndicator />}
|
||||||
|
|
|
@ -24,7 +24,7 @@ type Props = NativeStackScreenProps<
|
||||||
'Notifications'
|
'Notifications'
|
||||||
>
|
>
|
||||||
export const NotificationsScreen = withAuthRequired(
|
export const NotificationsScreen = withAuthRequired(
|
||||||
observer(({}: Props) => {
|
observer(function NotificationsScreenImpl({}: Props) {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const [onMainScroll, isScrolledDown, resetMainScroll] =
|
const [onMainScroll, isScrolledDown, resetMainScroll] =
|
||||||
useOnMainScroll(store)
|
useOnMainScroll(store)
|
||||||
|
|
|
@ -48,7 +48,9 @@ type Props = NativeStackScreenProps<
|
||||||
CommonNavigatorParams,
|
CommonNavigatorParams,
|
||||||
'PreferencesHomeFeed'
|
'PreferencesHomeFeed'
|
||||||
>
|
>
|
||||||
export const PreferencesHomeFeed = observer(({navigation}: Props) => {
|
export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({
|
||||||
|
navigation,
|
||||||
|
}: Props) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const {isTabletOrDesktop} = useWebMediaQueries()
|
const {isTabletOrDesktop} = useWebMediaQueries()
|
||||||
|
|
|
@ -32,7 +32,7 @@ import {combinedDisplayName} from 'lib/strings/display-names'
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'>
|
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'>
|
||||||
export const ProfileScreen = withAuthRequired(
|
export const ProfileScreen = withAuthRequired(
|
||||||
observer(({route}: Props) => {
|
observer(function ProfileScreenImpl({route}: Props) {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const {screen, track} = useAnalytics()
|
const {screen, track} = useAnalytics()
|
||||||
const viewSelectorRef = React.useRef<ViewSelectorHandle>(null)
|
const viewSelectorRef = React.useRef<ViewSelectorHandle>(null)
|
||||||
|
|
|
@ -23,7 +23,7 @@ import {s} from 'lib/styles'
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileList'>
|
type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileList'>
|
||||||
export const ProfileListScreen = withAuthRequired(
|
export const ProfileListScreen = withAuthRequired(
|
||||||
observer(({route}: Props) => {
|
observer(function ProfileListScreenImpl({route}: Props) {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const navigation = useNavigation<NavigationProp>()
|
const navigation = useNavigation<NavigationProp>()
|
||||||
const {isTabletOrDesktop} = useWebMediaQueries()
|
const {isTabletOrDesktop} = useWebMediaQueries()
|
||||||
|
|
|
@ -35,7 +35,7 @@ import {Link, TextLink} from 'view/com/util/Link'
|
||||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'SavedFeeds'>
|
type Props = NativeStackScreenProps<CommonNavigatorParams, 'SavedFeeds'>
|
||||||
|
|
||||||
export const SavedFeeds = withAuthRequired(
|
export const SavedFeeds = withAuthRequired(
|
||||||
observer(({}: Props) => {
|
observer(function SavedFeedsImpl({}: Props) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const {isMobile, isTabletOrDesktop} = useWebMediaQueries()
|
const {isMobile, isTabletOrDesktop} = useWebMediaQueries()
|
||||||
|
@ -151,96 +151,98 @@ export const SavedFeeds = withAuthRequired(
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const ListItem = observer(
|
const ListItem = observer(function ListItemImpl({
|
||||||
({item, drag}: {item: CustomFeedModel; drag: () => void}) => {
|
item,
|
||||||
const pal = usePalette('default')
|
drag,
|
||||||
const store = useStores()
|
}: {
|
||||||
const savedFeeds = useMemo(() => store.me.savedFeeds, [store])
|
item: CustomFeedModel
|
||||||
const isPinned = savedFeeds.isPinned(item)
|
drag: () => void
|
||||||
|
}) {
|
||||||
|
const pal = usePalette('default')
|
||||||
|
const store = useStores()
|
||||||
|
const savedFeeds = useMemo(() => store.me.savedFeeds, [store])
|
||||||
|
const isPinned = savedFeeds.isPinned(item)
|
||||||
|
|
||||||
const onTogglePinned = useCallback(() => {
|
const onTogglePinned = useCallback(() => {
|
||||||
Haptics.default()
|
Haptics.default()
|
||||||
savedFeeds.togglePinnedFeed(item).catch(e => {
|
savedFeeds.togglePinnedFeed(item).catch(e => {
|
||||||
|
Toast.show('There was an issue contacting the server')
|
||||||
|
store.log.error('Failed to toggle pinned feed', {e})
|
||||||
|
})
|
||||||
|
}, [savedFeeds, item, store])
|
||||||
|
const onPressUp = useCallback(
|
||||||
|
() =>
|
||||||
|
savedFeeds.movePinnedFeed(item, 'up').catch(e => {
|
||||||
Toast.show('There was an issue contacting the server')
|
Toast.show('There was an issue contacting the server')
|
||||||
store.log.error('Failed to toggle pinned feed', {e})
|
store.log.error('Failed to set pinned feed order', {e})
|
||||||
})
|
}),
|
||||||
}, [savedFeeds, item, store])
|
[store, savedFeeds, item],
|
||||||
const onPressUp = useCallback(
|
)
|
||||||
() =>
|
const onPressDown = useCallback(
|
||||||
savedFeeds.movePinnedFeed(item, 'up').catch(e => {
|
() =>
|
||||||
Toast.show('There was an issue contacting the server')
|
savedFeeds.movePinnedFeed(item, 'down').catch(e => {
|
||||||
store.log.error('Failed to set pinned feed order', {e})
|
Toast.show('There was an issue contacting the server')
|
||||||
}),
|
store.log.error('Failed to set pinned feed order', {e})
|
||||||
[store, savedFeeds, item],
|
}),
|
||||||
)
|
[store, savedFeeds, item],
|
||||||
const onPressDown = useCallback(
|
)
|
||||||
() =>
|
|
||||||
savedFeeds.movePinnedFeed(item, 'down').catch(e => {
|
|
||||||
Toast.show('There was an issue contacting the server')
|
|
||||||
store.log.error('Failed to set pinned feed order', {e})
|
|
||||||
}),
|
|
||||||
[store, savedFeeds, item],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScaleDecorator>
|
<ScaleDecorator>
|
||||||
<ShadowDecorator>
|
<ShadowDecorator>
|
||||||
<Pressable
|
<Pressable
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
onLongPress={isPinned ? drag : undefined}
|
onLongPress={isPinned ? drag : undefined}
|
||||||
delayLongPress={200}
|
delayLongPress={200}
|
||||||
style={[styles.itemContainer, pal.border]}>
|
style={[styles.itemContainer, pal.border]}>
|
||||||
{isPinned && isWeb ? (
|
{isPinned && isWeb ? (
|
||||||
<View style={styles.webArrowButtonsContainer}>
|
<View style={styles.webArrowButtonsContainer}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity accessibilityRole="button" onPress={onPressUp}>
|
||||||
accessibilityRole="button"
|
<FontAwesomeIcon
|
||||||
onPress={onPressUp}>
|
icon="arrow-up"
|
||||||
<FontAwesomeIcon
|
size={12}
|
||||||
icon="arrow-up"
|
style={[pal.text, styles.webArrowUpButton]}
|
||||||
size={12}
|
/>
|
||||||
style={[pal.text, styles.webArrowUpButton]}
|
</TouchableOpacity>
|
||||||
/>
|
<TouchableOpacity
|
||||||
</TouchableOpacity>
|
accessibilityRole="button"
|
||||||
<TouchableOpacity
|
onPress={onPressDown}>
|
||||||
accessibilityRole="button"
|
<FontAwesomeIcon
|
||||||
onPress={onPressDown}>
|
icon="arrow-down"
|
||||||
<FontAwesomeIcon
|
size={12}
|
||||||
icon="arrow-down"
|
style={[pal.text]}
|
||||||
size={12}
|
/>
|
||||||
style={[pal.text]}
|
</TouchableOpacity>
|
||||||
/>
|
</View>
|
||||||
</TouchableOpacity>
|
) : isPinned ? (
|
||||||
</View>
|
<FontAwesomeIcon
|
||||||
) : isPinned ? (
|
icon="bars"
|
||||||
<FontAwesomeIcon
|
size={20}
|
||||||
icon="bars"
|
color={pal.colors.text}
|
||||||
size={20}
|
style={s.ml20}
|
||||||
color={pal.colors.text}
|
|
||||||
style={s.ml20}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<CustomFeed
|
|
||||||
key={item.data.uri}
|
|
||||||
item={item}
|
|
||||||
showSaveBtn
|
|
||||||
style={styles.noBorder}
|
|
||||||
/>
|
/>
|
||||||
<TouchableOpacity
|
) : null}
|
||||||
accessibilityRole="button"
|
<CustomFeed
|
||||||
hitSlop={10}
|
key={item.data.uri}
|
||||||
onPress={onTogglePinned}>
|
item={item}
|
||||||
<FontAwesomeIcon
|
showSaveBtn
|
||||||
icon="thumb-tack"
|
style={styles.noBorder}
|
||||||
size={20}
|
/>
|
||||||
color={isPinned ? colors.blue3 : pal.colors.icon}
|
<TouchableOpacity
|
||||||
/>
|
accessibilityRole="button"
|
||||||
</TouchableOpacity>
|
hitSlop={10}
|
||||||
</Pressable>
|
onPress={onTogglePinned}>
|
||||||
</ShadowDecorator>
|
<FontAwesomeIcon
|
||||||
</ScaleDecorator>
|
icon="thumb-tack"
|
||||||
)
|
size={20}
|
||||||
},
|
color={isPinned ? colors.blue3 : pal.colors.icon}
|
||||||
)
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Pressable>
|
||||||
|
</ShadowDecorator>
|
||||||
|
</ScaleDecorator>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
desktopContainer: {
|
desktopContainer: {
|
||||||
|
|
|
@ -18,7 +18,7 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>
|
type Props = NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>
|
||||||
export const SearchScreen = withAuthRequired(
|
export const SearchScreen = withAuthRequired(
|
||||||
observer(({navigation, route}: Props) => {
|
observer(function SearchScreenImpl({navigation, route}: Props) {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const params = route.params || {}
|
const params = route.params || {}
|
||||||
const foafs = React.useMemo<FoafsModel>(
|
const foafs = React.useMemo<FoafsModel>(
|
||||||
|
|
|
@ -30,7 +30,7 @@ import {isAndroid, isIOS} from 'platform/detection'
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>
|
type Props = NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>
|
||||||
export const SearchScreen = withAuthRequired(
|
export const SearchScreen = withAuthRequired(
|
||||||
observer<Props>(({}: Props) => {
|
observer<Props>(function SearchScreenImpl({}: Props) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const scrollViewRef = React.useRef<ScrollView>(null)
|
const scrollViewRef = React.useRef<ScrollView>(null)
|
||||||
|
|
|
@ -6,73 +6,71 @@ import {ComposerOpts} from 'state/models/ui/shell'
|
||||||
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
|
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
|
|
||||||
export const Composer = observer(
|
export const Composer = observer(function ComposerImpl({
|
||||||
({
|
active,
|
||||||
active,
|
winHeight,
|
||||||
winHeight,
|
replyTo,
|
||||||
replyTo,
|
onPost,
|
||||||
onPost,
|
onClose,
|
||||||
onClose,
|
quote,
|
||||||
quote,
|
mention,
|
||||||
mention,
|
}: {
|
||||||
}: {
|
active: boolean
|
||||||
active: boolean
|
winHeight: number
|
||||||
winHeight: number
|
replyTo?: ComposerOpts['replyTo']
|
||||||
replyTo?: ComposerOpts['replyTo']
|
onPost?: ComposerOpts['onPost']
|
||||||
onPost?: ComposerOpts['onPost']
|
onClose: () => void
|
||||||
onClose: () => void
|
quote?: ComposerOpts['quote']
|
||||||
quote?: ComposerOpts['quote']
|
mention?: ComposerOpts['mention']
|
||||||
mention?: ComposerOpts['mention']
|
}) {
|
||||||
}) => {
|
const pal = usePalette('default')
|
||||||
const pal = usePalette('default')
|
const initInterp = useAnimatedValue(0)
|
||||||
const initInterp = useAnimatedValue(0)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (active) {
|
if (active) {
|
||||||
Animated.timing(initInterp, {
|
Animated.timing(initInterp, {
|
||||||
toValue: 1,
|
toValue: 1,
|
||||||
duration: 300,
|
duration: 300,
|
||||||
easing: Easing.out(Easing.exp),
|
easing: Easing.out(Easing.exp),
|
||||||
useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
}).start()
|
}).start()
|
||||||
} else {
|
} else {
|
||||||
initInterp.setValue(0)
|
initInterp.setValue(0)
|
||||||
}
|
|
||||||
}, [initInterp, active])
|
|
||||||
const wrapperAnimStyle = {
|
|
||||||
transform: [
|
|
||||||
{
|
|
||||||
translateY: initInterp.interpolate({
|
|
||||||
inputRange: [0, 1],
|
|
||||||
outputRange: [winHeight, 0],
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
}, [initInterp, active])
|
||||||
|
const wrapperAnimStyle = {
|
||||||
|
transform: [
|
||||||
|
{
|
||||||
|
translateY: initInterp.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: [winHeight, 0],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
// rendering
|
// rendering
|
||||||
// =
|
// =
|
||||||
|
|
||||||
if (!active) {
|
if (!active) {
|
||||||
return <View />
|
return <View />
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[styles.wrapper, pal.view, wrapperAnimStyle]}
|
style={[styles.wrapper, pal.view, wrapperAnimStyle]}
|
||||||
aria-modal
|
aria-modal
|
||||||
accessibilityViewIsModal>
|
accessibilityViewIsModal>
|
||||||
<ComposePost
|
<ComposePost
|
||||||
replyTo={replyTo}
|
replyTo={replyTo}
|
||||||
onPost={onPost}
|
onPost={onPost}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
quote={quote}
|
quote={quote}
|
||||||
mention={mention}
|
mention={mention}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
)
|
)
|
||||||
},
|
})
|
||||||
)
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
wrapper: {
|
wrapper: {
|
||||||
|
|
|
@ -8,54 +8,52 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||||
|
|
||||||
const BOTTOM_BAR_HEIGHT = 61
|
const BOTTOM_BAR_HEIGHT = 61
|
||||||
|
|
||||||
export const Composer = observer(
|
export const Composer = observer(function ComposerImpl({
|
||||||
({
|
active,
|
||||||
active,
|
replyTo,
|
||||||
replyTo,
|
quote,
|
||||||
quote,
|
onPost,
|
||||||
onPost,
|
onClose,
|
||||||
onClose,
|
mention,
|
||||||
mention,
|
}: {
|
||||||
}: {
|
active: boolean
|
||||||
active: boolean
|
winHeight: number
|
||||||
winHeight: number
|
replyTo?: ComposerOpts['replyTo']
|
||||||
replyTo?: ComposerOpts['replyTo']
|
quote: ComposerOpts['quote']
|
||||||
quote: ComposerOpts['quote']
|
onPost?: ComposerOpts['onPost']
|
||||||
onPost?: ComposerOpts['onPost']
|
onClose: () => void
|
||||||
onClose: () => void
|
mention?: ComposerOpts['mention']
|
||||||
mention?: ComposerOpts['mention']
|
}) {
|
||||||
}) => {
|
const pal = usePalette('default')
|
||||||
const pal = usePalette('default')
|
const {isMobile} = useWebMediaQueries()
|
||||||
const {isMobile} = useWebMediaQueries()
|
|
||||||
|
|
||||||
// rendering
|
// rendering
|
||||||
// =
|
// =
|
||||||
|
|
||||||
if (!active) {
|
if (!active) {
|
||||||
return <View />
|
return <View />
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.mask} aria-modal accessibilityViewIsModal>
|
<View style={styles.mask} aria-modal accessibilityViewIsModal>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.container,
|
styles.container,
|
||||||
isMobile && styles.containerMobile,
|
isMobile && styles.containerMobile,
|
||||||
pal.view,
|
pal.view,
|
||||||
pal.border,
|
pal.border,
|
||||||
]}>
|
]}>
|
||||||
<ComposePost
|
<ComposePost
|
||||||
replyTo={replyTo}
|
replyTo={replyTo}
|
||||||
quote={quote}
|
quote={quote}
|
||||||
onPost={onPost}
|
onPost={onPost}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
mention={mention}
|
mention={mention}
|
||||||
/>
|
/>
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
)
|
</View>
|
||||||
},
|
)
|
||||||
)
|
})
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
mask: {
|
mask: {
|
||||||
|
|
|
@ -44,7 +44,7 @@ import {useNavigationTabState} from 'lib/hooks/useNavigationTabState'
|
||||||
import {isWeb} from 'platform/detection'
|
import {isWeb} from 'platform/detection'
|
||||||
import {formatCount, formatCountShortOnly} from 'view/com/util/numeric/format'
|
import {formatCount, formatCountShortOnly} from 'view/com/util/numeric/format'
|
||||||
|
|
||||||
export const DrawerContent = observer(() => {
|
export const DrawerContent = observer(function DrawerContentImpl() {
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
|
@ -400,7 +400,7 @@ function MenuItem({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const InviteCodes = observer(() => {
|
const InviteCodes = observer(function InviteCodesImpl() {
|
||||||
const {track} = useAnalytics()
|
const {track} = useAnalytics()
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
|
|
|
@ -32,7 +32,9 @@ import {UserAvatar} from 'view/com/util/UserAvatar'
|
||||||
|
|
||||||
type TabOptions = 'Home' | 'Search' | 'Notifications' | 'MyProfile' | 'Feeds'
|
type TabOptions = 'Home' | 'Search' | 'Notifications' | 'MyProfile' | 'Feeds'
|
||||||
|
|
||||||
export const BottomBar = observer(({navigation}: BottomTabBarProps) => {
|
export const BottomBar = observer(function BottomBarImpl({
|
||||||
|
navigation,
|
||||||
|
}: BottomTabBarProps) {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const safeAreaInsets = useSafeAreaInsets()
|
const safeAreaInsets = useSafeAreaInsets()
|
||||||
|
|
|
@ -23,7 +23,7 @@ import {Link} from 'view/com/util/Link'
|
||||||
import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
|
import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
|
||||||
import {makeProfileLink} from 'lib/routes/links'
|
import {makeProfileLink} from 'lib/routes/links'
|
||||||
|
|
||||||
export const BottomBarWeb = observer(() => {
|
export const BottomBarWeb = observer(function BottomBarWebImpl() {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const safeAreaInsets = useSafeAreaInsets()
|
const safeAreaInsets = useSafeAreaInsets()
|
||||||
|
|
|
@ -40,7 +40,7 @@ import {NavigationProp, CommonNavigatorParams} from 'lib/routes/types'
|
||||||
import {router} from '../../../routes'
|
import {router} from '../../../routes'
|
||||||
import {makeProfileLink} from 'lib/routes/links'
|
import {makeProfileLink} from 'lib/routes/links'
|
||||||
|
|
||||||
const ProfileCard = observer(() => {
|
const ProfileCard = observer(function ProfileCardImpl() {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const {isDesktop} = useWebMediaQueries()
|
const {isDesktop} = useWebMediaQueries()
|
||||||
const size = isDesktop ? 64 : 48
|
const size = isDesktop ? 64 : 48
|
||||||
|
@ -103,78 +103,82 @@ interface NavItemProps {
|
||||||
iconFilled: JSX.Element
|
iconFilled: JSX.Element
|
||||||
label: string
|
label: string
|
||||||
}
|
}
|
||||||
const NavItem = observer(
|
const NavItem = observer(function NavItemImpl({
|
||||||
({count, href, icon, iconFilled, label}: NavItemProps) => {
|
count,
|
||||||
const pal = usePalette('default')
|
href,
|
||||||
const store = useStores()
|
icon,
|
||||||
const {isDesktop, isTablet} = useWebMediaQueries()
|
iconFilled,
|
||||||
const [pathName] = React.useMemo(() => router.matchPath(href), [href])
|
label,
|
||||||
const currentRouteInfo = useNavigationState(state => {
|
}: NavItemProps) {
|
||||||
if (!state) {
|
const pal = usePalette('default')
|
||||||
return {name: 'Home'}
|
const store = useStores()
|
||||||
|
const {isDesktop, isTablet} = useWebMediaQueries()
|
||||||
|
const [pathName] = React.useMemo(() => router.matchPath(href), [href])
|
||||||
|
const currentRouteInfo = useNavigationState(state => {
|
||||||
|
if (!state) {
|
||||||
|
return {name: 'Home'}
|
||||||
|
}
|
||||||
|
return getCurrentRoute(state)
|
||||||
|
})
|
||||||
|
let isCurrent =
|
||||||
|
currentRouteInfo.name === 'Profile'
|
||||||
|
? isTab(currentRouteInfo.name, pathName) &&
|
||||||
|
(currentRouteInfo.params as CommonNavigatorParams['Profile']).name ===
|
||||||
|
store.me.handle
|
||||||
|
: isTab(currentRouteInfo.name, pathName)
|
||||||
|
const {onPress} = useLinkProps({to: href})
|
||||||
|
const onPressWrapped = React.useCallback(
|
||||||
|
(e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
|
||||||
|
if (e.ctrlKey || e.metaKey || e.altKey) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
return getCurrentRoute(state)
|
e.preventDefault()
|
||||||
})
|
if (isCurrent) {
|
||||||
let isCurrent =
|
store.emitScreenSoftReset()
|
||||||
currentRouteInfo.name === 'Profile'
|
} else {
|
||||||
? isTab(currentRouteInfo.name, pathName) &&
|
onPress()
|
||||||
(currentRouteInfo.params as CommonNavigatorParams['Profile']).name ===
|
}
|
||||||
store.me.handle
|
},
|
||||||
: isTab(currentRouteInfo.name, pathName)
|
[onPress, isCurrent, store],
|
||||||
const {onPress} = useLinkProps({to: href})
|
)
|
||||||
const onPressWrapped = React.useCallback(
|
|
||||||
(e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
|
|
||||||
if (e.ctrlKey || e.metaKey || e.altKey) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
e.preventDefault()
|
|
||||||
if (isCurrent) {
|
|
||||||
store.emitScreenSoftReset()
|
|
||||||
} else {
|
|
||||||
onPress()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onPress, isCurrent, store],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PressableWithHover
|
<PressableWithHover
|
||||||
style={styles.navItemWrapper}
|
style={styles.navItemWrapper}
|
||||||
hoverStyle={pal.viewLight}
|
hoverStyle={pal.viewLight}
|
||||||
// @ts-ignore the function signature differs on web -prf
|
// @ts-ignore the function signature differs on web -prf
|
||||||
onPress={onPressWrapped}
|
onPress={onPressWrapped}
|
||||||
// @ts-ignore web only -prf
|
// @ts-ignore web only -prf
|
||||||
href={href}
|
href={href}
|
||||||
dataSet={{noUnderline: 1}}
|
dataSet={{noUnderline: 1}}
|
||||||
accessibilityRole="tab"
|
accessibilityRole="tab"
|
||||||
accessibilityLabel={label}
|
accessibilityLabel={label}
|
||||||
accessibilityHint="">
|
accessibilityHint="">
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.navItemIconWrapper,
|
styles.navItemIconWrapper,
|
||||||
isTablet && styles.navItemIconWrapperTablet,
|
isTablet && styles.navItemIconWrapperTablet,
|
||||||
]}>
|
]}>
|
||||||
{isCurrent ? iconFilled : icon}
|
{isCurrent ? iconFilled : icon}
|
||||||
{typeof count === 'string' && count ? (
|
{typeof count === 'string' && count ? (
|
||||||
<Text
|
<Text
|
||||||
type="button"
|
type="button"
|
||||||
style={[
|
style={[
|
||||||
styles.navItemCount,
|
styles.navItemCount,
|
||||||
isTablet && styles.navItemCountTablet,
|
isTablet && styles.navItemCountTablet,
|
||||||
]}>
|
]}>
|
||||||
{count}
|
{count}
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
{isDesktop && (
|
|
||||||
<Text type="title" style={[isCurrent ? s.bold : s.normal, pal.text]}>
|
|
||||||
{label}
|
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
) : null}
|
||||||
</PressableWithHover>
|
</View>
|
||||||
)
|
{isDesktop && (
|
||||||
},
|
<Text type="title" style={[isCurrent ? s.bold : s.normal, pal.text]}>
|
||||||
)
|
{label}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</PressableWithHover>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
function ComposeBtn() {
|
function ComposeBtn() {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
|
|
|
@ -13,7 +13,7 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||||
import {pluralize} from 'lib/strings/helpers'
|
import {pluralize} from 'lib/strings/helpers'
|
||||||
import {formatCount} from 'view/com/util/numeric/format'
|
import {formatCount} from 'view/com/util/numeric/format'
|
||||||
|
|
||||||
export const DesktopRightNav = observer(function DesktopRightNav() {
|
export const DesktopRightNav = observer(function DesktopRightNavImpl() {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const palError = usePalette('error')
|
const palError = usePalette('error')
|
||||||
|
@ -78,7 +78,7 @@ export const DesktopRightNav = observer(function DesktopRightNav() {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const InviteCodes = observer(() => {
|
const InviteCodes = observer(function InviteCodesImpl() {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
|
|
||||||
|
|
|
@ -24,7 +24,7 @@ import {isStateAtTabRoot} from 'lib/routes/helpers'
|
||||||
import {SafeAreaProvider} from 'react-native-safe-area-context'
|
import {SafeAreaProvider} from 'react-native-safe-area-context'
|
||||||
import {useOTAUpdate} from 'lib/hooks/useOTAUpdate'
|
import {useOTAUpdate} from 'lib/hooks/useOTAUpdate'
|
||||||
|
|
||||||
const ShellInner = observer(() => {
|
const ShellInner = observer(function ShellInnerImpl() {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
useOTAUpdate() // this hook polls for OTA updates every few seconds
|
useOTAUpdate() // this hook polls for OTA updates every few seconds
|
||||||
const winDim = useWindowDimensions()
|
const winDim = useWindowDimensions()
|
||||||
|
@ -81,7 +81,7 @@ const ShellInner = observer(() => {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const Shell: React.FC = observer(() => {
|
export const Shell: React.FC = observer(function ShellImpl() {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -17,7 +17,7 @@ import {BottomBarWeb} from './bottom-bar/BottomBarWeb'
|
||||||
import {useNavigation} from '@react-navigation/native'
|
import {useNavigation} from '@react-navigation/native'
|
||||||
import {NavigationProp} from 'lib/routes/types'
|
import {NavigationProp} from 'lib/routes/types'
|
||||||
|
|
||||||
const ShellInner = observer(() => {
|
const ShellInner = observer(function ShellInnerImpl() {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const {isDesktop, isMobile} = useWebMediaQueries()
|
const {isDesktop, isMobile} = useWebMediaQueries()
|
||||||
const navigator = useNavigation<NavigationProp>()
|
const navigator = useNavigation<NavigationProp>()
|
||||||
|
@ -71,7 +71,7 @@ const ShellInner = observer(() => {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const Shell: React.FC = observer(() => {
|
export const Shell: React.FC = observer(function ShellImpl() {
|
||||||
const pageBg = useColorSchemeStyle(styles.bgLight, styles.bgDark)
|
const pageBg = useColorSchemeStyle(styles.bgLight, styles.bgDark)
|
||||||
return (
|
return (
|
||||||
<View style={[s.hContentRegion, pageBg]}>
|
<View style={[s.hContentRegion, pageBg]}>
|
||||||
|
|
Loading…
Reference in New Issue