An ATproto social media client -- with an independent Appview.

[🐴] 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

authored by

Eric Bailey and committed by
GitHub
04aea931 ed892228

+121 -87
+87 -49
src/components/dms/MessageItem.tsx
··· 1 1 import React, {useCallback, useMemo, useRef} from 'react' 2 - import {LayoutAnimation, StyleProp, TextStyle, View} from 'react-native' 2 + import { 3 + GestureResponderEvent, 4 + LayoutAnimation, 5 + StyleProp, 6 + TextStyle, 7 + View, 8 + } from 'react-native' 3 9 import {ChatBskyConvoDefs, RichText as RichTextAPI} from '@atproto/api' 4 10 import {msg} from '@lingui/macro' 5 11 import {useLingui} from '@lingui/react' 6 12 13 + import {ConvoItem} from '#/state/messages/convo/types' 7 14 import {useSession} from '#/state/session' 8 15 import {TimeElapsed} from 'view/com/util/TimeElapsed' 9 16 import {atoms as a, useTheme} from '#/alf' 10 17 import {ActionsWrapper} from '#/components/dms/ActionsWrapper' 18 + import {InlineLinkText} from '#/components/Link' 11 19 import {Text} from '#/components/Typography' 12 20 import {RichText} from '../RichText' 13 21 14 22 let MessageItem = ({ 15 23 item, 16 - next, 17 - pending, 18 24 }: { 19 - item: ChatBskyConvoDefs.MessageView 20 - next: 21 - | ChatBskyConvoDefs.MessageView 22 - | ChatBskyConvoDefs.DeletedMessageView 23 - | null 24 - pending?: boolean 25 + item: ConvoItem & {type: 'message' | 'pending-message'} 25 26 }): React.ReactNode => { 26 27 const t = useTheme() 27 28 const {currentAccount} = useSession() 28 29 29 - const isFromSelf = item.sender?.did === currentAccount?.did 30 + const {message, nextMessage} = item 31 + const isPending = item.type === 'pending-message' 32 + 33 + const isFromSelf = message.sender?.did === currentAccount?.did 30 34 31 35 const isNextFromSelf = 32 - ChatBskyConvoDefs.isMessageView(next) && 33 - next.sender?.did === currentAccount?.did 36 + ChatBskyConvoDefs.isMessageView(nextMessage) && 37 + nextMessage.sender?.did === currentAccount?.did 34 38 35 39 const isLastInGroup = useMemo(() => { 36 - // TODO this means it's a placeholder. Let's figure out the right way to do this though! 37 - if (item.id.length > 13) { 40 + // if this message is pending, it means the next message is pending too 41 + if (isPending && nextMessage) { 38 42 return false 39 43 } 40 44 ··· 44 48 } 45 49 46 50 // or, if there's a 3 minute gap between this message and the next 47 - if (ChatBskyConvoDefs.isMessageView(next)) { 48 - const thisDate = new Date(item.sentAt) 49 - const nextDate = new Date(next.sentAt) 51 + if (ChatBskyConvoDefs.isMessageView(nextMessage)) { 52 + const thisDate = new Date(message.sentAt) 53 + const nextDate = new Date(nextMessage.sentAt) 50 54 51 55 const diff = nextDate.getTime() - thisDate.getTime() 52 56 ··· 55 59 } 56 60 57 61 return true 58 - }, [item, next, isFromSelf, isNextFromSelf]) 62 + }, [message, nextMessage, isFromSelf, isNextFromSelf, isPending]) 59 63 60 64 const lastInGroupRef = useRef(isLastInGroup) 61 65 if (lastInGroupRef.current !== isLastInGroup) { ··· 67 71 t.name === 'light' ? t.palette.primary_200 : t.palette.primary_800 68 72 69 73 const rt = useMemo(() => { 70 - return new RichTextAPI({text: item.text, facets: item.facets}) 71 - }, [item.text, item.facets]) 74 + return new RichTextAPI({text: message.text, facets: message.facets}) 75 + }, [message.text, message.facets]) 72 76 73 77 return ( 74 78 <View> 75 - <ActionsWrapper isFromSelf={isFromSelf} message={item}> 79 + <ActionsWrapper isFromSelf={isFromSelf} message={message}> 76 80 <View 77 81 style={[ 78 82 a.py_sm, ··· 82 86 paddingLeft: 14, 83 87 paddingRight: 14, 84 88 backgroundColor: isFromSelf 85 - ? pending 89 + ? isPending 86 90 ? pendingColor 87 91 : t.palette.primary_500 88 92 : t.palette.contrast_50, ··· 98 102 a.text_md, 99 103 a.leading_snug, 100 104 isFromSelf && {color: t.palette.white}, 101 - pending && t.name !== 'light' && {color: t.palette.primary_300}, 105 + isPending && t.name !== 'light' && {color: t.palette.primary_300}, 102 106 ]} 103 107 interactiveStyle={a.underline} 104 108 enableTags 105 109 /> 106 110 </View> 107 111 </ActionsWrapper> 108 - <MessageItemMetadata 109 - message={item} 110 - isLastInGroup={isLastInGroup} 111 - style={isFromSelf ? a.text_right : a.text_left} 112 - /> 112 + 113 + {isLastInGroup && ( 114 + <MessageItemMetadata 115 + item={item} 116 + style={isFromSelf ? a.text_right : a.text_left} 117 + /> 118 + )} 113 119 </View> 114 120 ) 115 121 } ··· 117 123 export {MessageItem} 118 124 119 125 let MessageItemMetadata = ({ 120 - message, 121 - isLastInGroup, 126 + item, 122 127 style, 123 128 }: { 124 - message: ChatBskyConvoDefs.MessageView 125 - isLastInGroup: boolean 129 + item: ConvoItem & {type: 'message' | 'pending-message'} 126 130 style: StyleProp<TextStyle> 127 131 }): React.ReactNode => { 128 132 const t = useTheme() 129 133 const {_} = useLingui() 134 + const {message} = item 135 + 136 + const handleRetry = useCallback( 137 + (e: GestureResponderEvent) => { 138 + if (item.type === 'pending-message' && item.retry) { 139 + e.preventDefault() 140 + item.retry() 141 + return false 142 + } 143 + }, 144 + [item], 145 + ) 130 146 131 147 const relativeTimestamp = useCallback( 132 148 (timestamp: string) => { ··· 169 185 [_], 170 186 ) 171 187 172 - if (!isLastInGroup) { 173 - return null 174 - } 175 - 176 188 return ( 177 - <TimeElapsed timestamp={message.sentAt} timeToString={relativeTimestamp}> 178 - {({timeElapsed}) => ( 179 - <Text 180 - style={[ 181 - t.atoms.text_contrast_medium, 182 - a.text_xs, 183 - a.mt_2xs, 184 - a.mb_lg, 185 - style, 186 - ]}> 187 - {timeElapsed} 188 - </Text> 189 + <Text 190 + style={[ 191 + a.text_xs, 192 + a.mt_2xs, 193 + a.mb_lg, 194 + t.atoms.text_contrast_medium, 195 + style, 196 + ]}> 197 + <TimeElapsed timestamp={message.sentAt} timeToString={relativeTimestamp}> 198 + {({timeElapsed}) => ( 199 + <Text style={[a.text_xs, t.atoms.text_contrast_medium]}> 200 + {timeElapsed} 201 + </Text> 202 + )} 203 + </TimeElapsed> 204 + 205 + {item.type === 'pending-message' && item.retry && ( 206 + <> 207 + {' '} 208 + &middot;{' '} 209 + <Text 210 + style={[ 211 + a.text_xs, 212 + { 213 + color: t.palette.negative_400, 214 + }, 215 + ]}> 216 + {_(msg`Failed to send`)} 217 + </Text>{' '} 218 + &middot;{' '} 219 + <InlineLinkText 220 + label={_(msg`Click to retry failed message`)} 221 + to="#" 222 + onPress={handleRetry} 223 + style={[a.text_xs]}> 224 + {_(msg`Retry`)} 225 + </InlineLinkText> 226 + </> 189 227 )} 190 - </TimeElapsed> 228 + </Text> 191 229 ) 192 230 } 193 231
+6 -2
src/components/dms/MessageReportDialog.tsx
··· 245 245 /> 246 246 </View> 247 247 <MessageItemMetadata 248 - message={message} 249 - isLastInGroup 248 + item={{ 249 + type: 'message', 250 + message, 251 + key: '', 252 + nextMessage: null, 253 + }} 250 254 style={[a.text_left, a.mb_0]} 251 255 /> 252 256 </View>
-1
src/screens/Messages/Conversation/MessageListError.tsx
··· 26 26 msg`This chat was disconnected due to a network error.`, 27 27 ), 28 28 [ConvoItemError.HistoryFailed]: _(msg`Failed to load past messages.`), 29 - [ConvoItemError.PendingFailed]: _(msg`Failed to send message(s).`), 30 29 }[item.code] 31 30 }, [_, item.code]) 32 31
+1 -7
src/screens/Messages/Conversation/MessagesList.tsx
··· 35 35 36 36 function renderItem({item}: {item: ConvoItem}) { 37 37 if (item.type === 'message' || item.type === 'pending-message') { 38 - return ( 39 - <MessageItem 40 - item={item.message} 41 - next={item.nextMessage} 42 - pending={item.type === 'pending-message'} 43 - /> 44 - ) 38 + return <MessageItem item={item} /> 45 39 } else if (item.type === 'deleted-message') { 46 40 return <Text>Deleted message</Text> 47 41 } else if (item.type === 'error-recoverable') {
+13 -23
src/state/messages/convo/agent.ts
··· 735 735 } 736 736 } 737 737 738 + private pendingFailed = false 739 + 738 740 async sendMessage(message: ChatBskyConvoSendMessage.InputSchema['message']) { 739 741 // Ignore empty messages for now since they have no other purpose atm 740 742 if (!message.text.trim()) return ··· 747 749 id: tempId, 748 750 message, 749 751 }) 750 - // remove on each send, it might go through now without user having to click 751 - this.footerItems.delete(ConvoItemError.PendingFailed) 752 752 this.commit() 753 753 754 - if (!this.isProcessingPendingMessages) { 754 + if (!this.isProcessingPendingMessages && !this.pendingFailed) { 755 755 this.processPendingMessages() 756 756 } 757 757 } ··· 805 805 this.commit() 806 806 } catch (e: any) { 807 807 logger.error(e, {context: `Convo: failed to send message`}) 808 - this.footerItems.set(ConvoItemError.PendingFailed, { 809 - type: 'error-recoverable', 810 - key: ConvoItemError.PendingFailed, 811 - code: ConvoItemError.PendingFailed, 812 - retry: () => { 813 - this.footerItems.delete(ConvoItemError.PendingFailed) 814 - this.commit() 815 - this.batchRetryPendingMessages() 816 - }, 817 - }) 808 + this.pendingFailed = true 818 809 this.commit() 819 810 } finally { 820 811 this.isProcessingPendingMessages = false ··· 868 859 ) 869 860 } catch (e: any) { 870 861 logger.error(e, {context: `Convo: failed to batch retry messages`}) 871 - this.footerItems.set(ConvoItemError.PendingFailed, { 872 - type: 'error-recoverable', 873 - key: ConvoItemError.PendingFailed, 874 - code: ConvoItemError.PendingFailed, 875 - retry: () => { 876 - this.footerItems.delete(ConvoItemError.PendingFailed) 877 - this.commit() 878 - this.batchRetryPendingMessages() 879 - }, 880 - }) 862 + this.pendingFailed = true 881 863 this.commit() 882 864 } 883 865 } ··· 958 940 key: m.id, 959 941 message: { 960 942 ...m.message, 943 + $type: 'chat.bsky.convo.defs#messageView', 961 944 id: nanoid(), 962 945 rev: '__fake__', 963 946 sentAt: new Date().toISOString(), ··· 968 951 sender: this.sender!, 969 952 }, 970 953 nextMessage: null, 954 + retry: this.pendingFailed 955 + ? () => { 956 + this.pendingFailed = false 957 + this.commit() 958 + this.batchRetryPendingMessages() 959 + } 960 + : undefined, 971 961 }) 972 962 }) 973 963
+14 -5
src/state/messages/convo/types.ts
··· 35 35 * Error fetching past messages 36 36 */ 37 37 HistoryFailed = 'historyFailed', 38 - /** 39 - * Error sending new message 40 - */ 41 - PendingFailed = 'pendingFailed', 42 38 } 43 39 44 40 export enum ConvoErrorCode { ··· 83 79 84 80 export type ConvoItem = 85 81 | { 86 - type: 'message' | 'pending-message' 82 + type: 'message' 83 + key: string 84 + message: ChatBskyConvoDefs.MessageView 85 + nextMessage: 86 + | ChatBskyConvoDefs.MessageView 87 + | ChatBskyConvoDefs.DeletedMessageView 88 + | null 89 + } 90 + | { 91 + type: 'pending-message' 87 92 key: string 88 93 message: ChatBskyConvoDefs.MessageView 89 94 nextMessage: 90 95 | ChatBskyConvoDefs.MessageView 91 96 | ChatBskyConvoDefs.DeletedMessageView 92 97 | null 98 + /** 99 + * Retry sending the message. If present, the message is in a failed state. 100 + */ 101 + retry?: () => void 93 102 } 94 103 | { 95 104 type: 'deleted-message'