[🐴] Better retry styling (#4032)

* Pass whole object to MessageItem for clarity

* Add retry to pending-message

* Style send failure, retry

* Group pending messages

* Remove todos

* Fix types with fake message
zio/stable
Eric Bailey 2024-05-15 11:45:18 -05:00 committed by GitHub
parent ed8922281a
commit 04aea93192
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 121 additions and 87 deletions

View File

@ -1,40 +1,44 @@
import React, {useCallback, useMemo, useRef} from 'react' import React, {useCallback, useMemo, useRef} from 'react'
import {LayoutAnimation, StyleProp, TextStyle, View} from 'react-native' import {
GestureResponderEvent,
LayoutAnimation,
StyleProp,
TextStyle,
View,
} from 'react-native'
import {ChatBskyConvoDefs, RichText as RichTextAPI} from '@atproto/api' import {ChatBskyConvoDefs, RichText as RichTextAPI} from '@atproto/api'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {ConvoItem} from '#/state/messages/convo/types'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {TimeElapsed} from 'view/com/util/TimeElapsed' import {TimeElapsed} from 'view/com/util/TimeElapsed'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {ActionsWrapper} from '#/components/dms/ActionsWrapper' import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
import {InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {RichText} from '../RichText' import {RichText} from '../RichText'
let MessageItem = ({ let MessageItem = ({
item, item,
next,
pending,
}: { }: {
item: ChatBskyConvoDefs.MessageView item: ConvoItem & {type: 'message' | 'pending-message'}
next:
| ChatBskyConvoDefs.MessageView
| ChatBskyConvoDefs.DeletedMessageView
| null
pending?: boolean
}): React.ReactNode => { }): React.ReactNode => {
const t = useTheme() const t = useTheme()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const isFromSelf = item.sender?.did === currentAccount?.did const {message, nextMessage} = item
const isPending = item.type === 'pending-message'
const isFromSelf = message.sender?.did === currentAccount?.did
const isNextFromSelf = const isNextFromSelf =
ChatBskyConvoDefs.isMessageView(next) && ChatBskyConvoDefs.isMessageView(nextMessage) &&
next.sender?.did === currentAccount?.did nextMessage.sender?.did === currentAccount?.did
const isLastInGroup = useMemo(() => { const isLastInGroup = useMemo(() => {
// TODO this means it's a placeholder. Let's figure out the right way to do this though! // if this message is pending, it means the next message is pending too
if (item.id.length > 13) { if (isPending && nextMessage) {
return false return false
} }
@ -44,9 +48,9 @@ let MessageItem = ({
} }
// or, if there's a 3 minute gap between this message and the next // or, if there's a 3 minute gap between this message and the next
if (ChatBskyConvoDefs.isMessageView(next)) { if (ChatBskyConvoDefs.isMessageView(nextMessage)) {
const thisDate = new Date(item.sentAt) const thisDate = new Date(message.sentAt)
const nextDate = new Date(next.sentAt) const nextDate = new Date(nextMessage.sentAt)
const diff = nextDate.getTime() - thisDate.getTime() const diff = nextDate.getTime() - thisDate.getTime()
@ -55,7 +59,7 @@ let MessageItem = ({
} }
return true return true
}, [item, next, isFromSelf, isNextFromSelf]) }, [message, nextMessage, isFromSelf, isNextFromSelf, isPending])
const lastInGroupRef = useRef(isLastInGroup) const lastInGroupRef = useRef(isLastInGroup)
if (lastInGroupRef.current !== isLastInGroup) { if (lastInGroupRef.current !== isLastInGroup) {
@ -67,12 +71,12 @@ let MessageItem = ({
t.name === 'light' ? t.palette.primary_200 : t.palette.primary_800 t.name === 'light' ? t.palette.primary_200 : t.palette.primary_800
const rt = useMemo(() => { const rt = useMemo(() => {
return new RichTextAPI({text: item.text, facets: item.facets}) return new RichTextAPI({text: message.text, facets: message.facets})
}, [item.text, item.facets]) }, [message.text, message.facets])
return ( return (
<View> <View>
<ActionsWrapper isFromSelf={isFromSelf} message={item}> <ActionsWrapper isFromSelf={isFromSelf} message={message}>
<View <View
style={[ style={[
a.py_sm, a.py_sm,
@ -82,7 +86,7 @@ let MessageItem = ({
paddingLeft: 14, paddingLeft: 14,
paddingRight: 14, paddingRight: 14,
backgroundColor: isFromSelf backgroundColor: isFromSelf
? pending ? isPending
? pendingColor ? pendingColor
: t.palette.primary_500 : t.palette.primary_500
: t.palette.contrast_50, : t.palette.contrast_50,
@ -98,18 +102,20 @@ let MessageItem = ({
a.text_md, a.text_md,
a.leading_snug, a.leading_snug,
isFromSelf && {color: t.palette.white}, isFromSelf && {color: t.palette.white},
pending && t.name !== 'light' && {color: t.palette.primary_300}, isPending && t.name !== 'light' && {color: t.palette.primary_300},
]} ]}
interactiveStyle={a.underline} interactiveStyle={a.underline}
enableTags enableTags
/> />
</View> </View>
</ActionsWrapper> </ActionsWrapper>
{isLastInGroup && (
<MessageItemMetadata <MessageItemMetadata
message={item} item={item}
isLastInGroup={isLastInGroup}
style={isFromSelf ? a.text_right : a.text_left} style={isFromSelf ? a.text_right : a.text_left}
/> />
)}
</View> </View>
) )
} }
@ -117,16 +123,26 @@ MessageItem = React.memo(MessageItem)
export {MessageItem} export {MessageItem}
let MessageItemMetadata = ({ let MessageItemMetadata = ({
message, item,
isLastInGroup,
style, style,
}: { }: {
message: ChatBskyConvoDefs.MessageView item: ConvoItem & {type: 'message' | 'pending-message'}
isLastInGroup: boolean
style: StyleProp<TextStyle> style: StyleProp<TextStyle>
}): React.ReactNode => { }): React.ReactNode => {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const {message} = item
const handleRetry = useCallback(
(e: GestureResponderEvent) => {
if (item.type === 'pending-message' && item.retry) {
e.preventDefault()
item.retry()
return false
}
},
[item],
)
const relativeTimestamp = useCallback( const relativeTimestamp = useCallback(
(timestamp: string) => { (timestamp: string) => {
@ -169,25 +185,47 @@ let MessageItemMetadata = ({
[_], [_],
) )
if (!isLastInGroup) {
return null
}
return ( return (
<TimeElapsed timestamp={message.sentAt} timeToString={relativeTimestamp}>
{({timeElapsed}) => (
<Text <Text
style={[ style={[
t.atoms.text_contrast_medium,
a.text_xs, a.text_xs,
a.mt_2xs, a.mt_2xs,
a.mb_lg, a.mb_lg,
t.atoms.text_contrast_medium,
style, style,
]}> ]}>
<TimeElapsed timestamp={message.sentAt} timeToString={relativeTimestamp}>
{({timeElapsed}) => (
<Text style={[a.text_xs, t.atoms.text_contrast_medium]}>
{timeElapsed} {timeElapsed}
</Text> </Text>
)} )}
</TimeElapsed> </TimeElapsed>
{item.type === 'pending-message' && item.retry && (
<>
{' '}
&middot;{' '}
<Text
style={[
a.text_xs,
{
color: t.palette.negative_400,
},
]}>
{_(msg`Failed to send`)}
</Text>{' '}
&middot;{' '}
<InlineLinkText
label={_(msg`Click to retry failed message`)}
to="#"
onPress={handleRetry}
style={[a.text_xs]}>
{_(msg`Retry`)}
</InlineLinkText>
</>
)}
</Text>
) )
} }

View File

@ -245,8 +245,12 @@ function PreviewMessage({message}: {message: ChatBskyConvoDefs.MessageView}) {
/> />
</View> </View>
<MessageItemMetadata <MessageItemMetadata
message={message} item={{
isLastInGroup type: 'message',
message,
key: '',
nextMessage: null,
}}
style={[a.text_left, a.mb_0]} style={[a.text_left, a.mb_0]}
/> />
</View> </View>

View File

@ -26,7 +26,6 @@ export function MessageListError({
msg`This chat was disconnected due to a network error.`, msg`This chat was disconnected due to a network error.`,
), ),
[ConvoItemError.HistoryFailed]: _(msg`Failed to load past messages.`), [ConvoItemError.HistoryFailed]: _(msg`Failed to load past messages.`),
[ConvoItemError.PendingFailed]: _(msg`Failed to send message(s).`),
}[item.code] }[item.code]
}, [_, item.code]) }, [_, item.code])

View File

@ -35,13 +35,7 @@ function MaybeLoader({isLoading}: {isLoading: boolean}) {
function renderItem({item}: {item: ConvoItem}) { function renderItem({item}: {item: ConvoItem}) {
if (item.type === 'message' || item.type === 'pending-message') { if (item.type === 'message' || item.type === 'pending-message') {
return ( return <MessageItem item={item} />
<MessageItem
item={item.message}
next={item.nextMessage}
pending={item.type === 'pending-message'}
/>
)
} else if (item.type === 'deleted-message') { } else if (item.type === 'deleted-message') {
return <Text>Deleted message</Text> return <Text>Deleted message</Text>
} else if (item.type === 'error-recoverable') { } else if (item.type === 'error-recoverable') {

View File

@ -735,6 +735,8 @@ export class Convo {
} }
} }
private pendingFailed = false
async sendMessage(message: ChatBskyConvoSendMessage.InputSchema['message']) { async sendMessage(message: ChatBskyConvoSendMessage.InputSchema['message']) {
// Ignore empty messages for now since they have no other purpose atm // Ignore empty messages for now since they have no other purpose atm
if (!message.text.trim()) return if (!message.text.trim()) return
@ -747,11 +749,9 @@ export class Convo {
id: tempId, id: tempId,
message, message,
}) })
// remove on each send, it might go through now without user having to click
this.footerItems.delete(ConvoItemError.PendingFailed)
this.commit() this.commit()
if (!this.isProcessingPendingMessages) { if (!this.isProcessingPendingMessages && !this.pendingFailed) {
this.processPendingMessages() this.processPendingMessages()
} }
} }
@ -805,16 +805,7 @@ export class Convo {
this.commit() this.commit()
} catch (e: any) { } catch (e: any) {
logger.error(e, {context: `Convo: failed to send message`}) logger.error(e, {context: `Convo: failed to send message`})
this.footerItems.set(ConvoItemError.PendingFailed, { this.pendingFailed = true
type: 'error-recoverable',
key: ConvoItemError.PendingFailed,
code: ConvoItemError.PendingFailed,
retry: () => {
this.footerItems.delete(ConvoItemError.PendingFailed)
this.commit()
this.batchRetryPendingMessages()
},
})
this.commit() this.commit()
} finally { } finally {
this.isProcessingPendingMessages = false this.isProcessingPendingMessages = false
@ -868,16 +859,7 @@ export class Convo {
) )
} catch (e: any) { } catch (e: any) {
logger.error(e, {context: `Convo: failed to batch retry messages`}) logger.error(e, {context: `Convo: failed to batch retry messages`})
this.footerItems.set(ConvoItemError.PendingFailed, { this.pendingFailed = true
type: 'error-recoverable',
key: ConvoItemError.PendingFailed,
code: ConvoItemError.PendingFailed,
retry: () => {
this.footerItems.delete(ConvoItemError.PendingFailed)
this.commit()
this.batchRetryPendingMessages()
},
})
this.commit() this.commit()
} }
} }
@ -958,6 +940,7 @@ export class Convo {
key: m.id, key: m.id,
message: { message: {
...m.message, ...m.message,
$type: 'chat.bsky.convo.defs#messageView',
id: nanoid(), id: nanoid(),
rev: '__fake__', rev: '__fake__',
sentAt: new Date().toISOString(), sentAt: new Date().toISOString(),
@ -968,6 +951,13 @@ export class Convo {
sender: this.sender!, sender: this.sender!,
}, },
nextMessage: null, nextMessage: null,
retry: this.pendingFailed
? () => {
this.pendingFailed = false
this.commit()
this.batchRetryPendingMessages()
}
: undefined,
}) })
}) })

