Poll working

But did not re-render
This commit is contained in:
Zhiyuan Zheng 2020-12-03 01:28:56 +01:00
parent 8986680c6d
commit 7d7c907fa3
No known key found for this signature in database
GPG Key ID: 078A93AB607D85E0
35 changed files with 1125 additions and 766 deletions

View File

@ -17,6 +17,7 @@
"expo": "~39.0.4",
"expo-auth-session": "~2.0.0",
"expo-av": "~8.6.0",
"expo-blur": "~8.2.0",
"expo-image-picker": "~9.1.1",
"expo-linear-gradient": "~8.3.0",
"expo-localization": "^9.0.0",

View File

@ -48,10 +48,10 @@ declare namespace Mastodon {
// Others
remote_url?: string
text_url?: string
meta: {
original: { width: number; height: number; size: string; aspect: number }
small: { width: number; height: number; size: string; aspect: number }
focus:
meta?: {
original?: { width: number; height: number; size: string; aspect: number }
small?: { width: number; height: number; size: string; aspect: number }
focus?:
| { x: number; y: number }
| {
length: string

View File

@ -103,7 +103,6 @@ const styles = StyleSheet.create({
justifyContent: 'flex-end'
},
container: {
padding: StyleConstants.Spacing.L,
paddingTop: StyleConstants.Spacing.M
},
handle: {
@ -115,9 +114,10 @@ const styles = StyleSheet.create({
},
cancel: {
padding: StyleConstants.Spacing.S,
marginLeft: StyleConstants.Spacing.L,
marginRight: StyleConstants.Spacing.L,
borderWidth: 1,
borderRadius: 100,
// marginBottom: StyleConstants.Spacing.L
borderRadius: 100
},
text: {
fontSize: StyleConstants.Font.Size.L,

View File

@ -1,42 +0,0 @@
import React from 'react'
import { Pressable, StyleSheet, Text } from 'react-native'
import { Feather } from '@expo/vector-icons'
import { useTheme } from 'src/utils/styles/ThemeManager'
import { StyleConstants } from 'src/utils/styles/constants'
export interface Props {
onPress: () => void
icon: string
text: string
}
const BottomSheetRow: React.FC<Props> = ({ onPress, icon, text }) => {
const { theme } = useTheme()
return (
<Pressable onPress={onPress} style={styles.pressable}>
<Feather
name={icon}
color={theme.primary}
size={StyleConstants.Font.Size.L}
/>
<Text style={[styles.text, { color: theme.primary }]}>{text}</Text>
</Pressable>
)
}
const styles = StyleSheet.create({
pressable: {
width: '100%',
flexDirection: 'row',
marginBottom: StyleConstants.Spacing.L
},
text: {
fontSize: StyleConstants.Font.Size.M,
lineHeight: StyleConstants.Font.Size.L,
marginLeft: StyleConstants.Spacing.S
}
})
export default BottomSheetRow

46
src/components/Button.tsx Normal file
View File

@ -0,0 +1,46 @@
import React from 'react'
import { Pressable, StyleSheet, Text } from 'react-native'
import { StyleConstants } from 'src/utils/styles/constants'
import { useTheme } from 'src/utils/styles/ThemeManager'
export interface Props {
onPress: () => void
text: string
fontSize?: 'S' | 'M' | 'L'
}
const Button: React.FC<Props> = ({ onPress, text, fontSize = 'M' }) => {
const { theme } = useTheme()
return (
<Pressable
onPress={onPress}
style={[styles.button, { borderColor: theme.primary }]}
>
<Text
style={[
styles.text,
{ color: theme.primary, fontSize: StyleConstants.Font.Size[fontSize] }
]}
>
{text}
</Text>
</Pressable>
)
}
const styles = StyleSheet.create({
button: {
paddingTop: StyleConstants.Spacing.S,
paddingBottom: StyleConstants.Spacing.S,
paddingLeft: StyleConstants.Spacing.M,
paddingRight: StyleConstants.Spacing.M,
borderWidth: 1,
borderRadius: 100
},
text: {
textAlign: 'center'
}
})
export default Button

View File

@ -34,7 +34,7 @@ const styles = StyleSheet.create({
paddingRight: StyleConstants.Spacing.S
},
text: {
fontSize: StyleConstants.Font.Size.L
fontSize: StyleConstants.Font.Size.M
}
})

View File

@ -6,6 +6,7 @@ import { useTheme } from 'src/utils/styles/ThemeManager'
import { StyleConstants } from 'src/utils/styles/constants'
type PropsBase = {
disabled?: boolean
onPress: () => void
}
@ -20,6 +21,7 @@ export interface PropsIcon extends PropsBase {
}
const HeaderRight: React.FC<PropsText | PropsIcon> = ({
disabled,
onPress,
text,
icon
@ -27,12 +29,21 @@ const HeaderRight: React.FC<PropsText | PropsIcon> = ({
const { theme } = useTheme()
return (
<Pressable onPress={onPress} style={styles.base}>
{text && <Text style={[styles.text, { color: theme.primary }]}>{text}</Text>}
<Pressable {...(!disabled && { onPress })} style={styles.base}>
{text && (
<Text
style={[
styles.text,
{ color: disabled ? theme.secondary : theme.primary }
]}
>
{text}
</Text>
)}
{icon && (
<Feather
name={icon}
color={theme.primary}
color={disabled ? theme.secondary : theme.primary}
size={StyleConstants.Font.Size.L}
/>
)}
@ -45,7 +56,7 @@ const styles = StyleSheet.create({
paddingLeft: StyleConstants.Spacing.S
},
text: {
fontSize: StyleConstants.Font.Size.L
fontSize: StyleConstants.Font.Size.M
}
})

View File

@ -1,5 +1,5 @@
import MenuContainer from './Menu/Container'
import MenuHeader from './Menu/Header'
import MenuItem from './Menu/Item'
import MenuRow from './Menu/Row'
export { MenuContainer, MenuHeader, MenuItem }
export { MenuContainer, MenuHeader, MenuRow }

View File

@ -1,36 +1,36 @@
import React from 'react'
import React, { Children } from 'react'
import { StyleSheet, View } from 'react-native'
import { useTheme } from 'src/utils/styles/ThemeManager'
import { StyleConstants } from 'src/utils/styles/constants'
export interface Props {
children: React.ReactNode
marginTop?: boolean
}
const MenuContainer: React.FC<Props> = ({ ...props }) => {
const MenuContainer: React.FC<Props> = ({ children }) => {
const { theme } = useTheme()
// @ts-ignore
const firstChild = Children.toArray(children)[0].type.name
return (
<View
style={[
styles.base,
{
borderTopColor: theme.separator,
marginTop: props.marginTop
? StyleConstants.Spacing.Global.PagePadding
: 0
...(firstChild !== 'MenuHeader' && {
borderTopColor: theme.separator,
borderTopWidth: 1
})
}
]}
>
{props.children}
{children}
</View>
)
}
const styles = StyleSheet.create({
base: {
borderTopWidth: 1,
marginBottom: StyleConstants.Spacing.L
}
})

View File

@ -1,19 +1,28 @@
import React from 'react'
import { StyleSheet, Text } from 'react-native'
import { StyleSheet, Text, View } from 'react-native'
import { StyleConstants } from 'src/utils/styles/constants'
import { useTheme } from 'src/utils/styles/ThemeManager'
export interface Props {
heading: string
}
const MenuHeader: React.FC<Props> = ({ heading }) => {
return <Text style={styles.header}>{heading}</Text>
const { theme } = useTheme()
return (
<View style={[styles.base, { borderBottomColor: theme.separator }]}>
<Text>{heading}</Text>
</View>
)
}
const styles = StyleSheet.create({
header: {
marginTop: 12,
paddingLeft: 12,
paddingRight: 12
base: {
borderBottomWidth: 1,
paddingLeft: StyleConstants.Spacing.Global.PagePadding,
paddingRight: StyleConstants.Spacing.Global.PagePadding,
paddingBottom: StyleConstants.Spacing.S
}
})

View File

@ -43,29 +43,31 @@ const Core: React.FC<Props> = ({
{title}
</Text>
</View>
<View style={styles.back}>
{content && (
<Text
style={[styles.content, { color: theme.secondary }]}
numberOfLines={1}
>
{content}
</Text>
)}
{iconBack && (
<Feather
name={iconBack}
size={StyleConstants.Font.Size.M + 2}
color={theme[iconBackColor]}
style={styles.iconBack}
/>
)}
</View>
{(content || iconBack) && (
<View style={styles.back}>
{content && (
<Text
style={[styles.content, { color: theme.secondary }]}
numberOfLines={1}
>
{content}
</Text>
)}
{iconBack && (
<Feather
name={iconBack}
size={StyleConstants.Font.Size.M + 2}
color={theme[iconBackColor]}
style={styles.iconBack}
/>
)}
</View>
)}
</View>
)
}
const MenuItem: React.FC<Props> = ({ ...props }) => {
const MenuRow: React.FC<Props> = ({ ...props }) => {
const { theme } = useTheme()
return props.onPress ? (
@ -95,11 +97,13 @@ const styles = StyleSheet.create({
},
front: {
flex: 1,
flexBasis: '70%',
flexDirection: 'row',
alignItems: 'center'
},
back: {
flex: 1,
flexBasis: '30%',
flexDirection: 'row',
justifyContent: 'flex-end',
alignItems: 'center'
@ -108,6 +112,7 @@ const styles = StyleSheet.create({
marginRight: 8
},
text: {
flex: 1,
fontSize: StyleConstants.Font.Size.M
},
content: {
@ -118,4 +123,4 @@ const styles = StyleSheet.create({
}
})
export default MenuItem
export default MenuRow

View File

@ -2,14 +2,15 @@ import React, { useCallback, useMemo } from 'react'
import { Dimensions, Pressable, StyleSheet, View } from 'react-native'
import { useNavigation } from '@react-navigation/native'
import Actioned from './Shared/Actioned'
import Avatar from './Shared/Avatar'
import HeaderDefault from './Shared/HeaderDefault'
import Content from './Shared/Content'
import Poll from './Shared/Poll'
import Attachment from './Shared/Attachment'
import Card from './Shared/Card'
import ActionsStatus from './Shared/ActionsStatus'
import TimelineActioned from './Shared/Actioned'
import TimelineActions from './Shared/Actions'
import TimelineAttachment from './Shared/Attachment'
import TimelineAvatar from './Shared/Avatar'
import TimelineCard from './Shared/Card'
import TimelineContent from './Shared/Content'
import TimelineHeaderDefault from './Shared/HeaderDefault'
import TimelinePoll from './Shared/Poll'
import { StyleConstants } from 'src/utils/styles/constants'
export interface Props {
@ -22,6 +23,11 @@ const TimelineDefault: React.FC<Props> = ({ item, queryKey }) => {
const navigation = useNavigation()
let actualStatus = item.reblog ? item.reblog : item
const contentWidth =
Dimensions.get('window').width -
StyleConstants.Spacing.Global.PagePadding * 2 - // Global page padding on both sides
StyleConstants.Avatar.S - // Avatar width
StyleConstants.Spacing.S // Avatar margin to the right
const pressableToot = useCallback(
() =>
@ -33,73 +39,37 @@ const TimelineDefault: React.FC<Props> = ({ item, queryKey }) => {
const childrenToot = useMemo(
() => (
<>
{actualStatus.content ? (
<Content
content={actualStatus.content}
emojis={actualStatus.emojis}
mentions={actualStatus.mentions}
spoiler_text={actualStatus.spoiler_text}
// tags={actualStatus.tags}
/>
) : (
<></>
{actualStatus.content.length > 0 && (
<TimelineContent status={actualStatus} />
)}
{actualStatus.poll && (
<TimelinePoll queryKey={queryKey} poll={actualStatus.poll} />
)}
{actualStatus.poll && <Poll poll={actualStatus.poll} />}
{actualStatus.media_attachments.length > 0 && (
<Attachment
media_attachments={actualStatus.media_attachments}
sensitive={actualStatus.sensitive}
width={
Dimensions.get('window').width - StyleConstants.Spacing.M * 2 - 50 - 8
}
/>
<TimelineAttachment status={actualStatus} width={contentWidth} />
)}
{actualStatus.card && <Card card={actualStatus.card} />}
{actualStatus.card && <TimelineCard card={actualStatus.card} />}
</>
),
[]
[actualStatus.poll?.voted]
)
const statusView = useMemo(() => {
return (
<View style={styles.statusView}>
{item.reblog && (
<Actioned
action='reblog'
name={item.account.display_name || item.account.username}
emojis={item.account.emojis}
/>
)}
<View style={styles.status}>
<Avatar
uri={actualStatus.account.avatar}
id={actualStatus.account.id}
/>
<View style={styles.details}>
<HeaderDefault
queryKey={queryKey}
accountId={actualStatus.account.id}
domain={actualStatus.uri.split(new RegExp(/\/\/(.*?)\//))[1]}
name={
actualStatus.account.display_name ||
actualStatus.account.username
}
emojis={actualStatus.account.emojis}
account={actualStatus.account.acct}
created_at={item.created_at}
visibility={item.visibility}
application={item.application}
/>
{/* Can pass toot info to next page to speed up performance */}
<Pressable onPress={pressableToot} children={childrenToot} />
<ActionsStatus queryKey={queryKey} status={actualStatus} />
</View>
return (
<View style={styles.statusView}>
{item.reblog && (
<TimelineActioned action='reblog' account={item.account} />
)}
<View style={styles.status}>
<TimelineAvatar account={actualStatus.account} />
<View style={styles.details}>
<TimelineHeaderDefault queryKey={queryKey} status={actualStatus} />
{/* Can pass toot info to next page to speed up performance */}
<Pressable onPress={pressableToot} children={childrenToot} />
<TimelineActions queryKey={queryKey} status={actualStatus} />
</View>
</View>
)
}, [item])
return statusView
</View>
)
}
const styles = StyleSheet.create({
@ -114,8 +84,7 @@ const styles = StyleSheet.create({
flexDirection: 'row'
},
details: {
flex: 1,
flexGrow: 1
flex: 1
}
})

View File

@ -9,7 +9,7 @@ import Content from './Shared/Content'
import Poll from './Shared/Poll'
import Attachment from './Shared/Attachment'
import Card from './Shared/Card'
import ActionsStatus from './Shared/ActionsStatus'
import ActionsStatus from './Shared/Actions'
import { StyleConstants } from 'src/utils/styles/constants'
export interface Props {

View File

@ -7,19 +7,19 @@ import { useTheme } from 'src/utils/styles/ThemeManager'
import { StyleConstants } from 'src/utils/styles/constants'
export interface Props {
account: Mastodon.Account
action: 'favourite' | 'follow' | 'mention' | 'poll' | 'reblog'
name?: string
emojis?: Mastodon.Emoji[]
notification?: boolean
}
const Actioned: React.FC<Props> = ({
const TimelineActioned: React.FC<Props> = ({
account,
action,
name,
emojis,
notification = false
}) => {
const { theme } = useTheme()
const name = account.display_name || account.username
const iconColor = theme.primary
let icon
@ -74,20 +74,18 @@ const Actioned: React.FC<Props> = ({
return (
<View style={styles.actioned}>
{icon}
{content ? (
{content && (
<View style={styles.content}>
{emojis ? (
{account.emojis ? (
<Emojis
content={content}
emojis={emojis}
emojis={account.emojis}
size={StyleConstants.Font.Size.S}
/>
) : (
<Text>{content}</Text>
)}
</View>
) : (
<></>
)}
</View>
)
@ -107,4 +105,4 @@ const styles = StyleSheet.create({
}
})
export default React.memo(Actioned)
export default React.memo(TimelineActioned, () => true)

View File

@ -1,16 +1,12 @@
import React, { useCallback, useMemo, useState } from 'react'
import React, { useCallback, useMemo } from 'react'
import { ActionSheetIOS, Pressable, StyleSheet, Text, View } from 'react-native'
import { useMutation, useQueryCache } from 'react-query'
import { Feather } from '@expo/vector-icons'
import client from 'src/api/client'
import { getLocalAccountId } from 'src/utils/slices/instancesSlice'
import { useTheme } from 'src/utils/styles/ThemeManager'
import { toast } from 'src/components/toast'
import { useSelector } from 'react-redux'
import { StyleConstants } from 'src/utils/styles/constants'
import BottomSheet from 'src/components/BottomSheet'
import BottomSheetRow from 'src/components/BottomSheet/Row'
const fireMutation = async ({
id,
@ -19,14 +15,8 @@ const fireMutation = async ({
prevState
}: {
id: string
type: 'favourite' | 'reblog' | 'bookmark' | 'mute' | 'pin' | 'delete'
stateKey:
| 'favourited'
| 'reblogged'
| 'bookmarked'
| 'muted'
| 'pinned'
| 'id'
type: 'favourite' | 'reblog' | 'bookmark'
stateKey: 'favourited' | 'reblogged' | 'bookmarked'
prevState?: boolean
}) => {
let res
@ -34,8 +24,6 @@ const fireMutation = async ({
case 'favourite':
case 'reblog':
case 'bookmark':
case 'mute':
case 'pin':
res = await client({
method: 'post',
instance: 'local',
@ -50,21 +38,6 @@ const fireMutation = async ({
return Promise.reject()
}
break
case 'delete':
res = await client({
method: 'delete',
instance: 'local',
endpoint: `statuses/${id}`
})
if (res.body[stateKey] === id) {
toast({ type: 'success', content: '删除成功' })
return Promise.resolve(res.body)
} else {
toast({ type: 'error', content: '删除失败' })
return Promise.reject()
}
break
}
}
@ -73,15 +46,12 @@ export interface Props {
status: Mastodon.Status
}
const ActionsStatus: React.FC<Props> = ({ queryKey, status }) => {
const TimelineActions: React.FC<Props> = ({ queryKey, status }) => {
const { theme } = useTheme()
const iconColor = theme.secondary
const iconColorAction = (state: boolean) =>
state ? theme.primary : theme.secondary
const localAccountId = useSelector(getLocalAccountId)
const [bottomSheetVisible, setBottomSheetVisible] = useState(false)
const queryCache = useQueryCache()
const [mutateAction] = useMutation(fireMutation, {
onMutate: ({ id, type, stateKey, prevState }) => {
@ -92,8 +62,6 @@ const ActionsStatus: React.FC<Props> = ({ queryKey, status }) => {
case 'favourite':
case 'reblog':
case 'bookmark':
case 'mute':
case 'pin':
queryCache.setQueryData(queryKey, old =>
(old as {}[]).map((paging: any) => ({
toots: paging.toots.map((toot: any) => {
@ -107,19 +75,6 @@ const ActionsStatus: React.FC<Props> = ({ queryKey, status }) => {
}))
)
break
case 'delete':
queryCache.setQueryData(queryKey, old =>
(old as {}[]).map((paging: any) => ({
toots: paging.toots.map((toot: any, index: number) => {
if (toot.id === id) {
paging.toots.splice(index, 1)
}
return toot
}),
pointer: paging.pointer
}))
)
break
}
return oldData
@ -161,7 +116,23 @@ const ActionsStatus: React.FC<Props> = ({ queryKey, status }) => {
}),
[status.bookmarked]
)
const onPressShare = useCallback(() => setBottomSheetVisible(true), [])
const onPressShare = useCallback(
() =>
ActionSheetIOS.showShareActionSheetWithOptions(
{
url: status.uri,
excludedActivityTypes: [
'com.apple.UIKit.activity.Mail',
'com.apple.UIKit.activity.Print',
'com.apple.UIKit.activity.SaveToCameraRoll',
'com.apple.UIKit.activity.OpenInIBooks'
]
},
() => {},
() => {}
),
[]
)
const childrenReply = useMemo(
() => (
@ -268,86 +239,6 @@ const ActionsStatus: React.FC<Props> = ({ queryKey, status }) => {
children={childrenShare}
/>
</View>
<BottomSheet
visible={bottomSheetVisible}
handleDismiss={() => setBottomSheetVisible(false)}
>
<BottomSheetRow
onPress={() => {
ActionSheetIOS.showShareActionSheetWithOptions(
{
url: status.uri,
excludedActivityTypes: [
'com.apple.UIKit.activity.Mail',
'com.apple.UIKit.activity.Print',
'com.apple.UIKit.activity.SaveToCameraRoll',
'com.apple.UIKit.activity.OpenInIBooks'
]
},
() => {},
() => {
setBottomSheetVisible(false)
toast({ type: 'success', content: '分享成功' })
}
)
}}
icon='share'
text={'分享嘟嘟'}
/>
{status.account.id === localAccountId && (
<BottomSheetRow
onPress={() => {
setBottomSheetVisible(false)
mutateAction({
id: status.id,
type: 'delete',
stateKey: 'id'
})
}}
icon='trash'
text='删除嘟嘟'
/>
)}
{status.account.id === localAccountId && (
<BottomSheetRow
onPress={() => {
console.warn('功能未开发')
}}
icon='trash'
text='删除并重发'
/>
)}
<BottomSheetRow
onPress={() => {
setBottomSheetVisible(false)
mutateAction({
id: status.id,
type: 'mute',
stateKey: 'muted',
prevState: status.muted
})
}}
icon='volume-x'
text={status.muted ? '取消静音' : '静音'}
/>
{/* Also note that reblogs cannot be pinned. */}
{status.account.id === localAccountId && (
<BottomSheetRow
onPress={() => {
setBottomSheetVisible(false)
mutateAction({
id: status.id,
type: 'pin',
stateKey: 'pinned',
prevState: status.pinned
})
}}
icon='anchor'
text={status.pinned ? '取消置顶' : '置顶'}
/>
)}
</BottomSheet>
</>
)
}
@ -363,22 +254,7 @@ const styles = StyleSheet.create({
width: '20%',
flexDirection: 'row',
justifyContent: 'center'
},
modalBackground: {
width: '100%',
height: '100%',
backgroundColor: 'rgba(0, 0, 0, 0.75)',
flex: 1,
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'flex-end'
},
modalSheet: {
width: '100%',
height: '50%',
backgroundColor: 'white',
flex: 1
}
})
export default ActionsStatus
export default TimelineActions

View File

@ -1,65 +1,58 @@
import React from 'react'
import { Text, View } from 'react-native'
import { BlurView } from 'expo-blur'
import React, { useCallback, useEffect, useState } from 'react'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { StyleConstants } from 'src/utils/styles/constants'
import { useTheme } from 'src/utils/styles/ThemeManager'
import AttachmentImage from './Attachment/AttachmentImage'
import AttachmentVideo from './Attachment/AttachmentVideo'
export interface Props {
media_attachments: Mastodon.Attachment[]
sensitive: boolean
status: Pick<Mastodon.Status, 'media_attachments' | 'sensitive'>
width: number
}
const Attachment: React.FC<Props> = ({
media_attachments,
sensitive,
width
}) => {
const TimelineAttachment: React.FC<Props> = ({ status, width }) => {
const { mode, theme } = useTheme()
const allTypes = status.media_attachments.map(m => m.type)
let attachment
let attachmentHeight
// if (width) {}
switch (media_attachments[0].type) {
case 'unknown':
attachment = <Text></Text>
attachmentHeight = 25
break
case 'image':
attachment = (
<AttachmentImage
media_attachments={media_attachments}
sensitive={sensitive}
width={width}
/>
)
attachmentHeight = width / 2
break
case 'gifv':
attachment = (
<AttachmentVideo
media_attachments={media_attachments}
sensitive={sensitive}
width={width}
/>
)
attachmentHeight =
(width / media_attachments[0].meta.original.width) *
media_attachments[0].meta.original.height
break
// Support multiple video
// Supoort when video meta is empty
// case 'video':
// attachment = (
// <AttachmentVideo
// media_attachments={media_attachments}
// sensitive={sensitive}
// width={width}
// />
// )
// attachmentHeight =
// (width / media_attachments[0].meta.original.width) *
// media_attachments[0].meta.original.height
// break
// case 'audio':
if (allTypes.includes('image')) {
attachment = (
<AttachmentImage
media_attachments={status.media_attachments}
width={width}
/>
)
attachmentHeight = (width / 16) * 9
} else if (allTypes.includes('gifv')) {
attachment = (
<AttachmentVideo
media_attachments={status.media_attachments}
width={width}
/>
)
attachmentHeight =
status.media_attachments[0].meta?.original?.width &&
status.media_attachments[0].meta?.original?.height
? (width / status.media_attachments[0].meta.original.width) *
status.media_attachments[0].meta.original.height
: (width / 16) * 9
} else if (allTypes.includes('video')) {
attachment = (
<AttachmentVideo
media_attachments={status.media_attachments}
width={width}
/>
)
attachmentHeight =
status.media_attachments[0].meta?.original?.width &&
status.media_attachments[0].meta?.original?.height
? (width / status.media_attachments[0].meta.original.width) *
status.media_attachments[0].meta.original.height
: (width / 16) * 9
} else if (allTypes.includes('audio')) {
// attachment = (
// <AttachmentAudio
// media_attachments={media_attachments}
@ -67,21 +60,60 @@ const Attachment: React.FC<Props> = ({
// width={width}
// />
// )
// break
} else {
attachment = <Text></Text>
attachmentHeight = 25
}
const [sensitiveShown, setSensitiveShown] = useState(true)
const onPressBlurView = useCallback(() => {
setSensitiveShown(false)
}, [])
useEffect(() => {
if (status.sensitive && sensitiveShown === false) {
setTimeout(() => {
setSensitiveShown(true)
}, 10000)
}
}, [sensitiveShown])
return (
<View
style={{
width: width + 8,
width: width + StyleConstants.Spacing.XS,
height: attachmentHeight,
marginTop: 4,
marginLeft: -4
marginTop: StyleConstants.Spacing.S,
marginLeft: -StyleConstants.Spacing.XS / 2
}}
>
{attachment}
{status.sensitive && sensitiveShown && (
<BlurView tint={mode} intensity={100} style={styles.blurView}>
<Pressable onPress={onPressBlurView} style={styles.blurViewPressable}>
<Text style={[styles.sensitiveText, { color: theme.primary }]}>
</Text>
</Pressable>
</BlurView>
)}
</View>
)
}
export default React.memo(Attachment, () => true)
const styles = StyleSheet.create({
blurView: {
position: 'absolute',
width: '100%',
height: '100%'
},
blurViewPressable: {
flex: 1,
justifyContent: 'center',
alignItems: 'center'
},
sensitiveText: {
fontSize: StyleConstants.Font.Size.M
}
})
export default React.memo(TimelineAttachment, () => true)

View File

@ -1,75 +1,46 @@
import React, { useEffect, useState } from 'react'
import { Button, Image, Modal, StyleSheet, Pressable, View } from 'react-native'
import React, { useState } from 'react'
import { Image, Modal, StyleSheet, Pressable, View } from 'react-native'
import ImageViewer from 'react-native-image-zoom-viewer'
import { StyleConstants } from 'src/utils/styles/constants'
export interface Props {
media_attachments: Mastodon.Attachment[]
sensitive: boolean
width: number
}
const AttachmentImage: React.FC<Props> = ({
media_attachments,
sensitive,
width
}) => {
const [mediaSensitive, setMediaSensitive] = useState(sensitive)
const AttachmentImage: React.FC<Props> = ({ media_attachments }) => {
const [imageModalVisible, setImageModalVisible] = useState(false)
const [imageModalIndex, setImageModalIndex] = useState(0)
useEffect(() => {
if (sensitive && mediaSensitive === false) {
setTimeout(() => {
setMediaSensitive(true)
}, 10000)
}
}, [mediaSensitive])
let images: { url: string; width: number; height: number }[] = []
let images: {
url: string
width: number | undefined
height: number | undefined
}[] = []
const imagesNode = media_attachments.map((m, i) => {
images.push({
url: m.url,
width: m.meta.original.width,
height: m.meta.original.height
width: m.meta?.original?.width || undefined,
height: m.meta?.original?.height || undefined
})
return (
<Pressable
key={i}
style={{ flexGrow: 1, height: width / 2, margin: 4 }}
style={[styles.imageContainer]}
onPress={() => {
setImageModalIndex(i)
setImageModalVisible(true)
}}
>
<Image
source={{ uri: m.preview_url }}
style={styles.image}
blurRadius={mediaSensitive ? width / 5 : 0}
/>
<Image source={{ uri: m.preview_url }} style={styles.image} />
</Pressable>
)
})
return (
<>
<View style={styles.media}>
{imagesNode}
{mediaSensitive && (
<View
style={{
position: 'absolute',
width: '100%',
height: '100%'
}}
>
<Button
title='Press me'
onPress={() => {
setMediaSensitive(false)
}}
/>
</View>
)}
</View>
<View style={[styles.media]}>{imagesNode}</View>
<Modal
visible={imageModalVisible}
transparent={true}
@ -82,6 +53,7 @@ const AttachmentImage: React.FC<Props> = ({
enableSwipeDown={true}
swipeDownThreshold={100}
useNativeDriver={true}
saveToLocalByLongPress={false}
/>
</Modal>
</>
@ -91,16 +63,18 @@ const AttachmentImage: React.FC<Props> = ({
const styles = StyleSheet.create({
media: {
flex: 1,
flexDirection: 'column',
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'space-between',
alignItems: 'stretch',
alignContent: 'stretch'
},
imageContainer: {
flex: 1,
flexBasis: '50%',
padding: StyleConstants.Spacing.XS / 2
},
image: {
width: '100%',
height: '100%'
flex: 1
}
})
export default AttachmentImage
export default React.memo(AttachmentImage, () => true)

View File

@ -1,27 +1,29 @@
import React, { useRef, useState } from 'react'
import React, { useCallback, useRef, useState } from 'react'
import { Pressable, View } from 'react-native'
import { Video } from 'expo-av'
import { Feather } from '@expo/vector-icons'
export interface Props {
media_attachments: Mastodon.Attachment[]
sensitive: boolean
width: number
}
const AttachmentVideo: React.FC<Props> = ({
media_attachments,
sensitive,
width
}) => {
const videoPlayer = useRef()
const [mediaSensitive, setMediaSensitive] = useState(sensitive)
const AttachmentVideo: React.FC<Props> = ({ media_attachments, width }) => {
const videoPlayer = useRef<Video>(null)
const [videoPlay, setVideoPlay] = useState(false)
const video = media_attachments[0]
const videoWidth = width
const videoHeight =
(width / video.meta.original.width) * video.meta.original.height
video.meta?.original?.width && video.meta?.original?.height
? (width / video.meta.original.width) * video.meta.original.height
: (width / 16) * 9
const onPressVideo = useCallback(() => {
// @ts-ignore
videoPlayer.current.presentFullscreenPlayer()
setVideoPlay(true)
}, [])
return (
<View
@ -39,17 +41,13 @@ const AttachmentVideo: React.FC<Props> = ({
}}
resizeMode='cover'
usePoster
posterSourceThe={{ uri: video.preview_url }}
posterSource={{ uri: video.preview_url }}
useNativeControls
shouldPlay={videoPlay}
/>
{!videoPlay && (
{videoPlayer.current && !videoPlay && (
<Pressable
onPress={() => {
setMediaSensitive(false)
videoPlayer.current.presentFullscreenPlayer()
setVideoPlay(true)
}}
onPress={onPressVideo}
style={{
position: 'absolute',
top: 0,

View File

@ -4,22 +4,21 @@ import { useNavigation } from '@react-navigation/native'
import { StyleConstants } from 'src/utils/styles/constants'
export interface Props {
uri: string
id: string
account: Mastodon.Account
}
const Avatar: React.FC<Props> = ({ uri, id }) => {
const TimelineAvatar: React.FC<Props> = ({ account }) => {
const navigation = useNavigation()
// Need to fix go back root
const onPress = useCallback(() => {
navigation.navigate('Screen-Shared-Account', {
id: id
id: account.id
})
}, [])
return (
<Pressable style={styles.avatar} onPress={onPress}>
<Image source={{ uri: uri }} style={styles.image} />
<Image source={{ uri: account.avatar }} style={styles.image} />
</Pressable>
)
}
@ -37,4 +36,4 @@ const styles = StyleSheet.create({
}
})
export default React.memo(Avatar, () => true)
export default React.memo(TimelineAvatar, () => true)

View File

@ -8,7 +8,7 @@ export interface Props {
card: Mastodon.Card
}
const Card: React.FC<Props> = ({ card }) => {
const TimelineCard: React.FC<Props> = ({ card }) => {
const { theme } = useTheme()
const navigation = useNavigation()
const onPress = useCallback(() => {
@ -83,4 +83,4 @@ const styles = StyleSheet.create({
}
})
export default React.memo(Card, () => true)
export default React.memo(TimelineCard, () => true)

View File

@ -8,60 +8,50 @@ import { useTheme } from 'src/utils/styles/ThemeManager'
import { StyleConstants } from 'src/utils/styles/constants'
export interface Props {
content: string
emojis: Mastodon.Emoji[]
mentions: Mastodon.Mention[]
spoiler_text?: string
status: Mastodon.Status
numberOfLines?: number
}
const Content: React.FC<Props> = ({
content,
emojis,
mentions,
spoiler_text,
numberOfLines
}) => {
const TimelineContent: React.FC<Props> = ({ status, numberOfLines }) => {
const { theme } = useTheme()
const [spoilerCollapsed, setSpoilerCollapsed] = useState(true)
return (
<>
{content &&
(spoiler_text ? (
<>
<Text style={{ fontSize: StyleConstants.Font.Size.M }}>
{spoiler_text}{' '}
<Text
onPress={() => setSpoilerCollapsed(!spoilerCollapsed)}
style={{
color: theme.link
}}
>
{spoilerCollapsed ? '点击展开' : '点击收起'}
</Text>
{status.spoiler_text ? (
<>
<Text style={{ fontSize: StyleConstants.Font.Size.M }}>
{status.spoiler_text}{' '}
<Text
onPress={() => setSpoilerCollapsed(!spoilerCollapsed)}
style={{
color: theme.link
}}
>
{spoilerCollapsed ? '点击展开' : '点击收起'}
</Text>
<Collapsible collapsed={spoilerCollapsed}>
<ParseContent
content={content}
size={StyleConstants.Font.Size.M}
emojis={emojis}
mentions={mentions}
{...(numberOfLines && { numberOfLines: numberOfLines })}
/>
</Collapsible>
</>
) : (
<ParseContent
content={content}
size={StyleConstants.Font.Size.M}
emojis={emojis}
mentions={mentions}
{...(numberOfLines && { numberOfLines: numberOfLines })}
/>
))}
</Text>
<Collapsible collapsed={spoilerCollapsed}>
<ParseContent
content={status.content}
size={StyleConstants.Font.Size.M}
emojis={status.emojis}
mentions={status.mentions}
{...(numberOfLines && { numberOfLines: numberOfLines })}
/>
</Collapsible>
</>
) : (
<ParseContent
content={status.content}
size={StyleConstants.Font.Size.M}
emojis={status.emojis}
mentions={status.mentions}
{...(numberOfLines && { numberOfLines: numberOfLines })}
/>
)}
</>
)
}
export default Content
export default React.memo(TimelineContent, () => true)

View File

@ -10,13 +10,15 @@ export interface Props {
emojis: Mastodon.Emoji[]
size: number
fontBold?: boolean
numberOfLines?: number
}
const Emojis: React.FC<Props> = ({
content,
emojis,
size,
fontBold = false
fontBold = false,
numberOfLines
}) => {
const { theme } = useTheme()
const styles = StyleSheet.create({
@ -35,7 +37,7 @@ const Emojis: React.FC<Props> = ({
const hasEmojis = content.match(regexEmoji)
return (
<Text>
<Text numberOfLines={numberOfLines || undefined}>
{content.split(regexEmoji).map((str, i) => {
if (str.match(regexEmoji)) {
const emojiShortcode = str.split(regexEmoji)[1]

View File

@ -2,152 +2,47 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { useNavigation } from '@react-navigation/native'
import { Feather } from '@expo/vector-icons'
import { useMutation, useQueryCache } from 'react-query'
import Emojis from './Emojis'
import relativeTime from 'src/utils/relativeTime'
import client from 'src/api/client'
import { getLocalAccountId, getLocalUrl } from 'src/utils/slices/instancesSlice'
import { useTheme } from 'src/utils/styles/ThemeManager'
import BottomSheet from 'src/components/BottomSheet'
import BottomSheetRow from 'src/components/BottomSheet/Row'
import { toast } from 'src/components/toast'
import { useSelector } from 'react-redux'
import { StyleConstants } from 'src/utils/styles/constants'
const fireMutation = async ({
id,
type,
stateKey
}: {
id: string
type: 'mute' | 'block' | 'domain_blocks' | 'reports'
stateKey?: 'muting' | 'blocking'
}) => {
let res
switch (type) {
case 'mute':
case 'block':
res = await client({
method: 'post',
instance: 'local',
endpoint: `accounts/${id}/${type}`
})
if (res.body[stateKey!] === true) {
toast({ type: 'success', content: '功能成功' })
return Promise.resolve()
} else {
toast({ type: 'error', content: '功能错误', autoHide: false })
return Promise.reject()
}
break
case 'domain_blocks':
res = await client({
method: 'post',
instance: 'local',
endpoint: `domain_blocks`,
query: {
domain: id || ''
}
})
if (!res.body.error) {
toast({ type: 'success', content: '隐藏域名成功' })
return Promise.resolve()
} else {
toast({
type: 'error',
content: '隐藏域名失败,请重试',
autoHide: false
})
return Promise.reject()
}
break
case 'reports':
console.log('reporting')
res = await client({
method: 'post',
instance: 'local',
endpoint: `reports`,
query: {
account_id: id || ''
}
})
console.log(res.body)
if (!res.body.error) {
toast({ type: 'success', content: '举报账户成功' })
return Promise.resolve()
} else {
toast({
type: 'error',
content: '举报账户失败,请重试',
autoHide: false
})
return Promise.reject()
}
break
}
}
import HeaderDefaultActionsAccount from './HeaderDefault/ActionsAccount'
import HeaderDefaultActionsStatus from './HeaderDefault/ActionsStatus'
import HeaderDefaultActionsDomain from './HeaderDefault/ActionsDomain'
export interface Props {
queryKey: App.QueryKey
accountId: string
domain: string
name: string
emojis?: Mastodon.Emoji[]
account: string
created_at: string
visibility: Mastodon.Status['visibility']
application?: Mastodon.Application
status: Mastodon.Status
}
const HeaderDefault: React.FC<Props> = ({
queryKey,
accountId,
domain,
name,
emojis,
account,
created_at,
visibility,
application
}) => {
const TimelineHeaderDefault: React.FC<Props> = ({ queryKey, status }) => {
const domain = status.uri.split(new RegExp(/\/\/(.*?)\//))[1]
const name = status.account.display_name || status.account.username
const emojis = status.account.emojis
const account = status.account.acct
const { theme } = useTheme()
const navigation = useNavigation()
const localAccountId = useSelector(getLocalAccountId)
const localDomain = useSelector(getLocalUrl)
const [since, setSince] = useState(relativeTime(created_at))
const [modalVisible, setModalVisible] = useState(false)
const queryCache = useQueryCache()
const [mutateAction] = useMutation(fireMutation, {
onMutate: () => {
queryCache.cancelQueries(queryKey)
const oldData = queryCache.getQueryData(queryKey)
return oldData
},
onError: (err, _, oldData) => {
toast({ type: 'error', content: '请重试', autoHide: false })
queryCache.setQueryData(queryKey, oldData)
},
onSettled: () => {
queryCache.invalidateQueries(queryKey)
}
})
const [since, setSince] = useState(relativeTime(status.created_at))
const [modalVisible, setBottomSheetVisible] = useState(false)
// causing full re-render
useEffect(() => {
setTimeout(() => {
setSince(relativeTime(created_at))
setSince(relativeTime(status.created_at))
}, 1000)
}, [since])
const onPressAction = useCallback(() => setModalVisible(true), [])
const onPressAction = useCallback(() => setBottomSheetVisible(true), [])
const onPressApplication = useCallback(() => {
navigation.navigate('Webview', {
uri: application!.website
uri: status.application!.website
})
}, [])
@ -188,13 +83,11 @@ const HeaderDefault: React.FC<Props> = ({
@{account}
</Text>
</View>
{(accountId !== localAccountId || domain !== localDomain) && (
<Pressable
style={styles.action}
onPress={onPressAction}
children={pressableAction}
/>
)}
<Pressable
style={styles.action}
onPress={onPressAction}
children={pressableAction}
/>
</View>
<View style={styles.meta}>
@ -203,7 +96,7 @@ const HeaderDefault: React.FC<Props> = ({
{since}
</Text>
</View>
{visibility === 'private' && (
{status.visibility === 'private' && (
<Feather
name='lock'
size={StyleConstants.Font.Size.S}
@ -211,73 +104,44 @@ const HeaderDefault: React.FC<Props> = ({
style={styles.visibility}
/>
)}
{application && application.name !== 'Web' && (
{status.application && status.application.name !== 'Web' && (
<View>
<Text
onPress={onPressApplication}
style={[styles.application, { color: theme.secondary }]}
>
- {application.name}
- {status.application.name}
</Text>
</View>
)}
</View>
<BottomSheet
visible={modalVisible}
handleDismiss={() => setModalVisible(false)}
handleDismiss={() => setBottomSheetVisible(false)}
>
{accountId !== localAccountId && (
<BottomSheetRow
onPress={() => {
setModalVisible(false)
mutateAction({
id: accountId,
type: 'mute',
stateKey: 'muting'
})
}}
icon='eye-off'
text={`隐藏 @${account} 的嘟嘟`}
{status.account.id !== localAccountId && (
<HeaderDefaultActionsAccount
queryKey={queryKey}
accountId={status.account.id}
account={status.account.acct}
setBottomSheetVisible={setBottomSheetVisible}
/>
)}
{accountId !== localAccountId && (
<BottomSheetRow
onPress={() => {
setModalVisible(false)
mutateAction({
id: accountId,
type: 'block',
stateKey: 'blocking'
})
}}
icon='x-circle'
text={`屏蔽用户 @${account}`}
{status.account.id === localAccountId && (
<HeaderDefaultActionsStatus
queryKey={queryKey}
status={status}
setBottomSheetVisible={setBottomSheetVisible}
/>
)}
{domain !== localDomain && (
<BottomSheetRow
onPress={() => {
setModalVisible(false)
mutateAction({
id: domain,
type: 'domain_blocks'
})
}}
icon='cloud-off'
text={`屏蔽域名 ${domain}`}
/>
)}
{accountId !== localAccountId && (
<BottomSheetRow
onPress={() => {
setModalVisible(false)
mutateAction({
id: accountId,
type: 'reports'
})
}}
icon='alert-triangle'
text={`举报 @${account}`}
<HeaderDefaultActionsDomain
queryKey={queryKey}
domain={domain}
setBottomSheetVisible={setBottomSheetVisible}
/>
)}
</BottomSheet>
@ -325,4 +189,4 @@ const styles = StyleSheet.create({
}
})
export default React.memo(HeaderDefault, () => true)
export default React.memo(TimelineHeaderDefault, () => true)

View File

@ -0,0 +1,130 @@
import React from 'react'
import { useMutation, useQueryCache } from 'react-query'
import client from 'src/api/client'
import { MenuContainer, MenuHeader, MenuRow } from 'src/components/Menu'
import { toast } from 'src/components/toast'
const fireMutation = async ({
type,
id,
stateKey
}: {
type: 'mute' | 'block' | 'reports'
id: string
stateKey?: 'muting' | 'blocking'
}) => {
let res
switch (type) {
case 'mute':
case 'block':
res = await client({
method: 'post',
instance: 'local',
endpoint: `accounts/${id}/${type}`
})
if (res.body[stateKey!] === true) {
toast({ type: 'success', content: '功能成功' })
return Promise.resolve()
} else {
toast({ type: 'error', content: '功能错误', autoHide: false })
return Promise.reject()
}
break
case 'reports':
console.log('reporting')
res = await client({
method: 'post',
instance: 'local',
endpoint: `reports`,
query: {
account_id: id!
}
})
if (!res.body.error) {
toast({ type: 'success', content: '举报账户成功' })
return Promise.resolve()
} else {
toast({
type: 'error',
content: '举报账户失败,请重试',
autoHide: false
})
return Promise.reject()
}
break
}
}
export interface Props {
queryKey: App.QueryKey
accountId: string
account: string
setBottomSheetVisible: React.Dispatch<React.SetStateAction<boolean>>
}
const HeaderDefaultActionsAccount: React.FC<Props> = ({
queryKey,
accountId,
account,
setBottomSheetVisible
}) => {
const queryCache = useQueryCache()
const [mutateAction] = useMutation(fireMutation, {
onMutate: () => {
queryCache.cancelQueries(queryKey)
const oldData = queryCache.getQueryData(queryKey)
return oldData
},
onError: (err, _, oldData) => {
toast({ type: 'error', content: '请重试', autoHide: false })
queryCache.setQueryData(queryKey, oldData)
},
onSettled: () => {
queryCache.invalidateQueries(queryKey)
}
})
return (
<MenuContainer>
<MenuHeader heading='关于账户' />
<MenuRow
onPress={() => {
setBottomSheetVisible(false)
mutateAction({
type: 'mute',
id: accountId,
stateKey: 'muting'
})
}}
iconFront='eye-off'
title={`隐藏 @${account} 的嘟嘟`}
/>
<MenuRow
onPress={() => {
setBottomSheetVisible(false)
mutateAction({
type: 'block',
id: accountId,
stateKey: 'blocking'
})
}}
iconFront='x-circle'
title={`屏蔽用户 @${account}`}
/>
<MenuRow
onPress={() => {
setBottomSheetVisible(false)
mutateAction({
type: 'reports',
id: accountId
})
}}
iconFront='alert-triangle'
title={`举报 @${account}`}
/>
</MenuContainer>
)
}
export default HeaderDefaultActionsAccount

View File

@ -0,0 +1,74 @@
import React from 'react'
import { useMutation, useQueryCache } from 'react-query'
import client from 'src/api/client'
import MenuContainer from 'src/components/Menu/Container'
import MenuHeader from 'src/components/Menu/Header'
import MenuRow from 'src/components/Menu/Row'
import { toast } from 'src/components/toast'
const fireMutation = async ({ domain }: { domain: string }) => {
const res = await client({
method: 'post',
instance: 'local',
endpoint: `domain_blocks`,
query: {
domain: domain!
}
})
if (!res.body.error) {
toast({ type: 'success', content: '隐藏域名成功' })
return Promise.resolve()
} else {
toast({
type: 'error',
content: '隐藏域名失败,请重试',
autoHide: false
})
return Promise.reject()
}
}
export interface Props {
queryKey: App.QueryKey
domain: string
setBottomSheetVisible: React.Dispatch<React.SetStateAction<boolean>>
}
const HeaderDefaultActionsDomain: React.FC<Props> = ({
queryKey,
domain,
setBottomSheetVisible
}) => {
const queryCache = useQueryCache()
const [mutateAction] = useMutation(fireMutation, {
onMutate: () => {
queryCache.cancelQueries(queryKey)
const oldData = queryCache.getQueryData(queryKey)
return oldData
},
onError: (err, _, oldData) => {
toast({ type: 'error', content: '请重试', autoHide: false })
queryCache.setQueryData(queryKey, oldData)
},
onSettled: () => {
queryCache.invalidateQueries(queryKey)
}
})
return (
<MenuContainer>
<MenuHeader heading='关于域名' />
<MenuRow
onPress={() => {
setBottomSheetVisible(false)
mutateAction({ domain })
}}
iconFront='cloud-off'
title={`屏蔽域名 ${domain}`}
/>
</MenuContainer>
)
}
export default HeaderDefaultActionsDomain

View File

@ -0,0 +1,160 @@
import React from 'react'
import { useMutation, useQueryCache } from 'react-query'
import client from 'src/api/client'
import { MenuContainer, MenuHeader, MenuRow } from 'src/components/Menu'
import { toast } from 'src/components/toast'
const fireMutation = async ({
id,
type,
stateKey,
prevState
}: {
id: string
type: 'mute' | 'pin' | 'delete'
stateKey: 'muted' | 'pinned' | 'id'
prevState?: boolean
}) => {
let res
switch (type) {
case 'mute':
case 'pin':
res = await client({
method: 'post',
instance: 'local',
endpoint: `statuses/${id}/${prevState ? 'un' : ''}${type}`
}) // bug in response from Mastodon
if (!res.body[stateKey] === prevState) {
toast({ type: 'success', content: '功能成功' })
return Promise.resolve(res.body)
} else {
toast({ type: 'error', content: '功能错误' })
return Promise.reject()
}
break
case 'delete':
res = await client({
method: 'delete',
instance: 'local',
endpoint: `statuses/${id}`
})
if (res.body[stateKey] === id) {
toast({ type: 'success', content: '删除成功' })
return Promise.resolve(res.body)
} else {
toast({ type: 'error', content: '删除失败' })
return Promise.reject()
}
break
}
}
export interface Props {
queryKey: App.QueryKey
status: Mastodon.Status
setBottomSheetVisible: React.Dispatch<React.SetStateAction<boolean>>
}
const HeaderDefaultActionsStatus: React.FC<Props> = ({
queryKey,
status,
setBottomSheetVisible
}) => {
const queryCache = useQueryCache()
const [mutateAction] = useMutation(fireMutation, {
onMutate: ({ id, type, stateKey, prevState }) => {
queryCache.cancelQueries(queryKey)
const oldData = queryCache.getQueryData(queryKey)
switch (type) {
case 'mute':
case 'pin':
queryCache.setQueryData(queryKey, old =>
(old as {}[]).map((paging: any) => ({
toots: paging.toots.map((toot: any) => {
if (toot.id === id) {
toot[stateKey] =
typeof prevState === 'boolean' ? !prevState : true
}
return toot
}),
pointer: paging.pointer
}))
)
break
case 'delete':
queryCache.setQueryData(queryKey, old =>
(old as {}[]).map((paging: any) => ({
toots: paging.toots.filter((toot: any) => toot.id !== id),
pointer: paging.pointer
}))
)
break
}
return oldData
},
onError: (err, _, oldData) => {
toast({ type: 'error', content: '请重试' })
queryCache.setQueryData(queryKey, oldData)
}
})
return (
<MenuContainer>
<MenuHeader heading='关于嘟嘟' />
<MenuRow
onPress={() => {
setBottomSheetVisible(false)
mutateAction({
type: 'delete',
id: status.id,
stateKey: 'id'
})
}}
iconFront='trash'
title='删除嘟嘟'
/>
<MenuRow
onPress={() => {
console.warn('功能未开发')
}}
iconFront='trash'
title='删除并重发'
/>
<MenuRow
onPress={() => {
setBottomSheetVisible(false)
mutateAction({
type: 'mute',
id: status.id,
stateKey: 'muted',
prevState: status.muted
})
}}
iconFront='volume-x'
title={status.muted ? '取消隐藏对话' : '隐藏对话'}
/>
{/* Also note that reblogs cannot be pinned. */}
{(status.visibility === 'public' || status.visibility === 'unlisted') && (
<MenuRow
onPress={() => {
setBottomSheetVisible(false)
mutateAction({
type: 'pin',
id: status.id,
stateKey: 'pinned',
prevState: status.pinned
})
}}
iconFront='anchor'
title={status.pinned ? '取消置顶' : '置顶'}
/>
)}
</MenuContainer>
)
}
export default HeaderDefaultActionsStatus

View File

@ -1,52 +1,302 @@
import React from 'react'
import { StyleSheet, Text, View } from 'react-native'
import { Feather } from '@expo/vector-icons'
import React, { useMemo, useState } from 'react'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { useMutation, useQueryCache } from 'react-query'
import client from 'src/api/client'
import Button from 'src/components/Button'
import { toast } from 'src/components/toast'
import relativeTime from 'src/utils/relativeTime'
import { StyleConstants } from 'src/utils/styles/constants'
import { useTheme } from 'src/utils/styles/ThemeManager'
import Emojis from './Emojis'
const fireMutation = async ({
id,
options
}: {
id: string
options: { [key: number]: boolean }
}) => {
const formData = new FormData()
Object.keys(options).forEach(option => {
// @ts-ignore
if (options[option]) {
formData.append('choices[]', option)
}
})
console.log(formData)
const res = await client({
method: 'post',
instance: 'local',
endpoint: `polls/${id}/votes`,
body: formData
})
if (res.body.id === id) {
toast({ type: 'success', content: '投票成功成功' })
return Promise.resolve()
} else {
toast({
type: 'error',
content: '隐藏域名失败,请重试',
autoHide: false
})
return Promise.reject()
}
}
export interface Props {
queryKey: App.QueryKey
poll: Mastodon.Poll
}
// When haven't voted, result should not be shown but intead let people vote
const Poll: React.FC<Props> = ({ poll }) => {
const TimelinePoll: React.FC<Props> = ({ queryKey, poll }) => {
console.log('render poll ' + Math.random())
console.log(poll)
const { theme } = useTheme()
const queryCache = useQueryCache()
const [mutateAction] = useMutation(fireMutation, {
onMutate: ({ id, options }) => {
queryCache.cancelQueries(queryKey)
const oldData = queryCache.getQueryData(queryKey)
queryCache.setQueryData(queryKey, old =>
(old as {}[]).map((paging: any) => ({
toots: paging.toots.map((toot: any) => {
if (toot.poll?.id === id) {
const poll = toot.poll
console.log('update votes')
console.log(
Object.keys(options)
.filter(option => options[option])
.map(option => parseInt(option))
)
console.log(toot.poll)
toot.poll = {
...toot.poll,
voters_count: poll.voters_count ? poll.voters_count + 1 : 1,
voted: true,
own_votes: [
Object.keys(options)
// @ts-ignore
.filter(option => options[option])
.map(option => parseInt(option))
]
}
console.log(toot.poll)
}
return toot
}),
pointer: paging.pointer
}))
)
return oldData
},
onError: (err, _, oldData) => {
toast({ type: 'error', content: '请重试' })
queryCache.setQueryData(queryKey, oldData)
}
})
const pollExpiration = useMemo(() => {
// how many voted
if (poll.expired) {
return (
<Text style={[styles.expiration, { color: theme.secondary }]}>
</Text>
)
} else {
return (
<Text style={[styles.expiration, { color: theme.secondary }]}>
{relativeTime(poll.expires_at)}
</Text>
)
}
}, [])
const [singleOptions, setSingleOptions] = useState({
...[false, false, false, false].slice(0, poll.options.length)
})
const [multipleOptions, setMultipleOptions] = useState({
...[false, false, false, false].slice(0, poll.options.length)
})
const isSelected = (index: number) => {
if (poll.multiple) {
return multipleOptions[index] ? 'check-square' : 'square'
} else {
return singleOptions[index] ? 'check-circle' : 'circle'
}
}
return (
<View>
{poll.options.map((option, index) => (
<View key={index}>
<View style={{ flexDirection: 'row' }}>
<Text>
{Math.round((option.votes_count / poll.votes_count) * 100)}%
</Text>
<Emojis
content={option.title}
emojis={poll.emojis}
size={14}
<View style={styles.base}>
{poll.options.map((option, index) =>
poll.voted ? (
<View key={index} style={styles.poll}>
<View style={styles.optionSelected}>
<View style={styles.contentSelected}>
<Emojis
content={option.title}
emojis={poll.emojis}
size={StyleConstants.Font.Size.M}
numberOfLines={1}
/>
{poll.own_votes!.includes(index) && (
<Feather
style={styles.voted}
name={poll.multiple ? 'check-square' : 'check-circle'}
size={StyleConstants.Font.Size.M}
color={theme.primary}
/>
)}
</View>
<Text style={[styles.percentage, { color: theme.primary }]}>
{Math.round((option.votes_count / poll.votes_count) * 100)}%
</Text>
</View>
<View
style={[
styles.background,
{
width: `${Math.round(
(option.votes_count / poll.votes_count) * 100
)}%`,
backgroundColor: theme.border
}
]}
/>
</View>
<View
style={{
width: `${Math.round(
(option.votes_count / poll.votes_count) * 100
)}%`,
height: 5,
backgroundColor: 'blue'
}}
/>
</View>
))}
) : (
<View key={index} style={styles.poll}>
<Pressable
style={[styles.optionUnselected]}
onPress={() => {
if (poll.multiple) {
setMultipleOptions({
...multipleOptions,
[index]: !multipleOptions[index]
})
} else {
setSingleOptions({
...[
index === 0,
index === 1,
index === 2,
index === 3
].slice(0, poll.options.length)
})
}
}}
>
<Feather
style={styles.votedNot}
name={isSelected(index)}
size={StyleConstants.Font.Size.L}
color={theme.primary}
/>
<View style={styles.contentUnselected}>
<Emojis
content={option.title}
emojis={poll.emojis}
size={StyleConstants.Font.Size.M}
/>
</View>
</Pressable>
</View>
)
)}
<View style={styles.meta}>
{!poll.expired && !poll.own_votes?.length && (
<View style={styles.button}>
<Button
onPress={() => {
if (poll.multiple) {
console.log(multipleOptions)
mutateAction({ id: poll.id, options: multipleOptions })
} else {
mutateAction({ id: poll.id, options: singleOptions })
}
}}
text='投票'
/>
</View>
)}
<Text style={[styles.votes, { color: theme.secondary }]}>
{poll.voters_count}{' • '}
</Text>
{pollExpiration}
</View>
</View>
)
}
const styles = StyleSheet.create({
avatar: {
width: 50,
height: 50,
marginRight: 8
base: {
marginTop: StyleConstants.Spacing.M
},
image: {
width: '100%',
height: '100%'
poll: {
minHeight: StyleConstants.Font.LineHeight.M * 1.5,
marginBottom: StyleConstants.Spacing.XS
},
optionSelected: {
flex: 1,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingLeft: StyleConstants.Spacing.M,
paddingRight: StyleConstants.Spacing.M
},
optionUnselected: {
flex: 1,
flexDirection: 'row'
},
contentSelected: {
flexBasis: '80%',
flexDirection: 'row',
alignItems: 'center'
},
contentUnselected: {
flexBasis: '90%'
},
voted: {
marginLeft: StyleConstants.Spacing.S
},
votedNot: {
marginRight: StyleConstants.Spacing.S
},
percentage: {
fontSize: StyleConstants.Font.Size.M
},
background: {
position: 'absolute',
top: 0,
left: 0,
height: '100%',
minWidth: 1,
borderTopRightRadius: 6,
borderBottomRightRadius: 6
},
meta: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
marginTop: StyleConstants.Spacing.XS
},
button: {
marginRight: StyleConstants.Spacing.M
},
votes: {
fontSize: StyleConstants.Font.Size.S
},
expiration: {
fontSize: StyleConstants.Font.Size.S
}
})
export default Poll
export default TimelinePoll

View File

@ -2,7 +2,7 @@ import { useNavigation } from '@react-navigation/native'
import React from 'react'
import { ActivityIndicator, Text } from 'react-native'
import { useQuery } from 'react-query'
import { MenuContainer, MenuItem } from 'src/components/Menu'
import { MenuContainer, MenuRow } from 'src/components/Menu'
import { listsFetch } from 'src/utils/fetches/listsFetch'
@ -20,7 +20,7 @@ const ScreenMeLists: React.FC = () => {
break
case 'success':
lists = data?.map((d: Mastodon.List, i: number) => (
<MenuItem
<MenuRow
key={i}
iconFront='list'
title={d.title}

View File

@ -2,7 +2,7 @@ import { useNavigation } from '@react-navigation/native'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { MenuContainer, MenuItem } from 'src/components/Menu'
import { MenuContainer, MenuRow } from 'src/components/Menu'
const Collections: React.FC = () => {
const { t } = useTranslation('meRoot')
@ -10,22 +10,22 @@ const Collections: React.FC = () => {
return (
<MenuContainer>
<MenuItem
<MenuRow
iconFront='mail'
title={t('content.collections.conversations')}
onPress={() => navigation.navigate('Screen-Me-Conversations')}
/>
<MenuItem
<MenuRow
iconFront='bookmark'
title={t('content.collections.bookmarks')}
onPress={() => navigation.navigate('Screen-Me-Bookmarks')}
/>
<MenuItem
<MenuRow
iconFront='star'
title={t('content.collections.favourites')}
onPress={() => navigation.navigate('Screen-Me-Favourites')}
/>
<MenuItem
<MenuRow
iconFront='list'
title={t('content.collections.lists')}
onPress={() => navigation.navigate('Screen-Me-Lists')}

View File

@ -2,7 +2,7 @@ import { useNavigation } from '@react-navigation/native'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { MenuContainer, MenuItem } from 'src/components/Menu'
import { MenuContainer, MenuRow } from 'src/components/Menu'
const Settings: React.FC = () => {
const { t } = useTranslation('meRoot')
@ -10,7 +10,7 @@ const Settings: React.FC = () => {
return (
<MenuContainer>
<MenuItem
<MenuRow
iconFront='settings'
title={t('content.settings')}
onPress={() => navigation.navigate('Screen-Me-Settings')}

View File

@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'
import { ActionSheetIOS, StyleSheet, Text } from 'react-native'
import { useDispatch, useSelector } from 'react-redux'
import { MenuContainer, MenuItem } from 'src/components/Menu'
import { MenuContainer, MenuRow } from 'src/components/Menu'
import {
changeLanguage,
changeTheme,
@ -23,7 +23,7 @@ const ScreenMeSettings: React.FC = () => {
return (
<>
<MenuContainer marginTop={true}>
<MenuItem
<MenuRow
title={t('content.language.heading')}
content={t(`content.language.options.${settingsLanguage}`)}
iconBack='chevron-right'
@ -52,7 +52,7 @@ const ScreenMeSettings: React.FC = () => {
)
}
/>
<MenuItem
<MenuRow
title={t('content.theme.heading')}
content={t(`content.theme.options.${settingsTheme}`)}
iconBack='chevron-right'
@ -87,10 +87,10 @@ const ScreenMeSettings: React.FC = () => {
/>
</MenuContainer>
<MenuContainer>
<MenuItem
<MenuRow
title={t('content.copyrights.heading')}
iconBack='chevron-right'
></MenuItem>
/>
<Text style={[styles.version, { color: theme.secondary }]}>
{t('content.version', { version: '1.0.0' })}
</Text>

View File

@ -1,11 +1,5 @@
import React, { ReactNode, useEffect, useReducer, useState } from 'react'
import {
Alert,
Keyboard,
KeyboardAvoidingView,
Pressable,
Text
} from 'react-native'
import { Alert, Keyboard, KeyboardAvoidingView } from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { createNativeStackNavigator } from 'react-native-screens/native-stack'
import { useNavigation } from '@react-navigation/native'
@ -14,6 +8,7 @@ import { store } from 'src/store'
import PostMain from './Compose/PostMain'
import client from 'src/api/client'
import { getLocalAccountPreferences } from 'src/utils/slices/instancesSlice'
import { HeaderLeft, HeaderRight } from 'src/components/Header'
const Stack = createNativeStackNavigator()
@ -271,7 +266,7 @@ const Compose: React.FC = () => {
name='PostMain'
options={{
headerLeft: () => (
<Pressable
<HeaderLeft
onPress={() =>
Alert.alert('确认取消编辑?', '', [
{ text: '继续编辑', style: 'cancel' },
@ -282,19 +277,20 @@ const Compose: React.FC = () => {
}
])
}
>
<Text>退</Text>
</Pressable>
text='退出编辑'
/>
),
headerCenter: () => <></>,
headerRight: () => (
<Pressable onPress={async () => tootPost()}>
<Text></Text>
</Pressable>
<HeaderRight
onPress={async () => tootPost()}
text='发嘟嘟'
disabled={postState.text.raw.length < 1}
/>
)
}}
>
{props => (
{() => (
<PostMain postState={postState} postDispatch={postDispatch} />
)}
</Stack.Screen>

View File

@ -5,9 +5,9 @@ import { ActionSheetIOS } from 'react-native'
import { createNativeStackNavigator } from 'react-native-screens/native-stack'
import { WebView } from 'react-native-webview'
import BottomSheet from 'src/components/BottomSheet'
import BottomSheetRow from 'src/components/BottomSheet/Row'
import { HeaderLeft, HeaderRight } from 'src/components/Header'
import { MenuContainer, MenuRow } from 'src/components/Menu'
const Stack = createNativeStackNavigator()
@ -63,35 +63,37 @@ const ScreenSharedWebview: React.FC<Props> = ({
visible={bottomSheet}
handleDismiss={() => showBottomSheet(false)}
>
<BottomSheetRow
onPress={() => {
ActionSheetIOS.showShareActionSheetWithOptions(
{
url: uri,
excludedActivityTypes: [
'com.apple.UIKit.activity.Mail',
'com.apple.UIKit.activity.Print',
'com.apple.UIKit.activity.SaveToCameraRoll',
'com.apple.UIKit.activity.OpenInIBooks'
]
},
() => {},
() => {
showBottomSheet(false)
}
)
}}
icon='share'
text={'分享链接'}
/>
<BottomSheetRow
onPress={() => {
showBottomSheet(false)
webview.current?.reload()
}}
icon='refresh-cw'
text='刷新'
/>
<MenuContainer>
<MenuRow
onPress={() => {
ActionSheetIOS.showShareActionSheetWithOptions(
{
url: uri,
excludedActivityTypes: [
'com.apple.UIKit.activity.Mail',
'com.apple.UIKit.activity.Print',
'com.apple.UIKit.activity.SaveToCameraRoll',
'com.apple.UIKit.activity.OpenInIBooks'
]
},
() => {},
() => {
showBottomSheet(false)
}
)
}}
iconFront='share'
title={'分享链接'}
/>
<MenuRow
onPress={() => {
showBottomSheet(false)
webview.current?.reload()
}}
iconFront='refresh-cw'
title='刷新'
/>
</MenuContainer>
</BottomSheet>
</>
)}

View File

@ -2,11 +2,13 @@ import { DefaultTheme, DarkTheme } from '@react-navigation/native'
export type ColorDefinitions =
| 'primary'
| 'primaryOverlay'
| 'secondary'
| 'disabled'
| 'background'
| 'backgroundGradientStart'
| 'backgroundGradientEnd'
| 'backgroundOverlay'
| 'link'
| 'border'
| 'separator'
@ -24,6 +26,10 @@ const themeColors: {
light: 'rgb(0, 0, 0)',
dark: 'rgb(255, 255, 255)'
},
primaryOverlay: {
light: 'rgb(255, 255, 255)',
dark: 'rgb(0, 0, 0)'
},
secondary: {
light: 'rgb(153, 153, 153)',
dark: 'rgb(117, 117, 117)'
@ -44,6 +50,10 @@ const themeColors: {
light: 'rgba(255, 255, 255, 1)',
dark: 'rgba(0, 0, 0, 1)'
},
backgroundOverlay: {
light: 'rgba(0, 0, 0, 0.5)',
dark: 'rgb(255, 255, 255, 0.5)'
},
link: {
light: 'rgb(0, 122, 255)',
dark: 'rgb(10, 132, 255)'

View File

@ -2701,6 +2701,11 @@ expo-av@~8.6.0:
lodash "^4.17.15"
nullthrows "^1.1.0"
expo-blur@~8.2.0:
version "8.2.0"
resolved "https://registry.yarnpkg.com/expo-blur/-/expo-blur-8.2.0.tgz#4f586b5019c70f93c9914b2ff6ecadda4a382d51"
integrity sha512-LXx8tyVMk1pE4Ug9fHNTIsZnUPewmWXyFhiEwUkeP5SKlg92CgWGbTiGkdhFiU5X0vDxgFKsVVilLcNQxWYSfQ==
expo-constants@^9.2.0, expo-constants@~9.2.0:
version "9.2.0"
resolved "https://registry.yarnpkg.com/expo-constants/-/expo-constants-9.2.0.tgz#e86a38793deaff9018878afac65bce2543c80a4c"