diff --git a/__tests__/lib/weechat/action_transformer.ts b/__tests__/lib/weechat/action_transformer.ts index c764181..dd0058e 100644 --- a/__tests__/lib/weechat/action_transformer.ts +++ b/__tests__/lib/weechat/action_transformer.ts @@ -6,6 +6,7 @@ import { fetchBuffersAction, fetchBuffersRemovedAction } from '../../../src/store/actions'; +import { AppState } from '../../../src/store/app'; describe('transformToReduxAction', () => { describe('on buffers', () => { @@ -19,10 +20,8 @@ describe('transformToReduxAction', () => { }, lines: { '8578d9c00': [], '83a41cd80': [] }, app: { - currentBufferId: '8578d9c00', - connected: true, - notificationBufferId: null - } + currentBufferId: '8578d9c00' + } as AppState }; const store = configureStore({ reducer, preloadedState }); @@ -88,10 +87,8 @@ describe('transformToReduxAction', () => { '83a41cd80': {} as WeechatBuffer }, app: { - currentBufferId: '8578d9c00', - connected: true, - notificationBufferId: null - } + currentBufferId: '8578d9c00' + } as AppState }; const store = configureStore({ reducer, preloadedState }); @@ -124,10 +121,8 @@ describe('transformToReduxAction', () => { }, lines: { '8578d9c00': [], '83a41cd80': [] }, app: { - currentBufferId: '83a41cd80', - connected: true, - notificationBufferId: null - } + currentBufferId: '83a41cd80' + } as AppState }; const store = configureStore({ reducer, preloadedState }); @@ -168,10 +163,8 @@ describe('transformToReduxAction', () => { }, lines: { '8578d9c00': [], '83a41cd80': [] }, app: { - currentBufferId: '83a41cd80', - connected: true, - notificationBufferId: null - } + currentBufferId: '83a41cd80' + } as AppState }; const store = configureStore({ reducer, preloadedState }); diff --git a/__tests__/usecase/App.tsx b/__tests__/usecase/App.tsx index 0877c48..96ceaa5 100644 --- a/__tests__/usecase/App.tsx +++ b/__tests__/usecase/App.tsx @@ -1,40 +1,123 @@ import 'react-native'; import App from '../../src/usecase/App'; -import { render } from '../../src/test-utils'; - -it('renders correctly', () => { - const tree = render( - {}} - fetchBufferInfo={() => {}} - sendMessageToBuffer={() => {}} - clearHotlistForBuffer={() => {}} - />, - { - preloadedState: { - buffers: { - '8578d9c00': { - full_name: 'irc.libera.#weechat', - hidden: 0, - id: '8578d9c00', - local_variables: { - channel: '#weechat', - name: 'libera.#weechat', - plugin: 'irc', - type: 'channel' - }, - notify: 3, - number: 2, - pointers: ['8578d9c00'], - short_name: '#weechat', - title: '', - type: 0 - } +import { act, render } from '../../src/test-utils'; +import { configureStore } from '@reduxjs/toolkit'; +import { reducer } from '../../src/store'; +import { AppState } from '../../src/store/app'; +import { bufferNotificationAction } from '../../src/store/actions'; + +jest.mock('react-native-drawer-layout'); + +describe('App', () => { + describe('on notification', () => { + it('changes the current buffer to the notification buffer', () => { + const bufferId = '86c417600'; + const store = configureStore({ + reducer, + preloadedState: { + buffers: { + [bufferId]: { + full_name: 'irc.libera.#weechat', + hidden: 0, + id: bufferId, + local_variables: { + channel: '#weechat', + name: 'libera.#weechat', + plugin: 'irc', + type: 'channel' + }, + notify: 3, + number: 2, + pointers: [bufferId], + short_name: '#weechat', + title: '', + type: 0 + } + }, + app: { currentBufferId: null } as AppState } - } - } - ); + }); + const clearHotlistForBuffer = jest.fn(); + const fetchBufferInfo = jest.fn(); + + render( + {}} + fetchBufferInfo={fetchBufferInfo} + sendMessageToBuffer={() => {}} + clearHotlistForBuffer={clearHotlistForBuffer} + />, + { store } + ); + + act(() => { + store.dispatch( + bufferNotificationAction({ + bufferId, + lineId: '8580dcc40', + identifier: '1fb4fc1d-530b-466f-85be-de27772de0a9' + }) + ); + }); + + expect(store.getState().app.currentBufferId).toEqual(bufferId); + expect(clearHotlistForBuffer).toHaveBeenCalledWith(null); + expect(fetchBufferInfo).toHaveBeenCalledWith(bufferId); + }); + + it('fetches buffer info if the notification is for the current buffer', () => { + const bufferId = '86c417600'; + const store = configureStore({ + reducer, + preloadedState: { + buffers: { + [bufferId]: { + full_name: 'irc.libera.#weechat', + hidden: 0, + id: bufferId, + local_variables: { + channel: '#weechat', + name: 'libera.#weechat', + plugin: 'irc', + type: 'channel' + }, + notify: 3, + number: 2, + pointers: [bufferId], + short_name: '#weechat', + title: '', + type: 0 + } + }, + app: { currentBufferId: bufferId } as AppState + } + }); + const fetchBufferInfo = jest.fn(); + const clearHotlistForBuffer = jest.fn(); + + render( + {}} + fetchBufferInfo={fetchBufferInfo} + sendMessageToBuffer={() => {}} + clearHotlistForBuffer={clearHotlistForBuffer} + />, + { store } + ); + + act(() => { + store.dispatch( + bufferNotificationAction({ + bufferId, + lineId: '8580dcc40', + identifier: '1fb4fc1d-530b-466f-85be-de27772de0a9' + }) + ); + }); - expect(tree).toMatchSnapshot(); + expect(fetchBufferInfo).toHaveBeenCalledWith(bufferId); + expect(clearHotlistForBuffer).not.toHaveBeenCalled(); + }); + }); }); diff --git a/__tests__/usecase/__snapshots__/App.tsx.snap b/__tests__/usecase/__snapshots__/App.tsx.snap deleted file mode 100644 index 5a2c455..0000000 --- a/__tests__/usecase/__snapshots__/App.tsx.snap +++ /dev/null @@ -1,518 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`renders correctly 1`] = ` - - - - - - - - - - - # - - - - - - - - - - - < - color="white" - name="lan-disconnect" - size={22} - /> - - - - - - - - - - - - - - - - - - - - #weechat - - - - - - - - - - - - -`; diff --git a/__tests__/usecase/buffers/ui/Buffer.tsx b/__tests__/usecase/buffers/ui/Buffer.tsx new file mode 100644 index 0000000..4c8f60b --- /dev/null +++ b/__tests__/usecase/buffers/ui/Buffer.tsx @@ -0,0 +1,113 @@ +import { + fireEvent, + render, + screen, + waitFor +} from '@testing-library/react-native'; +import React from 'react'; +import { ScrollView } from 'react-native'; +import Buffer from '../../../../src/usecase/buffers/ui/Buffer'; + +jest.useFakeTimers(); + +describe(Buffer, () => { + const measureNickWidth = () => { + const nickWidthText = screen.getByText('aaaaaaaa', { hidden: true }); + fireEvent(nickWidthText, 'layout', { + nativeEvent: { + layout: { height: 26.5, width: 68, x: 0, y: 0 } + } + }); + }; + + describe('scrollToLine', () => { + it('retries when the target line has not yet been measured', async () => { + const bufferRef = React.createRef(); + render( + {}} + parseArgs={[]} + bufferId={''} + fetchMoreLines={() => {}} + /> + ); + + measureNickWidth(); + + // Simulate layout event for the FlatList + const listElement = screen.getByLabelText('Message list'); + fireEvent(listElement, 'layout', { + nativeEvent: { + layout: { height: 26.5, width: 1024, x: 0, y: 0 } + } + }); + + // Simulate layout event for first line + let message = screen.getByTestId('renderCell(0)'); + fireEvent(message, 'layout', { + nativeEvent: { + layout: { height: 26.5, width: 1024, x: 0, y: 0 } + } + }); + + bufferRef.current?.scrollToLine('8580dcf80'); + + expect(ScrollView.prototype.scrollTo).toHaveBeenNthCalledWith(1, { + animated: false, + y: 0 + }); + + // This is effectively a no-op, we are already at 0, 0. However, scrollTo + // triggers this and the VirtualizedList will update internal state based + // on the layout properties, so fire it here as well. + fireEvent.scroll(listElement, { + nativeEvent: { + contentInset: { bottom: 0, left: 0, right: 0, top: 0 }, + contentOffset: { x: 0, y: 0 }, + contentSize: { height: 26.5, width: 1024 }, + layoutMeasurement: { height: 26.5, width: 1024 } + } + }); + + // Simulate layout event for second line + message = screen.getByTestId('renderCell(1)'); + fireEvent(message, 'layout', { + nativeEvent: { + layout: { height: 26.5, width: 1024, x: 0, y: 26.5 } + } + }); + + await waitFor(() => { + expect(ScrollView.prototype.scrollTo).toHaveBeenNthCalledWith(2, { + animated: false, + y: 26.5 + }); + }); + }); + }); +}); diff --git a/__tests__/usecase/buffers/ui/BufferContainer.tsx b/__tests__/usecase/buffers/ui/BufferContainer.tsx new file mode 100644 index 0000000..003ce51 --- /dev/null +++ b/__tests__/usecase/buffers/ui/BufferContainer.tsx @@ -0,0 +1,89 @@ +import 'react-native'; +import BufferContainer from '../../../../src/usecase/buffers/ui/BufferContainer'; +import { act, render } from '../../../../src/test-utils'; +import { reducer } from '../../../../src/store'; +import { configureStore } from '@reduxjs/toolkit'; +import { + bufferNotificationAction, + changeCurrentBufferAction, + fetchLinesAction +} from '../../../../src/store/actions'; +import Buffer from '../../../../src/usecase/buffers/ui/Buffer'; + +jest.mock('.../../../../src/usecase/buffers/ui/Buffer'); + +describe('BufferContainer', () => { + it('scrolls to line and clears notification after fetching lines', () => { + const bufferId = '86c417600'; + const store = configureStore({ + reducer, + preloadedState: { + buffers: { + [bufferId]: { + full_name: 'irc.libera.#weechat', + hidden: 0, + id: bufferId, + local_variables: { + channel: '#weechat', + name: 'libera.#weechat', + plugin: 'irc', + type: 'channel' + }, + notify: 3, + number: 2, + pointers: [bufferId], + short_name: '#weechat', + title: '', + type: 0 + } + } + } + }); + + Buffer.prototype.scrollToLine = jest.fn(); + + render( + {}} + fetchMoreLines={() => {}} + />, + { store } + ); + + act(() => { + store.dispatch( + bufferNotificationAction({ + bufferId, + lineId: '8580dcc40', + identifier: '1fb4fc1d-530b-466f-85be-de27772de0a9' + }) + ); + }); + + expect(Buffer.prototype.scrollToLine).not.toHaveBeenCalled(); + + act(() => { + store.dispatch(changeCurrentBufferAction(bufferId)); + store.dispatch( + fetchLinesAction([ + { + buffer: '86c417600', + date: '2024-04-05T02:40:09.000Z', + date_printed: '2024-04-06T17:20:30.000Z', + displayed: 1, + highlight: 0, + message: 'Second message', + pointers: ['86c417600', '8580eeec0', '8580dcc40', '86c2ff040'], + prefix: 'user', + tags_array: ['irc_privmsg', 'notify_message'] + } as WeechatLine + ]) + ); + }); + + expect(Buffer.prototype.scrollToLine).toHaveBeenCalledWith('8580dcc40'); + expect(store.getState().app.notification).toBeNull(); + }); +}); diff --git a/src/store/actions.ts b/src/store/actions.ts index 284ae05..c8c2ee3 100644 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -59,9 +59,11 @@ export const nicklistUpdatedAction = createAction<{ export const fetchScriptsAction = createAction('FETCH_SCRIPTS'); -export const bufferNotificationAction = createAction( - 'BUFFER_NOTIFICATION' -); +export const bufferNotificationAction = createAction<{ + bufferId: string; + lineId: string; + identifier: string; +}>('BUFFER_NOTIFICATION'); export const clearBufferNotificationAction = createAction( 'CLEAR_BUFFER_NOTIFICATION' ); diff --git a/src/store/app.ts b/src/store/app.ts index b48e5f8..b4dfb45 100644 --- a/src/store/app.ts +++ b/src/store/app.ts @@ -8,18 +8,21 @@ import { upgradeAction, bufferNotificationAction, clearBufferNotificationAction, + fetchLinesAction } from './actions'; -type AppState = { +export type AppState = { connected: boolean; currentBufferId: string | null; - notificationBufferId: string | null; + notification: { bufferId: string; lineId: string; identifier: string } | null; + notificationBufferLinesFetched: boolean; }; const initialState: AppState = { connected: false, currentBufferId: null, - notificationBufferId: null + notification: null, + notificationBufferLinesFetched: false }; export const app = createReducer(initialState, (builder) => { @@ -41,16 +44,27 @@ export const app = createReducer(initialState, (builder) => { currentBufferId: action.payload }; }); + builder.addCase(fetchLinesAction, (state, action) => { + return { + ...state, + notificationBufferLinesFetched: + action.payload.some( + (line) => line.buffer === state.notification?.bufferId + ) || state.notificationBufferLinesFetched + }; + }); builder.addCase(bufferNotificationAction, (state, action) => { return { ...state, - notificationBufferId: action.payload + notification: action.payload, + notificationBufferLinesFetched: false }; }); builder.addCase(clearBufferNotificationAction, (state) => { return { ...state, - notificationBufferId: null + notification: null, + notificationBufferLinesFetched: false }; }); builder.addCase(bufferClosedAction, (state, action) => { diff --git a/src/usecase/App.tsx b/src/usecase/App.tsx index 5408fe1..dc10fb2 100644 --- a/src/usecase/App.tsx +++ b/src/usecase/App.tsx @@ -19,10 +19,7 @@ import { ConnectedProps, connect } from 'react-redux'; import { Feather, MaterialCommunityIcons } from '@expo/vector-icons'; import { registerForPushNotificationsAsync } from '../lib/helpers/push-notifications'; import { StoreState } from '../store'; -import { - changeCurrentBufferAction, - clearBufferNotificationAction -} from '../store/actions'; +import { changeCurrentBufferAction } from '../store/actions'; import BufferGate from './buffers/ui/BufferGate'; import BufferList from './buffers/ui/BufferList'; import NicklistModal from './buffers/ui/NicklistModal'; @@ -42,7 +39,7 @@ const connector = connect((state: StoreState) => { currentBufferId, currentBuffer, hasHighlights: numHighlights > 0, - notificationBufferId: state.app.notificationBufferId + notification: state.app.notification }; }); @@ -156,14 +153,18 @@ class App extends React.Component { } componentDidUpdate(prevProps: Props) { - const { currentBufferId, notificationBufferId, dispatch } = this.props; + const { currentBufferId, notification } = this.props; if ( - notificationBufferId && - notificationBufferId !== prevProps.notificationBufferId + notification && + notification.identifier !== prevProps.notification?.identifier ) { - dispatch(clearBufferNotificationAction()); - this.changeCurrentBuffer(notificationBufferId); + if (currentBufferId !== notification.bufferId) + this.changeCurrentBuffer(notification.bufferId); + else { + this.props.fetchBufferInfo(notification.bufferId); + } + return; } diff --git a/src/usecase/Root.tsx b/src/usecase/Root.tsx index 4cef90e..9390ecf 100644 --- a/src/usecase/Root.tsx +++ b/src/usecase/Root.tsx @@ -52,13 +52,18 @@ export default class WeechatNative extends React.Component { responseListener = Notifications.addNotificationResponseReceivedListener( (response) => { - const { bufferId } = response.notification.request.content.data; + const request = response.notification.request; + const { bufferId, lineId } = request.content.data; - if (!bufferId) return; + if (!bufferId || !lineId) return; store.dispatch( fetchBuffersDispatchAction( - bufferNotificationAction(bufferId.replace(/^0x/, '')) + bufferNotificationAction({ + identifier: request.identifier, + bufferId: bufferId.replace(/^0x/, ''), + lineId: lineId.replace(/^0x/, '') + }) ) ); } @@ -103,7 +108,7 @@ export default class WeechatNative extends React.Component { await listenerApi.condition(fetchBuffersAction.match); const wrappedAction = action.payload; - if (listenerApi.getState().buffers[wrappedAction.payload]) { + if (listenerApi.getState().buffers[wrappedAction.payload.bufferId]) { listenerApi.dispatch(wrappedAction); } } diff --git a/src/usecase/buffers/ui/Buffer.tsx b/src/usecase/buffers/ui/Buffer.tsx index cec312c..5cedae7 100644 --- a/src/usecase/buffers/ui/Buffer.tsx +++ b/src/usecase/buffers/ui/Buffer.tsx @@ -1,5 +1,12 @@ import * as React from 'react'; -import { Button, FlatList, ListRenderItem, Text, View } from 'react-native'; +import { + Button, + CellRendererProps, + FlatList, + ListRenderItem, + Text, + View +} from 'react-native'; import { useEffect, useState } from 'react'; import { ParseShape } from 'react-native-parsed-text'; @@ -59,12 +66,44 @@ export default class Buffer extends React.PureComponent { nickWidth: 0 }; - componentDidUpdate(prevProps: Props) { - const { bufferId } = this.props; - if (bufferId !== prevProps.bufferId) { - this.linesList.current?.scrollToOffset({ animated: false, offset: 0 }); - } - } + onCellLayout?: (index: number) => void; + + onScrollToIndexFailed = async (info: { + index: number; + highestMeasuredFrameIndex: number; + averageItemLength: number; + }) => { + this.linesList.current?.scrollToIndex({ + index: info.highestMeasuredFrameIndex, + animated: false + }); + + await new Promise((resolve) => { + this.onCellLayout = (index: number) => { + if (index > info.highestMeasuredFrameIndex) resolve(); + }; + }); + this.onCellLayout = undefined; + + this.linesList.current?.scrollToIndex({ + index: info.index, + animated: false, + viewPosition: 0.5 + }); + }; + + scrollToLine = (lineId: string) => { + const index = this.props.lines.findIndex( + (line) => line.pointers[line.pointers.length - 2] === lineId + ); + if (index < 0) return; + + this.linesList.current?.scrollToIndex({ + index: index, + animated: false, + viewPosition: 0.5 + }); + }; renderBuffer: ListRenderItem = ({ item, index }) => { const { onLongPress, parseArgs, lastReadLine, lines } = this.props; @@ -88,6 +127,26 @@ export default class Buffer extends React.PureComponent { ); }; + renderCell: React.FC> = ({ + index, + children, + onLayout, + style + }) => { + return ( + { + onLayout?.(event); + this.onCellLayout?.(index); + }} + > + {children} + + ); + }; + render() { const { bufferId, lines, fetchMoreLines } = this.props; @@ -109,6 +168,7 @@ export default class Buffer extends React.PureComponent { return ( { maxToRenderPerBatch={35} removeClippedSubviews={true} windowSize={15} + CellRendererComponent={this.renderCell} ListFooterComponent={
{ fetchMoreLines={fetchMoreLines} /> } + onScrollToIndexFailed={this.onScrollToIndexFailed} /> ); } diff --git a/src/usecase/buffers/ui/BufferContainer.tsx b/src/usecase/buffers/ui/BufferContainer.tsx index 692c004..cd0ab81 100644 --- a/src/usecase/buffers/ui/BufferContainer.tsx +++ b/src/usecase/buffers/ui/BufferContainer.tsx @@ -25,12 +25,18 @@ import { StoreState } from '../../../store'; import Buffer from './Buffer'; import UndoTextInput from '../../shared/UndoTextInput'; import UploadButton from './UploadButton'; +import { clearBufferNotificationAction } from '../../../store/actions'; const connector = connect((state: StoreState, { bufferId }: OwnProps) => ({ lines: state.lines[bufferId] || [], nicklist: state.nicklists[bufferId] || [], buffer: state.buffers[bufferId], - mediaUploadOptions: state.connection.mediaUploadOptions + mediaUploadOptions: state.connection.mediaUploadOptions, + notification: + bufferId === state.app.notification?.bufferId && + state.app.notificationBufferLinesFetched + ? state.app.notification + : null })); type PropsFromRedux = ConnectedProps; @@ -72,6 +78,19 @@ class BufferContainer extends React.Component { this.handleOnLongPress ); + buffer = React.createRef(); + + componentDidUpdate(prevProps: Readonly): void { + const { notification } = this.props; + if ( + notification && + notification.identifier !== prevProps.notification?.identifier + ) { + this.buffer.current?.scrollToLine(notification.lineId); + this.props.dispatch(clearBufferNotificationAction()); + } + } + handleOnFocus = () => { this.setState({ showTabButton: true @@ -214,6 +233,7 @@ class BufferContainer extends React.Component { )}