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}
/>