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
This commit is contained in:
Paul Frazee
2023-02-17 13:00:57 -06:00
committed by GitHub
parent 70ec76970c
commit da41392422
29 changed files with 319 additions and 1576 deletions
+67
View File
@@ -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
-257
View File
@@ -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(<MobileShell />, rootStore)
const signUpScreen = getByTestId('signinOrCreateAccount')
expect(signUpScreen).toBeTruthy()
})
it('completes signin to the server', async () => {
const {getByTestId} = render(<MobileShell />, 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(<MobileShell />, 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(<MobileShell />, 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(<MobileShell />, 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(<MobileShell />, 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(<MobileShell />, 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(<MobileShell />, 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()
})
})
@@ -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()
}
})
})
})
-153
View File
@@ -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}',
)
})
})
-161
View File
@@ -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<any> => {
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<any> => {
return Promise.resolve({
data: {
count: 1,
},
})
})
await rootStore.me.fetchNotifications()
expect(rootStore.me.notificationCount).toBe(1)
expect(rootStore.me.notifications.refresh).toHaveBeenCalled()
})
})
-72
View File
@@ -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)
})
})
-66
View File
@@ -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()
})
})
@@ -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(<Autocomplete {...mockedProps} />)
const autocompleteButton = await findAllByTestId('autocompleteButton')
expect(autocompleteButton.length).toBe(2)
})
it('triggers onSelect by pressing the button', async () => {
const {findAllByTestId} = render(<Autocomplete {...mockedProps} />)
const autocompleteButton = await findAllByTestId('autocompleteButton')
fireEvent.press(autocompleteButton[0])
expect(onSelectMock).toHaveBeenCalledWith('handle.test')
fireEvent.press(autocompleteButton[1])
expect(onSelectMock).toHaveBeenCalledWith('handle2.test')
})
})
@@ -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(<ComposePost {...mockedProps} />)
const composePostView = await findByTestId('composePostView')
expect(composePostView).toBeTruthy()
})
it('closes composer', async () => {
const {findByTestId} = render(<ComposePost {...mockedProps} />)
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(<ComposePost {...mockedProps} />)
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<string>(),
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(<ComposePost {...mockedProps} />)
const autocompleteButton = await findAllByTestId('autocompleteButton')
fireEvent.press(autocompleteButton[0])
expect(mockedAutocompleteViewStore.setActive).toHaveBeenCalledWith(false)
})
it('selects photos', async () => {
const {findByTestId, queryByTestId} = render(
<ComposePost {...mockedProps} />,
)
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()
})
})
@@ -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(
<SelectedPhoto selectedPhotos={[]} onSelectPhotos={jest.fn()} />,
)
const selectedPhotosView = queryByTestId('selectedPhotosView')
expect(selectedPhotosView).toBeNull()
const selectedPhotoImage = queryByTestId('selectedPhotoImage')
expect(selectedPhotoImage).toBeNull()
})
it('has 1 photos to render', async () => {
const {findByTestId} = render(
<SelectedPhoto
selectedPhotos={['mock-uri']}
onSelectPhotos={jest.fn()}
/>,
)
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(<SelectedPhoto {...mockedProps} />)
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(
<SelectedPhoto
selectedPhotos={['mock-uri', 'mock-uri-2', 'mock-uri-3']}
onSelectPhotos={jest.fn()}
/>,
)
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(<SelectedPhoto {...mockedProps} />)
const removePhotoButton = await findAllByTestId('removePhotoButton')
fireEvent.press(removePhotoButton[0])
expect(mockedProps.onSelectPhotos).toHaveBeenCalledWith(['mock-uri-2'])
})
})
@@ -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(<CreateAccount {...mockedProps} />)
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(<CreateAccount {...mockedProps} />)
const registerSelectServiceButton = await findByTestId(
'registerSelectServiceButton',
)
expect(registerSelectServiceButton).toBeTruthy()
fireEvent.press(registerSelectServiceButton)
expect(mockedShellStore.openModal).toHaveBeenCalled()
expect(keyboardSpy).toHaveBeenCalled()
})
})
@@ -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(
<ProfileHeader
{...{
view: {
...mockedProfileStore,
hasError: true,
} as ProfileViewModel,
onRefreshAll: jest.fn(),
}}
/>,
)
const profileHeaderHasError = await findByTestId('profileHeaderHasError')
expect(profileHeaderHasError).toBeTruthy()
})
it('presses and opens edit profile', async () => {
const {findByTestId} = render(<ProfileHeader {...mockedProps} />)
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(<ProfileHeader {...mockedProps} />)
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(<ProfileHeader {...mockedProps} />)
// 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(<ProfileHeader {...mockedProps} />)
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(
<ProfileHeader
{...{
view: {
...mockedProfileStore,
did: 'test did 2',
} as ProfileViewModel,
onRefreshAll: jest.fn(),
}}
/>,
)
const profileHeaderToggleFollowButton = await findByTestId(
'profileHeaderToggleFollowButton',
)
expect(profileHeaderToggleFollowButton).toBeTruthy()
fireEvent.press(profileHeaderToggleFollowButton)
expect(mockedProps.view.toggleFollowing).toHaveBeenCalled()
})
})
@@ -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)
})
})
@@ -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(<Home {...mockedProps} />)
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(<Home {...mockedProps} />, {
...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,
)
})
})
-37
View File
@@ -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(<Login />)
const signUpScreen = getByTestId('signinOrCreateAccount')
expect(signUpScreen).toBeTruthy()
})
it('renders Signin screen', () => {
const {getByTestId} = render(<Login />)
const signInButton = getByTestId('signInButton')
fireEvent.press(signInButton)
const signInScreen = getByTestId('signIn')
expect(signInScreen).toBeTruthy()
})
it('renders CreateAccount screen', () => {
const {getByTestId} = render(<Login />)
const createAccountButton = getByTestId('createAccountButton')
fireEvent.press(createAccountButton)
const createAccountScreen = getByTestId('createAccount')
expect(createAccountScreen).toBeTruthy()
})
})
-21
View File
@@ -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(<NotFound />)
const navigateHomeButton = getByTestId('navigateHomeButton')
fireEvent.press(navigateHomeButton)
expect(navigationSpy).toHaveBeenCalledWith('/')
})
})
-56
View File
@@ -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(<Menu {...mockedProps} />)
const menuView = getByTestId('menuView')
expect(menuView).toBeTruthy()
})
it('presses profile card button', () => {
const {getByTestId} = render(<Menu {...mockedProps} />)
const profileCardButton = getByTestId('profileCardButton')
fireEvent.press(profileCardButton)
expect(onCloseMock).toHaveBeenCalled()
expect(mockedNavigationStore.switchTo).toHaveBeenCalledWith(0, true)
})
it('presses search button', () => {
const {getByTestId} = render(<Menu {...mockedProps} />)
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(<Menu {...mockedProps} />)
const menuItemButton = getByTestId('menuItemButton-Notifications')
fireEvent.press(menuItemButton)
expect(onCloseMock).toHaveBeenCalled()
expect(mockedNavigationStore.switchTo).toHaveBeenCalledWith(2, true)
})
})
@@ -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(<TabsSelector {...mockedProps} />)
const tabsSelectorView = getByTestId('tabsSelectorView')
expect(tabsSelectorView).toBeTruthy()
})
it('renders nothing if inactive', () => {
const {getByTestId} = render(
<TabsSelector {...{...mockedProps, active: false}} />,
)
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(<TabsSelector {...mockedProps} />)
// const shareButton = getByTestId('shareButton')
// fireEvent.press(shareButton)
// expect(onCloseMock).toHaveBeenCalled()
// expect(shareSpy).toHaveBeenCalledWith({url: 'https://bsky.app/'})
// })
it('presses clone button', () => {
const {getByTestId} = render(<TabsSelector {...mockedProps} />)
const cloneButton = getByTestId('cloneButton')
fireEvent.press(cloneButton)
expect(onCloseMock).toHaveBeenCalled()
expect(mockedNavigationStore.newTab).toHaveBeenCalled()
})
it('presses new tab button', () => {
const {getByTestId} = render(<TabsSelector {...mockedProps} />)
const newTabButton = getByTestId('newTabButton')
fireEvent.press(newTabButton)
expect(onCloseMock).toHaveBeenCalled()
expect(mockedNavigationStore.newTab).toHaveBeenCalledWith('/')
})
it('presses change tab button', () => {
const {getAllByTestId} = render(<TabsSelector {...mockedProps} />)
const changeTabButton = getAllByTestId('changeTabButton')
fireEvent.press(changeTabButton[0])
expect(onCloseMock).toHaveBeenCalled()
expect(mockedNavigationStore.newTab).toHaveBeenCalledWith('/')
})
it('presses close tab button', () => {
const {getAllByTestId} = render(<TabsSelector {...mockedProps} />)
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(<TabsSelector {...mockedProps} />)
const tabsSwipable = getByTestId('tabsSwipable')
fireEvent(tabsSwipable, 'swipeableRightOpen')
expect(onCloseMock).toHaveBeenCalled()
expect(mockedNavigationStore.setActiveTab).toHaveBeenCalledWith(0)
})
})
+7 -1
View File
@@ -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')
+2 -10
View File
@@ -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
})
}, [])
+1 -46
View File
@@ -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)
}
}
}
}
+91 -8
View File
@@ -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
}
+46 -6
View File
@@ -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)
}
+12 -12
View File
@@ -38,19 +38,19 @@ export const Feed = observer(function Feed({
}
return <FeedItem item={item} />
}
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) {
+10 -7
View File
@@ -17,13 +17,16 @@ export const PostThread = observer(function PostThread({
view: PostThreadViewModel
}) {
const ref = useRef<FlatList>(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}
+22 -2
View File
@@ -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
}
+3 -3
View File
@@ -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 {
+57 -21
View File
@@ -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<FlatList>(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 (
<View style={s.h100pct}>
+1 -1
View File
@@ -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}
/>
</Animated.View>
<Modal />