1
0
mirror of https://github.com/tooot-app/app synced 2025-06-05 22:19:13 +02:00
This commit is contained in:
Zhiyuan Zheng
2021-02-08 23:47:20 +01:00
parent f5414412d4
commit 383ebc2775
79 changed files with 150 additions and 137 deletions

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

View 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

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

View 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

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

View 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

View 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

View 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

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

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

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

View 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

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

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

View 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

View 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

View 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

View 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

View 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

View 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