mirror of
https://github.com/tooot-app/app
synced 2025-06-05 22:19:13 +02:00
Updates
1. Added more notification types 2. Use `react-native-reanimated` v2
This commit is contained in:
@ -1,16 +1,19 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import {
|
||||
Animated,
|
||||
Dimensions,
|
||||
Modal,
|
||||
PanResponder,
|
||||
StyleSheet,
|
||||
View
|
||||
} from 'react-native'
|
||||
import React from 'react'
|
||||
import { Dimensions, Modal, StyleSheet, View } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useTheme } from '@utils/styles/ThemeManager'
|
||||
import { StyleConstants } from '@utils/styles/constants'
|
||||
import Button from '@components/Button'
|
||||
import { PanGestureHandler } from 'react-native-gesture-handler'
|
||||
import Animated, {
|
||||
Extrapolate,
|
||||
interpolate,
|
||||
runOnJS,
|
||||
useAnimatedGestureHandler,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming
|
||||
} from 'react-native-reanimated'
|
||||
|
||||
export interface Props {
|
||||
children: React.ReactNode
|
||||
@ -22,76 +25,63 @@ const BottomSheet: React.FC<Props> = ({ children, visible, handleDismiss }) => {
|
||||
const { theme } = useTheme()
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
const panY = useRef(new Animated.Value(Dimensions.get('screen').height))
|
||||
.current
|
||||
const top = panY.interpolate({
|
||||
inputRange: [-1, 0, 1],
|
||||
outputRange: [0, 0, 1]
|
||||
})
|
||||
const resetModal = Animated.timing(panY, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
useNativeDriver: false
|
||||
})
|
||||
|
||||
const closeModal = Animated.timing(panY, {
|
||||
toValue: Dimensions.get('screen').height,
|
||||
duration: 350,
|
||||
useNativeDriver: false
|
||||
})
|
||||
|
||||
const panResponder = useRef(
|
||||
PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => true,
|
||||
onMoveShouldSetPanResponder: () => true,
|
||||
onPanResponderMove: Animated.event([null, { dy: panY }], {
|
||||
useNativeDriver: false
|
||||
}),
|
||||
onPanResponderRelease: (e, gs) => {
|
||||
if (gs.dy > 0 && gs.vy > 1) {
|
||||
return closeModal.start(() => handleDismiss())
|
||||
} else if (gs.dy === 0 && gs.vy === 0) {
|
||||
return closeModal.start(() => handleDismiss())
|
||||
}
|
||||
return resetModal.start()
|
||||
}
|
||||
})
|
||||
).current
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
resetModal.start()
|
||||
const screenHeight = Dimensions.get('screen').height
|
||||
const panY = useSharedValue(0)
|
||||
const styleTop = useAnimatedStyle(() => {
|
||||
return {
|
||||
top: interpolate(
|
||||
panY.value,
|
||||
[0, screenHeight],
|
||||
[0, screenHeight],
|
||||
Extrapolate.CLAMP
|
||||
)
|
||||
}
|
||||
}, [visible])
|
||||
})
|
||||
const callDismiss = () => {
|
||||
handleDismiss()
|
||||
}
|
||||
const onGestureEvent = useAnimatedGestureHandler({
|
||||
onActive: ({ translationY }) => {
|
||||
panY.value = translationY
|
||||
},
|
||||
onEnd: ({ velocityY }) => {
|
||||
if (velocityY > 500) {
|
||||
runOnJS(callDismiss)()
|
||||
} else {
|
||||
panY.value = withTiming(0)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Modal animated animationType='fade' visible={visible} transparent>
|
||||
<View
|
||||
style={[styles.overlay, { backgroundColor: theme.backgroundOverlay }]}
|
||||
{...panResponder.panHandlers}
|
||||
>
|
||||
<PanGestureHandler onGestureEvent={onGestureEvent}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
top,
|
||||
backgroundColor: theme.background,
|
||||
paddingBottom: insets.bottom || StyleConstants.Spacing.L
|
||||
}
|
||||
]}
|
||||
style={[styles.overlay, { backgroundColor: theme.backgroundOverlay }]}
|
||||
>
|
||||
<View
|
||||
style={[styles.handle, { backgroundColor: theme.background }]}
|
||||
/>
|
||||
{children}
|
||||
<Button
|
||||
type='text'
|
||||
content='取消'
|
||||
onPress={() => closeModal.start(() => handleDismiss())}
|
||||
style={styles.button}
|
||||
/>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.container,
|
||||
styleTop,
|
||||
{
|
||||
backgroundColor: theme.background,
|
||||
paddingBottom: insets.bottom || StyleConstants.Spacing.L
|
||||
}
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[styles.handle, { backgroundColor: theme.primaryOverlay }]}
|
||||
/>
|
||||
{children}
|
||||
<Button
|
||||
type='text'
|
||||
content='取消'
|
||||
onPress={() => handleDismiss()}
|
||||
style={styles.button}
|
||||
/>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</PanGestureHandler>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
@ -2,7 +2,7 @@ import Icon from '@components/Icon'
|
||||
import { StyleConstants } from '@utils/styles/constants'
|
||||
import layoutAnimation from '@utils/styles/layoutAnimation'
|
||||
import { useTheme } from '@utils/styles/ThemeManager'
|
||||
import React, { useEffect, useMemo } from 'react'
|
||||
import React, { useEffect, useMemo, useRef } from 'react'
|
||||
import {
|
||||
Pressable,
|
||||
StyleProp,
|
||||
@ -12,6 +12,7 @@ import {
|
||||
ViewStyle
|
||||
} from 'react-native'
|
||||
import { Chase } from 'react-native-animated-spinkit'
|
||||
import Animated from 'react-native-reanimated'
|
||||
|
||||
export interface Props {
|
||||
style?: StyleProp<ViewStyle>
|
||||
@ -48,7 +49,14 @@ const Button: React.FC<Props> = ({
|
||||
}) => {
|
||||
const { theme } = useTheme()
|
||||
|
||||
useEffect(() => layoutAnimation(), [content, loading, disabled])
|
||||
const mounted = useRef(false)
|
||||
useEffect(() => {
|
||||
if (mounted.current) {
|
||||
layoutAnimation()
|
||||
} else {
|
||||
mounted.current = true
|
||||
}
|
||||
}, [content, loading, disabled])
|
||||
|
||||
const loadingSpinkit = useMemo(
|
||||
() => (
|
||||
@ -139,24 +147,26 @@ const Button: React.FC<Props> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={[
|
||||
styles.button,
|
||||
{
|
||||
borderWidth: overlay ? 0 : 1,
|
||||
borderColor: colorBorder,
|
||||
backgroundColor: colorBackground,
|
||||
paddingVertical: StyleConstants.Spacing[spacing],
|
||||
paddingHorizontal:
|
||||
StyleConstants.Spacing[round ? spacing : spacingMapping[spacing]]
|
||||
},
|
||||
customStyle
|
||||
]}
|
||||
testID='base'
|
||||
onPress={onPress}
|
||||
children={children}
|
||||
disabled={disabled || loading}
|
||||
/>
|
||||
<Animated.View>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.button,
|
||||
{
|
||||
borderWidth: overlay ? 0 : 1,
|
||||
borderColor: colorBorder,
|
||||
backgroundColor: colorBackground,
|
||||
paddingVertical: StyleConstants.Spacing[spacing],
|
||||
paddingHorizontal:
|
||||
StyleConstants.Spacing[round ? spacing : spacingMapping[spacing]]
|
||||
},
|
||||
customStyle
|
||||
]}
|
||||
testID='base'
|
||||
onPress={onPress}
|
||||
children={children}
|
||||
disabled={disabled || loading}
|
||||
/>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -6,6 +6,7 @@ export interface Props {
|
||||
name: string
|
||||
size: number
|
||||
color: string
|
||||
fill?: string
|
||||
strokeWidth?: number
|
||||
inline?: boolean // When used in line of text, need to drag it down
|
||||
style?: StyleProp<ViewStyle>
|
||||
@ -15,6 +16,7 @@ const Icon: React.FC<Props> = ({
|
||||
name,
|
||||
size,
|
||||
color,
|
||||
fill,
|
||||
strokeWidth = 2,
|
||||
inline = false,
|
||||
style
|
||||
@ -36,6 +38,7 @@ const Icon: React.FC<Props> = ({
|
||||
width: size,
|
||||
height: size,
|
||||
color,
|
||||
fill,
|
||||
strokeWidth
|
||||
})}
|
||||
</View>
|
||||
|
@ -27,7 +27,8 @@ const ParseEmojis: React.FC<Props> = ({
|
||||
},
|
||||
image: {
|
||||
width: StyleConstants.Font.Size[size],
|
||||
height: StyleConstants.Font.Size[size]
|
||||
height: StyleConstants.Font.Size[size],
|
||||
marginBottom: -StyleConstants.Font.Size[size] * 0.125
|
||||
}
|
||||
})
|
||||
|
||||
@ -50,7 +51,7 @@ const ParseEmojis: React.FC<Props> = ({
|
||||
{/* When emoji starts a paragraph, lineHeight will break */}
|
||||
{i === 0 ? <Text> </Text> : null}
|
||||
<Image
|
||||
resizeMode='contain'
|
||||
// resizeMode='contain'
|
||||
source={{ uri: emojis[emojiIndex].url }}
|
||||
style={[styles.image]}
|
||||
/>
|
||||
|
@ -3,12 +3,18 @@ import openLink from '@components/openLink'
|
||||
import ParseEmojis from '@components/Parse/Emojis'
|
||||
import { useNavigation } from '@react-navigation/native'
|
||||
import { StyleConstants } from '@utils/styles/constants'
|
||||
import layoutAnimation from '@utils/styles/layoutAnimation'
|
||||
import { useTheme } from '@utils/styles/ThemeManager'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import { Pressable, Text, View } from 'react-native'
|
||||
import HTMLView from 'react-native-htmlview'
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useDerivedValue,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
withTiming
|
||||
} from 'react-native-reanimated'
|
||||
|
||||
// Prevent going to the same hashtag multiple times
|
||||
const renderNode = ({
|
||||
@ -170,6 +176,27 @@ const ParseHTML: React.FC<Props> = ({
|
||||
const [allowExpand, setAllowExpand] = useState(false)
|
||||
const [showAllText, setShowAllText] = useState(false)
|
||||
|
||||
const viewHeight = useDerivedValue(() => {
|
||||
if (allowExpand) {
|
||||
if (showAllText) {
|
||||
return heightOriginal as number
|
||||
} else {
|
||||
return heightTruncated as number
|
||||
}
|
||||
} else {
|
||||
return heightOriginal as number
|
||||
}
|
||||
}, [heightOriginal, heightTruncated, allowExpand, showAllText])
|
||||
const ViewHeight = useAnimatedStyle(() => {
|
||||
return {
|
||||
height: allowExpand
|
||||
? showAllText
|
||||
? withTiming(viewHeight.value)
|
||||
: withTiming(viewHeight.value)
|
||||
: undefined
|
||||
}
|
||||
})
|
||||
|
||||
const calNumberOfLines = useMemo(() => {
|
||||
if (numberOfLines === 0) {
|
||||
// For spoilers without calculation
|
||||
@ -179,11 +206,7 @@ const ParseHTML: React.FC<Props> = ({
|
||||
if (!heightTruncated) {
|
||||
return numberOfLines
|
||||
} else {
|
||||
if (allowExpand && !showAllText) {
|
||||
return numberOfLines
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
} else {
|
||||
return undefined
|
||||
@ -215,22 +238,21 @@ const ParseHTML: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Text
|
||||
style={{
|
||||
...StyleConstants.FontStyle[size],
|
||||
color: theme.primary,
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
children={children}
|
||||
numberOfLines={calNumberOfLines}
|
||||
onLayout={onLayout}
|
||||
/>
|
||||
<Animated.View style={[ViewHeight, { overflow: 'hidden' }]}>
|
||||
<Text
|
||||
style={{
|
||||
...StyleConstants.FontStyle[size],
|
||||
color: theme.primary,
|
||||
height: allowExpand ? heightOriginal : undefined
|
||||
}}
|
||||
children={children}
|
||||
numberOfLines={calNumberOfLines}
|
||||
onLayout={onLayout}
|
||||
/>
|
||||
</Animated.View>
|
||||
{allowExpand ? (
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
layoutAnimation()
|
||||
setShowAllText(!showAllText)
|
||||
}}
|
||||
onPress={() => setShowAllText(!showAllText)}
|
||||
style={{ marginTop: showAllText ? 0 : -lineHeight * 1.25 }}
|
||||
>
|
||||
<LinearGradient
|
||||
|
4
src/components/Relationship.tsx
Normal file
4
src/components/Relationship.tsx
Normal file
@ -0,0 +1,4 @@
|
||||
import RelationshipIncoming from '@components/Relationship/Incoming'
|
||||
import RelationshipOutgoing from '@components/Relationship/Outgoing'
|
||||
|
||||
export { RelationshipIncoming, RelationshipOutgoing }
|
86
src/components/Relationship/Incoming.tsx
Normal file
86
src/components/Relationship/Incoming.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import client from '@api/client'
|
||||
import Button from '@components/Button'
|
||||
import haptics from '@components/haptics'
|
||||
import { toast } from '@components/toast'
|
||||
import { StyleConstants } from '@utils/styles/constants'
|
||||
import React, { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { StyleSheet, View } from 'react-native'
|
||||
import { useMutation, useQueryClient } from 'react-query'
|
||||
|
||||
export interface Props {
|
||||
id: Mastodon.Account['id']
|
||||
}
|
||||
|
||||
const RelationshipIncoming: React.FC<Props> = ({ id }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const relationshipQueryKey = ['Relationship', { id }]
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const fireMutation = useCallback(
|
||||
({ type }: { type: 'authorize' | 'reject' }) => {
|
||||
return client({
|
||||
method: 'post',
|
||||
instance: 'local',
|
||||
url: `follow_requests/${id}/${type}`
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
const mutation = useMutation(fireMutation, {
|
||||
onSuccess: ({ body }) => {
|
||||
haptics('Success')
|
||||
queryClient.setQueryData(relationshipQueryKey, body)
|
||||
queryClient.invalidateQueries(['Notifications', {}])
|
||||
},
|
||||
onError: (err: any, { type }) => {
|
||||
haptics('Error')
|
||||
toast({
|
||||
type: 'error',
|
||||
message: t('common:toastMessage.error.message', {
|
||||
function: t(`relationship:${type}.function`)
|
||||
}),
|
||||
...(err.status &&
|
||||
typeof err.status === 'number' &&
|
||||
err.data &&
|
||||
err.data.error &&
|
||||
typeof err.data.error === 'string' && {
|
||||
description: err.data.error
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<View style={styles.base}>
|
||||
<Button
|
||||
round
|
||||
type='icon'
|
||||
content='X'
|
||||
loading={mutation.isLoading}
|
||||
onPress={() => mutation.mutate({ type: 'reject' })}
|
||||
/>
|
||||
<Button
|
||||
round
|
||||
type='icon'
|
||||
content='Check'
|
||||
loading={mutation.isLoading}
|
||||
onPress={() => mutation.mutate({ type: 'authorize' })}
|
||||
style={styles.approve}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
base: {
|
||||
flexShrink: 1,
|
||||
flexDirection: 'row'
|
||||
},
|
||||
approve: {
|
||||
marginLeft: StyleConstants.Spacing.M
|
||||
}
|
||||
})
|
||||
|
||||
export default RelationshipIncoming
|
111
src/components/Relationship/Outgoing.tsx
Normal file
111
src/components/Relationship/Outgoing.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
import client from '@api/client'
|
||||
import Button from '@components/Button'
|
||||
import haptics from '@components/haptics'
|
||||
import { toast } from '@components/toast'
|
||||
import { relationshipFetch } from '@utils/fetches/relationshipFetch'
|
||||
import React, { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMutation, useQuery, useQueryClient } from 'react-query'
|
||||
|
||||
export interface Props {
|
||||
id: Mastodon.Account['id']
|
||||
}
|
||||
|
||||
const RelationshipOutgoing: React.FC<Props> = ({ id }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const relationshipQueryKey = ['Relationship', { id }]
|
||||
const query = useQuery(relationshipQueryKey, relationshipFetch)
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const fireMutation = useCallback(
|
||||
({ type, state }: { type: 'follow' | 'block'; state: boolean }) => {
|
||||
return client({
|
||||
method: 'post',
|
||||
instance: 'local',
|
||||
url: `accounts/${id}/${state ? 'un' : ''}${type}`
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
const mutation = useMutation(fireMutation, {
|
||||
onSuccess: ({ body }) => {
|
||||
haptics('Success')
|
||||
queryClient.setQueryData(relationshipQueryKey, body)
|
||||
},
|
||||
onError: (err: any, { type }) => {
|
||||
haptics('Error')
|
||||
toast({
|
||||
type: 'error',
|
||||
message: t('common:toastMessage.error.message', {
|
||||
function: t(`relationship:${type}.function`)
|
||||
}),
|
||||
...(err.status &&
|
||||
typeof err.status === 'number' &&
|
||||
err.data &&
|
||||
err.data.error &&
|
||||
typeof err.data.error === 'string' && {
|
||||
description: err.data.error
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
let content: string
|
||||
let onPress: () => void
|
||||
|
||||
if (query.isError) {
|
||||
content = t('relationship:button.error')
|
||||
onPress = () => {}
|
||||
} else {
|
||||
if (query.data?.blocked_by) {
|
||||
content = t('relationship:button.blocked_by')
|
||||
onPress = () => null
|
||||
} else {
|
||||
if (query.data?.blocking) {
|
||||
content = t('relationship:button.blocking')
|
||||
onPress = () =>
|
||||
mutation.mutate({
|
||||
type: 'block',
|
||||
state: query.data?.blocking
|
||||
})
|
||||
} else {
|
||||
if (query.data?.following) {
|
||||
content = t('relationship:button.following')
|
||||
onPress = () =>
|
||||
mutation.mutate({
|
||||
type: 'follow',
|
||||
state: query.data?.following
|
||||
})
|
||||
} else {
|
||||
if (query.data?.requested) {
|
||||
content = t('relationship:button.requested')
|
||||
onPress = () =>
|
||||
mutation.mutate({
|
||||
type: 'follow',
|
||||
state: query.data?.requested
|
||||
})
|
||||
} else {
|
||||
content = t('relationship:button.default')
|
||||
onPress = () =>
|
||||
mutation.mutate({
|
||||
type: 'follow',
|
||||
state: false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
type='text'
|
||||
content={content}
|
||||
onPress={onPress}
|
||||
loading={query.isLoading || mutation.isLoading}
|
||||
disabled={query.isError || query.data?.blocked_by}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default RelationshipOutgoing
|
@ -1,21 +1,20 @@
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { Dimensions, StyleSheet, View } from 'react-native'
|
||||
import SegmentedControl from '@react-native-community/segmented-control'
|
||||
import { createNativeStackNavigator } from 'react-native-screens/native-stack'
|
||||
import { useSelector } from 'react-redux'
|
||||
|
||||
import { HeaderRight } from '@components/Header'
|
||||
import Timeline from '@components/Timelines/Timeline'
|
||||
import SegmentedControl from '@react-native-community/segmented-control'
|
||||
import { useNavigation } from '@react-navigation/native'
|
||||
import sharedScreens from '@screens/Shared/sharedScreens'
|
||||
import { getLocalUrl, getRemoteUrl } from '@utils/slices/instancesSlice'
|
||||
import { useTheme } from '@utils/styles/ThemeManager'
|
||||
import { useNavigation } from '@react-navigation/native'
|
||||
import { HeaderRight } from './Header'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { Dimensions, StyleSheet, View } from 'react-native'
|
||||
import { createNativeStackNavigator } from 'react-native-screens/native-stack'
|
||||
import { TabView } from 'react-native-tab-view'
|
||||
import { useSelector } from 'react-redux'
|
||||
|
||||
const Stack = createNativeStackNavigator()
|
||||
|
||||
export interface Props {
|
||||
name: 'Screen-Local-Root' | 'Screen-Public-Root'
|
||||
name: 'Local' | 'Public'
|
||||
content: { title: string; page: App.Pages }[]
|
||||
}
|
||||
|
||||
@ -27,7 +26,7 @@ const Timelines: React.FC<Props> = ({ name, content }) => {
|
||||
const [segment, setSegment] = useState(0)
|
||||
|
||||
const onPressSearch = useCallback(() => {
|
||||
navigation.navigate('Screen-Shared-Search')
|
||||
navigation.navigate(`Screen-${name}`, { screen: 'Screen-Shared-Search' })
|
||||
}, [])
|
||||
|
||||
const routes = content
|
||||
@ -69,9 +68,9 @@ const Timelines: React.FC<Props> = ({ name, content }) => {
|
||||
return (
|
||||
<Stack.Navigator screenOptions={{ headerHideShadow: true }}>
|
||||
<Stack.Screen
|
||||
name={name}
|
||||
name={`Screen-${name}-Root`}
|
||||
options={{
|
||||
headerTitle: name === 'Screen-Public-Root' ? publicDomain : '',
|
||||
headerTitle: name === 'Public' ? publicDomain : '',
|
||||
...(localRegistered && {
|
||||
headerCenter: () => (
|
||||
<View style={styles.segmentsContainer}>
|
||||
|
@ -9,6 +9,7 @@ import { StyleConstants } from '@utils/styles/constants'
|
||||
import TimelineActions from '@components/Timelines/Timeline/Shared/Actions'
|
||||
import client from '@root/api/client'
|
||||
import { useMutation, useQueryClient } from 'react-query'
|
||||
import { useTheme } from '@root/utils/styles/ThemeManager'
|
||||
|
||||
export interface Props {
|
||||
conversation: Mastodon.Conversation
|
||||
@ -35,6 +36,8 @@ const TimelineConversation: React.FC<Props> = ({
|
||||
queryKey,
|
||||
highlighted = false
|
||||
}) => {
|
||||
const { theme } = useTheme()
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const { mutate } = useMutation(fireMutation, {
|
||||
onSettled: () => {
|
||||
@ -54,7 +57,19 @@ const TimelineConversation: React.FC<Props> = ({
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Pressable style={styles.conversationView} onPress={onPress}>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.base,
|
||||
conversation.unread && {
|
||||
borderLeftWidth: StyleConstants.Spacing.XS,
|
||||
borderLeftColor: theme.blue,
|
||||
paddingLeft:
|
||||
StyleConstants.Spacing.Global.PagePadding -
|
||||
StyleConstants.Spacing.XS
|
||||
}
|
||||
]}
|
||||
onPress={onPress}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<TimelineAvatar
|
||||
queryKey={queryKey}
|
||||
@ -100,7 +115,7 @@ const TimelineConversation: React.FC<Props> = ({
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
conversationView: {
|
||||
base: {
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
padding: StyleConstants.Spacing.Global.PagePadding
|
||||
|
@ -58,11 +58,11 @@ const TimelineDefault: React.FC<Props> = ({
|
||||
{...(!isRemotePublic && { queryKey })}
|
||||
account={actualStatus.account}
|
||||
/>
|
||||
{/* <TimelineHeaderDefault
|
||||
<TimelineHeaderDefault
|
||||
{...(!isRemotePublic && { queryKey })}
|
||||
status={actualStatus}
|
||||
sameAccount={actualStatus.account.id === localAccountId}
|
||||
/> */}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View
|
||||
|
@ -32,6 +32,7 @@ const TimelineNotifications: React.FC<Props> = ({
|
||||
|
||||
const onPress = useCallback(
|
||||
() =>
|
||||
notification.status &&
|
||||
navigation.push('Screen-Shared-Toot', {
|
||||
toot: notification.status
|
||||
}),
|
||||
@ -49,7 +50,10 @@ const TimelineNotifications: React.FC<Props> = ({
|
||||
<View
|
||||
style={{
|
||||
opacity:
|
||||
notification.type === 'follow' || notification.type === 'mention'
|
||||
notification.type === 'follow' ||
|
||||
notification.type === 'follow_request' ||
|
||||
notification.type === 'mention' ||
|
||||
notification.type === 'status'
|
||||
? 1
|
||||
: 0.5
|
||||
}}
|
||||
|
@ -9,7 +9,7 @@ import { Pressable, StyleSheet, View } from 'react-native'
|
||||
|
||||
export interface Props {
|
||||
account: Mastodon.Account
|
||||
action: 'favourite' | 'follow' | 'poll' | 'reblog' | 'pinned' | 'mention'
|
||||
action: Mastodon.Notification['type'] | ('reblog' | 'pinned')
|
||||
notification?: boolean
|
||||
}
|
||||
|
||||
@ -77,6 +77,21 @@ const TimelineActioned: React.FC<Props> = ({
|
||||
</>
|
||||
)
|
||||
break
|
||||
case 'follow_request':
|
||||
return (
|
||||
<>
|
||||
<Icon
|
||||
name='UserPlus'
|
||||
size={StyleConstants.Font.Size.S}
|
||||
color={iconColor}
|
||||
style={styles.icon}
|
||||
/>
|
||||
<Pressable onPress={onPress}>
|
||||
{content(t('shared.actioned.follow_request', { name }))}
|
||||
</Pressable>
|
||||
</>
|
||||
)
|
||||
break
|
||||
case 'poll':
|
||||
return (
|
||||
<>
|
||||
@ -109,6 +124,21 @@ const TimelineActioned: React.FC<Props> = ({
|
||||
</>
|
||||
)
|
||||
break
|
||||
case 'status':
|
||||
return (
|
||||
<>
|
||||
<Icon
|
||||
name='Activity'
|
||||
size={StyleConstants.Font.Size.S}
|
||||
color={iconColor}
|
||||
style={styles.icon}
|
||||
/>
|
||||
<Pressable onPress={onPress}>
|
||||
{content(t('shared.actioned.status', { name }))}
|
||||
</Pressable>
|
||||
</>
|
||||
)
|
||||
break
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
@ -102,13 +102,20 @@ const TimelineActions: React.FC<Props> = ({ queryKey, status, reblog }) => {
|
||||
|
||||
return oldData
|
||||
},
|
||||
onError: (_, { type }, oldData) => {
|
||||
onError: (err: any, { type }, oldData) => {
|
||||
haptics('Error')
|
||||
toast({
|
||||
type: 'error',
|
||||
message: t('common:toastMessage.success.message', {
|
||||
message: t('common:toastMessage.error.message', {
|
||||
function: t(`timeline:shared.actions.${type}.function`)
|
||||
})
|
||||
}),
|
||||
...(err.status &&
|
||||
typeof err.status === 'number' &&
|
||||
err.data &&
|
||||
err.data.error &&
|
||||
typeof err.data.error === 'string' && {
|
||||
description: err.data.error
|
||||
})
|
||||
})
|
||||
queryClient.setQueryData(queryKey, oldData)
|
||||
}
|
||||
@ -118,8 +125,7 @@ const TimelineActions: React.FC<Props> = ({ queryKey, status, reblog }) => {
|
||||
() =>
|
||||
navigation.navigate('Screen-Shared-Compose', {
|
||||
type: 'reply',
|
||||
incomingStatus: status,
|
||||
visibilityLock: status.visibility === 'direct'
|
||||
incomingStatus: status
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
@ -1,12 +1,12 @@
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { Image, Pressable, StyleSheet, View } from 'react-native'
|
||||
import { Audio } from 'expo-av'
|
||||
import Button from '@components/Button'
|
||||
import { Slider } from '@sharcoux/slider'
|
||||
import { StyleConstants } from '@utils/styles/constants'
|
||||
import { useTheme } from '@utils/styles/ThemeManager'
|
||||
import { Audio } from 'expo-av'
|
||||
import { Surface } from 'gl-react-expo'
|
||||
import { Blurhash } from 'gl-react-blurhash'
|
||||
import Slider from '@react-native-community/slider'
|
||||
import { StyleConstants } from '@root/utils/styles/constants'
|
||||
import { useTheme } from '@root/utils/styles/ThemeManager'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { Image, StyleSheet, View } from 'react-native'
|
||||
|
||||
export interface Props {
|
||||
sensitiveShown: boolean
|
||||
@ -21,10 +21,6 @@ const AttachmentAudio: React.FC<Props> = ({ sensitiveShown, audio }) => {
|
||||
const [audioPosition, setAudioPosition] = useState(0)
|
||||
const playAudio = useCallback(async () => {
|
||||
if (!audioPlayer) {
|
||||
await Audio.setAudioModeAsync({
|
||||
playsInSilentModeIOS: true,
|
||||
interruptionModeIOS: 1
|
||||
})
|
||||
const { sound } = await Audio.Sound.createAsync(
|
||||
{ uri: audio.url },
|
||||
{},
|
||||
@ -44,7 +40,7 @@ const AttachmentAudio: React.FC<Props> = ({ sensitiveShown, audio }) => {
|
||||
}, [audioPlayer])
|
||||
|
||||
return (
|
||||
<View style={styles.base}>
|
||||
<View style={[styles.base, { backgroundColor: theme.disabled }]}>
|
||||
<View style={styles.overlay}>
|
||||
{sensitiveShown ? (
|
||||
audio.blurhash && (
|
||||
@ -80,32 +76,33 @@ const AttachmentAudio: React.FC<Props> = ({ sensitiveShown, audio }) => {
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
alignSelf: 'flex-end',
|
||||
width: '100%',
|
||||
height: StyleConstants.Spacing.M + StyleConstants.Spacing.S * 2,
|
||||
backgroundColor: theme.backgroundOverlay,
|
||||
paddingHorizontal: StyleConstants.Spacing.Global.PagePadding,
|
||||
paddingVertical: StyleConstants.Spacing.XS,
|
||||
borderRadius: 6,
|
||||
borderRadius: 100,
|
||||
opacity: sensitiveShown ? 0.35 : undefined
|
||||
}}
|
||||
>
|
||||
<Slider
|
||||
style={{
|
||||
width: '100%'
|
||||
}}
|
||||
minimumValue={0}
|
||||
maximumValue={audio.meta.original.duration * 1000}
|
||||
value={audioPosition}
|
||||
minimumTrackTintColor={theme.secondary}
|
||||
maximumTrackTintColor={theme.disabled}
|
||||
onSlidingStart={() => {
|
||||
audioPlayer?.pauseAsync()
|
||||
setAudioPlaying(false)
|
||||
}}
|
||||
onSlidingComplete={value => {
|
||||
setAudioPosition(value)
|
||||
}}
|
||||
// onSlidingStart={() => {
|
||||
// console.log('yes!!!')
|
||||
// audioPlayer?.pauseAsync()
|
||||
// setAudioPlaying(false)
|
||||
// }}
|
||||
// onSlidingComplete={value => {
|
||||
// console.log('no!!!')
|
||||
// setAudioPosition(value)
|
||||
// }}
|
||||
enabled={false} // Bug in above sliding actions
|
||||
thumbSize={StyleConstants.Spacing.M}
|
||||
thumbTintColor={theme.primaryOverlay}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
@ -117,7 +114,8 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
flexBasis: '50%',
|
||||
aspectRatio: 16 / 9,
|
||||
padding: StyleConstants.Spacing.XS / 2
|
||||
padding: StyleConstants.Spacing.XS / 2,
|
||||
flexDirection: 'row'
|
||||
},
|
||||
background: { position: 'absolute', width: '100%', height: '100%' },
|
||||
overlay: {
|
||||
|
@ -13,15 +13,18 @@ export interface Props {
|
||||
|
||||
const AttachmentVideo: React.FC<Props> = ({ sensitiveShown, video }) => {
|
||||
const videoPlayer = useRef<Video>(null)
|
||||
const [videoLoading, setVideoLoading] = useState(false)
|
||||
const [videoLoaded, setVideoLoaded] = useState(false)
|
||||
const [videoPosition, setVideoPosition] = useState<number>(0)
|
||||
const playOnPress = useCallback(async () => {
|
||||
setVideoLoading(true)
|
||||
if (!videoLoaded) {
|
||||
await videoPlayer.current?.loadAsync({ uri: video.url })
|
||||
}
|
||||
await videoPlayer.current?.setPositionAsync(videoPosition)
|
||||
await videoPlayer.current?.presentFullscreenPlayer()
|
||||
videoPlayer.current?.playAsync()
|
||||
setVideoLoading(false)
|
||||
videoPlayer.current?.setOnPlaybackStatusUpdate(props => {
|
||||
if (props.isLoaded) {
|
||||
setVideoLoaded(true)
|
||||
@ -46,7 +49,7 @@ const AttachmentVideo: React.FC<Props> = ({ sensitiveShown, video }) => {
|
||||
resizeMode='cover'
|
||||
usePoster
|
||||
posterSource={{ uri: video.preview_url }}
|
||||
posterStyle={{ flex: 1 }}
|
||||
posterStyle={{ resizeMode: 'cover' }}
|
||||
useNativeControls={false}
|
||||
/>
|
||||
<Pressable style={styles.overlay}>
|
||||
@ -63,12 +66,13 @@ const AttachmentVideo: React.FC<Props> = ({ sensitiveShown, video }) => {
|
||||
) : null
|
||||
) : (
|
||||
<Button
|
||||
type='icon'
|
||||
content='PlayCircle'
|
||||
size='L'
|
||||
round
|
||||
overlay
|
||||
size='L'
|
||||
type='icon'
|
||||
content='PlayCircle'
|
||||
onPress={playOnPress}
|
||||
loading={videoLoading}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
|
@ -5,6 +5,7 @@ import { toast } from '@components/toast'
|
||||
import { StyleConstants } from '@utils/styles/constants'
|
||||
import { useTheme } from '@utils/styles/ThemeManager'
|
||||
import React, { useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Pressable, StyleSheet, View } from 'react-native'
|
||||
import { useMutation, useQueryClient } from 'react-query'
|
||||
import HeaderSharedAccount from './HeaderShared/Account'
|
||||
@ -16,25 +17,15 @@ export interface Props {
|
||||
}
|
||||
|
||||
const HeaderConversation: React.FC<Props> = ({ queryKey, conversation }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const fireMutation = useCallback(async () => {
|
||||
const res = await client({
|
||||
const fireMutation = useCallback(() => {
|
||||
return client({
|
||||
method: 'delete',
|
||||
instance: 'local',
|
||||
url: `conversations/${conversation.id}`
|
||||
})
|
||||
|
||||
if (!res.body.error) {
|
||||
toast({ type: 'success', message: '删除私信成功' })
|
||||
return Promise.resolve()
|
||||
} else {
|
||||
toast({
|
||||
type: 'error',
|
||||
message: '删除私信失败,请重试',
|
||||
autoHide: false
|
||||
})
|
||||
return Promise.reject()
|
||||
}
|
||||
}, [])
|
||||
const { mutate } = useMutation(fireMutation, {
|
||||
onMutate: () => {
|
||||
@ -53,9 +44,22 @@ const HeaderConversation: React.FC<Props> = ({ queryKey, conversation }) => {
|
||||
|
||||
return oldData
|
||||
},
|
||||
onError: (err, _, oldData) => {
|
||||
onError: (err: any, _, oldData) => {
|
||||
haptics('Error')
|
||||
toast({ type: 'error', message: '请重试', autoHide: false })
|
||||
toast({
|
||||
type: 'error',
|
||||
message: t('common:toastMessage.error.message', {
|
||||
function: t(`timeline:shared.header.conversation.delete.function`)
|
||||
}),
|
||||
...(err.status &&
|
||||
typeof err.status === 'number' &&
|
||||
err.data &&
|
||||
err.data.error &&
|
||||
typeof err.data.error === 'string' && {
|
||||
description: err.data.error
|
||||
}),
|
||||
autoHide: false
|
||||
})
|
||||
queryClient.setQueryData(queryKey, oldData)
|
||||
}
|
||||
})
|
||||
@ -85,9 +89,6 @@ const HeaderConversation: React.FC<Props> = ({ queryKey, conversation }) => {
|
||||
created_at={conversation.last_status?.created_at}
|
||||
/>
|
||||
) : null}
|
||||
{conversation.unread && (
|
||||
<Icon name='Circle' color={theme.blue} style={styles.unread} />
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@ -117,9 +118,6 @@ const styles = StyleSheet.create({
|
||||
created_at: {
|
||||
...StyleConstants.FontStyle.S
|
||||
},
|
||||
unread: {
|
||||
marginLeft: StyleConstants.Spacing.XS
|
||||
},
|
||||
action: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
|
@ -65,7 +65,7 @@ const TimelineHeaderDefault: React.FC<Props> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{queryKey && (
|
||||
{queryKey && modalVisible && (
|
||||
<BottomSheet
|
||||
visible={modalVisible}
|
||||
handleDismiss={() => setBottomSheetVisible(false)}
|
||||
|
@ -18,6 +18,7 @@ const HeaderDefaultActionsAccount: React.FC<Props> = ({
|
||||
setBottomSheetVisible
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const fireMutation = useCallback(
|
||||
async ({ type }: { type: 'mute' | 'block' | 'reports' }) => {
|
||||
@ -57,7 +58,7 @@ const HeaderDefaultActionsAccount: React.FC<Props> = ({
|
||||
})
|
||||
})
|
||||
},
|
||||
onError: (_, { type }) => {
|
||||
onError: (err: any, { type }) => {
|
||||
haptics('Error')
|
||||
toast({
|
||||
type: 'error',
|
||||
@ -66,7 +67,14 @@ const HeaderDefaultActionsAccount: React.FC<Props> = ({
|
||||
`timeline:shared.header.default.actions.account.${type}.function`,
|
||||
{ acct: account.acct }
|
||||
)
|
||||
})
|
||||
}),
|
||||
...(err.status &&
|
||||
typeof err.status === 'number' &&
|
||||
err.data &&
|
||||
err.data.error &&
|
||||
typeof err.data.error === 'string' && {
|
||||
description: err.data.error
|
||||
})
|
||||
})
|
||||
},
|
||||
onSettled: () => {
|
||||
|
@ -105,14 +105,21 @@ const HeaderDefaultActionsStatus: React.FC<Props> = ({
|
||||
|
||||
return oldData
|
||||
},
|
||||
onError: (_, { type }, oldData) => {
|
||||
onError: (err: any, { type }, oldData) => {
|
||||
toast({
|
||||
type: 'error',
|
||||
message: t('common:toastMessage.success.message', {
|
||||
message: t('common:toastMessage.error.message', {
|
||||
function: t(
|
||||
`timeline:shared.header.default.actions.status.${type}.function`
|
||||
)
|
||||
})
|
||||
}),
|
||||
...(err.status &&
|
||||
typeof err.status === 'number' &&
|
||||
err.data &&
|
||||
err.data.error &&
|
||||
typeof err.data.error === 'string' && {
|
||||
description: err.data.error
|
||||
})
|
||||
})
|
||||
queryClient.setQueryData(queryKey, oldData)
|
||||
}
|
||||
|
@ -1,110 +1,18 @@
|
||||
import client from '@api/client'
|
||||
import haptics from '@components/haptics'
|
||||
import Icon from '@components/Icon'
|
||||
import { toast } from '@components/toast'
|
||||
import { relationshipFetch } from '@utils/fetches/relationshipFetch'
|
||||
import { RelationshipOutgoing } from '@components/Relationship'
|
||||
import { StyleConstants } from '@utils/styles/constants'
|
||||
import { useTheme } from '@utils/styles/ThemeManager'
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Pressable, StyleSheet, View } from 'react-native'
|
||||
import { Chase } from 'react-native-animated-spinkit'
|
||||
import { useQuery } from 'react-query'
|
||||
import React from 'react'
|
||||
import { StyleSheet, View } from 'react-native'
|
||||
import HeaderSharedAccount from './HeaderShared/Account'
|
||||
import HeaderSharedApplication from './HeaderShared/Application'
|
||||
import HeaderSharedCreated from './HeaderShared/Created'
|
||||
import HeaderSharedVisibility from './HeaderShared/Visibility'
|
||||
import RelationshipIncoming from '@root/components/Relationship/Incoming'
|
||||
|
||||
export interface Props {
|
||||
notification: Mastodon.Notification
|
||||
}
|
||||
|
||||
const TimelineHeaderNotification: React.FC<Props> = ({ notification }) => {
|
||||
const { theme } = useTheme()
|
||||
|
||||
const { status, data, refetch } = useQuery(
|
||||
['Relationship', { id: notification.account.id }],
|
||||
relationshipFetch,
|
||||
{
|
||||
enabled: false
|
||||
}
|
||||
)
|
||||
const [updateData, setUpdateData] = useState<
|
||||
Mastodon.Relationship | undefined
|
||||
>()
|
||||
|
||||
const relationshipOnPress = useCallback(() => {
|
||||
client({
|
||||
method: 'post',
|
||||
instance: 'local',
|
||||
url: `accounts/${notification.account.id}/${
|
||||
updateData
|
||||
? updateData.following || updateData.requested
|
||||
? 'un'
|
||||
: ''
|
||||
: data!.following || data!.requested
|
||||
? 'un'
|
||||
: ''
|
||||
}follow`
|
||||
}).then(res => {
|
||||
if (res.body.id === (updateData && updateData.id) || data!.id) {
|
||||
setUpdateData(res.body)
|
||||
haptics('Success')
|
||||
return Promise.resolve()
|
||||
} else {
|
||||
haptics('Error')
|
||||
toast({ type: 'error', message: '请重试', autoHide: false })
|
||||
return Promise.reject()
|
||||
}
|
||||
})
|
||||
}, [data, updateData])
|
||||
|
||||
useEffect(() => {
|
||||
if (notification.type === 'follow') {
|
||||
refetch()
|
||||
}
|
||||
}, [notification.type])
|
||||
const relationshipIcon = useMemo(() => {
|
||||
switch (status) {
|
||||
case 'idle':
|
||||
case 'loading':
|
||||
return (
|
||||
<Chase size={StyleConstants.Font.Size.L} color={theme.secondary} />
|
||||
)
|
||||
case 'success':
|
||||
return (
|
||||
<Pressable onPress={relationshipOnPress}>
|
||||
<Icon
|
||||
name={
|
||||
updateData
|
||||
? updateData.following
|
||||
? 'UserCheck'
|
||||
: updateData.requested
|
||||
? 'Loader'
|
||||
: 'UserPlus'
|
||||
: data!.following
|
||||
? 'UserCheck'
|
||||
: data!.requested
|
||||
? 'Loader'
|
||||
: 'UserPlus'
|
||||
}
|
||||
color={
|
||||
updateData
|
||||
? updateData.following
|
||||
? theme.primary
|
||||
: theme.secondary
|
||||
: data!.following
|
||||
? theme.primary
|
||||
: theme.secondary
|
||||
}
|
||||
size={StyleConstants.Font.Size.M + 2}
|
||||
/>
|
||||
</Pressable>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}, [status, data, updateData])
|
||||
|
||||
return (
|
||||
<View style={styles.base}>
|
||||
<View style={styles.accountAndMeta}>
|
||||
@ -114,6 +22,8 @@ const TimelineHeaderNotification: React.FC<Props> = ({ notification }) => {
|
||||
? notification.status.account
|
||||
: notification.account
|
||||
}
|
||||
{...((notification.type === 'follow' ||
|
||||
notification.type === 'follow_request') && { withoutName: true })}
|
||||
/>
|
||||
<View style={styles.meta}>
|
||||
<HeaderSharedCreated created_at={notification.created_at} />
|
||||
@ -127,7 +37,14 @@ const TimelineHeaderNotification: React.FC<Props> = ({ notification }) => {
|
||||
</View>
|
||||
|
||||
{notification.type === 'follow' && (
|
||||
<View style={styles.relationship}>{relationshipIcon}</View>
|
||||
<View style={styles.relationship}>
|
||||
<RelationshipOutgoing id={notification.account.id} />
|
||||
</View>
|
||||
)}
|
||||
{notification.type === 'follow_request' && (
|
||||
<View style={styles.relationship}>
|
||||
<RelationshipIncoming id={notification.account.id} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
@ -136,10 +53,13 @@ const TimelineHeaderNotification: React.FC<Props> = ({ notification }) => {
|
||||
const styles = StyleSheet.create({
|
||||
base: {
|
||||
flex: 1,
|
||||
flexDirection: 'row'
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start'
|
||||
},
|
||||
accountAndMeta: {
|
||||
flex: 4
|
||||
flex: 1,
|
||||
flexGrow: 1
|
||||
},
|
||||
meta: {
|
||||
flexDirection: 'row',
|
||||
@ -148,9 +68,8 @@ const styles = StyleSheet.create({
|
||||
marginBottom: StyleConstants.Spacing.S
|
||||
},
|
||||
relationship: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center'
|
||||
flexShrink: 1,
|
||||
marginLeft: StyleConstants.Spacing.M
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -6,20 +6,26 @@ import { StyleSheet, Text, View } from 'react-native'
|
||||
|
||||
export interface Props {
|
||||
account: Mastodon.Account
|
||||
withoutName?: boolean
|
||||
}
|
||||
|
||||
const HeaderSharedAccount: React.FC<Props> = ({ account }) => {
|
||||
const HeaderSharedAccount: React.FC<Props> = ({
|
||||
account,
|
||||
withoutName = false
|
||||
}) => {
|
||||
const { theme } = useTheme()
|
||||
|
||||
return (
|
||||
<View style={styles.base}>
|
||||
<Text numberOfLines={1}>
|
||||
<ParseEmojis
|
||||
content={account.display_name || account.username}
|
||||
emojis={account.emojis}
|
||||
fontBold
|
||||
/>
|
||||
</Text>
|
||||
{withoutName ? null : (
|
||||
<Text style={styles.name} numberOfLines={1}>
|
||||
<ParseEmojis
|
||||
content={account.display_name || account.username}
|
||||
emojis={account.emojis}
|
||||
fontBold
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
<Text style={[styles.acct, { color: theme.secondary }]} numberOfLines={1}>
|
||||
@{account.acct}
|
||||
</Text>
|
||||
@ -32,9 +38,11 @@ const styles = StyleSheet.create({
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center'
|
||||
},
|
||||
name: {
|
||||
marginRight: StyleConstants.Spacing.XS
|
||||
},
|
||||
acct: {
|
||||
flexShrink: 1,
|
||||
marginLeft: StyleConstants.Spacing.XS
|
||||
flexShrink: 1
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -5,6 +5,7 @@ import Icon from '@components/Icon'
|
||||
import relativeTime from '@components/relativeTime'
|
||||
import { TimelineData } from '@components/Timelines/Timeline'
|
||||
import { ParseEmojis } from '@root/components/Parse'
|
||||
import { toast } from '@root/components/toast'
|
||||
import { StyleConstants } from '@utils/styles/constants'
|
||||
import { useTheme } from '@utils/styles/ThemeManager'
|
||||
import { findIndex } from 'lodash'
|
||||
@ -13,35 +14,6 @@ import { useTranslation } from 'react-i18next'
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import { useMutation, useQueryClient } from 'react-query'
|
||||
|
||||
const fireMutation = async ({
|
||||
id,
|
||||
options
|
||||
}: {
|
||||
id: string
|
||||
options?: boolean[]
|
||||
}) => {
|
||||
const formData = new FormData()
|
||||
options &&
|
||||
options.forEach((o, i) => {
|
||||
if (options[i]) {
|
||||
formData.append('choices[]', i.toString())
|
||||
}
|
||||
})
|
||||
|
||||
const res = await client({
|
||||
method: options ? 'post' : 'get',
|
||||
instance: 'local',
|
||||
url: options ? `polls/${id}/votes` : `polls/${id}`,
|
||||
...(options && { body: formData })
|
||||
})
|
||||
|
||||
if (res.body.id === id) {
|
||||
return Promise.resolve(res.body as Mastodon.Poll)
|
||||
} else {
|
||||
return Promise.reject()
|
||||
}
|
||||
}
|
||||
|
||||
export interface Props {
|
||||
queryKey: QueryKey.Timeline
|
||||
poll: NonNullable<Mastodon.Status['poll']>
|
||||
@ -57,14 +29,33 @@ const TimelinePoll: React.FC<Props> = ({
|
||||
}) => {
|
||||
const { mode, theme } = useTheme()
|
||||
const { t, i18n } = useTranslation('timeline')
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [allOptions, setAllOptions] = useState(
|
||||
new Array(poll.options.length).fill(false)
|
||||
)
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const fireMutation = useCallback(
|
||||
({ type }: { type: 'vote' | 'refresh' }) => {
|
||||
const formData = new FormData()
|
||||
type === 'vote' &&
|
||||
allOptions.forEach((o, i) => {
|
||||
if (allOptions[i]) {
|
||||
formData.append('choices[]', i.toString())
|
||||
}
|
||||
})
|
||||
|
||||
return client({
|
||||
method: type === 'vote' ? 'post' : 'get',
|
||||
instance: 'local',
|
||||
url: type === 'vote' ? `polls/${poll.id}/votes` : `polls/${poll.id}`,
|
||||
...(type === 'vote' && { body: formData })
|
||||
})
|
||||
},
|
||||
[allOptions]
|
||||
)
|
||||
const mutation = useMutation(fireMutation, {
|
||||
onSuccess: (data, { id }) => {
|
||||
onSuccess: ({ body }) => {
|
||||
queryClient.cancelQueries(queryKey)
|
||||
|
||||
queryClient.setQueryData<TimelineData>(queryKey, old => {
|
||||
@ -72,7 +63,7 @@ const TimelinePoll: React.FC<Props> = ({
|
||||
const pageIndex = findIndex(old?.pages, page => {
|
||||
const tempIndex = findIndex(page.toots, [
|
||||
reblog ? 'reblog.poll.id' : 'poll.id',
|
||||
id
|
||||
poll.id
|
||||
])
|
||||
if (tempIndex >= 0) {
|
||||
tootIndex = tempIndex
|
||||
@ -84,9 +75,9 @@ const TimelinePoll: React.FC<Props> = ({
|
||||
|
||||
if (pageIndex >= 0 && tootIndex >= 0) {
|
||||
if (reblog) {
|
||||
old!.pages[pageIndex].toots[tootIndex].reblog!.poll = data
|
||||
old!.pages[pageIndex].toots[tootIndex].reblog!.poll = body
|
||||
} else {
|
||||
old!.pages[pageIndex].toots[tootIndex].poll = data
|
||||
old!.pages[pageIndex].toots[tootIndex].poll = body
|
||||
}
|
||||
}
|
||||
return old
|
||||
@ -94,8 +85,21 @@ const TimelinePoll: React.FC<Props> = ({
|
||||
|
||||
haptics('Success')
|
||||
},
|
||||
onError: () => {
|
||||
onError: (err: any) => {
|
||||
haptics('Error')
|
||||
toast({
|
||||
type: 'error',
|
||||
message: '投票错误',
|
||||
...(err.status &&
|
||||
typeof err.status === 'number' &&
|
||||
err.data &&
|
||||
err.data.error &&
|
||||
typeof err.data.error === 'string' && {
|
||||
description: err.data.error
|
||||
}),
|
||||
autoHide: false
|
||||
})
|
||||
queryClient.invalidateQueries(queryKey)
|
||||
}
|
||||
})
|
||||
|
||||
@ -105,9 +109,7 @@ const TimelinePoll: React.FC<Props> = ({
|
||||
return (
|
||||
<View style={styles.button}>
|
||||
<Button
|
||||
onPress={() =>
|
||||
mutation.mutate({ id: poll.id, options: allOptions })
|
||||
}
|
||||
onPress={() => mutation.mutate({ type: 'vote' })}
|
||||
type='text'
|
||||
content={t('shared.poll.meta.button.vote')}
|
||||
loading={mutation.isLoading}
|
||||
@ -119,7 +121,7 @@ const TimelinePoll: React.FC<Props> = ({
|
||||
return (
|
||||
<View style={styles.button}>
|
||||
<Button
|
||||
onPress={() => mutation.mutate({ id: poll.id })}
|
||||
onPress={() => mutation.mutate({ type: 'refresh' })}
|
||||
type='text'
|
||||
content={t('shared.poll.meta.button.refresh')}
|
||||
loading={mutation.isLoading}
|
||||
|
@ -5,6 +5,7 @@ import React from 'react'
|
||||
import { StyleSheet, Text, View } from 'react-native'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import Toast from 'react-native-toast-message'
|
||||
import * as Sentry from 'sentry-expo'
|
||||
|
||||
export interface Params {
|
||||
type: 'success' | 'error' | 'warning'
|
||||
@ -73,11 +74,17 @@ const ToastBase = ({ config }: { config: Config }) => {
|
||||
color={theme[colorMapping[config.type]]}
|
||||
/>
|
||||
<View style={styles.texts}>
|
||||
<Text style={[styles.text1, { color: theme.primary }]}>
|
||||
<Text
|
||||
style={[styles.text1, { color: theme.primary }]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{config.text1}
|
||||
</Text>
|
||||
{config.text2 && (
|
||||
<Text style={[styles.text2, { color: theme.secondary }]}>
|
||||
<Text
|
||||
style={[styles.text2, { color: theme.secondary }]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{config.text2}
|
||||
</Text>
|
||||
)}
|
||||
@ -89,8 +96,11 @@ const ToastBase = ({ config }: { config: Config }) => {
|
||||
|
||||
const toastConfig = {
|
||||
success: (config: Config) => <ToastBase config={config} />,
|
||||
error: (config: Config) => <ToastBase config={config} />,
|
||||
warning: (config: Config) => <ToastBase config={config} />
|
||||
warning: (config: Config) => <ToastBase config={config} />,
|
||||
error: (config: Config) => {
|
||||
Sentry.Native.captureException([config.text1, config.text2])
|
||||
return <ToastBase config={config} />
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
|
Reference in New Issue
Block a user