From da4139242250867fa26eca2ac86f816683553ec0 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Fri, 17 Feb 2023 13:00:57 -0600 Subject: [PATCH] Fixes to notifications (#216) * Improve push-notification for follows * Refresh notifications on screen open (close #214) * Avoid showing loader more than needed in post threads * Refactor notification polling to handle view-state more effectively * Delete a bunch of tests taht werent adding value * Remove the accounts integration test; we'll use the e2e test instead * Load latest in notifications when the screen is open rather than full refresh --- __mocks__/react-native-svg.js | 67 +++++ __tests__/accounts.test.tsx | 257 ------------------ .../state/models/link-metas-view.test.ts | 71 ----- __tests__/state/models/log.test.ts | 153 ----------- __tests__/state/models/me.test.ts | 161 ----------- __tests__/state/models/root-store.test.ts | 72 ----- __tests__/state/models/shell-ui.test.ts | 66 ----- .../view/com/composer/Autocomplete.test.tsx | 43 --- .../view/com/composer/ComposePost.test.tsx | 118 -------- .../view/com/composer/SelectedPhoto.test.tsx | 70 ----- .../view/com/login/CreateAccount.test.tsx | 58 ---- .../view/com/profile/ProfileHeader.test.tsx | 110 -------- __tests__/view/lib/useAnimatedValue.test.tsx | 17 -- __tests__/view/lib/useOnMainScroll.test.tsx | 49 ---- __tests__/view/screens/Login.test.tsx | 37 --- __tests__/view/screens/NotFound.test.tsx | 21 -- __tests__/view/shell/mobile/Menu.test.tsx | 56 ---- .../view/shell/mobile/TabsSelector.test.tsx | 100 ------- jest/jestSetup.js | 8 +- src/App.native.tsx | 12 +- src/state/models/me.ts | 47 +--- src/state/models/notifications-view.ts | 99 ++++++- src/state/models/root-store.ts | 52 +++- src/view/com/notifications/Feed.tsx | 24 +- src/view/com/post-thread/PostThread.tsx | 17 +- src/view/lib/notifee.ts | 24 +- src/view/screens/Home.tsx | 6 +- src/view/screens/Notifications.tsx | 78 ++++-- src/view/shell/mobile/index.tsx | 2 +- 29 files changed, 319 insertions(+), 1576 deletions(-) create mode 100644 __mocks__/react-native-svg.js delete mode 100644 __tests__/accounts.test.tsx delete mode 100644 __tests__/state/models/link-metas-view.test.ts delete mode 100644 __tests__/state/models/log.test.ts delete mode 100644 __tests__/state/models/me.test.ts delete mode 100644 __tests__/state/models/root-store.test.ts delete mode 100644 __tests__/state/models/shell-ui.test.ts delete mode 100644 __tests__/view/com/composer/Autocomplete.test.tsx delete mode 100644 __tests__/view/com/composer/ComposePost.test.tsx delete mode 100644 __tests__/view/com/composer/SelectedPhoto.test.tsx delete mode 100644 __tests__/view/com/login/CreateAccount.test.tsx delete mode 100644 __tests__/view/com/profile/ProfileHeader.test.tsx delete mode 100644 __tests__/view/lib/useAnimatedValue.test.tsx delete mode 100644 __tests__/view/lib/useOnMainScroll.test.tsx delete mode 100644 __tests__/view/screens/Login.test.tsx delete mode 100644 __tests__/view/screens/NotFound.test.tsx delete mode 100644 __tests__/view/shell/mobile/Menu.test.tsx delete mode 100644 __tests__/view/shell/mobile/TabsSelector.test.tsx diff --git a/__mocks__/react-native-svg.js b/__mocks__/react-native-svg.js new file mode 100644 index 0000000000..0b011bd9e0 --- /dev/null +++ b/__mocks__/react-native-svg.js @@ -0,0 +1,67 @@ +// @flow + +// https://github.com/FormidableLabs/react-native-svg-mock +import React from 'react' + +const createComponent = function (name: string) { + return class extends React.Component { + // overwrite the displayName, since this is a class created dynamically + static displayName = name + + render() { + return React.createElement(name, this.props, this.props.children) + } + } +} + +// Mock all react-native-svg exports +// from https://github.com/magicismight/react-native-svg/blob/master/index.js +const Svg = createComponent('Svg') +const Circle = createComponent('Circle') +const Ellipse = createComponent('Ellipse') +const G = createComponent('G') +const Text = createComponent('Text') +const TextPath = createComponent('TextPath') +const TSpan = createComponent('TSpan') +const Path = createComponent('Path') +const Polygon = createComponent('Polygon') +const Polyline = createComponent('Polyline') +const Line = createComponent('Line') +const Rect = createComponent('Rect') +const Use = createComponent('Use') +const Image = createComponent('Image') +const Symbol = createComponent('Symbol') +const Defs = createComponent('Defs') +const LinearGradient = createComponent('LinearGradient') +const RadialGradient = createComponent('RadialGradient') +const Stop = createComponent('Stop') +const ClipPath = createComponent('ClipPath') +const Pattern = createComponent('Pattern') +const Mask = createComponent('Mask') + +export { + Svg, + Circle, + Ellipse, + G, + Text, + TextPath, + TSpan, + Path, + Polygon, + Polyline, + Line, + Rect, + Use, + Image, + Symbol, + Defs, + LinearGradient, + RadialGradient, + Stop, + ClipPath, + Pattern, + Mask, +} + +export default Svg diff --git a/__tests__/accounts.test.tsx b/__tests__/accounts.test.tsx deleted file mode 100644 index 4cd0b961cf..0000000000 --- a/__tests__/accounts.test.tsx +++ /dev/null @@ -1,257 +0,0 @@ -import React from 'react' -import {MobileShell} from '../src/view/shell/mobile' -import {cleanup, fireEvent, render, waitFor} from '../jest/test-utils' -import {createServer, TestPDS} from '../jest/test-pds' -import {RootStoreModel, setupState} from '../src/state' - -const WAIT_OPTS = {timeout: 10e3} - -describe('Account flows', () => { - let pds: TestPDS | undefined - let rootStore: RootStoreModel | undefined - beforeAll(async () => { - jest.useFakeTimers() - pds = await createServer() - rootStore = await setupState(pds.pdsUrl) - }) - - afterAll(async () => { - jest.clearAllMocks() - cleanup() - await pds?.close() - }) - - it('renders initial screen', () => { - const {getByTestId} = render(, rootStore) - const signUpScreen = getByTestId('signinOrCreateAccount') - - expect(signUpScreen).toBeTruthy() - }) - - it('completes signin to the server', async () => { - const {getByTestId} = render(, rootStore) - - // move to signin view - fireEvent.press(getByTestId('signInButton')) - expect(getByTestId('signIn')).toBeTruthy() - expect(getByTestId('loginForm')).toBeTruthy() - - // input the target server - expect(getByTestId('loginSelectServiceButton')).toBeTruthy() - fireEvent.press(getByTestId('loginSelectServiceButton')) - expect(getByTestId('serverInputModal')).toBeTruthy() - fireEvent.changeText( - getByTestId('customServerTextInput'), - pds?.pdsUrl || '', - ) - fireEvent.press(getByTestId('customServerSelectBtn')) - await waitFor(() => { - expect(getByTestId('loginUsernameInput')).toBeTruthy() - }, WAIT_OPTS) - - // enter username & pass - fireEvent.changeText(getByTestId('loginUsernameInput'), 'alice') - fireEvent.changeText(getByTestId('loginPasswordInput'), 'hunter2') - await waitFor(() => { - expect(getByTestId('loginNextButton')).toBeTruthy() - }, WAIT_OPTS) - fireEvent.press(getByTestId('loginNextButton')) - - // signed in - await waitFor(() => { - expect(getByTestId('homeFeed')).toBeTruthy() - expect(rootStore?.me?.displayName).toBe('Alice') - expect(rootStore?.me?.handle).toBe('alice.test') - expect(rootStore?.session.accounts.length).toBe(1) - }, WAIT_OPTS) - expect(rootStore?.me?.displayName).toBe('Alice') - expect(rootStore?.me?.handle).toBe('alice.test') - expect(rootStore?.session.accounts.length).toBe(1) - }) - - it('opens the login screen when "add account" is pressed', async () => { - const {getByTestId, getAllByTestId} = render(, rootStore) - await waitFor(() => expect(getByTestId('homeFeed')).toBeTruthy(), WAIT_OPTS) - - // open side menu - fireEvent.press(getAllByTestId('viewHeaderBackOrMenuBtn')[0]) - await waitFor(() => expect(getByTestId('menuView')).toBeTruthy(), WAIT_OPTS) - - // nav to settings - fireEvent.press(getByTestId('menuItemButton-Settings')) - await waitFor( - () => expect(getByTestId('settingsScreen')).toBeTruthy(), - WAIT_OPTS, - ) - - // press '+ new account' in switcher - fireEvent.press(getByTestId('switchToNewAccountBtn')) - await waitFor( - () => expect(getByTestId('signinOrCreateAccount')).toBeTruthy(), - WAIT_OPTS, - ) - }) - - it('shows the "choose account" form when a previous session has been created', async () => { - const {getByTestId} = render(, rootStore) - - // move to signin view - fireEvent.press(getByTestId('signInButton')) - expect(getByTestId('signIn')).toBeTruthy() - expect(getByTestId('chooseAccountForm')).toBeTruthy() - }) - - it('logs directly into the account due to still possessing session tokens', async () => { - const {getByTestId} = render(, rootStore) - - // move to signin view - fireEvent.press(getByTestId('signInButton')) - expect(getByTestId('signIn')).toBeTruthy() - expect(getByTestId('chooseAccountForm')).toBeTruthy() - - // select the previous account - fireEvent.press(getByTestId('chooseAccountBtn-alice.test')) - - // signs in immediately - await waitFor(() => { - expect(getByTestId('homeFeed')).toBeTruthy() - expect(rootStore?.me?.displayName).toBe('Alice') - expect(rootStore?.me?.handle).toBe('alice.test') - expect(rootStore?.session.accounts.length).toBe(1) - }, WAIT_OPTS) - expect(rootStore?.me?.displayName).toBe('Alice') - expect(rootStore?.me?.handle).toBe('alice.test') - expect(rootStore?.session.accounts.length).toBe(1) - }) - - it('logs into a second account via the switcher', async () => { - const {getByTestId, getAllByTestId} = render(, rootStore) - await waitFor(() => expect(getByTestId('homeFeed')).toBeTruthy(), WAIT_OPTS) - - // open side menu - fireEvent.press(getAllByTestId('viewHeaderBackOrMenuBtn')[0]) - await waitFor(() => expect(getByTestId('menuView')).toBeTruthy(), WAIT_OPTS) - - // nav to settings - fireEvent.press(getByTestId('menuItemButton-Settings')) - await waitFor( - () => expect(getByTestId('settingsScreen')).toBeTruthy(), - WAIT_OPTS, - ) - - // press '+ new account' in switcher - fireEvent.press(getByTestId('switchToNewAccountBtn')) - await waitFor( - () => expect(getByTestId('signinOrCreateAccount')).toBeTruthy(), - WAIT_OPTS, - ) - - // move to signin view - fireEvent.press(getByTestId('signInButton')) - expect(getByTestId('signIn')).toBeTruthy() - expect(getByTestId('chooseAccountForm')).toBeTruthy() - - // select a new account - fireEvent.press(getByTestId('chooseNewAccountBtn')) - expect(getByTestId('loginForm')).toBeTruthy() - - // input the target server - expect(getByTestId('loginSelectServiceButton')).toBeTruthy() - fireEvent.press(getByTestId('loginSelectServiceButton')) - expect(getByTestId('serverInputModal')).toBeTruthy() - fireEvent.changeText( - getByTestId('customServerTextInput'), - pds?.pdsUrl || '', - ) - fireEvent.press(getByTestId('customServerSelectBtn')) - await waitFor( - () => expect(getByTestId('loginUsernameInput')).toBeTruthy(), - WAIT_OPTS, - ) - - // enter username & pass - fireEvent.changeText(getByTestId('loginUsernameInput'), 'bob') - fireEvent.changeText(getByTestId('loginPasswordInput'), 'hunter2') - await waitFor( - () => expect(getByTestId('loginNextButton')).toBeTruthy(), - WAIT_OPTS, - ) - fireEvent.press(getByTestId('loginNextButton')) - - // signed in - await waitFor(() => { - expect(getByTestId('homeFeed')).toBeTruthy() // we go back to settings in this situation - expect(rootStore?.me?.displayName).toBe('Bob') - expect(rootStore?.me?.handle).toBe('bob.test') - expect(rootStore?.session.accounts.length).toBe(2) - }, WAIT_OPTS) - expect(rootStore?.me?.displayName).toBe('Bob') - expect(rootStore?.me?.handle).toBe('bob.test') - expect(rootStore?.session.accounts.length).toBe(2) - }) - - it('can instantly switch between accounts', async () => { - const {getAllByTestId, getByTestId} = render(, rootStore) - await waitFor(() => expect(getByTestId('homeFeed')).toBeTruthy(), WAIT_OPTS) - - // open side menu - fireEvent.press(getAllByTestId('viewHeaderBackOrMenuBtn')[0]) - await waitFor(() => expect(getByTestId('menuView')).toBeTruthy(), WAIT_OPTS) - - // nav to settings - fireEvent.press(getByTestId('menuItemButton-Settings')) - await waitFor( - () => expect(getByTestId('settingsScreen')).toBeTruthy(), - WAIT_OPTS, - ) - - // select the alice account - fireEvent.press(getByTestId('switchToAccountBtn-alice.test')) - - // swapped account - await waitFor(() => { - expect(rootStore?.me?.displayName).toBe('Alice') - expect(rootStore?.me?.handle).toBe('alice.test') - expect(rootStore?.session.accounts.length).toBe(2) - }, WAIT_OPTS) - expect(rootStore?.me?.displayName).toBe('Alice') - expect(rootStore?.me?.handle).toBe('alice.test') - expect(rootStore?.session.accounts.length).toBe(2) - }) - - it('will prompt for a password if you sign out', async () => { - const {getAllByTestId, getByTestId} = render(, rootStore) - await waitFor(() => expect(getByTestId('homeFeed')).toBeTruthy(), WAIT_OPTS) - - // open side menu - fireEvent.press(getAllByTestId('viewHeaderBackOrMenuBtn')[0]) - await waitFor(() => expect(getByTestId('menuView')).toBeTruthy(), WAIT_OPTS) - - // nav to settings - fireEvent.press(getByTestId('menuItemButton-Settings')) - await waitFor( - () => expect(getByTestId('settingsScreen')).toBeTruthy(), - WAIT_OPTS, - ) - - // press the sign out button - fireEvent.press(getByTestId('signOutBtn')) - - // in the logged out state - await waitFor( - () => expect(getByTestId('signinOrCreateAccount')).toBeTruthy(), - WAIT_OPTS, - ) - - // move to signin view - fireEvent.press(getByTestId('signInButton')) - expect(getByTestId('signIn')).toBeTruthy() - expect(getByTestId('chooseAccountForm')).toBeTruthy() - - // select an existing account - fireEvent.press(getByTestId('chooseAccountBtn-alice.test')) - - // goes to login screen instead of straight back to settings - expect(getByTestId('loginForm')).toBeTruthy() - }) -}) diff --git a/__tests__/state/models/link-metas-view.test.ts b/__tests__/state/models/link-metas-view.test.ts deleted file mode 100644 index 80cdebb694..0000000000 --- a/__tests__/state/models/link-metas-view.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import {RootStoreModel} from '../../../src/state/models/root-store' -import {LinkMetasViewModel} from '../../../src/state/models/link-metas-view' -import * as LinkMetaLib from '../../../src/lib/link-meta' -import {LikelyType} from './../../../src/lib/link-meta' -import AtpAgent from '@atproto/api' -import {DEFAULT_SERVICE} from '../../../src/state' - -describe('LinkMetasViewModel', () => { - let viewModel: LinkMetasViewModel - let rootStore: RootStoreModel - - const getLinkMetaMockSpy = jest.spyOn(LinkMetaLib, 'getLinkMeta') - const mockedMeta = { - title: 'Test Title', - url: 'testurl', - likelyType: LikelyType.Other, - } - - beforeEach(() => { - rootStore = new RootStoreModel(new AtpAgent({service: DEFAULT_SERVICE})) - viewModel = new LinkMetasViewModel(rootStore) - }) - - afterAll(() => { - jest.clearAllMocks() - }) - - describe('getLinkMeta', () => { - it('should return link meta if it is cached', async () => { - const url = 'http://example.com' - - viewModel.cache.set(url, mockedMeta) - - const result = await viewModel.getLinkMeta(url) - - expect(getLinkMetaMockSpy).not.toHaveBeenCalled() - expect(result).toEqual(mockedMeta) - }) - - it('should return link meta if it is not cached', async () => { - getLinkMetaMockSpy.mockResolvedValueOnce(mockedMeta) - - const result = await viewModel.getLinkMeta(mockedMeta.url) - - expect(getLinkMetaMockSpy).toHaveBeenCalledWith(rootStore, mockedMeta.url) - expect(result).toEqual(mockedMeta) - }) - - it('should cache the link meta if it is successfully returned', async () => { - getLinkMetaMockSpy.mockResolvedValueOnce(mockedMeta) - - await viewModel.getLinkMeta(mockedMeta.url) - - expect(viewModel.cache.get(mockedMeta.url)).toEqual(mockedMeta) - }) - - it('should not cache the link meta if it fails to return', async () => { - const url = 'http://example.com' - const error = new Error('Failed to fetch link meta') - getLinkMetaMockSpy.mockRejectedValueOnce(error) - - try { - await viewModel.getLinkMeta(url) - fail('Error was not thrown') - } catch (e) { - expect(e).toEqual(error) - expect(viewModel.cache.get(url)).toBeUndefined() - } - }) - }) -}) diff --git a/__tests__/state/models/log.test.ts b/__tests__/state/models/log.test.ts deleted file mode 100644 index b5a6d0db0d..0000000000 --- a/__tests__/state/models/log.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import {LogModel} from '../../../src/state/models/log' - -describe('LogModel', () => { - let logModel: LogModel - - beforeEach(() => { - logModel = new LogModel() - jest.spyOn(console, 'debug') - }) - - afterAll(() => { - jest.clearAllMocks() - }) - - it('should call a log method and add a log entry to the entries array', () => { - logModel.debug('Test log') - expect(logModel.entries.length).toEqual(1) - expect(logModel.entries[0]).toEqual({ - id: logModel.entries[0].id, - type: 'debug', - summary: 'Test log', - details: undefined, - ts: logModel.entries[0].ts, - }) - - logModel.warn('Test log') - expect(logModel.entries.length).toEqual(2) - expect(logModel.entries[1]).toEqual({ - id: logModel.entries[1].id, - type: 'warn', - summary: 'Test log', - details: undefined, - ts: logModel.entries[1].ts, - }) - - logModel.error('Test log') - expect(logModel.entries.length).toEqual(3) - expect(logModel.entries[2]).toEqual({ - id: logModel.entries[2].id, - type: 'error', - summary: 'Test log', - details: undefined, - ts: logModel.entries[2].ts, - }) - }) - - it('should call the console.debug after calling the debug method', () => { - logModel.debug('Test log') - expect(console.debug).toHaveBeenCalledWith('Test log', '') - }) - - it('should call the serialize method', () => { - logModel.debug('Test log') - expect(logModel.serialize()).toEqual({ - entries: [ - { - id: logModel.entries[0].id, - type: 'debug', - summary: 'Test log', - details: undefined, - ts: logModel.entries[0].ts, - }, - ], - }) - }) - - it('should call the hydrate method with valid properties', () => { - logModel.hydrate({ - entries: [ - { - id: '123', - type: 'debug', - summary: 'Test log', - details: undefined, - ts: 123, - }, - ], - }) - expect(logModel.entries).toEqual([ - { - id: '123', - type: 'debug', - summary: 'Test log', - details: undefined, - ts: 123, - }, - ]) - }) - - it('should call the hydrate method with invalid properties', () => { - logModel.hydrate({ - entries: [ - { - id: '123', - type: 'debug', - summary: 'Test log', - details: undefined, - ts: 123, - }, - { - summary: 'Invalid entry', - }, - ], - }) - expect(logModel.entries).toEqual([ - { - id: '123', - type: 'debug', - summary: 'Test log', - details: undefined, - ts: 123, - }, - ]) - }) - - it('should stringify the details if it is not a string', () => { - logModel.debug('Test log', {details: 'test'}) - expect(logModel.entries[0].details).toEqual('{\n "details": "test"\n}') - }) - - it('should stringify the details object if it is of a specific error', () => { - class TestError extends Error { - constructor() { - super() - this.name = 'TestError' - } - } - const error = new TestError() - logModel.error('Test error log', error) - expect(logModel.entries[0].details).toEqual('TestError') - - class XRPCInvalidResponseErrorMock { - validationError = {toString: () => 'validationError'} - lexiconNsid = 'test' - } - const xrpcInvalidResponseError = new XRPCInvalidResponseErrorMock() - logModel.error('Test error log', xrpcInvalidResponseError) - expect(logModel.entries[1].details).toEqual( - '{\n "validationError": {},\n "lexiconNsid": "test"\n}', - ) - - class XRPCErrorMock { - status = 'status' - error = 'error' - message = 'message' - } - const xrpcError = new XRPCErrorMock() - logModel.error('Test error log', xrpcError) - expect(logModel.entries[2].details).toEqual( - '{\n "status": "status",\n "error": "error",\n "message": "message"\n}', - ) - }) -}) diff --git a/__tests__/state/models/me.test.ts b/__tests__/state/models/me.test.ts deleted file mode 100644 index 7e9a6670bb..0000000000 --- a/__tests__/state/models/me.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import {createServer, TestPDS} from '../../../jest/test-pds' -import {RootStoreModel, setupState} from '../../../src/state' -import {NotificationsViewModel} from '../../../src/state/models/notifications-view' - -describe('MeModel', () => { - let pds: TestPDS | undefined - let rootStore: RootStoreModel - beforeAll(async () => { - jest.useFakeTimers() - pds = await createServer() - rootStore = await setupState(pds.pdsUrl) - }) - - afterAll(async () => { - jest.clearAllMocks() - await pds?.close() - }) - - it('should clear() correctly', () => { - rootStore.me.did = '123' - rootStore.me.handle = 'handle' - rootStore.me.displayName = 'John Doe' - rootStore.me.description = 'description' - rootStore.me.avatar = 'avatar' - rootStore.me.notificationCount = 1 - rootStore.me.clear() - expect(rootStore.me.did).toEqual('') - expect(rootStore.me.handle).toEqual('') - expect(rootStore.me.displayName).toEqual('') - expect(rootStore.me.description).toEqual('') - expect(rootStore.me.avatar).toEqual('') - expect(rootStore.me.notificationCount).toEqual(0) - }) - - it('should hydrate() successfully with valid properties', () => { - rootStore.me.clear() - rootStore.me.hydrate({ - did: '123', - handle: 'handle', - displayName: 'John Doe', - description: 'description', - avatar: 'avatar', - }) - expect(rootStore.me.did).toEqual('123') - expect(rootStore.me.handle).toEqual('handle') - expect(rootStore.me.displayName).toEqual('John Doe') - expect(rootStore.me.description).toEqual('description') - expect(rootStore.me.avatar).toEqual('avatar') - }) - - it('should not hydrate() with invalid properties', () => { - rootStore.me.clear() - rootStore.me.hydrate({ - did: '', - handle: 'handle', - displayName: 'John Doe', - description: 'description', - avatar: 'avatar', - }) - expect(rootStore.me.did).toEqual('') - expect(rootStore.me.handle).toEqual('') - expect(rootStore.me.displayName).toEqual('') - expect(rootStore.me.description).toEqual('') - expect(rootStore.me.avatar).toEqual('') - - rootStore.me.hydrate({ - did: '123', - displayName: 'John Doe', - description: 'description', - avatar: 'avatar', - }) - expect(rootStore.me.did).toEqual('') - expect(rootStore.me.handle).toEqual('') - expect(rootStore.me.displayName).toEqual('') - expect(rootStore.me.description).toEqual('') - expect(rootStore.me.avatar).toEqual('') - }) - - it('should serialize() key information', () => { - rootStore.me.did = '123' - rootStore.me.handle = 'handle' - rootStore.me.displayName = 'John Doe' - rootStore.me.description = 'description' - rootStore.me.avatar = 'avatar' - - expect(rootStore.me.serialize()).toEqual({ - did: '123', - handle: 'handle', - displayName: 'John Doe', - description: 'description', - avatar: 'avatar', - }) - }) - - it('should load() successfully', async () => { - await rootStore.session.login({ - service: pds?.pdsUrl || '', - identifier: 'alice.test', - password: 'hunter2', - }) - - await rootStore.me.load() - expect(typeof rootStore.me.did).toEqual('string') - expect(rootStore.me.handle).toEqual('alice.test') - expect(rootStore.me.displayName).toEqual('Alice') - expect(rootStore.me.description).toEqual('Test user 1') - expect(rootStore.me.avatar).toEqual('') - }) - - it('should load() successfully without profile data', async () => { - jest - .spyOn(rootStore.api.app.bsky.actor, 'getProfile') - .mockImplementationOnce((): Promise => { - return Promise.resolve({ - data: null, - }) - }) - await rootStore.me.load() - expect(typeof rootStore.me.did).toEqual('string') - expect(rootStore.me.handle).toEqual('alice.test') - expect(rootStore.me.displayName).toEqual('') - expect(rootStore.me.description).toEqual('') - expect(rootStore.me.avatar).toEqual('') - }) - - it('should load() to nothing when no session', async () => { - await rootStore.session.logout() - await rootStore.me.load() - expect(rootStore.me.did).toEqual('') - expect(rootStore.me.handle).toEqual('') - expect(rootStore.me.displayName).toEqual('') - expect(rootStore.me.description).toEqual('') - expect(rootStore.me.avatar).toEqual('') - expect(rootStore.me.notificationCount).toEqual(0) - }) - - it('should clearNotificationCount() successfully', () => { - rootStore.me.clearNotificationCount() - expect(rootStore.me.notificationCount).toBe(0) - }) - - it('should update notifs count with fetchStateUpdate()', async () => { - rootStore.me.notifications = { - refresh: jest.fn().mockResolvedValue({}), - } as unknown as NotificationsViewModel - - jest - .spyOn(rootStore.api.app.bsky.notification, 'getCount') - .mockImplementationOnce((): Promise => { - return Promise.resolve({ - data: { - count: 1, - }, - }) - }) - - await rootStore.me.fetchNotifications() - expect(rootStore.me.notificationCount).toBe(1) - expect(rootStore.me.notifications.refresh).toHaveBeenCalled() - }) -}) diff --git a/__tests__/state/models/root-store.test.ts b/__tests__/state/models/root-store.test.ts deleted file mode 100644 index bbd96dca9b..0000000000 --- a/__tests__/state/models/root-store.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import {RootStoreModel} from '../../../src/state/models/root-store' -import {setupState} from '../../../src/state' - -describe('rootStore', () => { - let rootStore: RootStoreModel - - beforeAll(() => { - jest.useFakeTimers() - }) - - beforeEach(async () => { - rootStore = await setupState() - }) - - afterAll(() => { - jest.clearAllMocks() - }) - - it('should call the clearAll() resets state correctly', () => { - rootStore.clearAllSessionState() - - expect(rootStore.session.data).toEqual(null) - expect(rootStore.nav.tabs).toEqual([ - { - fixedTabPurpose: 0, - history: [ - { - id: expect.anything(), - ts: expect.anything(), - url: '/', - }, - ], - id: expect.anything(), - index: 0, - isNewTab: false, - }, - { - fixedTabPurpose: 1, - history: [ - { - id: expect.anything(), - ts: expect.anything(), - url: '/search', - }, - ], - id: expect.anything(), - index: 0, - isNewTab: false, - }, - { - fixedTabPurpose: 2, - history: [ - { - id: expect.anything(), - ts: expect.anything(), - url: '/notifications', - }, - ], - id: expect.anything(), - index: 0, - isNewTab: false, - }, - ]) - expect(rootStore.nav.tabIndex).toEqual(0) - expect(rootStore.me.did).toEqual('') - expect(rootStore.me.handle).toEqual('') - expect(rootStore.me.displayName).toEqual('') - expect(rootStore.me.description).toEqual('') - expect(rootStore.me.avatar).toEqual('') - expect(rootStore.me.notificationCount).toEqual(0) - }) -}) diff --git a/__tests__/state/models/shell-ui.test.ts b/__tests__/state/models/shell-ui.test.ts deleted file mode 100644 index adf59f17ea..0000000000 --- a/__tests__/state/models/shell-ui.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { - ConfirmModal, - ImagesLightbox, - ShellUiModel, -} from './../../../src/state/models/shell-ui' -import {RootStoreModel} from '../../../src/state' -import AtpAgent from '@atproto/api' -import {DEFAULT_SERVICE} from '../../../src/state' - -describe('ShellUiModel', () => { - let model: ShellUiModel - let rootStore: RootStoreModel - - beforeEach(() => { - rootStore = new RootStoreModel(new AtpAgent({service: DEFAULT_SERVICE})) - model = new ShellUiModel(rootStore) - }) - - afterAll(() => { - jest.clearAllMocks() - }) - - it('should call the openModal & closeModal method', () => { - const m = new ConfirmModal('Test Modal', 'Look good?', () => {}) - model.openModal(m) - expect(model.isModalActive).toEqual(true) - expect(model.activeModal).toEqual(m) - - model.closeModal() - expect(model.isModalActive).toEqual(false) - expect(model.activeModal).toBeUndefined() - }) - - it('should call the openLightbox & closeLightbox method', () => { - const lt = new ImagesLightbox(['uri'], 0) - model.openLightbox(lt) - expect(model.isLightboxActive).toEqual(true) - expect(model.activeLightbox).toEqual(lt) - - model.closeLightbox() - expect(model.isLightboxActive).toEqual(false) - expect(model.activeLightbox).toBeUndefined() - }) - - it('should call the openComposer & closeComposer method', () => { - const composer = { - replyTo: { - uri: 'uri', - cid: 'cid', - text: 'text', - author: { - handle: 'handle', - displayName: 'name', - }, - }, - onPost: jest.fn(), - } - model.openComposer(composer) - expect(model.isComposerActive).toEqual(true) - expect(model.composerOpts).toEqual(composer) - - model.closeComposer() - expect(model.isComposerActive).toEqual(false) - expect(model.composerOpts).toBeUndefined() - }) -}) diff --git a/__tests__/view/com/composer/Autocomplete.test.tsx b/__tests__/view/com/composer/Autocomplete.test.tsx deleted file mode 100644 index 57539b75ee..0000000000 --- a/__tests__/view/com/composer/Autocomplete.test.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import React from 'react' -import {Autocomplete} from '../../../../src/view/com/composer/Autocomplete' -import {cleanup, fireEvent, render} from '../../../../jest/test-utils' - -describe('Autocomplete', () => { - const onSelectMock = jest.fn() - const mockedProps = { - active: true, - items: [ - { - handle: 'handle.test', - displayName: 'Test Display', - }, - { - handle: 'handle2.test', - displayName: 'Test Display 2', - }, - ], - onSelect: onSelectMock, - } - - afterAll(() => { - jest.clearAllMocks() - cleanup() - }) - - it('renders a button for each user', async () => { - const {findAllByTestId} = render() - const autocompleteButton = await findAllByTestId('autocompleteButton') - expect(autocompleteButton.length).toBe(2) - }) - - it('triggers onSelect by pressing the button', async () => { - const {findAllByTestId} = render() - const autocompleteButton = await findAllByTestId('autocompleteButton') - - fireEvent.press(autocompleteButton[0]) - expect(onSelectMock).toHaveBeenCalledWith('handle.test') - - fireEvent.press(autocompleteButton[1]) - expect(onSelectMock).toHaveBeenCalledWith('handle2.test') - }) -}) diff --git a/__tests__/view/com/composer/ComposePost.test.tsx b/__tests__/view/com/composer/ComposePost.test.tsx deleted file mode 100644 index 5c5a61812c..0000000000 --- a/__tests__/view/com/composer/ComposePost.test.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import React from 'react' -import {ComposePost} from '../../../../src/view/com/composer/ComposePost' -import {cleanup, fireEvent, render, waitFor} from '../../../../jest/test-utils' -import * as apilib from '../../../../src/state/lib/api' -import { - mockedAutocompleteViewStore, - mockedRootStore, -} from '../../../../__mocks__/state-mock' -import Toast from 'react-native-root-toast' - -describe('ComposePost', () => { - const mockedProps = { - replyTo: { - uri: 'testUri', - cid: 'testCid', - text: 'testText', - author: { - handle: 'test.handle', - displayName: 'test name', - avatar: '', - }, - }, - onPost: jest.fn(), - onClose: jest.fn(), - } - - afterAll(() => { - jest.clearAllMocks() - cleanup() - }) - - it('renders post composer', async () => { - const {findByTestId} = render() - const composePostView = await findByTestId('composePostView') - expect(composePostView).toBeTruthy() - }) - - it('closes composer', async () => { - const {findByTestId} = render() - const composerCancelButton = await findByTestId('composerCancelButton') - fireEvent.press(composerCancelButton) - expect(mockedProps.onClose).toHaveBeenCalled() - }) - - it('changes text and publishes post', async () => { - const postSpy = jest.spyOn(apilib, 'post').mockResolvedValue({ - uri: '', - cid: '', - }) - const toastSpy = jest.spyOn(Toast, 'show') - - const wrapper = render() - - const composerTextInput = await wrapper.findByTestId('composerTextInput') - fireEvent.changeText(composerTextInput, 'testing publish') - - const composerPublishButton = await wrapper.findByTestId( - 'composerPublishButton', - ) - fireEvent.press(composerPublishButton) - - expect(postSpy).toHaveBeenCalledWith( - mockedRootStore, - 'testing publish', - 'testUri', - undefined, - [], - new Set(), - expect.anything(), - ) - - // Waits for request to be resolved - await waitFor(() => { - expect(mockedProps.onPost).toHaveBeenCalled() - expect(mockedProps.onClose).toHaveBeenCalled() - expect(toastSpy).toHaveBeenCalledWith('Your reply has been published', { - animation: true, - duration: 3500, - hideOnPress: true, - position: 50, - shadow: true, - }) - }) - }) - - it('selects autocomplete item', async () => { - jest - .spyOn(React, 'useMemo') - .mockReturnValueOnce(mockedAutocompleteViewStore) - - const {findAllByTestId} = render() - const autocompleteButton = await findAllByTestId('autocompleteButton') - - fireEvent.press(autocompleteButton[0]) - expect(mockedAutocompleteViewStore.setActive).toHaveBeenCalledWith(false) - }) - - it('selects photos', async () => { - const {findByTestId, queryByTestId} = render( - , - ) - let photoCarouselPickerView = queryByTestId('photoCarouselPickerView') - expect(photoCarouselPickerView).toBeFalsy() - - const composerSelectPhotosButton = await findByTestId( - 'composerSelectPhotosButton', - ) - fireEvent.press(composerSelectPhotosButton) - - photoCarouselPickerView = await findByTestId('photoCarouselPickerView') - expect(photoCarouselPickerView).toBeTruthy() - - fireEvent.press(composerSelectPhotosButton) - - photoCarouselPickerView = queryByTestId('photoCarouselPickerView') - expect(photoCarouselPickerView).toBeFalsy() - }) -}) diff --git a/__tests__/view/com/composer/SelectedPhoto.test.tsx b/__tests__/view/com/composer/SelectedPhoto.test.tsx deleted file mode 100644 index 18a14f1108..0000000000 --- a/__tests__/view/com/composer/SelectedPhoto.test.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import React from 'react' -import {SelectedPhoto} from '../../../../src/view/com/composer/SelectedPhoto' -import {cleanup, fireEvent, render} from '../../../../jest/test-utils' - -describe('SelectedPhoto', () => { - const mockedProps = { - selectedPhotos: ['mock-uri', 'mock-uri-2'], - onSelectPhotos: jest.fn(), - } - - afterAll(() => { - jest.clearAllMocks() - cleanup() - }) - - it('has no photos to render', () => { - const {queryByTestId} = render( - , - ) - const selectedPhotosView = queryByTestId('selectedPhotosView') - expect(selectedPhotosView).toBeNull() - - const selectedPhotoImage = queryByTestId('selectedPhotoImage') - expect(selectedPhotoImage).toBeNull() - }) - - it('has 1 photos to render', async () => { - const {findByTestId} = render( - , - ) - const selectedPhotosView = await findByTestId('selectedPhotosView') - expect(selectedPhotosView).toBeTruthy() - - const selectedPhotoImage = await findByTestId('selectedPhotoImage') - expect(selectedPhotoImage).toBeTruthy() - }) - - it('has 2 photos to render', async () => { - const {findAllByTestId} = render() - const selectedPhotoImage = await findAllByTestId('selectedPhotoImage') - expect(selectedPhotoImage[0]).toBeTruthy() - expect(selectedPhotoImage[1]).toBeTruthy() - expect(selectedPhotoImage[2]).toBeFalsy() - }) - - it('has 3 photos to render', async () => { - const {findAllByTestId} = render( - , - ) - const selectedPhotoImage = await findAllByTestId('selectedPhotoImage') - expect(selectedPhotoImage[0]).toBeTruthy() - expect(selectedPhotoImage[0]).toBeTruthy() - expect(selectedPhotoImage[1]).toBeTruthy() - expect(selectedPhotoImage[2]).toBeTruthy() - expect(selectedPhotoImage[3]).toBeFalsy() - }) - - it('removes a photo', async () => { - const {findAllByTestId} = render() - const removePhotoButton = await findAllByTestId('removePhotoButton') - fireEvent.press(removePhotoButton[0]) - expect(mockedProps.onSelectPhotos).toHaveBeenCalledWith(['mock-uri-2']) - }) -}) diff --git a/__tests__/view/com/login/CreateAccount.test.tsx b/__tests__/view/com/login/CreateAccount.test.tsx deleted file mode 100644 index 0c13db6a2c..0000000000 --- a/__tests__/view/com/login/CreateAccount.test.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import React from 'react' -import {Keyboard} from 'react-native' -import {CreateAccount} from '../../../../src/view/com/login/CreateAccount' -import {cleanup, fireEvent, render} from '../../../../jest/test-utils' -import { - mockedSessionStore, - mockedShellStore, -} from '../../../../__mocks__/state-mock' - -describe('CreateAccount', () => { - const mockedProps = { - onPressBack: jest.fn(), - } - afterAll(() => { - jest.clearAllMocks() - cleanup() - }) - - it('renders form and creates new account', async () => { - const {findByTestId} = render() - - const registerEmailInput = await findByTestId('registerEmailInput') - expect(registerEmailInput).toBeTruthy() - fireEvent.changeText(registerEmailInput, 'test@email.com') - - const registerHandleInput = await findByTestId('registerHandleInput') - expect(registerHandleInput).toBeTruthy() - fireEvent.changeText(registerHandleInput, 'test.handle') - - const registerPasswordInput = await findByTestId('registerPasswordInput') - expect(registerPasswordInput).toBeTruthy() - fireEvent.changeText(registerPasswordInput, 'testpass') - - const registerIs13Input = await findByTestId('registerIs13Input') - expect(registerIs13Input).toBeTruthy() - fireEvent.press(registerIs13Input) - - const createAccountButton = await findByTestId('createAccountButton') - expect(createAccountButton).toBeTruthy() - fireEvent.press(createAccountButton) - - expect(mockedSessionStore.createAccount).toHaveBeenCalled() - }) - - it('renders and selects service', async () => { - const keyboardSpy = jest.spyOn(Keyboard, 'dismiss') - const {findByTestId} = render() - - const registerSelectServiceButton = await findByTestId( - 'registerSelectServiceButton', - ) - expect(registerSelectServiceButton).toBeTruthy() - fireEvent.press(registerSelectServiceButton) - - expect(mockedShellStore.openModal).toHaveBeenCalled() - expect(keyboardSpy).toHaveBeenCalled() - }) -}) diff --git a/__tests__/view/com/profile/ProfileHeader.test.tsx b/__tests__/view/com/profile/ProfileHeader.test.tsx deleted file mode 100644 index 6ffe1a9a27..0000000000 --- a/__tests__/view/com/profile/ProfileHeader.test.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import React from 'react' -import {cleanup, fireEvent, render} from '../../../../jest/test-utils' -import {ProfileViewModel} from '../../../../src/state/models/profile-view' -import {ProfileHeader} from '../../../../src/view/com/profile/ProfileHeader' -import { - mockedNavigationStore, - mockedProfileStore, - mockedShellStore, -} from '../../../../__mocks__/state-mock' - -describe('ProfileHeader', () => { - const mockedProps = { - view: mockedProfileStore, - onRefreshAll: jest.fn(), - } - afterAll(() => { - jest.clearAllMocks() - cleanup() - }) - - it('renders ErrorMessage on error', async () => { - const {findByTestId} = render( - , - ) - - const profileHeaderHasError = await findByTestId('profileHeaderHasError') - expect(profileHeaderHasError).toBeTruthy() - }) - - it('presses and opens edit profile', async () => { - const {findByTestId} = render() - - const profileHeaderEditProfileButton = await findByTestId( - 'profileHeaderEditProfileButton', - ) - expect(profileHeaderEditProfileButton).toBeTruthy() - fireEvent.press(profileHeaderEditProfileButton) - - expect(mockedShellStore.openModal).toHaveBeenCalled() - }) - - it('presses and opens followers page', async () => { - const {findByTestId} = render() - - const profileHeaderFollowersButton = await findByTestId( - 'profileHeaderFollowersButton', - ) - expect(profileHeaderFollowersButton).toBeTruthy() - fireEvent.press(profileHeaderFollowersButton) - - expect(mockedNavigationStore.navigate).toHaveBeenCalledWith( - '/profile/testhandle/followers', - ) - }) - - // TODO - this will only pass if the profile has an avatar image set - // it('presses and opens avatar modal', async () => { - // const {findByTestId} = render() - - // const profileHeaderAviButton = await findByTestId('profileHeaderAviButton') - // expect(profileHeaderAviButton).toBeTruthy() - // fireEvent.press(profileHeaderAviButton) - - // expect(mockedShellStore.openLightbox).toHaveBeenCalled() - // }) - - it('presses and opens follows page', async () => { - const {findByTestId} = render() - - const profileHeaderFollowsButton = await findByTestId( - 'profileHeaderFollowsButton', - ) - expect(profileHeaderFollowsButton).toBeTruthy() - fireEvent.press(profileHeaderFollowsButton) - - expect(mockedNavigationStore.navigate).toHaveBeenCalledWith( - '/profile/testhandle/follows', - ) - }) - - it('toggles following', async () => { - const {findByTestId} = render( - , - ) - - const profileHeaderToggleFollowButton = await findByTestId( - 'profileHeaderToggleFollowButton', - ) - expect(profileHeaderToggleFollowButton).toBeTruthy() - fireEvent.press(profileHeaderToggleFollowButton) - - expect(mockedProps.view.toggleFollowing).toHaveBeenCalled() - }) -}) diff --git a/__tests__/view/lib/useAnimatedValue.test.tsx b/__tests__/view/lib/useAnimatedValue.test.tsx deleted file mode 100644 index 762dcc8f26..0000000000 --- a/__tests__/view/lib/useAnimatedValue.test.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import {renderHook} from '../../../jest/test-utils' -import {useAnimatedValue} from '../../../src/view/lib/hooks/useAnimatedValue' - -describe('useAnimatedValue', () => { - it('creates an Animated.Value with the initial value passed to the hook', () => { - const {result} = renderHook(() => useAnimatedValue(10)) - // @ts-expect-error - expect(result.current.__getValue()).toEqual(10) - }) - - it('returns the same Animated.Value instance on subsequent renders', () => { - const {result, rerender} = renderHook(() => useAnimatedValue(10)) - const firstValue = result.current - rerender({}) - expect(result.current).toBe(firstValue) - }) -}) diff --git a/__tests__/view/lib/useOnMainScroll.test.tsx b/__tests__/view/lib/useOnMainScroll.test.tsx deleted file mode 100644 index 9a31e79e8e..0000000000 --- a/__tests__/view/lib/useOnMainScroll.test.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import React from 'react' -import {fireEvent, render} from '../../../jest/test-utils' -import {Home} from '../../../src/view/screens/Home' -import {mockedRootStore, mockedShellStore} from '../../../__mocks__/state-mock' - -describe('useOnMainScroll', () => { - const mockedProps = { - navIdx: [0, 0] as [number, number], - params: {}, - visible: true, - } - - it('toggles minimalShellMode to true', () => { - jest.useFakeTimers() - const {getByTestId} = render() - - fireEvent.scroll(getByTestId('homeFeed'), { - nativeEvent: { - contentOffset: {y: 20}, - contentSize: {height: 100}, - layoutMeasurement: {height: 50}, - }, - }) - - expect(mockedRootStore.shell.setMinimalShellMode).toHaveBeenCalledWith(true) - }) - - it('toggles minimalShellMode to false', () => { - jest.useFakeTimers() - const {getByTestId} = render(, { - ...mockedRootStore, - shell: { - ...mockedShellStore, - minimalShellMode: true, - }, - }) - - fireEvent.scroll(getByTestId('homeFeed'), { - nativeEvent: { - contentOffset: {y: 0}, - contentSize: {height: 100}, - layoutMeasurement: {height: 50}, - }, - }) - expect(mockedRootStore.shell.setMinimalShellMode).toHaveBeenCalledWith( - false, - ) - }) -}) diff --git a/__tests__/view/screens/Login.test.tsx b/__tests__/view/screens/Login.test.tsx deleted file mode 100644 index e347534b42..0000000000 --- a/__tests__/view/screens/Login.test.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React from 'react' -import {Login} from '../../../src/view/screens/Login' -import {cleanup, fireEvent, render} from '../../../jest/test-utils' - -describe('Login', () => { - afterAll(() => { - jest.clearAllMocks() - cleanup() - }) - - it('renders initial screen', () => { - const {getByTestId} = render() - const signUpScreen = getByTestId('signinOrCreateAccount') - - expect(signUpScreen).toBeTruthy() - }) - - it('renders Signin screen', () => { - const {getByTestId} = render() - const signInButton = getByTestId('signInButton') - - fireEvent.press(signInButton) - - const signInScreen = getByTestId('signIn') - expect(signInScreen).toBeTruthy() - }) - - it('renders CreateAccount screen', () => { - const {getByTestId} = render() - const createAccountButton = getByTestId('createAccountButton') - - fireEvent.press(createAccountButton) - - const createAccountScreen = getByTestId('createAccount') - expect(createAccountScreen).toBeTruthy() - }) -}) diff --git a/__tests__/view/screens/NotFound.test.tsx b/__tests__/view/screens/NotFound.test.tsx deleted file mode 100644 index fd3c84b077..0000000000 --- a/__tests__/view/screens/NotFound.test.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from 'react' -import {NotFound} from '../../../src/view/screens/NotFound' -import {cleanup, fireEvent, render} from '../../../jest/test-utils' -import {mockedNavigationStore} from '../../../__mocks__/state-mock' - -describe('NotFound', () => { - afterAll(() => { - jest.clearAllMocks() - cleanup() - }) - - it('navigates home', async () => { - const navigationSpy = jest.spyOn(mockedNavigationStore, 'navigate') - const {getByTestId} = render() - const navigateHomeButton = getByTestId('navigateHomeButton') - - fireEvent.press(navigateHomeButton) - - expect(navigationSpy).toHaveBeenCalledWith('/') - }) -}) diff --git a/__tests__/view/shell/mobile/Menu.test.tsx b/__tests__/view/shell/mobile/Menu.test.tsx deleted file mode 100644 index 587a3a71d1..0000000000 --- a/__tests__/view/shell/mobile/Menu.test.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import React from 'react' -import {Menu} from '../../../../src/view/shell/mobile/Menu' -import {cleanup, fireEvent, render} from '../../../../jest/test-utils' -import {mockedNavigationStore} from '../../../../__mocks__/state-mock' - -describe('Menu', () => { - const onCloseMock = jest.fn() - - const mockedProps = { - visible: true, - onClose: onCloseMock, - } - - afterAll(() => { - jest.clearAllMocks() - cleanup() - }) - - it('renders menu', () => { - const {getByTestId} = render() - - const menuView = getByTestId('menuView') - - expect(menuView).toBeTruthy() - }) - - it('presses profile card button', () => { - const {getByTestId} = render() - - const profileCardButton = getByTestId('profileCardButton') - fireEvent.press(profileCardButton) - - expect(onCloseMock).toHaveBeenCalled() - expect(mockedNavigationStore.switchTo).toHaveBeenCalledWith(0, true) - }) - - it('presses search button', () => { - const {getByTestId} = render() - - const searchBtn = getByTestId('searchBtn') - fireEvent.press(searchBtn) - - expect(onCloseMock).toHaveBeenCalled() - expect(mockedNavigationStore.switchTo).toHaveBeenCalledWith(1, true) - }) - - it("presses notifications menu item' button", () => { - const {getByTestId} = render() - - const menuItemButton = getByTestId('menuItemButton-Notifications') - fireEvent.press(menuItemButton) - - expect(onCloseMock).toHaveBeenCalled() - expect(mockedNavigationStore.switchTo).toHaveBeenCalledWith(2, true) - }) -}) diff --git a/__tests__/view/shell/mobile/TabsSelector.test.tsx b/__tests__/view/shell/mobile/TabsSelector.test.tsx deleted file mode 100644 index be6bc967ab..0000000000 --- a/__tests__/view/shell/mobile/TabsSelector.test.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import React from 'react' -import {Animated} from 'react-native' -import {TabsSelector} from '../../../../src/view/shell/mobile/TabsSelector' -import {cleanup, fireEvent, render} from '../../../../jest/test-utils' -import {mockedNavigationStore} from '../../../../__mocks__/state-mock' - -describe('TabsSelector', () => { - const onCloseMock = jest.fn() - - const mockedProps = { - active: true, - tabMenuInterp: new Animated.Value(0), - onClose: onCloseMock, - } - - afterAll(() => { - jest.clearAllMocks() - cleanup() - }) - - it('renders tabs selector', () => { - const {getByTestId} = render() - - const tabsSelectorView = getByTestId('tabsSelectorView') - - expect(tabsSelectorView).toBeTruthy() - }) - - it('renders nothing if inactive', () => { - const {getByTestId} = render( - , - ) - - const emptyView = getByTestId('emptyView') - - expect(emptyView).toBeTruthy() - }) - - // TODO - this throws currently, but the tabs selector isnt being used atm so I just disabled -prf - // it('presses share button', () => { - // const shareSpy = jest.spyOn(Share, 'share') - // const {getByTestId} = render() - - // const shareButton = getByTestId('shareButton') - // fireEvent.press(shareButton) - - // expect(onCloseMock).toHaveBeenCalled() - // expect(shareSpy).toHaveBeenCalledWith({url: 'https://bsky.app/'}) - // }) - - it('presses clone button', () => { - const {getByTestId} = render() - - const cloneButton = getByTestId('cloneButton') - fireEvent.press(cloneButton) - - expect(onCloseMock).toHaveBeenCalled() - expect(mockedNavigationStore.newTab).toHaveBeenCalled() - }) - - it('presses new tab button', () => { - const {getByTestId} = render() - - const newTabButton = getByTestId('newTabButton') - fireEvent.press(newTabButton) - - expect(onCloseMock).toHaveBeenCalled() - expect(mockedNavigationStore.newTab).toHaveBeenCalledWith('/') - }) - - it('presses change tab button', () => { - const {getAllByTestId} = render() - - const changeTabButton = getAllByTestId('changeTabButton') - fireEvent.press(changeTabButton[0]) - - expect(onCloseMock).toHaveBeenCalled() - expect(mockedNavigationStore.newTab).toHaveBeenCalledWith('/') - }) - - it('presses close tab button', () => { - const {getAllByTestId} = render() - - const closeTabButton = getAllByTestId('closeTabButton') - fireEvent.press(closeTabButton[0]) - - expect(onCloseMock).toHaveBeenCalled() - expect(mockedNavigationStore.setActiveTab).toHaveBeenCalledWith(0) - }) - - it('presses swipes to close the tab', () => { - const {getByTestId} = render() - - const tabsSwipable = getByTestId('tabsSwipable') - fireEvent(tabsSwipable, 'swipeableRightOpen') - - expect(onCloseMock).toHaveBeenCalled() - expect(mockedNavigationStore.setActiveTab).toHaveBeenCalledWith(0) - }) -}) diff --git a/jest/jestSetup.js b/jest/jestSetup.js index 4f6c375b5b..f7d136a8cf 100644 --- a/jest/jestSetup.js +++ b/jest/jestSetup.js @@ -7,7 +7,13 @@ configure({asyncUtilTimeout: 20000}) jest.mock('@react-native-async-storage/async-storage', () => require('@react-native-async-storage/async-storage/jest/async-storage-mock'), ) -jest.mock('react-native/Libraries/EventEmitter/NativeEventEmitter') +jest.mock('react-native/Libraries/EventEmitter/NativeEventEmitter', () => { + const {EventEmitter} = require('events') + return { + __esModule: true, + default: EventEmitter, + } +}) // Silence the warning: Animated: `useNativeDriver` is not supported jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper') diff --git a/src/App.native.tsx b/src/App.native.tsx index a3ba261f87..60e0271aa5 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -7,13 +7,12 @@ import SplashScreen from 'react-native-splash-screen' import {SafeAreaProvider} from 'react-native-safe-area-context' import {observer} from 'mobx-react-lite' import {SegmentClient, AnalyticsProvider} from '@segment/analytics-react-native' -import {TabPurpose} from './state/models/navigation' import {ThemeProvider} from './view/lib/ThemeContext' import * as view from './view/index' import {RootStoreModel, setupState, RootStoreProvider} from './state' import {MobileShell} from './view/shell/mobile' import {s} from './view/lib/styles' -import notifee, {EventType} from '@notifee/react-native' +import * as notifee from './view/lib/notifee' import * as analytics from './lib/analytics' import * as Toast from './view/com/util/Toast' @@ -30,6 +29,7 @@ const App = observer(() => { setupState().then(store => { setRootStore(store) analytics.init(store) + notifee.init(store) SplashScreen.hide() Linking.getInitialURL().then((url: string | null) => { if (url) { @@ -42,14 +42,6 @@ const App = observer(() => { store.onSessionDropped(() => { Toast.show('Sorry! Your session expired. Please log in again.') }) - notifee.onForegroundEvent(async ({type}: {type: EventType}) => { - store.log.debug('Notifee foreground event', {type}) - if (type === EventType.PRESS) { - store.log.debug('User pressed a notifee, opening notifications') - store.nav.switchTo(TabPurpose.Notifs, true) - } - }) - notifee.onBackgroundEvent(async _e => {}) // notifee requires this but we handle it with onForegroundEvent }) }, []) diff --git a/src/state/models/me.ts b/src/state/models/me.ts index 5ba5442aa4..d6136bfd1e 100644 --- a/src/state/models/me.ts +++ b/src/state/models/me.ts @@ -1,11 +1,9 @@ import {makeAutoObservable, runInAction} from 'mobx' -import notifee from '@notifee/react-native' import {RootStoreModel} from './root-store' import {FeedModel} from './feed-view' import {NotificationsViewModel} from './notifications-view' import {MyFollowsModel} from './my-follows' import {isObj, hasProp} from '../lib/type-guards' -import {displayNotificationFromModel} from '../../view/lib/notifee' export class MeModel { did: string = '' @@ -13,7 +11,6 @@ export class MeModel { displayName: string = '' description: string = '' avatar: string = '' - notificationCount: number = 0 mainFeed: FeedModel notifications: NotificationsViewModel follows: MyFollowsModel @@ -39,7 +36,6 @@ export class MeModel { this.displayName = '' this.description = '' this.avatar = '' - this.notificationCount = 0 } serialize(): unknown { @@ -111,50 +107,9 @@ export class MeModel { this.rootStore.log.error('Failed to load my follows', e) }), ]) - - // request notifications permission once the user has logged in - notifee.requestPermission() + this.rootStore.emitSessionLoaded() } else { this.clear() } } - - clearNotificationCount() { - this.notificationCount = 0 - notifee.setBadgeCount(0) - } - - async fetchNotifications() { - const res = await this.rootStore.api.app.bsky.notification.getCount() - runInAction(() => { - const newNotifications = this.notificationCount !== res.data.count - this.notificationCount = res.data.count - notifee.setBadgeCount(this.notificationCount) - if (newNotifications) { - this.notifications.refresh() - } - }) - } - - async bgFetchNotifications() { - const res = await this.rootStore.api.app.bsky.notification.getCount() - // NOTE we don't update this.notificationCount to avoid repaints during bg - // this means `newNotifications` may not be accurate, so we rely on - // `mostRecent` to determine if there really is a new notif to show -prf - const newNotifications = this.notificationCount !== res.data.count - notifee.setBadgeCount(res.data.count) - this.rootStore.log.debug( - `Background fetch received unread count = ${res.data.count}`, - ) - if (newNotifications) { - this.rootStore.log.debug( - 'Background fetch detected potentially a new notification', - ) - const mostRecent = await this.notifications.getNewMostRecent() - if (mostRecent) { - this.rootStore.log.debug('Got the notification, triggering a push') - displayNotificationFromModel(mostRecent) - } - } - } } diff --git a/src/state/models/notifications-view.ts b/src/state/models/notifications-view.ts index bdd7e6bef9..b6e3a34881 100644 --- a/src/state/models/notifications-view.ts +++ b/src/state/models/notifications-view.ts @@ -198,6 +198,7 @@ export class NotificationsViewModel { // data notifications: NotificationsViewItemModel[] = [] + unreadCount = 0 // this is used to help trigger push notifications mostRecentNotificationUri: string | undefined @@ -245,6 +246,8 @@ export class NotificationsViewModel { this.hasMore = true this.loadMoreCursor = undefined this.notifications = [] + this.unreadCount = 0 + this.rootStore.emitUnreadNotifications(0) this.mostRecentNotificationUri = undefined } @@ -311,6 +314,34 @@ export class NotificationsViewModel { } }) + /** + * Load more posts at the start of the notifications + */ + loadLatest = bundleAsync(async () => { + if (this.notifications.length === 0 || this.unreadCount > PAGE_SIZE) { + return this.refresh() + } + this.lock.acquireAsync() + try { + this._xLoading() + try { + const res = await this.rootStore.api.app.bsky.notification.list({ + limit: PAGE_SIZE, + }) + await this._prependAll(res) + this._xIdle() + } catch (e: any) { + this._xIdle() // don't bubble the error to the user + this.rootStore.log.error('NotificationsView: Failed to load latest', { + params: this.params, + e, + }) + } + } finally { + this.lock.release() + } + }) + /** * Update content in-place */ @@ -350,15 +381,33 @@ export class NotificationsViewModel { } }) + // unread notification apis + // = + + /** + * Get the current number of unread notifications + * returns true if the number changed + */ + loadUnreadCount = bundleAsync(async () => { + const old = this.unreadCount + const res = await this.rootStore.api.app.bsky.notification.getCount() + runInAction(() => { + this.unreadCount = res.data.count + }) + this.rootStore.emitUnreadNotifications(this.unreadCount) + return this.unreadCount !== old + }) + /** * Update read/unread state */ - async updateReadState() { + async markAllRead() { try { + this.unreadCount = 0 + this.rootStore.emitUnreadNotifications(0) await this.rootStore.api.app.bsky.notification.updateSeen({ seenAt: new Date().toISOString(), }) - this.rootStore.me.clearNotificationCount() } catch (e: any) { this.rootStore.log.warn('Failed to update notifications read state', e) } @@ -442,14 +491,40 @@ export class NotificationsViewModel { }) } + private async _prependAll(res: ListNotifications.Response) { + const promises = [] + const itemModels: NotificationsViewItemModel[] = [] + const dedupedNotifs = res.data.notifications.filter( + n1 => + !this.notifications.find( + n2 => isEq(n1, n2) || n2.additional?.find(n3 => isEq(n1, n3)), + ), + ) + for (const item of groupNotifications(dedupedNotifs)) { + const itemModel = new NotificationsViewItemModel( + this.rootStore, + `item-${_idCounter++}`, + item, + ) + if (itemModel.needsAdditionalData) { + promises.push(itemModel.fetchAdditionalData()) + } + itemModels.push(itemModel) + } + await Promise.all(promises).catch(e => { + this.rootStore.log.error( + 'Uncaught failure during notifications-view _prependAll()', + e, + ) + }) + runInAction(() => { + this.notifications = itemModels.concat(this.notifications) + }) + } + private _updateAll(res: ListNotifications.Response) { for (const item of res.data.notifications) { - const existingItem = this.notifications.find( - // this find function has a key subtlety- the indexedAt comparison - // the reason for this is reposts: they set the URI of the original post, not of the repost record - // the indexedAt time will be for the repost however, so we use that to help us - item2 => item.uri === item2.uri && item.indexedAt === item2.indexedAt, - ) + const existingItem = this.notifications.find(item2 => isEq(item, item2)) if (existingItem) { existingItem.copy(item, true) } @@ -486,3 +561,11 @@ function groupNotifications( } return items2 } + +type N = ListNotifications.Notification | NotificationsViewItemModel +function isEq(a: N, b: N) { + // this function has a key subtlety- the indexedAt comparison + // the reason for this is reposts: they set the URI of the original post, not of the repost record + // the indexedAt time will be for the repost however, so we use that to help us + return a.uri === b.uri && a.indexedAt === b.indexedAt +} diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index fb93aaae87..ea56f3816a 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -15,6 +15,7 @@ import {NavigationModel} from './navigation' import {ShellUiModel} from './shell-ui' import {ProfilesViewModel} from './profiles-view' import {LinkMetasViewModel} from './link-metas-view' +import {NotificationsViewItemModel} from './notifications-view' import {MeModel} from './me' import {OnboardModel} from './onboard' @@ -152,7 +153,6 @@ export class RootStoreModel { return } try { - await this.me.fetchNotifications() await this.me.follows.fetchIfNeeded() } catch (e: any) { this.log.error('Failed to fetch latest state', e) @@ -164,26 +164,34 @@ export class RootStoreModel { // - some events need to be passed around between views and models // in order to keep state in sync; these methods are for that + // a post was deleted by the local user onPostDeleted(handler: (uri: string) => void): EmitterSubscription { return DeviceEventEmitter.addListener('post-deleted', handler) } - emitPostDeleted(uri: string) { DeviceEventEmitter.emit('post-deleted', uri) } + // the session has started and been fully hydrated + onSessionLoaded(handler: () => void): EmitterSubscription { + return DeviceEventEmitter.addListener('session-loaded', handler) + } + emitSessionLoaded() { + DeviceEventEmitter.emit('session-loaded') + } + + // the session was dropped due to bad/expired refresh tokens onSessionDropped(handler: () => void): EmitterSubscription { return DeviceEventEmitter.addListener('session-dropped', handler) } - emitSessionDropped() { DeviceEventEmitter.emit('session-dropped') } + // the current screen has changed onNavigation(handler: () => void): EmitterSubscription { return DeviceEventEmitter.addListener('navigation', handler) } - emitNavigation() { DeviceEventEmitter.emit('navigation') } @@ -193,11 +201,28 @@ export class RootStoreModel { onScreenSoftReset(handler: () => void): EmitterSubscription { return DeviceEventEmitter.addListener('screen-soft-reset', handler) } - emitScreenSoftReset() { DeviceEventEmitter.emit('screen-soft-reset') } + // the unread notifications count has changed + onUnreadNotifications(handler: (count: number) => void): EmitterSubscription { + return DeviceEventEmitter.addListener('unread-notifications', handler) + } + emitUnreadNotifications(count: number) { + DeviceEventEmitter.emit('unread-notifications', count) + } + + // a notification has been queued for push + onPushNotification( + handler: (notif: NotificationsViewItemModel) => void, + ): EmitterSubscription { + return DeviceEventEmitter.addListener('push-notification', handler) + } + emitPushNotification(notif: NotificationsViewItemModel) { + DeviceEventEmitter.emit('push-notification', notif) + } + // background fetch // = // - we use this to poll for unread notifications, which is not "ideal" behavior but @@ -220,7 +245,22 @@ export class RootStoreModel { async onBgFetch(taskId: string) { this.log.debug(`Background fetch fired for task ${taskId}`) if (this.session.hasSession) { - await this.me.bgFetchNotifications() + const res = await this.api.app.bsky.notification.getCount() + const hasNewNotifs = this.me.notifications.unreadCount !== res.data.count + this.emitUnreadNotifications(res.data.count) + this.log.debug( + `Background fetch received unread count = ${res.data.count}`, + ) + if (hasNewNotifs) { + this.log.debug( + 'Background fetch detected potentially a new notification', + ) + const mostRecent = await this.me.notifications.getNewMostRecent() + if (mostRecent) { + this.log.debug('Got the notification, triggering a push') + this.emitPushNotification(mostRecent) + } + } } BackgroundFetch.finish(taskId) } diff --git a/src/view/com/notifications/Feed.tsx b/src/view/com/notifications/Feed.tsx index 110eef769a..21f54e793f 100644 --- a/src/view/com/notifications/Feed.tsx +++ b/src/view/com/notifications/Feed.tsx @@ -38,19 +38,19 @@ export const Feed = observer(function Feed({ } return } - const onRefresh = () => { - view - .refresh() - .catch(err => - view.rootStore.log.error('Failed to refresh notifications feed', err), - ) + const onRefresh = async () => { + try { + await view.refresh() + } catch (err) { + view.rootStore.log.error('Failed to refresh notifications feed', err) + } } - const onEndReached = () => { - view - .loadMore() - .catch(err => - view.rootStore.log.error('Failed to load more notifications', err), - ) + const onEndReached = async () => { + try { + await view.loadMore() + } catch (err) { + view.rootStore.log.error('Failed to load more notifications', err) + } } let data if (view.hasLoaded) { diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index a52bc643cc..72a6eba9bd 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -17,13 +17,16 @@ export const PostThread = observer(function PostThread({ view: PostThreadViewModel }) { const ref = useRef(null) + const [isRefreshing, setIsRefreshing] = React.useState(false) const posts = view.thread ? Array.from(flattenThread(view.thread)) : [] - const onRefresh = () => { - view - ?.refresh() - .catch(err => - view.rootStore.log.error('Failed to refresh posts thread', err), - ) + const onRefresh = async () => { + setIsRefreshing(true) + try { + view?.refresh() + } catch (err) { + view.rootStore.log.error('Failed to refresh posts thread', err) + } + setIsRefreshing(false) } const onLayout = () => { const index = posts.findIndex(post => post._isHighlightedPost) @@ -77,7 +80,7 @@ export const PostThread = observer(function PostThread({ data={posts} keyExtractor={item => item._reactKey} renderItem={renderItem} - refreshing={view.isRefreshing} + refreshing={isRefreshing} onRefresh={onRefresh} onLayout={onLayout} onScrollToIndexFailed={onScrollToIndexFailed} diff --git a/src/view/lib/notifee.ts b/src/view/lib/notifee.ts index 5e19173812..6e476a74f1 100644 --- a/src/view/lib/notifee.ts +++ b/src/view/lib/notifee.ts @@ -1,8 +1,27 @@ -import notifee from '@notifee/react-native' +import notifee, {EventType} from '@notifee/react-native' import {AppBskyEmbedImages} from '@atproto/api' +import {RootStoreModel} from '../../state/models/root-store' +import {TabPurpose} from '../../state/models/navigation' import {NotificationsViewItemModel} from '../../state/models/notifications-view' import {enforceLen} from '../../lib/strings' +export function init(store: RootStoreModel) { + store.onUnreadNotifications(count => notifee.setBadgeCount(count)) + store.onPushNotification(displayNotificationFromModel) + store.onSessionLoaded(() => { + // request notifications permission once the user has logged in + notifee.requestPermission() + }) + notifee.onForegroundEvent(async ({type}: {type: EventType}) => { + store.log.debug('Notifee foreground event', {type}) + if (type === EventType.PRESS) { + store.log.debug('User pressed a notifee, opening notifications') + store.nav.switchTo(TabPurpose.Notifs, true) + } + }) + notifee.onBackgroundEvent(async _e => {}) // notifee requires this but we handle it with onForegroundEvent +} + export function displayNotification( title: string, body?: string, @@ -39,7 +58,8 @@ export function displayNotificationFromModel( title = `${author} replied to your post` body = notif.additionalPost?.thread?.postRecord?.text || '' } else if (notif.isFollow) { - title = `${author} followed you` + title = 'New follower!' + body = `${author} has followed you` } else { return } diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index fbef6bc291..9df542da17 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -37,7 +37,7 @@ export const Home = observer(function Home({navIdx, visible}: ScreenParams) { if (store.me.mainFeed.isLoading) { return } - store.log.debug('Polling home feed') + store.log.debug('HomeScreen: Polling for new posts') store.me.mainFeed.checkForLatest() }, [appState, visible, store], @@ -52,7 +52,7 @@ export const Home = observer(function Home({navIdx, visible}: ScreenParams) { useEffect(() => { const softResetSub = store.onScreenSoftReset(scrollToTop) const feedCleanup = store.me.mainFeed.registerListeners() - const pollInterval = setInterval(() => doPoll(), 15e3) + const pollInterval = setInterval(doPoll, 15e3) const cleanup = () => { clearInterval(pollInterval) softResetSub.remove() @@ -73,7 +73,7 @@ export const Home = observer(function Home({navIdx, visible}: ScreenParams) { // just became visible screen('Feed') store.nav.setTitle(navIdx, 'Home') - store.log.debug('Updating home feed') + store.log.debug('HomeScreen: Updating feed') if (store.me.mainFeed.hasContent) { store.me.mainFeed.update() } else { diff --git a/src/view/screens/Notifications.tsx b/src/view/screens/Notifications.tsx index ccc90b621a..7f7e4ecaff 100644 --- a/src/view/screens/Notifications.tsx +++ b/src/view/screens/Notifications.tsx @@ -1,5 +1,6 @@ import React, {useEffect} from 'react' import {FlatList, View} from 'react-native' +import useAppState from 'react-native-appstate-hook' import {ViewHeader} from '../com/util/ViewHeader' import {Feed} from '../com/notifications/Feed' import {useStores} from '../../state' @@ -8,36 +9,71 @@ import {useOnMainScroll} from '../lib/hooks/useOnMainScroll' import {s} from '../lib/styles' import {useAnalytics} from '@segment/analytics-react-native' +const NOTIFICATIONS_POLL_INTERVAL = 15e3 + export const Notifications = ({navIdx, visible}: ScreenParams) => { const store = useStores() const onMainScroll = useOnMainScroll(store) const scrollElRef = React.useRef(null) const {screen} = useAnalytics() + const {appState} = useAppState({ + onForeground: () => doPoll(true), + }) - const onSoftReset = () => { - scrollElRef.current?.scrollToOffset({offset: 0}) - } - - useEffect(() => { - const softResetSub = store.onScreenSoftReset(onSoftReset) - const cleanup = () => { - softResetSub.remove() - } - if (!visible) { - return cleanup - } - store.log.debug('Updating notifications feed') - store.me.notifications.update().then(() => { - store.me.notifications.updateReadState() - }) - screen('Notifications') - store.nav.setTitle(navIdx, 'Notifications') - return cleanup - }, [visible, store, navIdx, screen]) - + // event handlers + // = const onPressTryAgain = () => { store.me.notifications.refresh() } + const scrollToTop = React.useCallback(() => { + scrollElRef.current?.scrollToOffset({offset: 0}) + }, [scrollElRef]) + + // periodic polling + // = + const doPoll = React.useCallback( + async (isForegrounding = false) => { + if (isForegrounding) { + // app is foregrounding, refresh optimistically + store.log.debug('NotificationsScreen: Refreshing on app foreground') + await Promise.all([ + store.me.notifications.loadUnreadCount(), + store.me.notifications.refresh(), + ]) + } else if (appState === 'active') { + // periodic poll, refresh if there are new notifs + store.log.debug('NotificationsScreen: Polling for new notifications') + const didChange = await store.me.notifications.loadUnreadCount() + if (didChange) { + store.log.debug('NotificationsScreen: Loading new notifications') + await store.me.notifications.loadLatest() + } + } + }, + [appState, store], + ) + useEffect(() => { + const pollInterval = setInterval(doPoll, NOTIFICATIONS_POLL_INTERVAL) + return () => clearInterval(pollInterval) + }, [doPoll]) + + // on-visible setup + // = + useEffect(() => { + if (!visible) { + return + } + store.log.debug('NotificationsScreen: Updating feed') + const softResetSub = store.onScreenSoftReset(scrollToTop) + store.me.notifications.update().then(() => { + store.me.notifications.markAllRead() + }) + screen('Notifications') + store.nav.setTitle(navIdx, 'Notifications') + return () => { + softResetSub.remove() + } + }, [visible, store, navIdx, screen, scrollToTop]) return ( diff --git a/src/view/shell/mobile/index.tsx b/src/view/shell/mobile/index.tsx index a1039bda26..9e928780e1 100644 --- a/src/view/shell/mobile/index.tsx +++ b/src/view/shell/mobile/index.tsx @@ -513,7 +513,7 @@ export const MobileShell: React.FC = observer(() => { icon={isAtNotifications ? 'bell-solid' : 'bell'} onPress={onPressNotifications} onLongPress={TABS_ENABLED ? doNewTab('/notifications') : undefined} - notificationCount={store.me.notificationCount} + notificationCount={store.me.notifications.unreadCount} />