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:
Vendored
+67
@@ -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
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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}',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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('/')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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
@@ -7,7 +7,13 @@ configure({asyncUtilTimeout: 20000})
|
|||||||
jest.mock('@react-native-async-storage/async-storage', () =>
|
jest.mock('@react-native-async-storage/async-storage', () =>
|
||||||
require('@react-native-async-storage/async-storage/jest/async-storage-mock'),
|
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
|
// Silence the warning: Animated: `useNativeDriver` is not supported
|
||||||
jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper')
|
jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper')
|
||||||
|
|||||||
+2
-10
@@ -7,13 +7,12 @@ import SplashScreen from 'react-native-splash-screen'
|
|||||||
import {SafeAreaProvider} from 'react-native-safe-area-context'
|
import {SafeAreaProvider} from 'react-native-safe-area-context'
|
||||||
import {observer} from 'mobx-react-lite'
|
import {observer} from 'mobx-react-lite'
|
||||||
import {SegmentClient, AnalyticsProvider} from '@segment/analytics-react-native'
|
import {SegmentClient, AnalyticsProvider} from '@segment/analytics-react-native'
|
||||||
import {TabPurpose} from './state/models/navigation'
|
|
||||||
import {ThemeProvider} from './view/lib/ThemeContext'
|
import {ThemeProvider} from './view/lib/ThemeContext'
|
||||||
import * as view from './view/index'
|
import * as view from './view/index'
|
||||||
import {RootStoreModel, setupState, RootStoreProvider} from './state'
|
import {RootStoreModel, setupState, RootStoreProvider} from './state'
|
||||||
import {MobileShell} from './view/shell/mobile'
|
import {MobileShell} from './view/shell/mobile'
|
||||||
import {s} from './view/lib/styles'
|
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 analytics from './lib/analytics'
|
||||||
import * as Toast from './view/com/util/Toast'
|
import * as Toast from './view/com/util/Toast'
|
||||||
|
|
||||||
@@ -30,6 +29,7 @@ const App = observer(() => {
|
|||||||
setupState().then(store => {
|
setupState().then(store => {
|
||||||
setRootStore(store)
|
setRootStore(store)
|
||||||
analytics.init(store)
|
analytics.init(store)
|
||||||
|
notifee.init(store)
|
||||||
SplashScreen.hide()
|
SplashScreen.hide()
|
||||||
Linking.getInitialURL().then((url: string | null) => {
|
Linking.getInitialURL().then((url: string | null) => {
|
||||||
if (url) {
|
if (url) {
|
||||||
@@ -42,14 +42,6 @@ const App = observer(() => {
|
|||||||
store.onSessionDropped(() => {
|
store.onSessionDropped(() => {
|
||||||
Toast.show('Sorry! Your session expired. Please log in again.')
|
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
@@ -1,11 +1,9 @@
|
|||||||
import {makeAutoObservable, runInAction} from 'mobx'
|
import {makeAutoObservable, runInAction} from 'mobx'
|
||||||
import notifee from '@notifee/react-native'
|
|
||||||
import {RootStoreModel} from './root-store'
|
import {RootStoreModel} from './root-store'
|
||||||
import {FeedModel} from './feed-view'
|
import {FeedModel} from './feed-view'
|
||||||
import {NotificationsViewModel} from './notifications-view'
|
import {NotificationsViewModel} from './notifications-view'
|
||||||
import {MyFollowsModel} from './my-follows'
|
import {MyFollowsModel} from './my-follows'
|
||||||
import {isObj, hasProp} from '../lib/type-guards'
|
import {isObj, hasProp} from '../lib/type-guards'
|
||||||
import {displayNotificationFromModel} from '../../view/lib/notifee'
|
|
||||||
|
|
||||||
export class MeModel {
|
export class MeModel {
|
||||||
did: string = ''
|
did: string = ''
|
||||||
@@ -13,7 +11,6 @@ export class MeModel {
|
|||||||
displayName: string = ''
|
displayName: string = ''
|
||||||
description: string = ''
|
description: string = ''
|
||||||
avatar: string = ''
|
avatar: string = ''
|
||||||
notificationCount: number = 0
|
|
||||||
mainFeed: FeedModel
|
mainFeed: FeedModel
|
||||||
notifications: NotificationsViewModel
|
notifications: NotificationsViewModel
|
||||||
follows: MyFollowsModel
|
follows: MyFollowsModel
|
||||||
@@ -39,7 +36,6 @@ export class MeModel {
|
|||||||
this.displayName = ''
|
this.displayName = ''
|
||||||
this.description = ''
|
this.description = ''
|
||||||
this.avatar = ''
|
this.avatar = ''
|
||||||
this.notificationCount = 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
serialize(): unknown {
|
serialize(): unknown {
|
||||||
@@ -111,50 +107,9 @@ export class MeModel {
|
|||||||
this.rootStore.log.error('Failed to load my follows', e)
|
this.rootStore.log.error('Failed to load my follows', e)
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
this.rootStore.emitSessionLoaded()
|
||||||
// request notifications permission once the user has logged in
|
|
||||||
notifee.requestPermission()
|
|
||||||
} else {
|
} else {
|
||||||
this.clear()
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ export class NotificationsViewModel {
|
|||||||
|
|
||||||
// data
|
// data
|
||||||
notifications: NotificationsViewItemModel[] = []
|
notifications: NotificationsViewItemModel[] = []
|
||||||
|
unreadCount = 0
|
||||||
|
|
||||||
// this is used to help trigger push notifications
|
// this is used to help trigger push notifications
|
||||||
mostRecentNotificationUri: string | undefined
|
mostRecentNotificationUri: string | undefined
|
||||||
@@ -245,6 +246,8 @@ export class NotificationsViewModel {
|
|||||||
this.hasMore = true
|
this.hasMore = true
|
||||||
this.loadMoreCursor = undefined
|
this.loadMoreCursor = undefined
|
||||||
this.notifications = []
|
this.notifications = []
|
||||||
|
this.unreadCount = 0
|
||||||
|
this.rootStore.emitUnreadNotifications(0)
|
||||||
this.mostRecentNotificationUri = undefined
|
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
|
* 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
|
* Update read/unread state
|
||||||
*/
|
*/
|
||||||
async updateReadState() {
|
async markAllRead() {
|
||||||
try {
|
try {
|
||||||
|
this.unreadCount = 0
|
||||||
|
this.rootStore.emitUnreadNotifications(0)
|
||||||
await this.rootStore.api.app.bsky.notification.updateSeen({
|
await this.rootStore.api.app.bsky.notification.updateSeen({
|
||||||
seenAt: new Date().toISOString(),
|
seenAt: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
this.rootStore.me.clearNotificationCount()
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
this.rootStore.log.warn('Failed to update notifications read state', e)
|
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) {
|
private _updateAll(res: ListNotifications.Response) {
|
||||||
for (const item of res.data.notifications) {
|
for (const item of res.data.notifications) {
|
||||||
const existingItem = this.notifications.find(
|
const existingItem = this.notifications.find(item2 => isEq(item, item2))
|
||||||
// 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,
|
|
||||||
)
|
|
||||||
if (existingItem) {
|
if (existingItem) {
|
||||||
existingItem.copy(item, true)
|
existingItem.copy(item, true)
|
||||||
}
|
}
|
||||||
@@ -486,3 +561,11 @@ function groupNotifications(
|
|||||||
}
|
}
|
||||||
return items2
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {NavigationModel} from './navigation'
|
|||||||
import {ShellUiModel} from './shell-ui'
|
import {ShellUiModel} from './shell-ui'
|
||||||
import {ProfilesViewModel} from './profiles-view'
|
import {ProfilesViewModel} from './profiles-view'
|
||||||
import {LinkMetasViewModel} from './link-metas-view'
|
import {LinkMetasViewModel} from './link-metas-view'
|
||||||
|
import {NotificationsViewItemModel} from './notifications-view'
|
||||||
import {MeModel} from './me'
|
import {MeModel} from './me'
|
||||||
import {OnboardModel} from './onboard'
|
import {OnboardModel} from './onboard'
|
||||||
|
|
||||||
@@ -152,7 +153,6 @@ export class RootStoreModel {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await this.me.fetchNotifications()
|
|
||||||
await this.me.follows.fetchIfNeeded()
|
await this.me.follows.fetchIfNeeded()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
this.log.error('Failed to fetch latest state', e)
|
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
|
// - some events need to be passed around between views and models
|
||||||
// in order to keep state in sync; these methods are for that
|
// 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 {
|
onPostDeleted(handler: (uri: string) => void): EmitterSubscription {
|
||||||
return DeviceEventEmitter.addListener('post-deleted', handler)
|
return DeviceEventEmitter.addListener('post-deleted', handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
emitPostDeleted(uri: string) {
|
emitPostDeleted(uri: string) {
|
||||||
DeviceEventEmitter.emit('post-deleted', uri)
|
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 {
|
onSessionDropped(handler: () => void): EmitterSubscription {
|
||||||
return DeviceEventEmitter.addListener('session-dropped', handler)
|
return DeviceEventEmitter.addListener('session-dropped', handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
emitSessionDropped() {
|
emitSessionDropped() {
|
||||||
DeviceEventEmitter.emit('session-dropped')
|
DeviceEventEmitter.emit('session-dropped')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// the current screen has changed
|
||||||
onNavigation(handler: () => void): EmitterSubscription {
|
onNavigation(handler: () => void): EmitterSubscription {
|
||||||
return DeviceEventEmitter.addListener('navigation', handler)
|
return DeviceEventEmitter.addListener('navigation', handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
emitNavigation() {
|
emitNavigation() {
|
||||||
DeviceEventEmitter.emit('navigation')
|
DeviceEventEmitter.emit('navigation')
|
||||||
}
|
}
|
||||||
@@ -193,11 +201,28 @@ export class RootStoreModel {
|
|||||||
onScreenSoftReset(handler: () => void): EmitterSubscription {
|
onScreenSoftReset(handler: () => void): EmitterSubscription {
|
||||||
return DeviceEventEmitter.addListener('screen-soft-reset', handler)
|
return DeviceEventEmitter.addListener('screen-soft-reset', handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
emitScreenSoftReset() {
|
emitScreenSoftReset() {
|
||||||
DeviceEventEmitter.emit('screen-soft-reset')
|
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
|
// background fetch
|
||||||
// =
|
// =
|
||||||
// - we use this to poll for unread notifications, which is not "ideal" behavior but
|
// - 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) {
|
async onBgFetch(taskId: string) {
|
||||||
this.log.debug(`Background fetch fired for task ${taskId}`)
|
this.log.debug(`Background fetch fired for task ${taskId}`)
|
||||||
if (this.session.hasSession) {
|
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)
|
BackgroundFetch.finish(taskId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,19 +38,19 @@ export const Feed = observer(function Feed({
|
|||||||
}
|
}
|
||||||
return <FeedItem item={item} />
|
return <FeedItem item={item} />
|
||||||
}
|
}
|
||||||
const onRefresh = () => {
|
const onRefresh = async () => {
|
||||||
view
|
try {
|
||||||
.refresh()
|
await view.refresh()
|
||||||
.catch(err =>
|
} catch (err) {
|
||||||
view.rootStore.log.error('Failed to refresh notifications feed', err),
|
view.rootStore.log.error('Failed to refresh notifications feed', err)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
const onEndReached = () => {
|
const onEndReached = async () => {
|
||||||
view
|
try {
|
||||||
.loadMore()
|
await view.loadMore()
|
||||||
.catch(err =>
|
} catch (err) {
|
||||||
view.rootStore.log.error('Failed to load more notifications', err),
|
view.rootStore.log.error('Failed to load more notifications', err)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
let data
|
let data
|
||||||
if (view.hasLoaded) {
|
if (view.hasLoaded) {
|
||||||
|
|||||||
@@ -17,13 +17,16 @@ export const PostThread = observer(function PostThread({
|
|||||||
view: PostThreadViewModel
|
view: PostThreadViewModel
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<FlatList>(null)
|
const ref = useRef<FlatList>(null)
|
||||||
|
const [isRefreshing, setIsRefreshing] = React.useState(false)
|
||||||
const posts = view.thread ? Array.from(flattenThread(view.thread)) : []
|
const posts = view.thread ? Array.from(flattenThread(view.thread)) : []
|
||||||
const onRefresh = () => {
|
const onRefresh = async () => {
|
||||||
view
|
setIsRefreshing(true)
|
||||||
?.refresh()
|
try {
|
||||||
.catch(err =>
|
view?.refresh()
|
||||||
view.rootStore.log.error('Failed to refresh posts thread', err),
|
} catch (err) {
|
||||||
)
|
view.rootStore.log.error('Failed to refresh posts thread', err)
|
||||||
|
}
|
||||||
|
setIsRefreshing(false)
|
||||||
}
|
}
|
||||||
const onLayout = () => {
|
const onLayout = () => {
|
||||||
const index = posts.findIndex(post => post._isHighlightedPost)
|
const index = posts.findIndex(post => post._isHighlightedPost)
|
||||||
@@ -77,7 +80,7 @@ export const PostThread = observer(function PostThread({
|
|||||||
data={posts}
|
data={posts}
|
||||||
keyExtractor={item => item._reactKey}
|
keyExtractor={item => item._reactKey}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
refreshing={view.isRefreshing}
|
refreshing={isRefreshing}
|
||||||
onRefresh={onRefresh}
|
onRefresh={onRefresh}
|
||||||
onLayout={onLayout}
|
onLayout={onLayout}
|
||||||
onScrollToIndexFailed={onScrollToIndexFailed}
|
onScrollToIndexFailed={onScrollToIndexFailed}
|
||||||
|
|||||||
+22
-2
@@ -1,8 +1,27 @@
|
|||||||
import notifee from '@notifee/react-native'
|
import notifee, {EventType} from '@notifee/react-native'
|
||||||
import {AppBskyEmbedImages} from '@atproto/api'
|
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 {NotificationsViewItemModel} from '../../state/models/notifications-view'
|
||||||
import {enforceLen} from '../../lib/strings'
|
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(
|
export function displayNotification(
|
||||||
title: string,
|
title: string,
|
||||||
body?: string,
|
body?: string,
|
||||||
@@ -39,7 +58,8 @@ export function displayNotificationFromModel(
|
|||||||
title = `${author} replied to your post`
|
title = `${author} replied to your post`
|
||||||
body = notif.additionalPost?.thread?.postRecord?.text || ''
|
body = notif.additionalPost?.thread?.postRecord?.text || ''
|
||||||
} else if (notif.isFollow) {
|
} else if (notif.isFollow) {
|
||||||
title = `${author} followed you`
|
title = 'New follower!'
|
||||||
|
body = `${author} has followed you`
|
||||||
} else {
|
} else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export const Home = observer(function Home({navIdx, visible}: ScreenParams) {
|
|||||||
if (store.me.mainFeed.isLoading) {
|
if (store.me.mainFeed.isLoading) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
store.log.debug('Polling home feed')
|
store.log.debug('HomeScreen: Polling for new posts')
|
||||||
store.me.mainFeed.checkForLatest()
|
store.me.mainFeed.checkForLatest()
|
||||||
},
|
},
|
||||||
[appState, visible, store],
|
[appState, visible, store],
|
||||||
@@ -52,7 +52,7 @@ export const Home = observer(function Home({navIdx, visible}: ScreenParams) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const softResetSub = store.onScreenSoftReset(scrollToTop)
|
const softResetSub = store.onScreenSoftReset(scrollToTop)
|
||||||
const feedCleanup = store.me.mainFeed.registerListeners()
|
const feedCleanup = store.me.mainFeed.registerListeners()
|
||||||
const pollInterval = setInterval(() => doPoll(), 15e3)
|
const pollInterval = setInterval(doPoll, 15e3)
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
clearInterval(pollInterval)
|
clearInterval(pollInterval)
|
||||||
softResetSub.remove()
|
softResetSub.remove()
|
||||||
@@ -73,7 +73,7 @@ export const Home = observer(function Home({navIdx, visible}: ScreenParams) {
|
|||||||
// just became visible
|
// just became visible
|
||||||
screen('Feed')
|
screen('Feed')
|
||||||
store.nav.setTitle(navIdx, 'Home')
|
store.nav.setTitle(navIdx, 'Home')
|
||||||
store.log.debug('Updating home feed')
|
store.log.debug('HomeScreen: Updating feed')
|
||||||
if (store.me.mainFeed.hasContent) {
|
if (store.me.mainFeed.hasContent) {
|
||||||
store.me.mainFeed.update()
|
store.me.mainFeed.update()
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, {useEffect} from 'react'
|
import React, {useEffect} from 'react'
|
||||||
import {FlatList, View} from 'react-native'
|
import {FlatList, View} from 'react-native'
|
||||||
|
import useAppState from 'react-native-appstate-hook'
|
||||||
import {ViewHeader} from '../com/util/ViewHeader'
|
import {ViewHeader} from '../com/util/ViewHeader'
|
||||||
import {Feed} from '../com/notifications/Feed'
|
import {Feed} from '../com/notifications/Feed'
|
||||||
import {useStores} from '../../state'
|
import {useStores} from '../../state'
|
||||||
@@ -8,36 +9,71 @@ import {useOnMainScroll} from '../lib/hooks/useOnMainScroll'
|
|||||||
import {s} from '../lib/styles'
|
import {s} from '../lib/styles'
|
||||||
import {useAnalytics} from '@segment/analytics-react-native'
|
import {useAnalytics} from '@segment/analytics-react-native'
|
||||||
|
|
||||||
|
const NOTIFICATIONS_POLL_INTERVAL = 15e3
|
||||||
|
|
||||||
export const Notifications = ({navIdx, visible}: ScreenParams) => {
|
export const Notifications = ({navIdx, visible}: ScreenParams) => {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const onMainScroll = useOnMainScroll(store)
|
const onMainScroll = useOnMainScroll(store)
|
||||||
const scrollElRef = React.useRef<FlatList>(null)
|
const scrollElRef = React.useRef<FlatList>(null)
|
||||||
const {screen} = useAnalytics()
|
const {screen} = useAnalytics()
|
||||||
|
const {appState} = useAppState({
|
||||||
|
onForeground: () => doPoll(true),
|
||||||
|
})
|
||||||
|
|
||||||
const onSoftReset = () => {
|
// event handlers
|
||||||
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])
|
|
||||||
|
|
||||||
const onPressTryAgain = () => {
|
const onPressTryAgain = () => {
|
||||||
store.me.notifications.refresh()
|
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 (
|
return (
|
||||||
<View style={s.h100pct}>
|
<View style={s.h100pct}>
|
||||||
|
|||||||
@@ -513,7 +513,7 @@ export const MobileShell: React.FC = observer(() => {
|
|||||||
icon={isAtNotifications ? 'bell-solid' : 'bell'}
|
icon={isAtNotifications ? 'bell-solid' : 'bell'}
|
||||||
onPress={onPressNotifications}
|
onPress={onPressNotifications}
|
||||||
onLongPress={TABS_ENABLED ? doNewTab('/notifications') : undefined}
|
onLongPress={TABS_ENABLED ? doNewTab('/notifications') : undefined}
|
||||||
notificationCount={store.me.notificationCount}
|
notificationCount={store.me.notifications.unreadCount}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
<Modal />
|
<Modal />
|
||||||
|
|||||||
Reference in New Issue
Block a user