mirror of
https://github.com/tooot-app/app
synced 2025-06-05 22:19:13 +02:00
Fixed #17
This commit is contained in:
166
src/components/Timeline/Shared/Actioned.tsx
Normal file
166
src/components/Timeline/Shared/Actioned.tsx
Normal file
@ -0,0 +1,166 @@
|
||||
import analytics from '@components/analytics'
|
||||
import Icon from '@components/Icon'
|
||||
import { ParseEmojis } from '@components/Parse'
|
||||
import { useNavigation } from '@react-navigation/native'
|
||||
import { StackNavigationProp } from '@react-navigation/stack'
|
||||
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'
|
||||
|
||||
export interface Props {
|
||||
account: Mastodon.Account
|
||||
action: Mastodon.Notification['type'] | ('reblog' | 'pinned')
|
||||
notification?: boolean
|
||||
}
|
||||
|
||||
const TimelineActioned: React.FC<Props> = ({
|
||||
account,
|
||||
action,
|
||||
notification = false
|
||||
}) => {
|
||||
const { t } = useTranslation('componentTimeline')
|
||||
const { theme } = useTheme()
|
||||
const navigation = useNavigation<
|
||||
StackNavigationProp<Nav.TabLocalStackParamList>
|
||||
>()
|
||||
const name = account.display_name || account.username
|
||||
const iconColor = theme.primary
|
||||
|
||||
const content = (content: string) => (
|
||||
<ParseEmojis content={content} emojis={account.emojis} size='S' />
|
||||
)
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
analytics('timeline_shared_actioned_press', { action })
|
||||
navigation.push('Tab-Shared-Account', { account })
|
||||
}, [])
|
||||
|
||||
const children = useMemo(() => {
|
||||
switch (action) {
|
||||
case 'pinned':
|
||||
return (
|
||||
<>
|
||||
<Icon
|
||||
name='Anchor'
|
||||
size={StyleConstants.Font.Size.S}
|
||||
color={iconColor}
|
||||
style={styles.icon}
|
||||
/>
|
||||
{content(t('shared.actioned.pinned'))}
|
||||
</>
|
||||
)
|
||||
break
|
||||
case 'favourite':
|
||||
return (
|
||||
<>
|
||||
<Icon
|
||||
name='Heart'
|
||||
size={StyleConstants.Font.Size.S}
|
||||
color={iconColor}
|
||||
style={styles.icon}
|
||||
/>
|
||||
<Pressable onPress={onPress}>
|
||||
{content(t('shared.actioned.favourite', { name }))}
|
||||
</Pressable>
|
||||
</>
|
||||
)
|
||||
break
|
||||
case 'follow':
|
||||
return (
|
||||
<>
|
||||
<Icon
|
||||
name='UserPlus'
|
||||
size={StyleConstants.Font.Size.S}
|
||||
color={iconColor}
|
||||
style={styles.icon}
|
||||
/>
|
||||
<Pressable onPress={onPress}>
|
||||
{content(t('shared.actioned.follow', { name }))}
|
||||
</Pressable>
|
||||
</>
|
||||
)
|
||||
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 (
|
||||
<>
|
||||
<Icon
|
||||
name='BarChart2'
|
||||
size={StyleConstants.Font.Size.S}
|
||||
color={iconColor}
|
||||
style={styles.icon}
|
||||
/>
|
||||
{content(t('shared.actioned.poll'))}
|
||||
</>
|
||||
)
|
||||
break
|
||||
case 'reblog':
|
||||
return (
|
||||
<>
|
||||
<Icon
|
||||
name='Repeat'
|
||||
size={StyleConstants.Font.Size.S}
|
||||
color={iconColor}
|
||||
style={styles.icon}
|
||||
/>
|
||||
<Pressable onPress={onPress}>
|
||||
{content(
|
||||
notification
|
||||
? t('shared.actioned.reblog.notification', { name })
|
||||
: t('shared.actioned.reblog.default', { name })
|
||||
)}
|
||||
</Pressable>
|
||||
</>
|
||||
)
|
||||
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
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <View style={styles.actioned} children={children} />
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
actioned: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: StyleConstants.Spacing.S,
|
||||
paddingLeft: StyleConstants.Avatar.M - StyleConstants.Font.Size.S,
|
||||
paddingRight: StyleConstants.Spacing.Global.PagePadding
|
||||
},
|
||||
icon: {
|
||||
marginRight: StyleConstants.Spacing.S
|
||||
}
|
||||
})
|
||||
|
||||
export default React.memo(TimelineActioned, () => true)
|
306
src/components/Timeline/Shared/Actions.tsx
Normal file
306
src/components/Timeline/Shared/Actions.tsx
Normal file
@ -0,0 +1,306 @@
|
||||
import analytics from '@components/analytics'
|
||||
import haptics from '@components/haptics'
|
||||
import Icon from '@components/Icon'
|
||||
import { toast } from '@components/toast'
|
||||
import { useNavigation } from '@react-navigation/native'
|
||||
import {
|
||||
MutationVarsTimelineUpdateStatusProperty,
|
||||
QueryKeyTimeline,
|
||||
useTimelineMutation
|
||||
} from '@utils/queryHooks/timeline'
|
||||
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, Text, View } from 'react-native'
|
||||
import { useQueryClient } from 'react-query'
|
||||
|
||||
export interface Props {
|
||||
queryKey: QueryKeyTimeline
|
||||
status: Mastodon.Status
|
||||
accts: Mastodon.Account['acct'][] // When replying to conversations
|
||||
reblog: boolean
|
||||
}
|
||||
|
||||
const TimelineActions: React.FC<Props> = ({
|
||||
queryKey,
|
||||
status,
|
||||
accts,
|
||||
reblog
|
||||
}) => {
|
||||
const navigation = useNavigation()
|
||||
const { t } = useTranslation('componentTimeline')
|
||||
const { theme } = useTheme()
|
||||
const iconColor = theme.secondary
|
||||
const iconColorAction = (state: boolean) =>
|
||||
state ? theme.primary : theme.secondary
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const mutation = useTimelineMutation({
|
||||
queryClient,
|
||||
onMutate: true,
|
||||
onSuccess: (_, params) => {
|
||||
const theParams = params as MutationVarsTimelineUpdateStatusProperty
|
||||
if (
|
||||
// Un-bookmark from bookmarks page
|
||||
(queryKey[1].page === 'Bookmarks' &&
|
||||
theParams.payload.property === 'bookmarked') ||
|
||||
// Un-favourite from favourites page
|
||||
(queryKey[1].page === 'Favourites' &&
|
||||
theParams.payload.property === 'favourited') ||
|
||||
// Un-reblog from following page
|
||||
(queryKey[1].page === 'Following' &&
|
||||
theParams.payload.property === 'reblogged' &&
|
||||
theParams.payload.currentValue === true)
|
||||
) {
|
||||
queryClient.invalidateQueries(queryKey)
|
||||
} else if (theParams.payload.property === 'reblogged') {
|
||||
// When reblogged, update cache of following page
|
||||
const tempQueryKey: QueryKeyTimeline = [
|
||||
'Timeline',
|
||||
{ page: 'Following' }
|
||||
]
|
||||
queryClient.invalidateQueries(tempQueryKey)
|
||||
} else if (theParams.payload.property === 'favourited') {
|
||||
// When favourited, update favourited page
|
||||
const tempQueryKey: QueryKeyTimeline = [
|
||||
'Timeline',
|
||||
{ page: 'Favourites' }
|
||||
]
|
||||
queryClient.invalidateQueries(tempQueryKey)
|
||||
} else if (theParams.payload.property === 'bookmarked') {
|
||||
// When bookmarked, update bookmark page
|
||||
const tempQueryKey: QueryKeyTimeline = [
|
||||
'Timeline',
|
||||
{ page: 'Bookmarks' }
|
||||
]
|
||||
queryClient.invalidateQueries(tempQueryKey)
|
||||
}
|
||||
},
|
||||
onError: (err: any, params, oldData) => {
|
||||
const correctParam = params as MutationVarsTimelineUpdateStatusProperty
|
||||
haptics('Error')
|
||||
toast({
|
||||
type: 'error',
|
||||
message: t('common:toastMessage.error.message', {
|
||||
function: t(
|
||||
`shared.actions.${correctParam.payload.property}.function`
|
||||
)
|
||||
}),
|
||||
...(err.status &&
|
||||
typeof err.status === 'number' &&
|
||||
err.data &&
|
||||
err.data.error &&
|
||||
typeof err.data.error === 'string' && {
|
||||
description: err.data.error
|
||||
})
|
||||
})
|
||||
queryClient.invalidateQueries(queryKey)
|
||||
}
|
||||
})
|
||||
|
||||
const onPressReply = useCallback(() => {
|
||||
analytics('timeline_shared_actions_reply_press', {
|
||||
page: queryKey[1].page,
|
||||
count: status.replies_count
|
||||
})
|
||||
navigation.navigate('Screen-Compose', {
|
||||
type: 'reply',
|
||||
incomingStatus: status,
|
||||
accts,
|
||||
queryKey
|
||||
})
|
||||
}, [status.replies_count])
|
||||
const onPressReblog = useCallback(() => {
|
||||
analytics('timeline_shared_actions_reblog_press', {
|
||||
page: queryKey[1].page,
|
||||
count: status.reblogs_count,
|
||||
current: status.reblogged
|
||||
})
|
||||
mutation.mutate({
|
||||
type: 'updateStatusProperty',
|
||||
queryKey,
|
||||
id: status.id,
|
||||
reblog,
|
||||
payload: {
|
||||
property: 'reblogged',
|
||||
currentValue: status.reblogged,
|
||||
propertyCount: 'reblogs_count',
|
||||
countValue: status.reblogs_count
|
||||
}
|
||||
})
|
||||
}, [status.reblogged, status.reblogs_count])
|
||||
const onPressFavourite = useCallback(() => {
|
||||
analytics('timeline_shared_actions_favourite_press', {
|
||||
page: queryKey[1].page,
|
||||
count: status.favourites_count,
|
||||
current: status.favourited
|
||||
})
|
||||
mutation.mutate({
|
||||
type: 'updateStatusProperty',
|
||||
queryKey,
|
||||
id: status.id,
|
||||
reblog,
|
||||
payload: {
|
||||
property: 'favourited',
|
||||
currentValue: status.favourited,
|
||||
propertyCount: 'favourites_count',
|
||||
countValue: status.favourites_count
|
||||
}
|
||||
})
|
||||
}, [status.favourited, status.favourites_count])
|
||||
const onPressBookmark = useCallback(() => {
|
||||
analytics('timeline_shared_actions_bookmark_press', {
|
||||
page: queryKey[1].page,
|
||||
current: status.bookmarked
|
||||
})
|
||||
mutation.mutate({
|
||||
type: 'updateStatusProperty',
|
||||
queryKey,
|
||||
id: status.id,
|
||||
reblog,
|
||||
payload: {
|
||||
property: 'bookmarked',
|
||||
currentValue: status.bookmarked,
|
||||
propertyCount: undefined,
|
||||
countValue: undefined
|
||||
}
|
||||
})
|
||||
}, [status.bookmarked])
|
||||
|
||||
const childrenReply = useMemo(
|
||||
() => (
|
||||
<>
|
||||
<Icon
|
||||
name='MessageCircle'
|
||||
color={iconColor}
|
||||
size={StyleConstants.Font.Size.L}
|
||||
/>
|
||||
{status.replies_count > 0 && (
|
||||
<Text
|
||||
style={{
|
||||
color: theme.secondary,
|
||||
fontSize: StyleConstants.Font.Size.M,
|
||||
marginLeft: StyleConstants.Spacing.XS
|
||||
}}
|
||||
>
|
||||
{status.replies_count}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
[status.replies_count]
|
||||
)
|
||||
const childrenReblog = useMemo(
|
||||
() => (
|
||||
<>
|
||||
<Icon
|
||||
name='Repeat'
|
||||
color={
|
||||
status.visibility === 'private' || status.visibility === 'direct'
|
||||
? theme.disabled
|
||||
: iconColorAction(status.reblogged)
|
||||
}
|
||||
size={StyleConstants.Font.Size.L}
|
||||
strokeWidth={status.reblogged ? 3 : undefined}
|
||||
/>
|
||||
{status.reblogs_count > 0 && (
|
||||
<Text
|
||||
style={{
|
||||
color: iconColorAction(status.reblogged),
|
||||
fontSize: StyleConstants.Font.Size.M,
|
||||
marginLeft: StyleConstants.Spacing.XS
|
||||
}}
|
||||
>
|
||||
{status.reblogs_count}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
[status.reblogged, status.reblogs_count]
|
||||
)
|
||||
const childrenFavourite = useMemo(
|
||||
() => (
|
||||
<>
|
||||
<Icon
|
||||
name='Heart'
|
||||
color={iconColorAction(status.favourited)}
|
||||
size={StyleConstants.Font.Size.L}
|
||||
strokeWidth={status.favourited ? 3 : undefined}
|
||||
/>
|
||||
{status.favourites_count > 0 && (
|
||||
<Text
|
||||
style={{
|
||||
color: iconColorAction(status.favourited),
|
||||
fontSize: StyleConstants.Font.Size.M,
|
||||
marginLeft: StyleConstants.Spacing.XS,
|
||||
marginTop: 0
|
||||
}}
|
||||
>
|
||||
{status.favourites_count}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
[status.favourited, status.favourites_count]
|
||||
)
|
||||
const childrenBookmark = useMemo(
|
||||
() => (
|
||||
<Icon
|
||||
name='Bookmark'
|
||||
color={iconColorAction(status.bookmarked)}
|
||||
size={StyleConstants.Font.Size.L}
|
||||
strokeWidth={status.bookmarked ? 3 : undefined}
|
||||
/>
|
||||
),
|
||||
[status.bookmarked]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={styles.actions}>
|
||||
<Pressable
|
||||
style={styles.action}
|
||||
onPress={onPressReply}
|
||||
children={childrenReply}
|
||||
/>
|
||||
|
||||
<Pressable
|
||||
style={styles.action}
|
||||
onPress={onPressReblog}
|
||||
children={childrenReblog}
|
||||
disabled={
|
||||
status.visibility === 'private' || status.visibility === 'direct'
|
||||
}
|
||||
/>
|
||||
|
||||
<Pressable
|
||||
style={styles.action}
|
||||
onPress={onPressFavourite}
|
||||
children={childrenFavourite}
|
||||
/>
|
||||
|
||||
<Pressable
|
||||
style={styles.action}
|
||||
onPress={onPressBookmark}
|
||||
children={childrenBookmark}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
actions: {
|
||||
flexDirection: 'row'
|
||||
},
|
||||
action: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: StyleConstants.Font.Size.L + StyleConstants.Spacing.S * 4
|
||||
}
|
||||
})
|
||||
|
||||
export default TimelineActions
|
173
src/components/Timeline/Shared/Attachment.tsx
Normal file
173
src/components/Timeline/Shared/Attachment.tsx
Normal file
@ -0,0 +1,173 @@
|
||||
import analytics from '@components/analytics'
|
||||
import Button from '@components/Button'
|
||||
import haptics from '@components/haptics'
|
||||
import AttachmentAudio from '@components/Timeline/Shared/Attachment/Audio'
|
||||
import AttachmentImage from '@components/Timeline/Shared/Attachment/Image'
|
||||
import AttachmentUnsupported from '@components/Timeline/Shared/Attachment/Unsupported'
|
||||
import AttachmentVideo from '@components/Timeline/Shared/Attachment/Video'
|
||||
import { useNavigation } from '@react-navigation/native'
|
||||
import { StyleConstants } from '@utils/styles/constants'
|
||||
import layoutAnimation from '@utils/styles/layoutAnimation'
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Pressable, StyleSheet, View } from 'react-native'
|
||||
|
||||
export interface Props {
|
||||
status: Pick<Mastodon.Status, 'media_attachments' | 'sensitive'>
|
||||
}
|
||||
|
||||
const TimelineAttachment: React.FC<Props> = ({ status }) => {
|
||||
const { t } = useTranslation('componentTimeline')
|
||||
|
||||
const [sensitiveShown, setSensitiveShown] = useState(status.sensitive)
|
||||
const onPressBlurView = useCallback(() => {
|
||||
analytics('timeline_shared_attachment_blurview_press_show')
|
||||
layoutAnimation()
|
||||
setSensitiveShown(false)
|
||||
haptics('Light')
|
||||
}, [])
|
||||
const onPressShow = useCallback(() => {
|
||||
analytics('timeline_shared_attachment_blurview_press_hide')
|
||||
setSensitiveShown(true)
|
||||
haptics('Light')
|
||||
}, [])
|
||||
|
||||
let imageUrls: (App.IImageInfo & {
|
||||
preview_url: Mastodon.AttachmentImage['preview_url']
|
||||
remote_url?: Mastodon.AttachmentImage['remote_url']
|
||||
imageIndex: number
|
||||
})[] = []
|
||||
const navigation = useNavigation()
|
||||
const navigateToImagesViewer = (imageIndex: number) =>
|
||||
navigation.navigate('Screen-ImagesViewer', {
|
||||
imageUrls,
|
||||
imageIndex
|
||||
})
|
||||
const attachments = useMemo(
|
||||
() =>
|
||||
status.media_attachments.map((attachment, index) => {
|
||||
switch (attachment.type) {
|
||||
case 'image':
|
||||
imageUrls.push({
|
||||
url: attachment.url,
|
||||
width: attachment.meta?.original?.width,
|
||||
height: attachment.meta?.original?.height,
|
||||
preview_url: attachment.preview_url,
|
||||
remote_url: attachment.remote_url,
|
||||
imageIndex: index
|
||||
})
|
||||
return (
|
||||
<AttachmentImage
|
||||
key={index}
|
||||
total={status.media_attachments.length}
|
||||
index={index}
|
||||
sensitiveShown={sensitiveShown}
|
||||
image={attachment}
|
||||
navigateToImagesViewer={navigateToImagesViewer}
|
||||
/>
|
||||
)
|
||||
case 'video':
|
||||
return (
|
||||
<AttachmentVideo
|
||||
key={index}
|
||||
total={status.media_attachments.length}
|
||||
index={index}
|
||||
sensitiveShown={sensitiveShown}
|
||||
video={attachment}
|
||||
/>
|
||||
)
|
||||
case 'gifv':
|
||||
return (
|
||||
<AttachmentVideo
|
||||
key={index}
|
||||
total={status.media_attachments.length}
|
||||
index={index}
|
||||
sensitiveShown={sensitiveShown}
|
||||
video={attachment}
|
||||
gifv
|
||||
/>
|
||||
)
|
||||
case 'audio':
|
||||
return (
|
||||
<AttachmentAudio
|
||||
key={index}
|
||||
total={status.media_attachments.length}
|
||||
index={index}
|
||||
sensitiveShown={sensitiveShown}
|
||||
audio={attachment}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<AttachmentUnsupported
|
||||
key={index}
|
||||
total={status.media_attachments.length}
|
||||
index={index}
|
||||
sensitiveShown={sensitiveShown}
|
||||
attachment={attachment}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}),
|
||||
[sensitiveShown]
|
||||
)
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View style={styles.container} children={attachments} />
|
||||
|
||||
{status.sensitive &&
|
||||
(sensitiveShown ? (
|
||||
<Pressable style={styles.sensitiveBlur}>
|
||||
<Button
|
||||
type='text'
|
||||
content={t('shared.attachment.sensitive.button')}
|
||||
overlay
|
||||
onPress={onPressBlurView}
|
||||
/>
|
||||
</Pressable>
|
||||
) : (
|
||||
<Button
|
||||
type='icon'
|
||||
content='EyeOff'
|
||||
round
|
||||
overlay
|
||||
onPress={onPressShow}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: StyleConstants.Spacing.S * 2,
|
||||
left: StyleConstants.Spacing.S
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginTop: StyleConstants.Spacing.S,
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'center',
|
||||
alignContent: 'stretch'
|
||||
},
|
||||
sensitiveBlur: {
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center'
|
||||
},
|
||||
sensitiveBlurButton: {
|
||||
padding: StyleConstants.Spacing.S,
|
||||
borderRadius: 6
|
||||
},
|
||||
sensitiveText: {
|
||||
...StyleConstants.FontStyle.M
|
||||
}
|
||||
})
|
||||
|
||||
export default React.memo(TimelineAttachment, () => true)
|
149
src/components/Timeline/Shared/Attachment/Audio.tsx
Normal file
149
src/components/Timeline/Shared/Attachment/Audio.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
import analytics from '@components/analytics'
|
||||
import Button from '@components/Button'
|
||||
import GracefullyImage from '@components/GracefullyImage'
|
||||
import { Slider } from '@sharcoux/slider'
|
||||
import { StyleConstants } from '@utils/styles/constants'
|
||||
import { useTheme } from '@utils/styles/ThemeManager'
|
||||
import { Audio } from 'expo-av'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { StyleSheet, View } from 'react-native'
|
||||
import { Blurhash } from 'react-native-blurhash'
|
||||
import attachmentAspectRatio from './aspectRatio'
|
||||
|
||||
export interface Props {
|
||||
total: number
|
||||
index: number
|
||||
sensitiveShown: boolean
|
||||
audio: Mastodon.AttachmentAudio
|
||||
}
|
||||
|
||||
const AttachmentAudio: React.FC<Props> = ({
|
||||
total,
|
||||
index,
|
||||
sensitiveShown,
|
||||
audio
|
||||
}) => {
|
||||
const { theme } = useTheme()
|
||||
|
||||
const [audioPlayer, setAudioPlayer] = useState<Audio.Sound>()
|
||||
const [audioPlaying, setAudioPlaying] = useState(false)
|
||||
const [audioPosition, setAudioPosition] = useState(0)
|
||||
const playAudio = useCallback(async () => {
|
||||
analytics('timeline_shared_attachment_audio_play_press', { id: audio.id })
|
||||
if (!audioPlayer) {
|
||||
const { sound } = await Audio.Sound.createAsync(
|
||||
{ uri: audio.url },
|
||||
{},
|
||||
// @ts-ignore
|
||||
props => setAudioPosition(props.positionMillis)
|
||||
)
|
||||
setAudioPlayer(sound)
|
||||
} else {
|
||||
await audioPlayer.setPositionAsync(audioPosition)
|
||||
audioPlayer.playAsync()
|
||||
setAudioPlaying(true)
|
||||
}
|
||||
}, [audioPlayer, audioPosition])
|
||||
const pauseAudio = useCallback(async () => {
|
||||
analytics('timeline_shared_attachment_audio_pause_press', { id: audio.id })
|
||||
audioPlayer!.pauseAsync()
|
||||
setAudioPlaying(false)
|
||||
}, [audioPlayer])
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.base,
|
||||
{
|
||||
backgroundColor: theme.disabled,
|
||||
aspectRatio: attachmentAspectRatio({ total, index })
|
||||
}
|
||||
]}
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
{sensitiveShown ? (
|
||||
audio.blurhash ? (
|
||||
<Blurhash
|
||||
blurhash={audio.blurhash}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
}}
|
||||
/>
|
||||
) : null
|
||||
) : (
|
||||
<>
|
||||
{audio.preview_url && (
|
||||
<GracefullyImage
|
||||
uri={{
|
||||
original: audio.preview_url,
|
||||
remote: audio.preview_remote_url
|
||||
}}
|
||||
style={styles.background}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type='icon'
|
||||
content={audioPlaying ? 'PauseCircle' : 'PlayCircle'}
|
||||
size='L'
|
||||
round
|
||||
overlay
|
||||
{...(audioPlaying
|
||||
? { onPress: pauseAudio }
|
||||
: { onPress: playAudio })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
alignSelf: 'flex-end',
|
||||
width: '100%',
|
||||
height: StyleConstants.Spacing.M + StyleConstants.Spacing.S * 2,
|
||||
backgroundColor: theme.backgroundOverlay,
|
||||
paddingHorizontal: StyleConstants.Spacing.Global.PagePadding,
|
||||
borderRadius: 100,
|
||||
opacity: sensitiveShown ? 0.35 : undefined
|
||||
}}
|
||||
>
|
||||
<Slider
|
||||
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)
|
||||
// }}
|
||||
enabled={false} // Bug in above sliding actions
|
||||
thumbSize={StyleConstants.Spacing.M}
|
||||
thumbTintColor={theme.primaryOverlay}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
base: {
|
||||
flex: 1,
|
||||
flexBasis: '50%',
|
||||
padding: StyleConstants.Spacing.XS / 2,
|
||||
flexDirection: 'row'
|
||||
},
|
||||
background: { position: 'absolute', width: '100%', height: '100%' },
|
||||
overlay: {
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center'
|
||||
}
|
||||
})
|
||||
|
||||
export default AttachmentAudio
|
58
src/components/Timeline/Shared/Attachment/Image.tsx
Normal file
58
src/components/Timeline/Shared/Attachment/Image.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import analytics from '@components/analytics'
|
||||
import GracefullyImage from '@components/GracefullyImage'
|
||||
import { StyleConstants } from '@utils/styles/constants'
|
||||
import React, { useCallback } from 'react'
|
||||
import { StyleSheet } from 'react-native'
|
||||
import attachmentAspectRatio from './aspectRatio'
|
||||
|
||||
export interface Props {
|
||||
total: number
|
||||
index: number
|
||||
sensitiveShown: boolean
|
||||
image: Mastodon.AttachmentImage
|
||||
navigateToImagesViewer: (imageIndex: number) => void
|
||||
}
|
||||
|
||||
const AttachmentImage: React.FC<Props> = ({
|
||||
total,
|
||||
index,
|
||||
sensitiveShown,
|
||||
image,
|
||||
navigateToImagesViewer
|
||||
}) => {
|
||||
const onPress = useCallback(() => {
|
||||
analytics('timeline_shared_attachment_image_press', { id: image.id })
|
||||
navigateToImagesViewer(index)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<GracefullyImage
|
||||
hidden={sensitiveShown}
|
||||
uri={{
|
||||
preview: image.preview_url,
|
||||
original: image.url,
|
||||
remote: image.remote_url
|
||||
}}
|
||||
sharedElement={image.url}
|
||||
blurhash={image.blurhash}
|
||||
onPress={onPress}
|
||||
style={[
|
||||
styles.base,
|
||||
{ aspectRatio: attachmentAspectRatio({ total, index }) }
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
base: {
|
||||
flex: 1,
|
||||
flexBasis: '50%',
|
||||
padding: StyleConstants.Spacing.XS / 2
|
||||
}
|
||||
})
|
||||
|
||||
export default React.memo(
|
||||
AttachmentImage,
|
||||
(prev, next) => prev.sensitiveShown === next.sensitiveShown
|
||||
)
|
88
src/components/Timeline/Shared/Attachment/Unsupported.tsx
Normal file
88
src/components/Timeline/Shared/Attachment/Unsupported.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import analytics from '@components/analytics'
|
||||
import Button from '@components/Button'
|
||||
import openLink from '@components/openLink'
|
||||
import { StyleConstants } from '@utils/styles/constants'
|
||||
import { useTheme } from '@utils/styles/ThemeManager'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { StyleSheet, Text, View } from 'react-native'
|
||||
import { Blurhash } from 'react-native-blurhash'
|
||||
import attachmentAspectRatio from './aspectRatio'
|
||||
|
||||
export interface Props {
|
||||
total: number
|
||||
index: number
|
||||
sensitiveShown: boolean
|
||||
attachment: Mastodon.AttachmentUnknown
|
||||
}
|
||||
|
||||
const AttachmentUnsupported: React.FC<Props> = ({
|
||||
total,
|
||||
index,
|
||||
sensitiveShown,
|
||||
attachment
|
||||
}) => {
|
||||
const { t } = useTranslation('componentTimeline')
|
||||
const { theme } = useTheme()
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.base,
|
||||
{ aspectRatio: attachmentAspectRatio({ total, index }) }
|
||||
]}
|
||||
>
|
||||
{attachment.blurhash ? (
|
||||
<Blurhash
|
||||
blurhash={attachment.blurhash}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{!sensitiveShown ? (
|
||||
<>
|
||||
<Text
|
||||
style={[
|
||||
styles.text,
|
||||
{ color: attachment.blurhash ? theme.background : theme.primary }
|
||||
]}
|
||||
>
|
||||
{t('shared.attachment.unsupported.text')}
|
||||
</Text>
|
||||
{attachment.remote_url ? (
|
||||
<Button
|
||||
type='text'
|
||||
content={t('shared.attachment.unsupported.button')}
|
||||
size='S'
|
||||
overlay
|
||||
onPress={async () => {
|
||||
analytics('timeline_shared_attachment_unsupported_press')
|
||||
attachment.remote_url && (await openLink(attachment.remote_url))
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
base: {
|
||||
flex: 1,
|
||||
flexBasis: '50%',
|
||||
padding: StyleConstants.Spacing.XS / 2,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center'
|
||||
},
|
||||
text: {
|
||||
...StyleConstants.FontStyle.S,
|
||||
textAlign: 'center',
|
||||
marginBottom: StyleConstants.Spacing.S
|
||||
}
|
||||
})
|
||||
|
||||
export default AttachmentUnsupported
|
129
src/components/Timeline/Shared/Attachment/Video.tsx
Normal file
129
src/components/Timeline/Shared/Attachment/Video.tsx
Normal file
@ -0,0 +1,129 @@
|
||||
import Button from '@components/Button'
|
||||
import { StyleConstants } from '@utils/styles/constants'
|
||||
import { Video } from 'expo-av'
|
||||
import React, { useCallback, useRef, useState } from 'react'
|
||||
import { Pressable, StyleSheet, View } from 'react-native'
|
||||
import { Blurhash } from 'react-native-blurhash'
|
||||
import attachmentAspectRatio from './aspectRatio'
|
||||
import analytics from '@components/analytics'
|
||||
|
||||
export interface Props {
|
||||
total: number
|
||||
index: number
|
||||
sensitiveShown: boolean
|
||||
video: Mastodon.AttachmentVideo | Mastodon.AttachmentGifv
|
||||
gifv?: boolean
|
||||
}
|
||||
|
||||
const AttachmentVideo: React.FC<Props> = ({
|
||||
total,
|
||||
index,
|
||||
sensitiveShown,
|
||||
video,
|
||||
gifv = false
|
||||
}) => {
|
||||
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 () => {
|
||||
analytics('timeline_shared_attachment_video_length', {
|
||||
length: video.meta?.length
|
||||
})
|
||||
analytics('timeline_shared_attachment_vide_play_press', {
|
||||
id: video.id,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
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)
|
||||
}
|
||||
// @ts-ignore
|
||||
if (props.positionMillis) {
|
||||
// @ts-ignore
|
||||
setVideoPosition(props.positionMillis)
|
||||
}
|
||||
})
|
||||
}, [videoLoaded, videoPosition])
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.base,
|
||||
{ aspectRatio: attachmentAspectRatio({ total, index }) }
|
||||
]}
|
||||
>
|
||||
<Video
|
||||
ref={videoPlayer}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
opacity: sensitiveShown ? 0 : 1
|
||||
}}
|
||||
resizeMode='cover'
|
||||
usePoster
|
||||
posterSource={{ uri: video.preview_url }}
|
||||
posterStyle={{ resizeMode: 'cover' }}
|
||||
useNativeControls={false}
|
||||
onFullscreenUpdate={event => {
|
||||
if (event.fullscreenUpdate === 3) {
|
||||
analytics('timeline_shared_attachment_video_pause_press', {
|
||||
id: video.id,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
videoPlayer.current?.pauseAsync()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Pressable style={styles.overlay}>
|
||||
{sensitiveShown ? (
|
||||
video.blurhash ? (
|
||||
<Blurhash
|
||||
blurhash={video.blurhash}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
}}
|
||||
/>
|
||||
) : null
|
||||
) : gifv ? null : (
|
||||
<Button
|
||||
round
|
||||
overlay
|
||||
size='L'
|
||||
type='icon'
|
||||
content='PlayCircle'
|
||||
onPress={playOnPress}
|
||||
loading={videoLoading}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
base: {
|
||||
flex: 1,
|
||||
flexBasis: '50%',
|
||||
padding: StyleConstants.Spacing.XS / 2
|
||||
},
|
||||
overlay: {
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center'
|
||||
}
|
||||
})
|
||||
|
||||
export default AttachmentVideo
|
25
src/components/Timeline/Shared/Attachment/aspectRatio.ts
Normal file
25
src/components/Timeline/Shared/Attachment/aspectRatio.ts
Normal file
@ -0,0 +1,25 @@
|
||||
const attachmentAspectRatio = ({
|
||||
total,
|
||||
index
|
||||
}: {
|
||||
total: number
|
||||
index?: number
|
||||
}) => {
|
||||
switch (total) {
|
||||
case 1:
|
||||
case 4:
|
||||
return 16 / 9
|
||||
case 2:
|
||||
return 8 / 9
|
||||
case 3:
|
||||
if (index === 2) {
|
||||
return 32 / 9
|
||||
} else {
|
||||
return 16 / 9
|
||||
}
|
||||
default:
|
||||
return 16 / 9
|
||||
}
|
||||
}
|
||||
|
||||
export default attachmentAspectRatio
|
43
src/components/Timeline/Shared/Avatar.tsx
Normal file
43
src/components/Timeline/Shared/Avatar.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { StyleConstants } from '@utils/styles/constants'
|
||||
import { useNavigation } from '@react-navigation/native'
|
||||
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
|
||||
import GracefullyImage from '@components/GracefullyImage'
|
||||
import { StackNavigationProp } from '@react-navigation/stack'
|
||||
import analytics from '@components/analytics'
|
||||
|
||||
export interface Props {
|
||||
queryKey?: QueryKeyTimeline
|
||||
account: Mastodon.Account
|
||||
}
|
||||
|
||||
const TimelineAvatar: React.FC<Props> = ({ queryKey, account }) => {
|
||||
const navigation = useNavigation<
|
||||
StackNavigationProp<Nav.TabLocalStackParamList>
|
||||
>()
|
||||
// Need to fix go back root
|
||||
const onPress = useCallback(() => {
|
||||
analytics('timeline_shared_avatar_press', {
|
||||
page: queryKey && queryKey[1].page
|
||||
})
|
||||
queryKey && navigation.push('Tab-Shared-Account', { account })
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<GracefullyImage
|
||||
onPress={onPress}
|
||||
uri={{ original: account.avatar_static }}
|
||||
dimension={{
|
||||
width: StyleConstants.Avatar.M,
|
||||
height: StyleConstants.Avatar.M
|
||||
}}
|
||||
style={{
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
marginRight: StyleConstants.Spacing.S
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(TimelineAvatar, () => true)
|
96
src/components/Timeline/Shared/Card.tsx
Normal file
96
src/components/Timeline/Shared/Card.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
import analytics from '@components/analytics'
|
||||
import GracefullyImage from '@components/GracefullyImage'
|
||||
import openLink from '@components/openLink'
|
||||
import { StyleConstants } from '@utils/styles/constants'
|
||||
import { useTheme } from '@utils/styles/ThemeManager'
|
||||
import React from 'react'
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
|
||||
export interface Props {
|
||||
card: Mastodon.Card
|
||||
}
|
||||
|
||||
const TimelineCard: React.FC<Props> = ({ card }) => {
|
||||
const { theme } = useTheme()
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={[styles.card, { borderColor: theme.border }]}
|
||||
onPress={async () => {
|
||||
analytics('timeline_shared_card_press')
|
||||
await openLink(card.url)
|
||||
}}
|
||||
testID='base'
|
||||
>
|
||||
{card.image && (
|
||||
<GracefullyImage
|
||||
uri={{ original: card.image }}
|
||||
blurhash={card.blurhash}
|
||||
style={styles.left}
|
||||
imageStyle={styles.image}
|
||||
/>
|
||||
)}
|
||||
<View style={styles.right}>
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
style={[styles.rightTitle, { color: theme.primary }]}
|
||||
testID='title'
|
||||
>
|
||||
{card.title}
|
||||
</Text>
|
||||
{card.description ? (
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[styles.rightDescription, { color: theme.primary }]}
|
||||
testID='description'
|
||||
>
|
||||
{card.description}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[styles.rightLink, { color: theme.secondary }]}
|
||||
>
|
||||
{card.url}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
height: StyleConstants.Font.LineHeight.M * 5,
|
||||
marginTop: StyleConstants.Spacing.M,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderRadius: 6,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
left: {
|
||||
flexBasis: StyleConstants.Font.LineHeight.M * 5
|
||||
},
|
||||
image: {
|
||||
borderTopLeftRadius: 6,
|
||||
borderBottomLeftRadius: 6
|
||||
},
|
||||
right: {
|
||||
flex: 1,
|
||||
padding: StyleConstants.Spacing.S
|
||||
},
|
||||
rightTitle: {
|
||||
...StyleConstants.FontStyle.S,
|
||||
marginBottom: StyleConstants.Spacing.XS,
|
||||
fontWeight: StyleConstants.Font.Weight.Bold
|
||||
},
|
||||
rightDescription: {
|
||||
...StyleConstants.FontStyle.S,
|
||||
marginBottom: StyleConstants.Spacing.XS
|
||||
},
|
||||
rightLink: {
|
||||
...StyleConstants.FontStyle.S
|
||||
}
|
||||
})
|
||||
|
||||
export default React.memo(TimelineCard, () => true)
|
63
src/components/Timeline/Shared/Content.tsx
Normal file
63
src/components/Timeline/Shared/Content.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import { ParseHTML } from '@components/Parse'
|
||||
import { StyleConstants } from '@utils/styles/constants'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { View } from 'react-native'
|
||||
|
||||
export interface Props {
|
||||
status: Mastodon.Status
|
||||
numberOfLines?: number
|
||||
highlighted?: boolean
|
||||
disableDetails?: boolean
|
||||
}
|
||||
|
||||
const TimelineContent: React.FC<Props> = ({
|
||||
status,
|
||||
numberOfLines,
|
||||
highlighted = false,
|
||||
disableDetails = false
|
||||
}) => {
|
||||
const { t } = useTranslation('componentTimeline')
|
||||
|
||||
return (
|
||||
<>
|
||||
{status.spoiler_text ? (
|
||||
<>
|
||||
<View style={{ marginBottom: StyleConstants.Font.Size.M }}>
|
||||
<ParseHTML
|
||||
content={status.spoiler_text}
|
||||
size={highlighted ? 'L' : 'M'}
|
||||
emojis={status.emojis}
|
||||
mentions={status.mentions}
|
||||
tags={status.tags}
|
||||
numberOfLines={999}
|
||||
disableDetails={disableDetails}
|
||||
/>
|
||||
</View>
|
||||
<ParseHTML
|
||||
content={status.content}
|
||||
size={highlighted ? 'L' : 'M'}
|
||||
emojis={status.emojis}
|
||||
mentions={status.mentions}
|
||||
tags={status.tags}
|
||||
numberOfLines={0}
|
||||
expandHint={t('shared.content.expandHint')}
|
||||
disableDetails={disableDetails}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<ParseHTML
|
||||
content={status.content}
|
||||
size={highlighted ? 'L' : 'M'}
|
||||
emojis={status.emojis}
|
||||
mentions={status.mentions}
|
||||
tags={status.tags}
|
||||
numberOfLines={highlighted ? 999 : numberOfLines}
|
||||
disableDetails={disableDetails}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(TimelineContent, () => true)
|
147
src/components/Timeline/Shared/HeaderConversation.tsx
Normal file
147
src/components/Timeline/Shared/HeaderConversation.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
import analytics from '@components/analytics'
|
||||
import haptics from '@components/haptics'
|
||||
import Icon from '@components/Icon'
|
||||
import { ParseEmojis } from '@components/Parse'
|
||||
import { toast } from '@components/toast'
|
||||
import {
|
||||
QueryKeyTimeline,
|
||||
useTimelineMutation
|
||||
} from '@utils/queryHooks/timeline'
|
||||
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, Text, View } from 'react-native'
|
||||
import { useQueryClient } from 'react-query'
|
||||
import HeaderSharedCreated from './HeaderShared/Created'
|
||||
import HeaderSharedMuted from './HeaderShared/Muted'
|
||||
|
||||
const Names: React.FC<{ accounts: Mastodon.Account[] }> = ({ accounts }) => {
|
||||
const { t } = useTranslation('componentTimeline')
|
||||
const { theme } = useTheme()
|
||||
|
||||
return (
|
||||
<Text numberOfLines={1}>
|
||||
<Text style={[styles.namesLeading, { color: theme.secondary }]}>
|
||||
{t('shared.header.conversation.withAccounts')}
|
||||
</Text>
|
||||
{accounts.map((account, index) => (
|
||||
<Text key={account.id} numberOfLines={1}>
|
||||
{index !== 0 ? t('common:separator') : undefined}
|
||||
<ParseEmojis
|
||||
content={account.display_name || account.username}
|
||||
emojis={account.emojis}
|
||||
fontBold
|
||||
/>
|
||||
</Text>
|
||||
))}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
export interface Props {
|
||||
queryKey: QueryKeyTimeline
|
||||
conversation: Mastodon.Conversation
|
||||
}
|
||||
|
||||
const HeaderConversation: React.FC<Props> = ({ queryKey, conversation }) => {
|
||||
const { t } = useTranslation('componentTimeline')
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const mutation = useTimelineMutation({
|
||||
queryClient,
|
||||
onMutate: true,
|
||||
onError: (err: any, _, oldData) => {
|
||||
haptics('Error')
|
||||
toast({
|
||||
type: 'error',
|
||||
message: t('common:toastMessage.error.message', {
|
||||
function: t(`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)
|
||||
}
|
||||
})
|
||||
|
||||
const { theme } = useTheme()
|
||||
|
||||
const actionOnPress = useCallback(() => {
|
||||
analytics('timeline_conversation_delete_press')
|
||||
mutation.mutate({
|
||||
type: 'deleteItem',
|
||||
source: 'conversations',
|
||||
queryKey,
|
||||
id: conversation.id
|
||||
})
|
||||
}, [])
|
||||
|
||||
const actionChildren = useMemo(
|
||||
() => (
|
||||
<Icon
|
||||
name='Trash'
|
||||
color={theme.secondary}
|
||||
size={StyleConstants.Font.Size.L}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={styles.base}>
|
||||
<View style={styles.nameAndMeta}>
|
||||
<Names accounts={conversation.accounts} />
|
||||
<View style={styles.meta}>
|
||||
{conversation.last_status?.created_at ? (
|
||||
<HeaderSharedCreated
|
||||
created_at={conversation.last_status?.created_at}
|
||||
/>
|
||||
) : null}
|
||||
<HeaderSharedMuted muted={conversation.last_status?.muted} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
style={styles.action}
|
||||
onPress={actionOnPress}
|
||||
children={actionChildren}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
base: {
|
||||
flex: 1,
|
||||
flexDirection: 'row'
|
||||
},
|
||||
nameAndMeta: {
|
||||
flex: 3
|
||||
},
|
||||
meta: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: StyleConstants.Spacing.XS,
|
||||
marginBottom: StyleConstants.Spacing.S
|
||||
},
|
||||
created_at: {
|
||||
...StyleConstants.FontStyle.S
|
||||
},
|
||||
action: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
namesLeading: {
|
||||
...StyleConstants.FontStyle.M
|
||||
}
|
||||
})
|
||||
|
||||
export default HeaderConversation
|
87
src/components/Timeline/Shared/HeaderDefault.tsx
Normal file
87
src/components/Timeline/Shared/HeaderDefault.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
import { StyleConstants } from '@utils/styles/constants'
|
||||
import React from 'react'
|
||||
import { Pressable, 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 { QueryKeyTimeline } from '@utils/queryHooks/timeline'
|
||||
import HeaderSharedMuted from './HeaderShared/Muted'
|
||||
import { useNavigation } from '@react-navigation/native'
|
||||
import Icon from '@components/Icon'
|
||||
import { useTheme } from '@utils/styles/ThemeManager'
|
||||
|
||||
export interface Props {
|
||||
queryKey?: QueryKeyTimeline
|
||||
status: Mastodon.Status
|
||||
}
|
||||
|
||||
const TimelineHeaderDefault: React.FC<Props> = ({ queryKey, status }) => {
|
||||
const navigation = useNavigation()
|
||||
const { theme } = useTheme()
|
||||
|
||||
return (
|
||||
<View style={styles.base}>
|
||||
<View style={styles.accountAndMeta}>
|
||||
<HeaderSharedAccount account={status.account} />
|
||||
<View style={styles.meta}>
|
||||
<HeaderSharedCreated created_at={status.created_at} />
|
||||
<HeaderSharedVisibility visibility={status.visibility} />
|
||||
<HeaderSharedMuted muted={status.muted} />
|
||||
<HeaderSharedApplication application={status.application} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{queryKey ? (
|
||||
<Pressable
|
||||
style={styles.action}
|
||||
onPress={() =>
|
||||
navigation.navigate('Screen-Actions', {
|
||||
queryKey,
|
||||
status,
|
||||
url: status.url || status.uri,
|
||||
type: 'status'
|
||||
})
|
||||
}
|
||||
children={
|
||||
<Icon
|
||||
name='MoreHorizontal'
|
||||
color={theme.secondary}
|
||||
size={StyleConstants.Font.Size.L}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
base: {
|
||||
flex: 1,
|
||||
flexDirection: 'row'
|
||||
},
|
||||
accountAndMeta: {
|
||||
flex: 5
|
||||
},
|
||||
meta: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: StyleConstants.Spacing.XS,
|
||||
marginBottom: StyleConstants.Spacing.S
|
||||
},
|
||||
created_at: {
|
||||
...StyleConstants.FontStyle.S
|
||||
},
|
||||
action: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
paddingBottom: StyleConstants.Spacing.S
|
||||
}
|
||||
})
|
||||
|
||||
export default React.memo(
|
||||
TimelineHeaderDefault,
|
||||
(prev, next) => prev.status.muted !== next.status.muted
|
||||
)
|
132
src/components/Timeline/Shared/HeaderNotification.tsx
Normal file
132
src/components/Timeline/Shared/HeaderNotification.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
import Icon from '@components/Icon'
|
||||
import {
|
||||
RelationshipIncoming,
|
||||
RelationshipOutgoing
|
||||
} from '@components/Relationship'
|
||||
import { useNavigation } from '@react-navigation/native'
|
||||
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
|
||||
import { StyleConstants } from '@utils/styles/constants'
|
||||
import { useTheme } from '@utils/styles/ThemeManager'
|
||||
import React, { useMemo } from 'react'
|
||||
import { Pressable, StyleSheet, View } from 'react-native'
|
||||
import HeaderSharedAccount from './HeaderShared/Account'
|
||||
import HeaderSharedApplication from './HeaderShared/Application'
|
||||
import HeaderSharedCreated from './HeaderShared/Created'
|
||||
import HeaderSharedMuted from './HeaderShared/Muted'
|
||||
import HeaderSharedVisibility from './HeaderShared/Visibility'
|
||||
|
||||
export interface Props {
|
||||
queryKey: QueryKeyTimeline
|
||||
notification: Mastodon.Notification
|
||||
}
|
||||
|
||||
const TimelineHeaderNotification: React.FC<Props> = ({
|
||||
queryKey,
|
||||
notification
|
||||
}) => {
|
||||
const navigation = useNavigation()
|
||||
const { theme } = useTheme()
|
||||
|
||||
const actions = useMemo(() => {
|
||||
switch (notification.type) {
|
||||
case 'follow':
|
||||
return <RelationshipOutgoing id={notification.account.id} />
|
||||
case 'follow_request':
|
||||
return <RelationshipIncoming id={notification.account.id} />
|
||||
default:
|
||||
if (notification.status) {
|
||||
return (
|
||||
<Pressable
|
||||
style={{
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
paddingBottom: StyleConstants.Spacing.S
|
||||
}}
|
||||
onPress={() =>
|
||||
navigation.navigate('Screen-Actions', {
|
||||
queryKey,
|
||||
status,
|
||||
url: notification.status?.url || notification.status?.uri,
|
||||
type: 'status'
|
||||
})
|
||||
}
|
||||
children={
|
||||
<Icon
|
||||
name='MoreHorizontal'
|
||||
color={theme.secondary}
|
||||
size={StyleConstants.Font.Size.L}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
}, [notification.type])
|
||||
|
||||
return (
|
||||
<View style={styles.base}>
|
||||
<View
|
||||
style={{
|
||||
flex:
|
||||
notification.type === 'follow' ||
|
||||
notification.type === 'follow_request'
|
||||
? 1
|
||||
: 4
|
||||
}}
|
||||
>
|
||||
<HeaderSharedAccount
|
||||
account={
|
||||
notification.status
|
||||
? notification.status.account
|
||||
: notification.account
|
||||
}
|
||||
{...((notification.type === 'follow' ||
|
||||
notification.type === 'follow_request') && { withoutName: true })}
|
||||
/>
|
||||
<View style={styles.meta}>
|
||||
<HeaderSharedCreated created_at={notification.created_at} />
|
||||
{notification.status?.visibility ? (
|
||||
<HeaderSharedVisibility
|
||||
visibility={notification.status.visibility}
|
||||
/>
|
||||
) : null}
|
||||
<HeaderSharedMuted muted={notification.status?.muted} />
|
||||
<HeaderSharedApplication
|
||||
application={notification.status?.application}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
styles.relationship,
|
||||
notification.type === 'follow' ||
|
||||
notification.type === 'follow_request'
|
||||
? { flexShrink: 1 }
|
||||
: { flex: 1 }
|
||||
]}
|
||||
>
|
||||
{actions}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
base: {
|
||||
flex: 1,
|
||||
flexDirection: 'row'
|
||||
},
|
||||
meta: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: StyleConstants.Spacing.XS,
|
||||
marginBottom: StyleConstants.Spacing.S
|
||||
},
|
||||
relationship: {
|
||||
marginLeft: StyleConstants.Spacing.M
|
||||
}
|
||||
})
|
||||
|
||||
export default React.memo(TimelineHeaderNotification, () => true)
|
49
src/components/Timeline/Shared/HeaderShared/Account.tsx
Normal file
49
src/components/Timeline/Shared/HeaderShared/Account.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import { ParseEmojis } from '@root/components/Parse'
|
||||
import { StyleConstants } from '@utils/styles/constants'
|
||||
import { useTheme } from '@utils/styles/ThemeManager'
|
||||
import React from 'react'
|
||||
import { StyleSheet, Text, View } from 'react-native'
|
||||
|
||||
export interface Props {
|
||||
account: Mastodon.Account
|
||||
withoutName?: boolean // For notification follow request etc.
|
||||
}
|
||||
|
||||
const HeaderSharedAccount: React.FC<Props> = ({
|
||||
account,
|
||||
withoutName = false
|
||||
}) => {
|
||||
const { theme } = useTheme()
|
||||
|
||||
return (
|
||||
<View style={styles.base}>
|
||||
{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>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
base: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center'
|
||||
},
|
||||
name: {
|
||||
marginRight: StyleConstants.Spacing.XS
|
||||
},
|
||||
acct: {
|
||||
flexShrink: 1
|
||||
}
|
||||
})
|
||||
|
||||
export default HeaderSharedAccount
|
39
src/components/Timeline/Shared/HeaderShared/Application.tsx
Normal file
39
src/components/Timeline/Shared/HeaderShared/Application.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import analytics from '@components/analytics'
|
||||
import openLink from '@components/openLink'
|
||||
import { StyleConstants } from '@utils/styles/constants'
|
||||
import { useTheme } from '@utils/styles/ThemeManager'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { StyleSheet, Text } from 'react-native'
|
||||
|
||||
export interface Props {
|
||||
application?: Mastodon.Application
|
||||
}
|
||||
|
||||
const HeaderSharedApplication: React.FC<Props> = ({ application }) => {
|
||||
const { theme } = useTheme()
|
||||
const { t } = useTranslation('componentTimeline')
|
||||
|
||||
return application && application.name !== 'Web' ? (
|
||||
<Text
|
||||
onPress={async () => {
|
||||
analytics('timeline_shared_header_application_press', {
|
||||
application
|
||||
})
|
||||
application.website && (await openLink(application.website))
|
||||
}}
|
||||
style={[styles.application, { color: theme.secondary }]}
|
||||
>
|
||||
{t('shared.header.shared.application', { application: application.name })}
|
||||
</Text>
|
||||
) : null
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
application: {
|
||||
...StyleConstants.FontStyle.S,
|
||||
marginLeft: StyleConstants.Spacing.S
|
||||
}
|
||||
})
|
||||
|
||||
export default HeaderSharedApplication
|
30
src/components/Timeline/Shared/HeaderShared/Created.tsx
Normal file
30
src/components/Timeline/Shared/HeaderShared/Created.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import RelativeTime from '@components/RelativeTime'
|
||||
import { StyleConstants } from '@utils/styles/constants'
|
||||
import { useTheme } from '@utils/styles/ThemeManager'
|
||||
import React from 'react'
|
||||
import { StyleSheet, Text } from 'react-native'
|
||||
|
||||
export interface Props {
|
||||
created_at: Mastodon.Status['created_at'] | number
|
||||
}
|
||||
|
||||
const HeaderSharedCreated = React.memo(
|
||||
({ created_at }: Props) => {
|
||||
const { theme } = useTheme()
|
||||
|
||||
return (
|
||||
<Text style={[styles.created_at, { color: theme.secondary }]}>
|
||||
<RelativeTime date={created_at} />
|
||||
</Text>
|
||||
)
|
||||
},
|
||||
() => true
|
||||
)
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
created_at: {
|
||||
...StyleConstants.FontStyle.S
|
||||
}
|
||||
})
|
||||
|
||||
export default HeaderSharedCreated
|
30
src/components/Timeline/Shared/HeaderShared/Muted.tsx
Normal file
30
src/components/Timeline/Shared/HeaderShared/Muted.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import Icon from '@components/Icon'
|
||||
import { StyleConstants } from '@utils/styles/constants'
|
||||
import { useTheme } from '@utils/styles/ThemeManager'
|
||||
import React from 'react'
|
||||
import { StyleSheet } from 'react-native'
|
||||
|
||||
export interface Props {
|
||||
muted?: Mastodon.Status['muted']
|
||||
}
|
||||
|
||||
const HeaderSharedMuted: React.FC<Props> = ({ muted }) => {
|
||||
const { theme } = useTheme()
|
||||
|
||||
return muted ? (
|
||||
<Icon
|
||||
name='VolumeX'
|
||||
size={StyleConstants.Font.Size.S}
|
||||
color={theme.secondary}
|
||||
style={styles.visibility}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
visibility: {
|
||||
marginLeft: StyleConstants.Spacing.S
|
||||
}
|
||||
})
|
||||
|
||||
export default HeaderSharedMuted
|
44
src/components/Timeline/Shared/HeaderShared/Visibility.tsx
Normal file
44
src/components/Timeline/Shared/HeaderShared/Visibility.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import Icon from '@components/Icon'
|
||||
import { StyleConstants } from '@utils/styles/constants'
|
||||
import { useTheme } from '@utils/styles/ThemeManager'
|
||||
import React from 'react'
|
||||
import { StyleSheet } from 'react-native'
|
||||
|
||||
export interface Props {
|
||||
visibility: Mastodon.Status['visibility']
|
||||
}
|
||||
|
||||
const HeaderSharedVisibility: React.FC<Props> = ({ visibility }) => {
|
||||
const { theme } = useTheme()
|
||||
|
||||
switch (visibility) {
|
||||
case 'private':
|
||||
return (
|
||||
<Icon
|
||||
name='Lock'
|
||||
size={StyleConstants.Font.Size.S}
|
||||
color={theme.secondary}
|
||||
style={styles.visibility}
|
||||
/>
|
||||
)
|
||||
case 'direct':
|
||||
return (
|
||||
<Icon
|
||||
name='Mail'
|
||||
size={StyleConstants.Font.Size.S}
|
||||
color={theme.secondary}
|
||||
style={styles.visibility}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
visibility: {
|
||||
marginLeft: StyleConstants.Spacing.S
|
||||
}
|
||||
})
|
||||
|
||||
export default HeaderSharedVisibility
|
321
src/components/Timeline/Shared/Poll.tsx
Normal file
321
src/components/Timeline/Shared/Poll.tsx
Normal file
@ -0,0 +1,321 @@
|
||||
import analytics from '@components/analytics'
|
||||
import Button from '@components/Button'
|
||||
import haptics from '@components/haptics'
|
||||
import Icon from '@components/Icon'
|
||||
import { ParseEmojis } from '@components/Parse'
|
||||
import RelativeTime from '@components/RelativeTime'
|
||||
import { toast } from '@components/toast'
|
||||
import {
|
||||
MutationVarsTimelineUpdateStatusProperty,
|
||||
QueryKeyTimeline,
|
||||
useTimelineMutation
|
||||
} from '@utils/queryHooks/timeline'
|
||||
import { StyleConstants } from '@utils/styles/constants'
|
||||
import { useTheme } from '@utils/styles/ThemeManager'
|
||||
import { maxBy } from 'lodash'
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import { useQueryClient } from 'react-query'
|
||||
|
||||
export interface Props {
|
||||
queryKey: QueryKeyTimeline
|
||||
statusId: Mastodon.Status['id']
|
||||
poll: NonNullable<Mastodon.Status['poll']>
|
||||
reblog: boolean
|
||||
sameAccount: boolean
|
||||
}
|
||||
|
||||
const TimelinePoll: React.FC<Props> = ({
|
||||
queryKey,
|
||||
statusId,
|
||||
poll,
|
||||
reblog,
|
||||
sameAccount
|
||||
}) => {
|
||||
const { mode, theme } = useTheme()
|
||||
const { t } = useTranslation('componentTimeline')
|
||||
|
||||
const [allOptions, setAllOptions] = useState(
|
||||
new Array(poll.options.length).fill(false)
|
||||
)
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const mutation = useTimelineMutation({
|
||||
queryClient,
|
||||
onSuccess: true,
|
||||
onError: (err: any, params) => {
|
||||
const theParams = params as MutationVarsTimelineUpdateStatusProperty
|
||||
haptics('Error')
|
||||
toast({
|
||||
type: 'error',
|
||||
message: t('common:toastMessage.error.message', {
|
||||
// @ts-ignore
|
||||
function: t(`shared.poll.meta.button.${theParams.payload.type}`)
|
||||
}),
|
||||
...(err.status &&
|
||||
typeof err.status === 'number' &&
|
||||
err.data &&
|
||||
err.data.error &&
|
||||
typeof err.data.error === 'string' && {
|
||||
description: err.data.error
|
||||
})
|
||||
})
|
||||
queryClient.invalidateQueries(queryKey)
|
||||
}
|
||||
})
|
||||
|
||||
const pollButton = useMemo(() => {
|
||||
if (!poll.expired) {
|
||||
if (!sameAccount && !poll.voted) {
|
||||
return (
|
||||
<View style={styles.button}>
|
||||
<Button
|
||||
onPress={() => {
|
||||
analytics('timeline_shared_vote_vote_press')
|
||||
mutation.mutate({
|
||||
type: 'updateStatusProperty',
|
||||
queryKey,
|
||||
id: statusId,
|
||||
reblog,
|
||||
payload: {
|
||||
property: 'poll',
|
||||
id: poll.id,
|
||||
type: 'vote',
|
||||
options: allOptions
|
||||
}
|
||||
})
|
||||
}}
|
||||
type='text'
|
||||
content={t('shared.poll.meta.button.vote')}
|
||||
loading={mutation.isLoading}
|
||||
disabled={allOptions.filter(o => o !== false).length === 0}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<View style={styles.button}>
|
||||
<Button
|
||||
onPress={() => {
|
||||
analytics('timeline_shared_vote_refresh_press')
|
||||
mutation.mutate({
|
||||
type: 'updateStatusProperty',
|
||||
queryKey,
|
||||
id: statusId,
|
||||
reblog,
|
||||
payload: {
|
||||
property: 'poll',
|
||||
id: poll.id,
|
||||
type: 'refresh'
|
||||
}
|
||||
})
|
||||
}}
|
||||
type='text'
|
||||
content={t('shared.poll.meta.button.refresh')}
|
||||
loading={mutation.isLoading}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
}
|
||||
}, [mode, poll.expired, poll.voted, allOptions, mutation.isLoading])
|
||||
|
||||
const pollExpiration = useMemo(() => {
|
||||
if (poll.expired) {
|
||||
return (
|
||||
<Text style={[styles.expiration, { color: theme.secondary }]}>
|
||||
{t('shared.poll.meta.expiration.expired')}
|
||||
</Text>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<Text style={[styles.expiration, { color: theme.secondary }]}>
|
||||
<Trans
|
||||
i18nKey='componentTimeline:shared.poll.meta.expiration.until'
|
||||
components={[<RelativeTime date={poll.expires_at} />]}
|
||||
/>
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
}, [mode, poll.expired, poll.expires_at])
|
||||
|
||||
const isSelected = useCallback(
|
||||
(index: number): string =>
|
||||
allOptions[index]
|
||||
? `Check${poll.multiple ? 'Square' : 'Circle'}`
|
||||
: `${poll.multiple ? 'Square' : 'Circle'}`,
|
||||
[allOptions]
|
||||
)
|
||||
|
||||
const pollBodyDisallow = useMemo(() => {
|
||||
const maxValue = maxBy(poll.options, option => option.votes_count)
|
||||
?.votes_count
|
||||
return poll.options.map((option, index) => (
|
||||
<View key={index} style={styles.optionContainer}>
|
||||
<View style={styles.optionContent}>
|
||||
<Icon
|
||||
style={styles.optionSelection}
|
||||
name={
|
||||
`${poll.own_votes?.includes(index) ? 'Check' : ''}${
|
||||
poll.multiple ? 'Square' : 'Circle'
|
||||
}` as any
|
||||
}
|
||||
size={StyleConstants.Font.Size.M}
|
||||
color={
|
||||
poll.own_votes?.includes(index) ? theme.blue : theme.disabled
|
||||
}
|
||||
/>
|
||||
<Text style={styles.optionText}>
|
||||
<ParseEmojis content={option.title} emojis={poll.emojis} />
|
||||
</Text>
|
||||
<Text style={[styles.optionPercentage, { color: theme.primary }]}>
|
||||
{poll.votes_count
|
||||
? Math.round(
|
||||
(option.votes_count /
|
||||
(poll.voters_count || poll.votes_count)) *
|
||||
100
|
||||
)
|
||||
: 0}
|
||||
%
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
styles.background,
|
||||
{
|
||||
width: `${Math.round(
|
||||
(option.votes_count / (poll.voters_count || poll.votes_count)) *
|
||||
100
|
||||
)}%`,
|
||||
backgroundColor:
|
||||
option.votes_count === maxValue ? theme.blue : theme.disabled
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
))
|
||||
}, [mode, poll.options])
|
||||
const pollBodyAllow = useMemo(() => {
|
||||
return poll.options.map((option, index) => (
|
||||
<Pressable
|
||||
key={index}
|
||||
style={styles.optionContainer}
|
||||
onPress={() => {
|
||||
analytics('timeline_shared_vote_option_press')
|
||||
haptics('Light')
|
||||
if (poll.multiple) {
|
||||
setAllOptions(allOptions.map((o, i) => (i === index ? !o : o)))
|
||||
} else {
|
||||
{
|
||||
const otherOptions =
|
||||
allOptions[index] === false ? false : undefined
|
||||
setAllOptions(
|
||||
allOptions.map((o, i) =>
|
||||
i === index
|
||||
? !o
|
||||
: otherOptions !== undefined
|
||||
? otherOptions
|
||||
: o
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<View style={[styles.optionContent]}>
|
||||
<Icon
|
||||
style={styles.optionSelection}
|
||||
name={isSelected(index)}
|
||||
size={StyleConstants.Font.Size.M}
|
||||
color={theme.primary}
|
||||
/>
|
||||
<Text style={styles.optionText}>
|
||||
<ParseEmojis content={option.title} emojis={poll.emojis} />
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
))
|
||||
}, [mode, allOptions])
|
||||
|
||||
const pollVoteCounts = useMemo(() => {
|
||||
if (poll.voters_count !== null) {
|
||||
return (
|
||||
<Text style={[styles.votes, { color: theme.secondary }]}>
|
||||
{t('shared.poll.meta.count.voters', { count: poll.voters_count })}
|
||||
</Text>
|
||||
)
|
||||
} else if (poll.votes_count !== null) {
|
||||
return (
|
||||
<Text style={[styles.votes, { color: theme.secondary }]}>
|
||||
{t('shared.poll.meta.count.votes', { count: poll.votes_count })}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
}, [poll.voters_count, poll.votes_count])
|
||||
|
||||
return (
|
||||
<View style={styles.base}>
|
||||
{poll.expired || poll.voted ? pollBodyDisallow : pollBodyAllow}
|
||||
<View style={styles.meta}>
|
||||
{pollButton}
|
||||
{pollVoteCounts}
|
||||
{pollExpiration}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
base: {
|
||||
marginTop: StyleConstants.Spacing.M
|
||||
},
|
||||
optionContainer: {
|
||||
flex: 1,
|
||||
paddingVertical: StyleConstants.Spacing.S
|
||||
},
|
||||
optionContent: {
|
||||
flex: 1,
|
||||
flexDirection: 'row'
|
||||
},
|
||||
optionText: {
|
||||
flex: 1
|
||||
},
|
||||
optionSelection: {
|
||||
paddingTop: StyleConstants.Font.LineHeight.M - StyleConstants.Font.Size.M,
|
||||
marginRight: StyleConstants.Spacing.S
|
||||
},
|
||||
optionPercentage: {
|
||||
...StyleConstants.FontStyle.M,
|
||||
alignSelf: 'center',
|
||||
marginLeft: StyleConstants.Spacing.S,
|
||||
flexBasis: '20%',
|
||||
textAlign: 'center'
|
||||
},
|
||||
background: {
|
||||
height: StyleConstants.Spacing.XS,
|
||||
minWidth: 2,
|
||||
borderTopRightRadius: 10,
|
||||
borderBottomRightRadius: 10,
|
||||
marginTop: StyleConstants.Spacing.XS,
|
||||
marginBottom: StyleConstants.Spacing.S
|
||||
},
|
||||
meta: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: StyleConstants.Spacing.XS
|
||||
},
|
||||
button: {
|
||||
marginRight: StyleConstants.Spacing.S
|
||||
},
|
||||
votes: {
|
||||
...StyleConstants.FontStyle.S
|
||||
},
|
||||
expiration: {
|
||||
...StyleConstants.FontStyle.S
|
||||
}
|
||||
})
|
||||
|
||||
export default TimelinePoll
|
Reference in New Issue
Block a user