mirror of
https://github.com/tooot-app/app
synced 2025-06-05 22:19:13 +02:00
Spoiler is done
This commit is contained in:
@ -21,7 +21,11 @@ const TimelineContent: React.FC<Props> = ({ status, numberOfLines }) => {
|
||||
{status.spoiler_text ? (
|
||||
<>
|
||||
<Text style={{ fontSize: StyleConstants.Font.Size.M }}>
|
||||
{status.spoiler_text}{' '}
|
||||
<ParseContent
|
||||
content={status.spoiler_text}
|
||||
size={StyleConstants.Font.Size.M}
|
||||
emojis={status.emojis}
|
||||
/>{' '}
|
||||
<Text
|
||||
onPress={() => setSpoilerCollapsed(!spoilerCollapsed)}
|
||||
style={{
|
||||
|
@ -119,7 +119,7 @@ const HeaderDefaultActionsAccount: React.FC<Props> = ({
|
||||
id: accountId
|
||||
})
|
||||
}}
|
||||
iconFront='alert-triangle'
|
||||
iconFront='flag'
|
||||
title={`举报 @${account}`}
|
||||
/>
|
||||
</MenuContainer>
|
||||
|
@ -1,24 +1,41 @@
|
||||
import React, { ReactNode, useEffect, useReducer, useState } from 'react'
|
||||
import { Alert, Keyboard, KeyboardAvoidingView } from 'react-native'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Keyboard,
|
||||
KeyboardAvoidingView,
|
||||
StyleSheet,
|
||||
Text
|
||||
} 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'
|
||||
import sha256 from 'crypto-js/sha256'
|
||||
|
||||
import { store } from 'src/store'
|
||||
import ComposeRoot from './Compose/Root'
|
||||
import client from 'src/api/client'
|
||||
import { getLocalAccountPreferences } from 'src/utils/slices/instancesSlice'
|
||||
import { HeaderLeft, HeaderRight } from 'src/components/Header'
|
||||
import { StyleConstants } from 'src/utils/styles/constants'
|
||||
import { useTheme } from 'src/utils/styles/ThemeManager'
|
||||
|
||||
const Stack = createNativeStackNavigator()
|
||||
|
||||
export type PostState = {
|
||||
spoiler: {
|
||||
active: boolean
|
||||
count: number
|
||||
raw: string
|
||||
formatted: ReactNode
|
||||
selection: { start: number; end: number }
|
||||
}
|
||||
text: {
|
||||
count: number
|
||||
raw: string
|
||||
formatted: ReactNode
|
||||
selection: { start: number; end: number }
|
||||
}
|
||||
selection: { start: number; end: number }
|
||||
tag:
|
||||
| {
|
||||
type: 'url' | 'accounts' | 'hashtags'
|
||||
@ -57,12 +74,12 @@ export type PostState = {
|
||||
|
||||
export type PostAction =
|
||||
| {
|
||||
type: 'text'
|
||||
payload: Partial<PostState['text']>
|
||||
type: 'spoiler'
|
||||
payload: Partial<PostState['spoiler']>
|
||||
}
|
||||
| {
|
||||
type: 'selection'
|
||||
payload: PostState['selection']
|
||||
type: 'text'
|
||||
payload: Partial<PostState['text']>
|
||||
}
|
||||
| {
|
||||
type: 'tag'
|
||||
@ -94,22 +111,29 @@ export type PostAction =
|
||||
}
|
||||
|
||||
const postInitialState: PostState = {
|
||||
text: {
|
||||
count: 500,
|
||||
spoiler: {
|
||||
active: false,
|
||||
count: 0,
|
||||
raw: '',
|
||||
formatted: undefined
|
||||
formatted: undefined,
|
||||
selection: { start: 0, end: 0 }
|
||||
},
|
||||
text: {
|
||||
count: 0,
|
||||
raw: '',
|
||||
formatted: undefined,
|
||||
selection: { start: 0, end: 0 }
|
||||
},
|
||||
selection: { start: 0, end: 0 },
|
||||
tag: undefined,
|
||||
emoji: { active: false, emojis: undefined },
|
||||
poll: {
|
||||
active: false,
|
||||
total: 2,
|
||||
options: {
|
||||
'0': undefined,
|
||||
'1': undefined,
|
||||
'2': undefined,
|
||||
'3': undefined,
|
||||
'4': undefined
|
||||
'3': undefined
|
||||
},
|
||||
multiple: false,
|
||||
expire: '86400'
|
||||
@ -123,10 +147,10 @@ const postInitialState: PostState = {
|
||||
}
|
||||
const postReducer = (state: PostState, action: PostAction): PostState => {
|
||||
switch (action.type) {
|
||||
case 'spoiler':
|
||||
return { ...state, spoiler: { ...state.spoiler, ...action.payload } }
|
||||
case 'text':
|
||||
return { ...state, text: { ...state.text, ...action.payload } }
|
||||
case 'selection':
|
||||
return { ...state, selection: action.payload }
|
||||
case 'tag':
|
||||
return { ...state, tag: action.payload }
|
||||
case 'emoji':
|
||||
@ -152,8 +176,11 @@ const postReducer = (state: PostState, action: PostAction): PostState => {
|
||||
}
|
||||
|
||||
const Compose: React.FC = () => {
|
||||
const { theme } = useTheme()
|
||||
const navigation = useNavigation()
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const [hasKeyboard, setHasKeyboard] = useState(false)
|
||||
useEffect(() => {
|
||||
Keyboard.addListener('keyboardWillShow', _keyboardDidShow)
|
||||
@ -175,6 +202,7 @@ const Compose: React.FC = () => {
|
||||
const [postState, postDispatch] = useReducer(postReducer, postInitialState)
|
||||
|
||||
const tootPost = async () => {
|
||||
setIsSubmitting(true)
|
||||
if (postState.text.count < 0) {
|
||||
Alert.alert('字数超限', '', [
|
||||
{
|
||||
@ -184,6 +212,10 @@ const Compose: React.FC = () => {
|
||||
} else {
|
||||
const formData = new FormData()
|
||||
|
||||
if (postState.spoiler.active) {
|
||||
formData.append('spoiler_text', postState.spoiler.raw)
|
||||
}
|
||||
|
||||
formData.append('status', postState.text.raw)
|
||||
|
||||
if (postState.poll.active) {
|
||||
@ -207,13 +239,25 @@ const Compose: React.FC = () => {
|
||||
instance: 'local',
|
||||
url: 'statuses',
|
||||
headers: {
|
||||
'Idempotency-Key': Date.now().toString() + Math.random().toString()
|
||||
'Idempotency-Key': sha256(
|
||||
postState.spoiler.raw +
|
||||
postState.text.raw +
|
||||
postState.poll.options['0'] +
|
||||
postState.poll.options['1'] +
|
||||
postState.poll.options['2'] +
|
||||
postState.poll.options['3'] +
|
||||
postState.poll.multiple +
|
||||
postState.poll.expire +
|
||||
postState.attachments.map(attachment => attachment.id) +
|
||||
postState.visibility
|
||||
).toString()
|
||||
},
|
||||
body: formData
|
||||
})
|
||||
.then(
|
||||
res => {
|
||||
if (res.body.id) {
|
||||
setIsSubmitting(false)
|
||||
Alert.alert('发布成功', '', [
|
||||
{
|
||||
text: '好的',
|
||||
@ -224,6 +268,7 @@ const Compose: React.FC = () => {
|
||||
}
|
||||
])
|
||||
} else {
|
||||
setIsSubmitting(false)
|
||||
Alert.alert('发布失败', '', [
|
||||
{
|
||||
text: '返回重试'
|
||||
@ -232,6 +277,7 @@ const Compose: React.FC = () => {
|
||||
}
|
||||
},
|
||||
error => {
|
||||
setIsSubmitting(false)
|
||||
Alert.alert('发布失败', error.body, [
|
||||
{
|
||||
text: '返回重试'
|
||||
@ -240,6 +286,7 @@ const Compose: React.FC = () => {
|
||||
}
|
||||
)
|
||||
.catch(() => {
|
||||
setIsSubmitting(false)
|
||||
Alert.alert('发布失败', '', [
|
||||
{
|
||||
text: '返回重试'
|
||||
@ -249,6 +296,10 @@ const Compose: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const totalTextCount =
|
||||
(postState.spoiler.active ? postState.spoiler.count : 0) +
|
||||
postState.text.count
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView behavior='padding' style={{ flex: 1 }}>
|
||||
<SafeAreaView
|
||||
@ -274,16 +325,31 @@ const Compose: React.FC = () => {
|
||||
text='退出编辑'
|
||||
/>
|
||||
),
|
||||
headerCenter: () => <></>,
|
||||
headerRight: () => (
|
||||
<HeaderRight
|
||||
onPress={async () => tootPost()}
|
||||
text='发嘟嘟'
|
||||
disabled={
|
||||
postState.text.raw.length < 1 || postState.text.count < 0
|
||||
}
|
||||
/>
|
||||
)
|
||||
headerCenter: () => (
|
||||
<Text
|
||||
style={[
|
||||
styles.count,
|
||||
{
|
||||
color:
|
||||
totalTextCount > 500 ? theme.error : theme.secondary
|
||||
}
|
||||
]}
|
||||
>
|
||||
{totalTextCount} / 500
|
||||
</Text>
|
||||
),
|
||||
headerRight: () =>
|
||||
isSubmitting ? (
|
||||
<ActivityIndicator />
|
||||
) : (
|
||||
<HeaderRight
|
||||
onPress={async () => tootPost()}
|
||||
text='发嘟嘟'
|
||||
disabled={
|
||||
postState.text.raw.length < 1 || totalTextCount > 500
|
||||
}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
>
|
||||
{() => (
|
||||
@ -296,4 +362,11 @@ const Compose: React.FC = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
count: {
|
||||
textAlign: 'center',
|
||||
fontSize: StyleConstants.Font.Size.M
|
||||
}
|
||||
})
|
||||
|
||||
export default Compose
|
||||
|
@ -169,20 +169,23 @@ const ComposeActions: React.FC<Props> = ({
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Feather
|
||||
name='alert-triangle'
|
||||
size={24}
|
||||
color={postState.spoiler.active ? theme.primary : theme.secondary}
|
||||
onPress={() =>
|
||||
postDispatch({
|
||||
type: 'spoiler',
|
||||
payload: { active: !postState.spoiler.active }
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Feather
|
||||
name='smile'
|
||||
size={24}
|
||||
color={emojiColor}
|
||||
onPress={emojiOnPress}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.count,
|
||||
{ color: postState.text.count < 0 ? theme.error : theme.secondary }
|
||||
]}
|
||||
>
|
||||
{postState.text.count}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
@ -48,12 +48,14 @@ const ComposeEmojis: React.FC<Props> = ({
|
||||
key={emoji.shortcode}
|
||||
onPress={() => {
|
||||
updateText({
|
||||
origin: textInputRef.current?.isFocused()
|
||||
? 'text'
|
||||
: 'spoiler',
|
||||
postState,
|
||||
postDispatch,
|
||||
newText: `:${emoji.shortcode}:`,
|
||||
type: 'emoji'
|
||||
})
|
||||
textInputRef.current?.focus()
|
||||
postDispatch({
|
||||
type: 'emoji',
|
||||
payload: { ...postState.emoji, active: false }
|
||||
|
@ -22,6 +22,7 @@ import ComposeActions from './Actions'
|
||||
import ComposeAttachments from './Attachments'
|
||||
import ComposeEmojis from './Emojis'
|
||||
import ComposePoll from './Poll'
|
||||
import ComposeSpoilerInput from './SpoilerInput'
|
||||
import ComposeTextInput from './TextInput'
|
||||
import updateText from './updateText'
|
||||
import * as Permissions from 'expo-permissions'
|
||||
@ -94,11 +95,19 @@ const ComposeRoot: React.FC<Props> = ({ postState, postDispatch }) => {
|
||||
<FlatList
|
||||
keyboardShouldPersistTaps='handled'
|
||||
ListHeaderComponent={
|
||||
<ComposeTextInput
|
||||
postState={postState}
|
||||
postDispatch={postDispatch}
|
||||
textInputRef={textInputRef}
|
||||
/>
|
||||
<>
|
||||
{postState.spoiler.active ? (
|
||||
<ComposeSpoilerInput
|
||||
postState={postState}
|
||||
postDispatch={postDispatch}
|
||||
/>
|
||||
) : null}
|
||||
<ComposeTextInput
|
||||
postState={postState}
|
||||
postDispatch={postDispatch}
|
||||
textInputRef={textInputRef}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
ListFooterComponent={
|
||||
<>
|
||||
@ -138,19 +147,26 @@ const ComposeRoot: React.FC<Props> = ({ postState, postDispatch }) => {
|
||||
<Pressable
|
||||
key={index}
|
||||
onPress={() => {
|
||||
const focusedInput = textInputRef.current?.isFocused()
|
||||
? 'text'
|
||||
: 'spoiler'
|
||||
updateText({
|
||||
origin: focusedInput,
|
||||
postState: {
|
||||
...postState,
|
||||
selection: {
|
||||
start: postState.tag!.offset,
|
||||
end: postState.tag!.offset + postState.tag!.text.length + 1
|
||||
[focusedInput]: {
|
||||
...postState[focusedInput],
|
||||
selection: {
|
||||
start: postState.tag!.offset,
|
||||
end:
|
||||
postState.tag!.offset + postState.tag!.text.length + 1
|
||||
}
|
||||
}
|
||||
},
|
||||
postDispatch,
|
||||
newText: item.acct ? `@${item.acct}` : `#${item.name}`,
|
||||
type: 'suggestion'
|
||||
})
|
||||
textInputRef.current?.focus()
|
||||
}}
|
||||
style={styles.suggestion}
|
||||
>
|
||||
|
77
src/screens/Shared/Compose/SpoilerInput.tsx
Normal file
77
src/screens/Shared/Compose/SpoilerInput.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
import React, { Dispatch, RefObject } from 'react'
|
||||
import { StyleSheet, Text, TextInput } from 'react-native'
|
||||
import { StyleConstants } from 'src/utils/styles/constants'
|
||||
import { useTheme } from 'src/utils/styles/ThemeManager'
|
||||
import { PostAction, PostState } from '../Compose'
|
||||
import formatText from './formatText'
|
||||
|
||||
export interface Props {
|
||||
postState: PostState
|
||||
postDispatch: Dispatch<PostAction>
|
||||
// textInputRef: RefObject<TextInput>
|
||||
}
|
||||
|
||||
const ComposeSpoilerInput: React.FC<Props> = ({
|
||||
postState,
|
||||
postDispatch,
|
||||
// textInputRef
|
||||
}) => {
|
||||
const { theme } = useTheme()
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
style={[
|
||||
styles.spoilerInput,
|
||||
{
|
||||
color: theme.primary,
|
||||
borderBottomColor: theme.border
|
||||
}
|
||||
]}
|
||||
autoCapitalize='none'
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
enablesReturnKeyAutomatically
|
||||
multiline
|
||||
placeholder='折叠部分的警告信息'
|
||||
placeholderTextColor={theme.secondary}
|
||||
onChangeText={content =>
|
||||
formatText({
|
||||
origin: 'spoiler',
|
||||
postDispatch,
|
||||
content
|
||||
})
|
||||
}
|
||||
onSelectionChange={({
|
||||
nativeEvent: {
|
||||
selection: { start, end }
|
||||
}
|
||||
}) => {
|
||||
postDispatch({
|
||||
type: 'spoiler',
|
||||
payload: { selection: { start, end } }
|
||||
})
|
||||
}}
|
||||
// ref={textInputRef}
|
||||
scrollEnabled
|
||||
>
|
||||
<Text>{postState.spoiler.formatted}</Text>
|
||||
</TextInput>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
spoilerInput: {
|
||||
fontSize: StyleConstants.Font.Size.M,
|
||||
marginTop: StyleConstants.Spacing.S,
|
||||
paddingBottom: StyleConstants.Spacing.M,
|
||||
marginLeft: StyleConstants.Spacing.Global.PagePadding,
|
||||
marginRight: StyleConstants.Spacing.Global.PagePadding,
|
||||
borderBottomWidth: 0.5
|
||||
}
|
||||
})
|
||||
|
||||
export default React.memo(
|
||||
ComposeSpoilerInput,
|
||||
(prev, next) =>
|
||||
prev.postState.spoiler.formatted === next.postState.spoiler.formatted
|
||||
)
|
@ -36,6 +36,7 @@ const ComposeTextInput: React.FC<Props> = ({
|
||||
placeholderTextColor={theme.secondary}
|
||||
onChangeText={content =>
|
||||
formatText({
|
||||
origin: 'text',
|
||||
postDispatch,
|
||||
content
|
||||
})
|
||||
@ -45,7 +46,7 @@ const ComposeTextInput: React.FC<Props> = ({
|
||||
selection: { start, end }
|
||||
}
|
||||
}) => {
|
||||
postDispatch({ type: 'selection', payload: { start, end } })
|
||||
postDispatch({ type: 'text', payload: { selection: { start, end } } })
|
||||
}}
|
||||
ref={textInputRef}
|
||||
scrollEnabled
|
||||
@ -61,7 +62,7 @@ const styles = StyleSheet.create({
|
||||
marginTop: StyleConstants.Spacing.S,
|
||||
paddingBottom: StyleConstants.Spacing.M,
|
||||
marginLeft: StyleConstants.Spacing.Global.PagePadding,
|
||||
marginRight: StyleConstants.Spacing.Global.PagePadding,
|
||||
marginRight: StyleConstants.Spacing.Global.PagePadding
|
||||
// borderBottomWidth: 0.5
|
||||
}
|
||||
})
|
||||
|
@ -7,6 +7,7 @@ import { useTheme } from 'src/utils/styles/ThemeManager'
|
||||
import { PostAction, PostState } from '../Compose'
|
||||
|
||||
export interface Params {
|
||||
origin: 'text' | 'spoiler'
|
||||
postDispatch: Dispatch<PostAction>
|
||||
content: string
|
||||
refetch?: (options?: RefetchOptions | undefined) => Promise<any>
|
||||
@ -36,6 +37,7 @@ const debouncedSuggestions = debounce(
|
||||
let prevTags: PostState['tag'][] = []
|
||||
|
||||
const formatText = ({
|
||||
origin,
|
||||
postDispatch,
|
||||
content,
|
||||
disableDebounce = false
|
||||
@ -70,7 +72,6 @@ const formatText = ({
|
||||
})
|
||||
|
||||
const changedTag = differenceWith(tags, prevTags, isEqual)
|
||||
// quick delete causes flicking of suggestion box
|
||||
if (changedTag.length && !disableDebounce) {
|
||||
if (changedTag[0]!.type !== 'url') {
|
||||
debouncedSuggestions(postDispatch, changedTag[0])
|
||||
@ -107,9 +108,9 @@ const formatText = ({
|
||||
contentLength = contentLength + _content.length
|
||||
|
||||
postDispatch({
|
||||
type: 'text',
|
||||
type: origin,
|
||||
payload: {
|
||||
count: 500 - contentLength,
|
||||
count: contentLength,
|
||||
raw: content,
|
||||
formatted: createElement(Text, null, children)
|
||||
}
|
||||
|
@ -3,19 +3,26 @@ import { PostAction, PostState } from '../Compose'
|
||||
import formatText from './formatText'
|
||||
|
||||
const updateText = ({
|
||||
origin,
|
||||
postState,
|
||||
postDispatch,
|
||||
newText,
|
||||
type
|
||||
}: {
|
||||
origin: 'text' | 'spoiler'
|
||||
postState: PostState
|
||||
postDispatch: Dispatch<PostAction>
|
||||
newText: string
|
||||
type: 'emoji' | 'suggestion'
|
||||
}) => {
|
||||
if (postState.text.raw.length) {
|
||||
const contentFront = postState.text.raw.slice(0, postState.selection.start)
|
||||
const contentRear = postState.text.raw.slice(postState.selection.end)
|
||||
if (postState[origin].raw.length) {
|
||||
const contentFront = postState[origin].raw.slice(
|
||||
0,
|
||||
postState[origin].selection.start
|
||||
)
|
||||
const contentRear = postState[origin].raw.slice(
|
||||
postState[origin].selection.end
|
||||
)
|
||||
|
||||
const whiteSpaceFront = /\s/g.test(contentFront.slice(-1))
|
||||
const whiteSpaceRear = /\s/g.test(contentRear.slice(-1))
|
||||
@ -25,12 +32,14 @@ const updateText = ({
|
||||
}${newText}${whiteSpaceRear ? '' : ' '}`
|
||||
|
||||
formatText({
|
||||
origin,
|
||||
postDispatch,
|
||||
content: [contentFront, newTextWithSpace, contentRear].join(''),
|
||||
disableDebounce: true
|
||||
})
|
||||
} else {
|
||||
formatText({
|
||||
origin,
|
||||
postDispatch,
|
||||
content: `${newText} `,
|
||||
disableDebounce: true
|
||||
|
Reference in New Issue
Block a user