View File

@ -35,10 +35,6 @@ export enum ConvoItemError {
* Error fetching past messages * Error fetching past messages
*/ */
HistoryFailed = 'historyFailed', HistoryFailed = 'historyFailed',
/**
* Error sending new message
*/
PendingFailed = 'pendingFailed',
} }
export enum ConvoErrorCode { export enum ConvoErrorCode {
@ -83,7 +79,7 @@ export type ConvoDispatch =
export type ConvoItem = export type ConvoItem =
| { | {
type: 'message' | 'pending-message' type: 'message'
key: string key: string
message: ChatBskyConvoDefs.MessageView message: ChatBskyConvoDefs.MessageView
nextMessage: nextMessage:
@ -91,6 +87,19 @@ export type ConvoItem =
| ChatBskyConvoDefs.DeletedMessageView | ChatBskyConvoDefs.DeletedMessageView
| null | null
} }
| {
type: 'pending-message'
key: string
message: ChatBskyConvoDefs.MessageView
nextMessage:
| ChatBskyConvoDefs.MessageView
| ChatBskyConvoDefs.DeletedMessageView
| null
/**
* Retry sending the message. If present, the message is in a failed state.
*/
retry?: () => void
}
| { | {
type: 'deleted-message' type: 'deleted-message'
key: string key: string