Draft basic done

#6
This commit is contained in:
Zhiyuan Zheng 2021-02-07 00:39:11 +01:00
parent 700b9ad433
commit 03dc94c7c9
No known key found for this signature in database
GPG Key ID: 078A93AB607D85E0
32 changed files with 684 additions and 170 deletions

View File

@ -60,6 +60,7 @@
"react-native-screens": "~2.17.1",
"react-native-shared-element": "^0.7.0",
"react-native-svg": "12.1.0",
"react-native-swipe-list-view": "^3.2.6",
"react-native-tab-view": "^2.15.2",
"react-native-tab-view-viewpager-adapter": "^1.1.0",
"react-native-toast-message": "^1.4.3",

8
src/@types/app.d.ts vendored
View File

@ -14,4 +14,12 @@ declare namespace App {
| 'Conversations'
| 'Bookmarks'
| 'Favourites'
interface IImageInfo {
url: string
width?: number
height?: number
originUrl?: string
props?: any
}
}

View File

@ -1,11 +1,3 @@
interface IImageInfo {
url: string
width?: number
height?: number
originUrl?: string
props?: any
}
declare namespace Nav {
type RootStackParamList = {
'Screen-Tabs': undefined
@ -56,7 +48,7 @@ declare namespace Nav {
}
| undefined
'Screen-ImagesViewer': {
imageUrls: (IImageInfo & {
imageUrls: (App.IImageInfo & {
preview_url: Mastodon.AttachmentImage['preview_url']
remote_url?: Mastodon.AttachmentImage['remote_url']
imageIndex: number
@ -68,6 +60,7 @@ declare namespace Nav {
type ScreenComposeStackParamList = {
'Screen-Compose-Root': undefined
'Screen-Compose-EditAttachment': { index: number }
'Screen-Compose-DraftsList': { timestamp: number }
}
type ScreenTabsStackParamList = {

View File

@ -12,7 +12,7 @@ import ScreenImagesViewer from '@screens/ImagesViewer'
import ScreenTabs from '@screens/Tabs'
import {
getLocalActiveIndex,
localUpdateAccountPreferences
updateLocalAccountPreferences
} from '@utils/slices/instancesSlice'
import { useTheme } from '@utils/styles/ThemeManager'
import { themes } from '@utils/styles/themes'
@ -103,7 +103,7 @@ const Index: React.FC<Props> = ({ localCorrupt }) => {
// Lazily update users's preferences, for e.g. composing default visibility
useEffect(() => {
if (localActiveIndex !== null) {
dispatch(localUpdateAccountPreferences())
dispatch(updateLocalAccountPreferences())
}
}, [])

View File

@ -6,7 +6,7 @@ import TimeAgo from 'react-timeago'
import buildFormatter from 'react-timeago/lib/formatters/buildFormatter'
export interface Props {
date: string
date: string | number
}
const RelativeTime: React.FC<Props> = ({ date }) => {

View File

@ -1,8 +1,7 @@
import ComponentSeparator from '@components/Separator'
import { useNavigation, useScrollToTop } from '@react-navigation/native'
import { QueryKeyTimeline, useTimelineQuery } from '@utils/queryHooks/timeline'
import { getPublicRemoteNotice } from '@utils/slices/contextsSlice'
import { localUpdateNotification } from '@utils/slices/instancesSlice'
import { updateLocalNotification } from '@utils/slices/instancesSlice'
import { StyleConstants } from '@utils/styles/constants'
import { findIndex } from 'lodash'
import React, { useCallback, useEffect, useMemo, useRef } from 'react'
@ -13,7 +12,7 @@ import {
StyleSheet
} from 'react-native'
import { FlatList } from 'react-native-gesture-handler'
import { useDispatch, useSelector } from 'react-redux'
import { useDispatch } from 'react-redux'
import TimelineConversation from './Timeline/Conversation'
import TimelineDefault from './Timeline/Default'
import TimelineEmpty from './Timeline/Empty'
@ -85,7 +84,7 @@ const Timeline: React.FC<Props> = ({
if (props.target && props.target.includes('Tab-Notifications-Root')) {
if (flattenData.length) {
dispatch(
localUpdateNotification({
updateLocalNotification({
latestTime: (flattenData[0] as Mastodon.Notification).created_at
})
)
@ -197,8 +196,6 @@ const Timeline: React.FC<Props> = ({
)
}, [])
const publicRemoteNotice = useSelector(getPublicRemoteNotice).hidden
useScrollToTop(flRef)
return (

View File

@ -33,7 +33,7 @@ const TimelineAttachment: React.FC<Props> = ({ status }) => {
haptics('Light')
}, [])
let imageUrls: (IImageInfo & {
let imageUrls: (App.IImageInfo & {
preview_url: Mastodon.AttachmentImage['preview_url']
remote_url?: Mastodon.AttachmentImage['remote_url']
imageIndex: number

View File

@ -34,12 +34,7 @@ const TimelineHeaderDefault: React.FC<Props> = ({ queryKey, status }) => {
{queryKey ? (
<Pressable
style={{
flex: 1,
flexDirection: 'row',
justifyContent: 'center',
paddingBottom: StyleConstants.Spacing.S
}}
style={styles.action}
onPress={() =>
navigation.navigate('Screen-Actions', {
queryKey,
@ -77,6 +72,12 @@ const styles = StyleSheet.create({
},
created_at: {
...StyleConstants.FontStyle.S
},
action: {
flex: 1,
flexDirection: 'row',
justifyContent: 'center',
paddingBottom: StyleConstants.Spacing.S
}
})

View File

@ -5,18 +5,21 @@ import React from 'react'
import { StyleSheet, Text } from 'react-native'
export interface Props {
created_at: Mastodon.Status['created_at']
created_at: Mastodon.Status['created_at'] | number
}
const HeaderSharedCreated: React.FC<Props> = ({ created_at }) => {
const { theme } = useTheme()
const HeaderSharedCreated = React.memo(
({ created_at }: Props) => {
const { theme } = useTheme()
return (
<Text style={[styles.created_at, { color: theme.secondary }]}>
<RelativeTime date={created_at} />
</Text>
)
}
return (
<Text style={[styles.created_at, { color: theme.secondary }]}>
<RelativeTime date={created_at} />
</Text>
)
},
() => true
)
const styles = StyleSheet.create({
created_at: {
@ -24,4 +27,4 @@ const styles = StyleSheet.create({
}
})
export default React.memo(HeaderSharedCreated, () => true)
export default HeaderSharedCreated

View File

@ -5,8 +5,9 @@ export default {
alert: {
title: 'Cancel editing?',
buttons: {
exit: 'Confirm',
continue: 'Continue'
save: 'Save draft',
delete: 'Delete draft',
cancel: 'Cancel'
}
}
},
@ -114,19 +115,16 @@ export default {
cancel: '$t(common:buttons.cancel)'
}
}
}
},
drafts: 'Draft ({{count}})',
drafts_plural: 'Drafts ({{count}})'
},
editAttachment: {
header: {
left: 'Cancel',
title: 'Edit attachment',
right: {
button: 'Apply',
succeed: {
title: 'Apply update succeed',
button: 'Ok'
},
failed: {
title: 'Apply update failed',
title: 'Editing failed',
button: 'Try again'
}
}
@ -139,6 +137,14 @@ export default {
},
imageFocus: 'Drag the focus circle to update focus point'
}
},
draftsList: {
header: {
title: 'Draft'
},
content: {
textEmpty: 'Content empty'
}
}
}
}

View File

@ -5,8 +5,9 @@ export default {
alert: {
title: '确认退出编辑?',
buttons: {
exit: '退出编辑',
continue: '继续编辑'
save: '保存草稿',
delete: '删除草稿',
cancel: '继续编辑'
}
}
},
@ -114,19 +115,15 @@ export default {
cancel: '$t(common:buttons.cancel)'
}
}
}
},
drafts: '草稿 ({{count}})'
},
editAttachment: {
header: {
left: '取消修改',
title: '编辑附件',
right: {
button: '应用修改',
succeed: {
title: '修改成功',
button: '好的'
},
failed: {
title: '修改失败',
title: '编辑失败',
button: '返回重试'
}
}
@ -139,6 +136,14 @@ export default {
},
imageFocus: '在预览图上拖动圆圈,以选择缩略图的焦点'
}
},
draftsList: {
header: {
title: '草稿'
},
content: {
textEmpty: '无正文内容'
}
}
}
}

View File

@ -2,32 +2,40 @@ import analytics from '@components/analytics'
import { HeaderLeft, HeaderRight } from '@components/Header'
import { StackScreenProps } from '@react-navigation/stack'
import haptics from '@root/components/haptics'
import { store } from '@root/store'
import formatText from '@screens/Compose/formatText'
import ComposeRoot from '@screens/Compose/Root'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { updateStoreReview } from '@utils/slices/contextsSlice'
import {
getLocalAccount,
getLocalMaxTootChar
getLocalMaxTootChar,
removeLocalDraft,
updateLocalDraft
} from '@utils/slices/instancesSlice'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useCallback, useEffect, useReducer, useState } from 'react'
import { filter } from 'lodash'
import React, {
useCallback,
useEffect,
useMemo,
useReducer,
useState
} from 'react'
import { useTranslation } from 'react-i18next'
import {
Alert,
Keyboard,
KeyboardAvoidingView,
Platform,
StyleSheet,
Text
StyleSheet
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { createNativeStackNavigator } from 'react-native-screens/native-stack'
import { useQueryClient } from 'react-query'
import { useDispatch, useSelector } from 'react-redux'
import * as Sentry from 'sentry-expo'
import ComposeDraftsList from './Compose/DraftsList'
import ComposeEditAttachment from './Compose/EditAttachment'
import ComposeContext from './Compose/utils/createContext'
import composeInitialState from './Compose/utils/initialState'
@ -55,7 +63,6 @@ const ScreenCompose: React.FC<ScreenComposeProp> = ({
Keyboard.addListener('keyboardWillShow', _keyboardDidShow)
Keyboard.addListener('keyboardWillHide', _keyboardDidHide)
// cleanup function
return () => {
Keyboard.removeListener('keyboardWillShow', _keyboardDidShow)
Keyboard.removeListener('keyboardWillHide', _keyboardDidHide)
@ -68,21 +75,53 @@ const ScreenCompose: React.FC<ScreenComposeProp> = ({
setHasKeyboard(false)
}
const localAccount = getLocalAccount(store.getState())
// const draft = useSelector(getLocalDraft, () => true)
const initialReducerState = useMemo(() => {
if (params) {
return composeParseState(params)
} else {
return {
...composeInitialState,
timestamp: Date.now(),
visibility:
localAccount?.preferences &&
localAccount.preferences['posting:default:visibility']
? localAccount.preferences['posting:default:visibility']
: 'public'
}
}
}, [])
const localAccount = useSelector(getLocalAccount)
const [composeState, composeDispatch] = useReducer(
composeReducer,
params
? composeParseState(params)
: {
...composeInitialState,
visibility:
localAccount?.preferences &&
localAccount.preferences['posting:default:visibility']
? localAccount.preferences['posting:default:visibility']
: 'public'
}
initialReducerState
)
const maxTootChars = useSelector(getLocalMaxTootChar)
const totalTextCount =
(composeState.spoiler.active ? composeState.spoiler.count : 0) +
composeState.text.count
// If compose state is dirty, then disallow add back drafts
useEffect(() => {
composeDispatch({
type: 'dirty',
payload:
totalTextCount !== 0 ||
composeState.attachments.uploads.length !== 0 ||
(composeState.poll.active === true &&
filter(composeState.poll.options, o => {
return o !== undefined && o.length > 0
}).length > 0)
})
}, [
totalTextCount,
composeState.attachments.uploads.length,
composeState.poll.active,
composeState.poll.options
])
useEffect(() => {
switch (params?.type) {
case 'edit':
@ -113,10 +152,31 @@ const ScreenCompose: React.FC<ScreenComposeProp> = ({
}
}, [params?.type])
const maxTootChars = useSelector(getLocalMaxTootChar)
const totalTextCount =
(composeState.spoiler.active ? composeState.spoiler.count : 0) +
composeState.text.count
const saveDraft = () => {
dispatch(
updateLocalDraft({
timestamp: composeState.timestamp,
spoiler: composeState.spoiler.raw,
text: composeState.text.raw,
poll: composeState.poll,
attachments: composeState.attachments,
visibility: composeState.visibility,
visibilityLock: composeState.visibilityLock,
replyToStatus: composeState.replyToStatus
})
)
}
const removeDraft = useCallback(() => {
dispatch(removeLocalDraft(composeState.timestamp))
}, [composeState.timestamp])
useEffect(() => {
const autoSave = composeState.dirty
? setInterval(() => {
saveDraft()
}, 2000)
: removeDraft()
return () => autoSave && clearInterval(autoSave)
}, [composeState])
const headerLeft = useCallback(
() => (
@ -125,11 +185,7 @@ const ScreenCompose: React.FC<ScreenComposeProp> = ({
content={t('heading.left.button')}
onPress={() => {
analytics('compose_header_back_press')
if (
totalTextCount === 0 &&
composeState.attachments.uploads.length === 0 &&
composeState.poll.active === false
) {
if (!composeState.dirty) {
analytics('compose_header_back_empty')
navigation.goBack()
return
@ -137,15 +193,24 @@ const ScreenCompose: React.FC<ScreenComposeProp> = ({
analytics('compose_header_back_state_occupied')
Alert.alert(t('heading.left.alert.title'), undefined, [
{
text: t('heading.left.alert.buttons.exit'),
text: t('heading.left.alert.buttons.delete'),
style: 'destructive',
onPress: () => {
analytics('compose_header_back_occupied_confirm')
analytics('compose_header_back_occupied_save')
removeDraft()
navigation.goBack()
}
},
{
text: t('heading.left.alert.buttons.continue'),
text: t('heading.left.alert.buttons.save'),
onPress: () => {
analytics('compose_header_back_occupied_delete')
saveDraft()
navigation.goBack()
}
},
{
text: t('heading.left.alert.buttons.cancel'),
style: 'cancel',
onPress: () => {
analytics('compose_header_back_occupied_cancel')
@ -156,22 +221,7 @@ const ScreenCompose: React.FC<ScreenComposeProp> = ({
}}
/>
),
[totalTextCount, composeState]
)
const headerCenter = useCallback(
() => (
<Text
style={[
styles.count,
{
color: totalTextCount > maxTootChars ? theme.red : theme.secondary
}
]}
>
{totalTextCount} / {maxTootChars}
</Text>
),
[totalTextCount, maxTootChars]
[composeState]
)
const dispatch = useDispatch()
const headerRight = useCallback(
@ -228,7 +278,7 @@ const ScreenCompose: React.FC<ScreenComposeProp> = ({
}
/>
),
[totalTextCount, maxTootChars, composeState]
[totalTextCount, composeState]
)
return (
@ -249,7 +299,26 @@ const ScreenCompose: React.FC<ScreenComposeProp> = ({
<Stack.Screen
name='Screen-Compose-Root'
component={ComposeRoot}
options={{ headerLeft, headerCenter, headerRight }}
options={{
headerLeft,
headerTitle: `${totalTextCount} / ${maxTootChars}${__DEV__ &&
` Dirty: ${composeState.dirty.toString()}`}`,
headerTitleStyle: {
fontWeight:
totalTextCount > maxTootChars
? StyleConstants.Font.Weight.Bold
: StyleConstants.Font.Weight.Normal,
fontSize: StyleConstants.Font.Size.M
},
headerTintColor:
totalTextCount > maxTootChars ? theme.red : theme.secondary,
headerRight
}}
/>
<Stack.Screen
name='Screen-Compose-DraftsList'
component={ComposeDraftsList}
options={{ stackPresentation: 'modal' }}
/>
<Stack.Screen
name='Screen-Compose-EditAttachment'
@ -264,11 +333,7 @@ const ScreenCompose: React.FC<ScreenComposeProp> = ({
}
const styles = StyleSheet.create({
base: { flex: 1 },
count: {
textAlign: 'center',
...StyleConstants.FontStyle.M
}
base: { flex: 1 }
})
export default ScreenCompose

View File

@ -0,0 +1,59 @@
import { HeaderCenter, HeaderLeft } from '@components/Header'
import { StackScreenProps } from '@react-navigation/stack'
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { Platform } from 'react-native'
import { createNativeStackNavigator } from 'react-native-screens/native-stack'
import ComposeDraftsListRoot from './DraftsList/Root'
const Stack = createNativeStackNavigator()
export type ScreenComposeEditAttachmentProp = StackScreenProps<
Nav.ScreenComposeStackParamList,
'Screen-Compose-DraftsList'
>
const ComposeDraftsList: React.FC<ScreenComposeEditAttachmentProp> = ({
route: {
params: { timestamp }
},
navigation
}) => {
const { t } = useTranslation('sharedCompose')
const children = useCallback(
() => <ComposeDraftsListRoot timestamp={timestamp} />,
[]
)
const headerLeft = useCallback(
() => (
<HeaderLeft
type='icon'
content='ChevronDown'
onPress={() => navigation.goBack()}
/>
),
[]
)
return (
<Stack.Navigator screenOptions={{ headerTopInsetEnabled: false }}>
<Stack.Screen
name='Screen-Compose-EditAttachment-Root'
children={children}
options={{
headerLeft,
headerTitle: t('content.draftsList.header.title'),
...(Platform.OS === 'android' && {
headerCenter: () => (
<HeaderCenter content={t('content.draftsList.header.title')} />
)
}),
headerHideShadow: true
}}
/>
</Stack.Navigator>
)
}
export default ComposeDraftsList

View File

@ -0,0 +1,238 @@
import client from '@api/client'
import Icon from '@components/Icon'
import ComponentSeparator from '@components/Separator'
import HeaderSharedCreated from '@components/Timelines/Timeline/Shared/HeaderShared/Created'
import { useNavigation } from '@react-navigation/native'
import { getLocalDrafts, removeLocalDraft } from '@utils/slices/instancesSlice'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useCallback, useContext, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
Dimensions,
Modal,
Pressable,
StyleSheet,
Text,
View
} from 'react-native'
import FastImage from 'react-native-fast-image'
import { SwipeListView } from 'react-native-swipe-list-view'
import { useDispatch, useSelector } from 'react-redux'
import formatText from '../formatText'
import ComposeContext from '../utils/createContext'
import { ComposeStateDraft, ExtendedAttachment } from '../utils/types'
export interface Props {
timestamp: number
}
const ComposeDraftsListRoot: React.FC<Props> = ({ timestamp }) => {
const { composeDispatch } = useContext(ComposeContext)
const { t } = useTranslation('sharedCompose')
const navigation = useNavigation()
const dispatch = useDispatch()
const { mode, theme } = useTheme()
const localDrafts = useSelector(getLocalDrafts)?.filter(
draft => draft.timestamp !== timestamp
)
const actionWidth =
StyleConstants.Font.Size.L + StyleConstants.Spacing.Global.PagePadding * 4
const [checkingAttachments, setCheckingAttachments] = useState(false)
const removeDraft = useCallback(ts => {
dispatch(removeLocalDraft(ts))
}, [])
const renderItem = useCallback(
({ item }: { item: ComposeStateDraft }) => {
return (
<Pressable
style={[styles.draft, { backgroundColor: theme.background }]}
onPress={async () => {
setCheckingAttachments(true)
let tempDraft = item
let tempUploads: ExtendedAttachment[] = []
if (item.attachments && item.attachments.uploads.length) {
for (const attachment of item.attachments.uploads) {
await client<Mastodon.Attachment>({
method: 'get',
instance: 'local',
url: `media/${attachment.remote?.id}`
})
.then(res => {
if (res.id === attachment.remote?.id) {
tempUploads.push(attachment)
}
})
.catch(() => {})
}
tempDraft = {
...tempDraft,
attachments: { ...item.attachments, uploads: tempUploads }
}
}
tempDraft.spoiler?.length &&
formatText({
textInput: 'text',
composeDispatch,
content: tempDraft.spoiler
})
tempDraft.text?.length &&
formatText({
textInput: 'text',
composeDispatch,
content: tempDraft.text
})
composeDispatch({
type: 'loadDraft',
payload: tempDraft
})
dispatch(removeLocalDraft(item.timestamp))
navigation.goBack()
}}
>
<View style={{ flex: 1 }}>
<HeaderSharedCreated created_at={item.timestamp} />
<Text
numberOfLines={2}
style={[styles.text, { color: theme.primary }]}
>
{item.text ||
item.spoiler ||
t('content.draftsList.content.textEmpty')}
</Text>
{item.attachments?.uploads.length ? (
<View style={styles.attachments}>
{item.attachments.uploads.map((attachment, index) => (
<FastImage
key={index}
style={[
styles.attachment,
{ marginLeft: index !== 0 ? StyleConstants.Spacing.S : 0 }
]}
source={{
uri:
attachment.local?.local_thumbnail ||
attachment.remote?.preview_url
}}
/>
))}
</View>
) : null}
</View>
</Pressable>
)
},
[mode]
)
const renderHiddenItem = useCallback(
({ item }) => (
<View
style={[styles.hiddenBase, { backgroundColor: theme.red }]}
children={
<Pressable
style={styles.action}
onPress={() => removeDraft(item.timestamp)}
children={
<Icon
name='Trash'
size={StyleConstants.Font.Size.L}
color={theme.primaryOverlay}
/>
}
/>
}
/>
),
[mode]
)
return (
<>
<SwipeListView
data={localDrafts}
renderItem={renderItem}
renderHiddenItem={renderHiddenItem}
disableRightSwipe={true}
rightOpenValue={-actionWidth}
// previewRowKey={
// localDrafts?.length ? localDrafts[0].timestamp.toString() : undefined
// }
// previewDuration={350}
// previewOpenValue={-actionWidth / 2}
ItemSeparatorComponent={ComponentSeparator}
keyExtractor={item => item.timestamp.toString()}
/>
<Modal
transparent
animationType='fade'
visible={checkingAttachments}
children={
<View
style={[styles.modal, { backgroundColor: theme.backgroundOverlay }]}
children={
<Text
children='检查附件在服务器的状态…'
style={{
...StyleConstants.FontStyle.M,
color: theme.primaryOverlay
}}
/>
}
/>
}
/>
</>
)
}
const styles = StyleSheet.create({
draft: {
flex: 1,
padding: StyleConstants.Spacing.Global.PagePadding
},
text: {
marginTop: StyleConstants.Spacing.XS,
...StyleConstants.FontStyle.M
},
attachments: {
flex: 1,
flexDirection: 'row',
marginTop: StyleConstants.Spacing.S
},
attachment: {
width:
(Dimensions.get('screen').width -
StyleConstants.Spacing.Global.PagePadding * 2 -
StyleConstants.Spacing.S * 3) /
4,
height:
(Dimensions.get('screen').width -
StyleConstants.Spacing.Global.PagePadding * 2 -
StyleConstants.Spacing.S * 3) /
4
},
hiddenBase: {
flex: 1,
flexDirection: 'row',
justifyContent: 'flex-end'
},
action: {
flexBasis:
StyleConstants.Font.Size.L +
StyleConstants.Spacing.Global.PagePadding * 4,
justifyContent: 'center',
alignItems: 'center'
},
modal: {
flex: 1,
justifyContent: 'center',
alignItems: 'center'
}
})
export default ComposeDraftsListRoot

View File

@ -1,7 +1,7 @@
import client from '@api/client'
import analytics from '@components/analytics'
import haptics from '@components/haptics'
import { HeaderLeft, HeaderRight } from '@components/Header'
import { HeaderCenter, HeaderLeft, HeaderRight } from '@components/Header'
import { StackScreenProps } from '@react-navigation/stack'
import React, {
useCallback,
@ -71,8 +71,8 @@ const ComposeEditAttachment: React.FC<ScreenComposeEditAttachmentProp> = ({
const headerLeft = useCallback(
() => (
<HeaderLeft
type='text'
content={t('content.editAttachment.header.left')}
type='icon'
content='ChevronDown'
onPress={() => navigation.goBack()}
/>
),
@ -81,8 +81,8 @@ const ComposeEditAttachment: React.FC<ScreenComposeEditAttachmentProp> = ({
const headerRight = useCallback(
() => (
<HeaderRight
type='text'
content={t('content.editAttachment.header.right.button')}
type='icon'
content='Save'
loading={isSubmitting}
onPress={() => {
analytics('editattachment_confirm_press')
@ -107,20 +107,7 @@ const ComposeEditAttachment: React.FC<ScreenComposeEditAttachmentProp> = ({
})
.then(() => {
haptics('Success')
Alert.alert(
t('content.editAttachment.header.right.succeed.title'),
undefined,
[
{
text: t(
'content.editAttachment.header.right.succeed.button'
),
onPress: () => {
navigation.goBack()
}
}
]
)
navigation.goBack()
})
.catch(() => {
setIsSubmitting(false)
@ -167,7 +154,18 @@ const ComposeEditAttachment: React.FC<ScreenComposeEditAttachmentProp> = ({
<Stack.Screen
name='Screen-Compose-EditAttachment-Root'
children={children}
options={{ headerLeft, headerRight, headerCenter: () => null }}
options={{
headerLeft,
headerRight,
headerTitle: t('content.editAttachment.header.title'),
...(Platform.OS === 'android' && {
headerCenter: () => (
<HeaderCenter
content={t('content.editAttachment.header.title')}
/>
)
})
}}
/>
</Stack.Navigator>
</SafeAreaView>

View File

@ -13,6 +13,7 @@ import ComposeRootFooter from './Root/Footer'
import ComposeRootHeader from './Root/Header'
import ComposeRootSuggestion from './Root/Suggestion'
import ComposeContext from './utils/createContext'
import ComposeDrafts from './Root/Drafts'
const ComposeRoot: React.FC = () => {
const { theme } = useTheme()
@ -68,9 +69,8 @@ const ComposeRoot: React.FC = () => {
}, [isFetching])
const listItem = useCallback(
({ item, index }) => (
({ item }) => (
<ComposeRootSuggestion
key={index}
item={item}
composeState={composeState}
composeDispatch={composeDispatch}
@ -90,8 +90,10 @@ const ComposeRoot: React.FC = () => {
ItemSeparatorComponent={ComponentSeparator}
// @ts-ignore
data={data ? data[composeState.tag?.type] : undefined}
keyExtractor={() => Math.random().toString()}
/>
<ComposeActions />
<ComposeDrafts />
<ComposePosting />
</View>
)

View File

@ -0,0 +1,56 @@
import Button from '@components/Button'
import { useNavigation } from '@react-navigation/native'
import { getLocalDrafts } from '@utils/slices/instancesSlice'
import { StyleConstants } from '@utils/styles/constants'
import layoutAnimation from '@utils/styles/layoutAnimation'
import React, { useContext, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet, View } from 'react-native'
import { useSelector } from 'react-redux'
import ComposeContext from '../utils/createContext'
const ComposeDrafts: React.FC = () => {
const { t } = useTranslation('sharedCompose')
const navigation = useNavigation()
const { composeState } = useContext(ComposeContext)
const localDrafts = useSelector(getLocalDrafts)?.filter(
draft => draft.timestamp !== composeState.timestamp
)
useEffect(() => {
layoutAnimation()
}, [composeState.dirty])
if (!composeState.dirty && localDrafts?.length) {
return (
<View
style={styles.base}
children={
<Button
type='text'
content={t('content.root.drafts', {
count: localDrafts.length
})}
onPress={() =>
navigation.navigate('Screen-Compose-DraftsList', {
timestamp: composeState.timestamp
})
}
/>
}
/>
)
} else {
return null
}
}
const styles = StyleSheet.create({
base: {
position: 'absolute',
bottom: 45 + StyleConstants.Spacing.Global.PagePadding,
right: StyleConstants.Spacing.Global.PagePadding
}
})
export default ComposeDrafts

View File

@ -1,9 +1,9 @@
import React, { useContext } from 'react'
import ComposeAttachments from '@screens/Compose/Root/Footer/Attachments'
import ComposeEmojis from '@screens/Compose/Root/Footer/Emojis'
import ComposePoll from '@screens/Compose/Root/Footer/Poll'
import ComposeReply from '@screens/Compose/Root/Footer/Reply'
import ComposeContext from '@screens/Compose/utils/createContext'
import React, { useContext } from 'react'
const ComposeRootFooter: React.FC = () => {
const { composeState } = useContext(ComposeContext)

View File

@ -250,7 +250,9 @@ const ComposeAttachments: React.FC = () => {
keyboardShouldPersistTaps='handled'
showsHorizontalScrollIndicator={false}
data={composeState.attachments.uploads}
keyExtractor={item => item.local?.uri || item.remote?.url}
keyExtractor={item =>
item.local?.url || item.remote?.url || Math.random().toString()
}
ListFooterComponent={
composeState.attachments.uploads.length < 4 ? listFooter : null
}

View File

@ -1,7 +1,8 @@
import { createRef } from 'react'
import { ComposeState } from './types'
const composeInitialState: ComposeState = {
const composeInitialState: Omit<ComposeState, 'timestamp'> = {
dirty: false,
posting: false,
spoiler: {
active: false,

View File

@ -4,12 +4,14 @@ import composeInitialState from './initialState'
import { ComposeState } from './types'
const composeParseState = (
params: NonNullable<Nav.SharedStackParamList['Screen-Compose']>
params: NonNullable<Nav.RootStackParamList['Screen-Compose']>
): ComposeState => {
switch (params.type) {
case 'edit':
return {
...composeInitialState,
dirty: true,
timestamp: Date.now(),
...(params.incomingStatus.spoiler_text && {
spoiler: { ...composeInitialState.spoiler, active: true }
}),
@ -49,6 +51,8 @@ const composeParseState = (
const actualStatus = params.incomingStatus.reblog || params.incomingStatus
return {
...composeInitialState,
dirty: true,
timestamp: Date.now(),
visibility: actualStatus.visibility,
visibilityLock: actualStatus.visibility === 'direct',
replyToStatus: actualStatus
@ -56,6 +60,8 @@ const composeParseState = (
case 'conversation':
return {
...composeInitialState,
dirty: true,
timestamp: Date.now(),
visibility: 'direct',
visibilityLock: true
}

View File

@ -1,15 +1,14 @@
import client from '@root/api/client'
import { ComposeState } from '@screens/Compose/utils/types'
import { SharedComposeProp } from '@screens/Tabs/Shared/sharedScreens'
import * as Crypto from 'expo-crypto'
const composePost = async (
params: SharedComposeProp['route']['params'],
params: Nav.RootStackParamList['Screen-Compose'],
composeState: ComposeState
) => {
const formData = new FormData()
if (params?.type === 'reply') {
if (composeState.replyToStatus) {
formData.append('in_reply_to_id', composeState.replyToStatus!.id)
}

View File

@ -5,6 +5,29 @@ const composeReducer = (
action: ComposeAction
): ComposeState => {
switch (action.type) {
case 'loadDraft':
const draft = action.payload
console.log(draft.text)
return {
...state,
...(draft.spoiler?.length && {
spoiler: {
...state.spoiler,
active: true,
raw: draft.spoiler
}
}),
...(draft.text?.length && {
text: { ...state.text, raw: draft.text }
}),
...(draft.poll && { poll: draft.poll }),
...(draft.attachments && { attachments: draft.attachments }),
visibility: draft.visibility,
visibilityLock: draft.visibilityLock,
replyToStatus: draft.replyToStatus
}
case 'dirty':
return { ...state, dirty: action.payload }
case 'posting':
return { ...state, posting: action.payload }
case 'spoiler':

View File

@ -1,10 +1,23 @@
export type ExtendedAttachment = {
remote?: Mastodon.Attachment
local?: ImageInfo & { local_thumbnail?: string; hash?: string }
local?: App.IImageInfo & { local_thumbnail?: string; hash?: string }
uploading?: boolean
}
export type ComposeStateDraft = {
timestamp: number
spoiler?: string
text?: string
poll?: ComposeState['poll']
attachments?: ComposeState['attachments']
visibility: ComposeState['visibility']
visibilityLock: ComposeState['visibilityLock']
replyToStatus?: ComposeState['replyToStatus']
}
export type ComposeState = {
dirty: boolean
timestamp: number
posting: boolean
spoiler: {
active: boolean
@ -55,6 +68,14 @@ export type ComposeState = {
}
export type ComposeAction =
| {
type: 'loadDraft'
payload: ComposeStateDraft
}
| {
type: 'dirty'
payload: ComposeState['dirty']
}
| {
type: 'posting'
payload: ComposeState['posting']

View File

@ -7,7 +7,7 @@ import {
getLocalActiveIndex,
getLocalInstances,
InstanceLocal,
localUpdateActiveIndex
updateLocalActiveIndex
} from '@utils/slices/instancesSlice'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
@ -46,7 +46,7 @@ const AccountButton: React.FC<Props> = ({ instance, disabled = false }) => {
haptics('Light')
analytics('switch_existing_press')
queryClient.clear()
dispatch(localUpdateActiveIndex(instance))
dispatch(updateLocalActiveIndex(instance))
navigation.goBack()
}}
/>

View File

@ -3,7 +3,7 @@ import NetInfo from '@react-native-community/netinfo'
import { store } from '@root/store'
import {
localRemoveInstance,
localUpdateAccount
updateLocalAccount
} from '@utils/slices/instancesSlice'
import log from './log'
@ -35,7 +35,7 @@ const netInfo = async (): Promise<{
return Promise.resolve({ connected: true, corruputed: '' })
} else {
store.dispatch(
localUpdateAccount({
updateLocalAccount({
acct: res.acct,
avatarStatic: res.avatar_static
})

View File

@ -1,9 +0,0 @@
import { preventScreenCaptureAsync } from 'expo-screen-capture'
import log from './log'
const preventScreenshot = () => {
log('log', 'Screenshot', 'preventing')
preventScreenCaptureAsync()
}
export default preventScreenshot

View File

@ -28,6 +28,7 @@ const instancesMigration = {
...state.local,
instances: state.local.instances.map(instance => {
instance.max_toot_chars = 500
instance.drafts = []
return instance
})
}

View File

@ -2,8 +2,10 @@ import client from '@api/client'
import analytics from '@components/analytics'
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'
import { RootState } from '@root/store'
import { ComposeStateDraft } from '@screens/Compose/utils/types'
import * as AuthSession from 'expo-auth-session'
import * as Localization from 'expo-localization'
import { findIndex } from 'lodash'
export type InstanceLocal = {
appData: {
@ -23,6 +25,7 @@ export type InstanceLocal = {
notification: {
latestTime?: Mastodon.Notification['created_at']
}
drafts: ComposeStateDraft[]
}
export type InstancesState = {
@ -36,8 +39,8 @@ export type InstancesState = {
}
}
export const localUpdateAccountPreferences = createAsyncThunk(
'instances/localUpdateAccountPreferences',
export const updateLocalAccountPreferences = createAsyncThunk(
'instances/updateLocalAccountPreferences',
async (): Promise<Mastodon.Preferences> => {
const preferences = await client<Mastodon.Preferences>({
method: 'get',
@ -119,7 +122,8 @@ export const localAddInstance = createAsyncThunk(
},
notification: {
latestTime: undefined
}
},
drafts: []
}
})
}
@ -179,7 +183,7 @@ const instancesSlice = createSlice({
name: 'instances',
initialState: instancesInitialState,
reducers: {
localUpdateActiveIndex: (state, action: PayloadAction<InstanceLocal>) => {
updateLocalActiveIndex: (state, action: PayloadAction<InstanceLocal>) => {
state.local.activeIndex = state.local.instances.findIndex(
instance =>
instance.url === action.payload.url &&
@ -187,7 +191,7 @@ const instancesSlice = createSlice({
instance.account.id === action.payload.account.id
)
},
localUpdateAccount: (
updateLocalAccount: (
state,
action: PayloadAction<
Pick<InstanceLocal['account'], 'acct' & 'avatarStatic'>
@ -200,18 +204,42 @@ const instancesSlice = createSlice({
}
}
},
localUpdateNotification: (
updateLocalNotification: (
state,
action: PayloadAction<Partial<InstanceLocal['notification']>>
) => {
state.local.instances[state.local.activeIndex!].notification =
action.payload
if (state.local.activeIndex !== null) {
state.local.instances[state.local.activeIndex].notification =
action.payload
}
},
remoteUpdate: (
updateLocalDraft: (state, action: PayloadAction<ComposeStateDraft>) => {
if (state.local.activeIndex !== null) {
const draftIndex = findIndex(
state.local.instances[state.local.activeIndex].drafts,
['timestamp', action.payload.timestamp]
)
if (draftIndex === -1) {
state.local.instances[state.local.activeIndex].drafts.unshift(
action.payload
)
} else {
state.local.instances[state.local.activeIndex].drafts[draftIndex] =
action.payload
}
}
},
removeLocalDraft: (
state,
action: PayloadAction<InstancesState['remote']['url']>
action: PayloadAction<ComposeStateDraft['timestamp']>
) => {
state.remote.url = action.payload
if (state.local.activeIndex !== null) {
state.local.instances[
state.local.activeIndex
].drafts = state.local.instances[
state.local.activeIndex
].drafts?.filter(draft => draft.timestamp !== action.payload)
}
}
},
extraReducers: builder => {
@ -255,11 +283,11 @@ const instancesSlice = createSlice({
console.error(action.error)
})
.addCase(localUpdateAccountPreferences.fulfilled, (state, action) => {
.addCase(updateLocalAccountPreferences.fulfilled, (state, action) => {
state.local.instances[state.local.activeIndex!].account.preferences =
action.payload
})
.addCase(localUpdateAccountPreferences.rejected, (_, action) => {
.addCase(updateLocalAccountPreferences.rejected, (_, action) => {
console.error(action.error)
})
}
@ -289,13 +317,18 @@ export const getLocalNotification = ({ instances: { local } }: RootState) =>
local.activeIndex !== null
? local.instances[local.activeIndex].notification
: undefined
export const getLocalDrafts = ({ instances: { local } }: RootState) =>
local.activeIndex !== null
? local.instances[local.activeIndex].drafts
: undefined
export const getRemoteUrl = ({ instances: { remote } }: RootState) => remote.url
export const {
localUpdateActiveIndex,
localUpdateAccount,
localUpdateNotification,
remoteUpdate
updateLocalActiveIndex,
updateLocalAccount,
updateLocalNotification,
updateLocalDraft,
removeLocalDraft
} = instancesSlice.actions
export default instancesSlice.reducer

View File

@ -4,7 +4,7 @@ export const StyleConstants = {
Font: {
Size: { S: 14, M: 16, L: 18 },
LineHeight: { S: 20, M: 22, L: 30 },
Weight: { Bold: '600' as '600' }
Weight: { Normal: '400' as '400', Bold: '600' as '600' }
},
FontStyle: {
S: { fontSize: 14, lineHeight: 20 },

View File

@ -27,7 +27,7 @@ const themeColors: {
},
primaryOverlay: {
light: 'rgb(250, 250, 250)',
dark: 'rgb(180, 180, 180)'
dark: 'rgb(200, 200, 200)'
},
secondary: {
light: 'rgb(135, 135, 135)',
@ -43,7 +43,7 @@ const themeColors: {
},
red: {
light: 'rgb(225, 45, 35)',
dark: 'rgb(225, 98, 89)'
dark: 'rgb(225, 78, 79)'
},
background: {

View File

@ -8651,6 +8651,11 @@ react-native-svg@12.1.0:
css-select "^2.1.0"
css-tree "^1.0.0-alpha.39"
react-native-swipe-list-view@^3.2.6:
version "3.2.6"
resolved "https://registry.yarnpkg.com/react-native-swipe-list-view/-/react-native-swipe-list-view-3.2.6.tgz#dde8f65dcdc24f50fafc05da9e90b139b1c3b185"
integrity sha512-FIrLzYjdxpMmD1hinP6bLSVS2eR2bhFdFuaqOJ+6/EuWDqZMBQcL4FLbkIwulMOc39JGdGZAxGExJYs7VKiQeQ==
react-native-tab-view-viewpager-adapter@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/react-native-tab-view-viewpager-adapter/-/react-native-tab-view-viewpager-adapter-1.1.0.tgz#d6e085ed1c91a13e714d87395d428f8afc2b3377"