···55 type KeyboardChatScrollViewProps,
66 KeyboardGestureArea,
77} from 'react-native-keyboard-controller'
88-import Animated, {
88+import {
99 runOnJS,
1010 type ScrollEvent,
1111 type SharedValue,
···7777 )
7878}
79798080-function renderItem({item}: {item: ConvoItem}) {
8181- if (item.type === 'message' || item.type === 'pending-message') {
8282- return <MessageItem item={item} />
8383- } else if (item.type === 'deleted-message') {
8484- return <Text>Deleted message</Text>
8585- } else if (item.type === 'error') {
8686- return <MessageListError item={item} />
8787- }
8888-8989- return null
9090-}
9191-9280function keyExtractor(item: ConvoItem) {
9381 return item.key
9482}
···10391 blocked,
10492 footer,
10593 hasAcceptOverride,
9494+ transparentHeaderHeight,
10695}: {
10796 hasScrolled: boolean
10897 setHasScrolled: React.Dispatch<React.SetStateAction<boolean>>
10998 blocked?: boolean
11099 footer?: React.ReactNode
111100 hasAcceptOverride?: boolean
101101+ transparentHeaderHeight?: number
112102}) {
113103 const ax = useAnalytics()
114104 const convoState = useConvoActive()
···155145 const prevContentHeight = useRef(0)
156146 const prevItemCount = useRef(0)
157147148148+ // Tracks whether the initial scroll-to-bottom has been triggered. Separated from isAtBottom so that contentInset
149149+ // (which causes an early onScroll with negative offset) can't prevent the first scroll.
150150+ // Reset when hasScrolled goes back to false (e.g. convo re-initialization after backgrounding).
151151+ const hasInitiallyScrolled = useRef(false)
152152+ const prevHasScrolled = useRef(hasScrolled)
153153+ if (prevHasScrolled.current && !hasScrolled) {
154154+ hasInitiallyScrolled.current = false
155155+ }
156156+ prevHasScrolled.current = hasScrolled
157157+158158 // -- Keep track of background state and positioning for new pill
159159 const layoutHeight = useSharedValue(0)
160160 const didBackground = useRef(false)
···187187 })
188188 }
189189190190- // This number _must_ be the height of the MaybeLoader component
191191- if (height > 50 && isAtBottom.get()) {
190190+ // Initial scroll to bottom — unconditional, not gated on isAtBottom. This is separated because contentInset
191191+ // can cause an early onScroll with a negative offset that sets isAtBottom to false before we get here.
192192+ if (!hasInitiallyScrolled.current && convoState.items.length > 0) {
193193+ hasInitiallyScrolled.current = true
194194+ flatListRef.current?.scrollToOffset({offset: height, animated: false})
195195+ // If history is already done loading, mark ready after a frame for the scroll to settle.
196196+ // Otherwise, the footer sentinel's onLayout will handle it when history finishes.
197197+ if (!convoState.isFetchingHistory) {
198198+ requestAnimationFrame(() => {
199199+ setHasScrolled(true)
200200+ })
201201+ }
202202+ prevContentHeight.current = height
203203+ prevItemCount.current = convoState.items.length
204204+ return
205205+ }
206206+207207+ // Subsequent: auto-scroll only if user is at the bottom
208208+ if (isAtBottom.get()) {
192209 // If the size of the content is changing by more than the height of the screen, then we don't
193210 // want to scroll further than the start of all the new content. Since we are storing the previous offset,
194211 // we can just scroll the user to that offset and add a little bit of padding. We'll also show the pill
···212229 offset: height,
213230 animated: hasScrolled && height > prevContentHeight.current,
214231 })
215215-216216- // HACK Unfortunately, we need to call `setHasScrolled` after a brief delay,
217217- // because otherwise there is too much of a delay between the time the content
218218- // scrolls and the time the screen appears, causing a flicker.
219219- // We cannot actually use a synchronous scroll here, because `onContentSizeChange`
220220- // is actually async itself - all the info has to come across the bridge first.
221221- if (!hasScrolled && !convoState.isFetchingHistory) {
222222- setTimeout(() => {
223223- setHasScrolled(true)
224224- }, 100)
225225- }
226232 }
227233 }
228234···369375 setEmojiPickerState({isOpen: true, pos})
370376 }, [])
371377378378+ const renderItem = ({item}: {item: ConvoItem}) => {
379379+ if (item.type === 'message' || item.type === 'pending-message') {
380380+ return (
381381+ <MessageItem
382382+ item={item}
383383+ profile={convoState.convo.members.find(
384384+ member => member.did === item.message.sender.did,
385385+ )}
386386+ isGroupChat={convoState.getGroupInfo?.() != null}
387387+ />
388388+ )
389389+ } else if (item.type === 'deleted-message') {
390390+ return <Text>Deleted message</Text>
391391+ } else if (item.type === 'error') {
392392+ return <MessageListError item={item} />
393393+ }
394394+395395+ return null
396396+ }
397397+398398+ // Footer sentinel: when history is still loading during the initial scroll, the footer's onLayout fires each time
399399+ // new items are prepended (shifting its position). Once history finishes, this triggers setHasScrolled.
400400+ const onFooterLayout = useCallback(() => {
401401+ if (
402402+ hasInitiallyScrolled.current &&
403403+ !hasScrolled &&
404404+ !convoState.isFetchingHistory
405405+ ) {
406406+ requestAnimationFrame(() => {
407407+ setHasScrolled(true)
408408+ })
409409+ }
410410+ }, [hasScrolled, setHasScrolled, convoState.isFetchingHistory])
411411+372412 const renderScrollComponent = useCallback(
373413 (props: ScrollViewProps) => (
374414 <ChatScrollComponent {...props} inputHeight={inputHeightUI} />
···382422 interpolator="ios"
383423 // HACKFIX: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1419
384424 offset={Math.round(inputHeightJS)}
385385- textInputNativeID={textInputId}
425425+ // slightly too buggy unfortunately, enable when possible
426426+ // textInputNativeID={textInputId}
386427 style={[a.flex_1]}>
387428 {/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
388429 <ScrollProvider onScroll={onScroll}>
···411452 }
412453 // native only (prop is not supported on web)
413454 renderScrollComponent={renderScrollComponent}
414414- // pushes up the content under the input on web (renderScrollComponent handles it on native)
415415- ListFooterComponent={web(
416416- <WebInputSpacer inputHeight={inputHeightJS} />,
417417- )}
455455+ contentContainerStyle={{
456456+ paddingBottom: platform({
457457+ // ios is slightly larger as the input has no top padding
458458+ ios: tokens.space.lg,
459459+ android: tokens.space.md,
460460+ web: 0, // web uses ListFooterComponent instead for scroll reasons
461461+ }),
462462+ }}
463463+ ListFooterComponent={
464464+ <View
465465+ style={web({height: tokens.space.md + inputHeightJS})}
466466+ onLayout={onFooterLayout}
467467+ />
468468+ }
418469 style={web({
419470 scrollbarWidth: 'thin',
420471 scrollbarColor: `${t.palette.contrast_100} transparent`,
421472 scrollbarGutter: 'stable both-edges',
422473 })}
474474+ contentInset={{top: transparentHeaderHeight}}
475475+ scrollIndicatorInsets={{top: transparentHeaderHeight}}
423476 />
424477 </ScrollProvider>
425478 <KeyboardStickyView
···444497 {ax.features.enabled(ax.features.DmsNewMessageComposerEnable) ? (
445498 <MessageComposer
446499 textInputId={textInputId}
447447- onSendMessage={onSendMessage}
500500+ onSendMessage={(message: string) =>
501501+ void onSendMessage(message)
502502+ }
448503 hasEmbed={!!embedUri}
449504 setEmbed={setEmbed}>
450505 <MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} />
···516571 {...props}
517572 />
518573 )
519519-}
520520-521521-function WebInputSpacer({inputHeight}: {inputHeight: number}) {
522522- if (!IS_WEB) return null
523523-524524- return <Animated.View style={{height: inputHeight}} />
525574}
526575527576type FooterState = 'loading' | 'new-chat' | 'request' | 'standard'
+85-29
src/state/messages/convo/agent.ts
···11import {
22 type AtpAgent,
33- type ChatBskyActorDefs,
33+ ChatBskyActorDefs,
44 ChatBskyConvoDefs,
55 type ChatBskyConvoGetLog,
66 type ChatBskyConvoSendMessage,
···3737import {type MessagesEventBus} from '#/state/messages/events/agent'
3838import {type MessagesEventBusError} from '#/state/messages/events/types'
3939import {IS_NATIVE} from '#/env'
4040+import * as bsky from '#/types/bsky'
40414142const logger = Logger.create(Logger.Context.ConversationAgent)
4243···112113 this.markConvoAccepted = this.markConvoAccepted.bind(this)
113114 this.addReaction = this.addReaction.bind(this)
114115 this.removeReaction = this.removeReaction.bind(this)
116116+ this.isGroup = this.isGroup.bind(this)
117117+ this.getGroupInfo = this.getGroupInfo.bind(this)
118118+ this.getPrimaryMember = this.getPrimaryMember.bind(this)
115119 }
116120117121 private commit() {
···155159 markConvoAccepted: undefined,
156160 addReaction: undefined,
157161 removeReaction: undefined,
162162+ isGroup: this.isGroup,
163163+ getGroupInfo: this.getGroupInfo,
164164+ getPrimaryMember: this.getPrimaryMember,
158165 }
159166 }
160167 case ConvoStatus.Disabled:
···175182 markConvoAccepted: this.markConvoAccepted,
176183 addReaction: this.addReaction,
177184 removeReaction: this.removeReaction,
185185+ isGroup: this.isGroup,
186186+ getGroupInfo: this.getGroupInfo,
187187+ getPrimaryMember: this.getPrimaryMember,
178188 }
179189 }
180190 case ConvoStatus.Error: {
···192202 markConvoAccepted: undefined,
193203 addReaction: undefined,
194204 removeReaction: undefined,
205205+ isGroup: undefined,
206206+ getGroupInfo: undefined,
207207+ getPrimaryMember: undefined,
195208 }
196209 }
197210 default: {
···209222 markConvoAccepted: undefined,
210223 addReaction: undefined,
211224 removeReaction: undefined,
225225+ isGroup: this.isGroup,
226226+ getGroupInfo: this.getGroupInfo,
227227+ getPrimaryMember: this.getPrimaryMember,
212228 }
213229 }
214230 }
···222238 switch (action.event) {
223239 case ConvoDispatchEvent.Init: {
224240 this.status = ConvoStatus.Initializing
225225- this.setup()
241241+ void this.setup()
226242 this.setupFirehose()
227243 this.requestPollInterval(ACTIVE_POLL_INTERVAL)
228244 break
···234250 switch (action.event) {
235251 case ConvoDispatchEvent.Ready: {
236252 this.status = ConvoStatus.Ready
237237- this.fetchMessageHistory()
253253+ void this.fetchMessageHistory()
238254 break
239255 }
240256 case ConvoDispatchEvent.Background: {
241257 this.status = ConvoStatus.Backgrounded
242242- this.fetchMessageHistory()
258258+ void this.fetchMessageHistory()
243259 this.requestPollInterval(BACKGROUND_POLL_INTERVAL)
244260 break
245261 }
···258274 }
259275 case ConvoDispatchEvent.Disable: {
260276 this.status = ConvoStatus.Disabled
261261- this.fetchMessageHistory() // finish init
277277+ void this.fetchMessageHistory() // finish init
262278 this.cleanupFirehoseConnection?.()
263279 this.withdrawRequestedPollInterval()
264280 break
···269285 case ConvoStatus.Ready: {
270286 switch (action.event) {
271287 case ConvoDispatchEvent.Resume: {
272272- this.refreshConvo()
288288+ void this.refreshConvo()
273289 this.requestPollInterval(ACTIVE_POLL_INTERVAL)
274290 break
275291 }
···308324 } else {
309325 if (this.convo) {
310326 this.status = ConvoStatus.Ready
311311- this.refreshConvo()
327327+ void this.refreshConvo()
312328 this.maybeRecoverFromNetworkError()
313329 } else {
314330 this.status = ConvoStatus.Initializing
315315- this.setup()
331331+ void this.setup()
316332 }
317333 this.requestPollInterval(ACTIVE_POLL_INTERVAL)
318334 }
···435451 this.firehoseError = undefined
436452 this.commit()
437453 } else {
438438- this.batchRetryPendingMessages()
454454+ void this.batchRetryPendingMessages()
439455 }
440456441457 if (this.fetchMessageHistoryError) {
···487503 } else {
488504 this.dispatch({event: ConvoDispatchEvent.Ready})
489505 }
490490- } catch (e: any) {
506506+ } catch (err) {
507507+ const e = err as Error
491508 if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) {
492509 logger.error('setup failed', {
493510 safeMessage: e.message,
···557574 async fetchConvo() {
558575 if (this.pendingFetchConvo) return this.pendingFetchConvo
559576560560- this.pendingFetchConvo = new Promise<{
561561- convo: ChatBskyConvoDefs.ConvoView
562562- sender: ChatBskyActorDefs.ProfileViewBasic | undefined
563563- recipients: ChatBskyActorDefs.ProfileViewBasic[]
564564- }>(async (resolve, reject) => {
577577+ this.pendingFetchConvo = (async () => {
565578 try {
566579 const response = await networkRetry(2, () => {
567580 return this.agent.api.chat.bsky.convo.getConvo(
···574587575588 const convo = response.data.convo
576589577577- resolve({
590590+ return {
578591 convo,
579592 sender: convo.members.find(m => m.did === this.senderUserDid),
580593 recipients: convo.members.filter(m => m.did !== this.senderUserDid),
581581- })
582582- } catch (e) {
583583- reject(e)
594594+ }
584595 } finally {
585596 this.pendingFetchConvo = undefined
586597 }
587587- })
598598+ })()
588599589600 return this.pendingFetchConvo
590601 }
···596607 this.convo = convo || this.convo
597608 this.sender = sender || this.sender
598609 this.recipients = recipients || this.recipients
599599- } catch (e: any) {
610610+ } catch (err) {
611611+ const e = err as Error
600612 if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) {
601613 logger.error(`failed to refresh convo`, {
602614 safeMessage: e.message,
···664676 this.pastMessages.set(message.id, message)
665677 }
666678 }
667667- } catch (e: any) {
679679+ } catch (err) {
680680+ const e = err as Error
668681 if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) {
669682 logger.error('failed to fetch message history', {
670683 safeMessage: e.message,
···673686674687 this.fetchMessageHistoryError = {
675688 retry: () => {
676676- this.fetchMessageHistory()
689689+ void this.fetchMessageHistory()
677690 },
678691 }
679692 } finally {
···716729717730 onFirehoseConnect() {
718731 this.firehoseError = undefined
719719- this.batchRetryPendingMessages()
732732+ void this.batchRetryPendingMessages()
720733 this.commit()
721734 }
722735···761774 /**
762775 * If this message is already in new messages, it was added by our
763776 * sending logic, and is based on client-ordering. When we receive
764764- * the "commited" event from the log, we should replace this
765765- * reference and re-insert in order to respect the order we receied
777777+ * the "committed" event from the log, we should replace this
778778+ * reference and re-insert in order to respect the order we received
766779 * from the log.
767780 */
768781 if (this.newMessages.has(ev.message.id)) {
···836849 this.commit()
837850838851 if (!this.isProcessingPendingMessages && !this.pendingMessageFailure) {
839839- this.processPendingMessages()
852852+ void this.processPendingMessages()
840853 }
841854 }
842855···912925 }
913926 }
914927915915- private handleSendMessageFailure(e: any) {
928928+ private handleSendMessageFailure(e: Error | XRPCError) {
916929 if (e instanceof XRPCError) {
917930 if (NETWORK_FAILURE_STATUSES.includes(e.status)) {
918931 this.pendingMessageFailure = 'recoverable'
···10261039 {encoding: 'application/json', headers: DM_SERVICE_HEADERS},
10271040 )
10281041 })
10291029- } catch (e: any) {
10421042+ } catch (err) {
10431043+ const e = err as Error
10301044 if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) {
10311045 logger.error(`failed to delete message`, {
10321046 safeMessage: e.message,
···13321346 } catch (error) {
13331347 if (restore) restore()
13341348 throw error
13491349+ }
13501350+ }
13511351+13521352+ // Group utilities
13531353+13541354+ isGroup(): boolean | undefined {
13551355+ if (!this.convo) return undefined
13561356+ const info = this.getGroupInfo()
13571357+ return !!info
13581358+ }
13591359+13601360+ getGroupInfo(): ChatBskyConvoDefs.GroupConvo | undefined {
13611361+ if (
13621362+ this.convo &&
13631363+ bsky.dangerousIsType<ChatBskyConvoDefs.GroupConvo>(
13641364+ this.convo.kind,
13651365+ ChatBskyConvoDefs.isGroupConvo,
13661366+ )
13671367+ ) {
13681368+ return this.convo.kind
13691369+ }
13701370+ return undefined
13711371+ }
13721372+13731373+ getPrimaryMember(): ChatBskyActorDefs.ProfileViewBasic | undefined {
13741374+ if (this.isGroup()) {
13751375+ return this.recipients?.find(r => {
13761376+ if (
13771377+ bsky.dangerousIsType<ChatBskyActorDefs.GroupConvoMember>(
13781378+ r.kind,
13791379+ ChatBskyActorDefs.isGroupConvoMember,
13801380+ )
13811381+ ) {
13821382+ return r.kind.role === 'owner'
13831383+ } else {
13841384+ throw new Error(
13851385+ 'Expected a GroupConvoMember, got an unknown kind of member',
13861386+ )
13871387+ }
13881388+ })
13891389+ } else {
13901390+ return this.recipients?.find(r => r.did !== this.senderUserDid)
13351391 }
13361392 }
13371393}