+6
__mocks__/@notifee/react-native.ts
+6
__mocks__/@notifee/react-native.ts
+4
__mocks__/react-native-background-fetch.ts
+4
__mocks__/react-native-background-fetch.ts
+11
-10
__mocks__/state-mock.ts
+11
-10
__mocks__/state-mock.ts
···
64
64
isUser: true,
65
65
isScene: false,
66
66
setup: jest.fn().mockResolvedValue({aborted: false}),
67
-
refresh: jest.fn(),
67
+
refresh: jest.fn().mockResolvedValue({}),
68
68
toggleFollowing: jest.fn().mockResolvedValue({}),
69
69
updateProfile: jest.fn(),
70
70
// unknown required because of the missing private methods: _xLoading, _xIdle, _load, _replaceAll
···
106
106
isEmpty: false,
107
107
isMember: jest.fn(),
108
108
setup: jest.fn().mockResolvedValue({aborted: false}),
109
-
refresh: jest.fn(),
109
+
refresh: jest.fn().mockResolvedValue({}),
110
110
loadMore: jest.fn(),
111
111
removeMember: jest.fn(),
112
112
// unknown required because of the missing private methods: _xLoading, _xIdle, _fetch, _replaceAll, _append
···
149
149
isEmpty: false,
150
150
isMemberOf: jest.fn(),
151
151
setup: jest.fn().mockResolvedValue({aborted: false}),
152
-
refresh: jest.fn(),
152
+
refresh: jest.fn().mockResolvedValue({}),
153
153
loadMore: jest.fn(),
154
154
// unknown required because of the missing private methods: _xLoading, _xIdle, _fetch, _replaceAll, _append
155
155
} as unknown as MembershipsViewModel
···
413
413
createdAt: '',
414
414
}),
415
415
fetchAdditionalData: jest.fn(),
416
+
toNotifeeOpts: jest.fn(),
416
417
} as NotificationsViewItemModel
417
418
418
419
export const mockedNotificationsStore = {
···
510
511
},
511
512
],
512
513
navigate: jest.fn(),
513
-
refresh: jest.fn(),
514
+
refresh: jest.fn().mockResolvedValue({}),
514
515
goBack: jest.fn(),
515
516
fixedTabReset: jest.fn(),
516
517
goForward: jest.fn(),
···
539
540
tabCount: 1,
540
541
isCurrentScreen: jest.fn(),
541
542
navigate: jest.fn(),
542
-
refresh: jest.fn(),
543
+
refresh: jest.fn().mockResolvedValue({}),
543
544
setTitle: jest.fn(),
544
545
handleLink: jest.fn(),
545
546
switchTo: jest.fn(),
···
587
588
clear: jest.fn(),
588
589
load: jest.fn(),
589
590
clearNotificationCount: jest.fn(),
590
-
fetchStateUpdate: jest.fn(),
591
+
fetchNotifications: jest.fn(),
591
592
refreshMemberships: jest.fn(),
592
593
} as MeModel
593
594
···
679
680
setSelectedViewIndex: jest.fn(),
680
681
setup: jest.fn().mockResolvedValue({aborted: false}),
681
682
update: jest.fn(),
682
-
refresh: jest.fn(),
683
+
refresh: jest.fn().mockResolvedValue({}),
683
684
loadMore: jest.fn(),
684
685
} as ProfileUiModel
685
686
···
788
789
hasError: false,
789
790
isEmpty: false,
790
791
setup: jest.fn().mockResolvedValue(null),
791
-
refresh: jest.fn(),
792
+
refresh: jest.fn().mockResolvedValue({}),
792
793
// unknown required because of the missing private methods: _xLoading, _xIdle, _fetch, _appendAll, _append
793
794
} as unknown as SuggestedActorsViewModel
794
795
···
828
829
hasError: false,
829
830
isEmpty: false,
830
831
setup: jest.fn(),
831
-
refresh: jest.fn(),
832
+
refresh: jest.fn().mockResolvedValue({}),
832
833
loadMore: jest.fn(),
833
834
// unknown required because of the missing private methods: _xIdle, _xLoading, _fetch, _replaceAll, _append
834
835
} as unknown as UserFollowersViewModel
···
869
870
hasError: false,
870
871
isEmpty: false,
871
872
setup: jest.fn(),
872
-
refresh: jest.fn(),
873
+
refresh: jest.fn().mockResolvedValue({}),
873
874
loadMore: jest.fn(),
874
875
// unknown required because of the missing private methods: _xIdle, _xLoading, _fetch, _replaceAll, _append
875
876
} as unknown as UserFollowsViewModel
+2
-2
__tests__/state/models/me.test.ts
+2
-2
__tests__/state/models/me.test.ts
···
160
160
161
161
it('should update notifs count with fetchStateUpdate()', async () => {
162
162
meModel.notifications = {
163
-
refresh: jest.fn(),
163
+
refresh: jest.fn().mockResolvedValue({}),
164
164
} as unknown as NotificationsViewModel
165
165
166
166
jest
···
173
173
})
174
174
})
175
175
176
-
await meModel.fetchStateUpdate()
176
+
await meModel.fetchNotifications()
177
177
expect(meModel.notificationCount).toBe(1)
178
178
expect(meModel.notifications.refresh).toHaveBeenCalled()
179
179
})
+15
ios/Podfile.lock
+15
ios/Podfile.lock
···
340
340
- React-perflogger (= 0.71.0)
341
341
- rn-fetch-blob (0.12.0):
342
342
- React-Core
343
+
- RNBackgroundFetch (4.1.8):
344
+
- React-Core
343
345
- RNCAsyncStorage (1.17.11):
344
346
- React-Core
345
347
- RNCClipboard (1.11.1):
···
358
360
- React-RCTImage
359
361
- TOCropViewController
360
362
- RNInAppBrowser (3.7.0):
363
+
- React-Core
364
+
- RNNotifee (7.4.0):
365
+
- React-Core
366
+
- RNNotifee/NotifeeCore (= 7.4.0)
367
+
- RNNotifee/NotifeeCore (7.4.0):
361
368
- React-Core
362
369
- RNReactNativeHapticFeedback (1.14.0):
363
370
- React-Core
···
448
455
- React-runtimeexecutor (from `../node_modules/react-native/ReactCommon/runtimeexecutor`)
449
456
- ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`)
450
457
- rn-fetch-blob (from `../node_modules/rn-fetch-blob`)
458
+
- RNBackgroundFetch (from `../node_modules/react-native-background-fetch`)
451
459
- "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)"
452
460
- "RNCClipboard (from `../node_modules/@react-native-clipboard/clipboard`)"
453
461
- RNFS (from `../node_modules/react-native-fs`)
454
462
- RNGestureHandler (from `../node_modules/react-native-gesture-handler`)
455
463
- RNImageCropPicker (from `../node_modules/react-native-image-crop-picker`)
456
464
- RNInAppBrowser (from `../node_modules/react-native-inappbrowser-reborn`)
465
+
- "RNNotifee (from `../node_modules/@notifee/react-native`)"
457
466
- RNReactNativeHapticFeedback (from `../node_modules/react-native-haptic-feedback`)
458
467
- RNReanimated (from `../node_modules/react-native-reanimated`)
459
468
- RNScreens (from `../node_modules/react-native-screens`)
···
556
565
:path: "../node_modules/react-native/ReactCommon"
557
566
rn-fetch-blob:
558
567
:path: "../node_modules/rn-fetch-blob"
568
+
RNBackgroundFetch:
569
+
:path: "../node_modules/react-native-background-fetch"
559
570
RNCAsyncStorage:
560
571
:path: "../node_modules/@react-native-async-storage/async-storage"
561
572
RNCClipboard:
···
568
579
:path: "../node_modules/react-native-image-crop-picker"
569
580
RNInAppBrowser:
570
581
:path: "../node_modules/react-native-inappbrowser-reborn"
582
+
RNNotifee:
583
+
:path: "../node_modules/@notifee/react-native"
571
584
RNReactNativeHapticFeedback:
572
585
:path: "../node_modules/react-native-haptic-feedback"
573
586
RNReanimated:
···
629
642
React-runtimeexecutor: ac80782d9d76ba2b0f709f4de0c427fe33c352dc
630
643
ReactCommon: 20e38a9be5fe1341b5e422220877cc94034776ba
631
644
rn-fetch-blob: f065bb7ab7fb48dd002629f8bdcb0336602d3cba
645
+
RNBackgroundFetch: 8e16176ff415daac743a6eb57afc8e9e14dbe623
632
646
RNCAsyncStorage: 8616bd5a58af409453ea4e1b246521bb76578d60
633
647
RNCClipboard: 2834e1c4af68697089cdd455ee4a4cdd198fa7dd
634
648
RNFS: 4ac0f0ea233904cb798630b3c077808c06931688
635
649
RNGestureHandler: 62232ba8f562f7dea5ba1b3383494eb5bf97a4d3
636
650
RNImageCropPicker: 648356d68fbf9911a1016b3e3723885d28373eda
637
651
RNInAppBrowser: e36d6935517101ccba0e875bac8ad7b0cb655364
652
+
RNNotifee: da8dcf09f079ea22f46e239d7c406e10d4525a5f
638
653
RNReactNativeHapticFeedback: 1e3efeca9628ff9876ee7cdd9edec1b336913f8c
639
654
RNReanimated: d8d9d3d3801bda5e35e85cdffc871577d044dc2e
640
655
RNScreens: 34cc502acf1b916c582c60003dc3089fa01dc66d
+2
package.json
+2
package.json
···
25
25
"@fortawesome/react-native-fontawesome": "^0.3.0",
26
26
"@gorhom/bottom-sheet": "^4",
27
27
"@mattermost/react-native-paste-input": "^0.6.0",
28
+
"@notifee/react-native": "^7.4.0",
28
29
"@react-native-async-storage/async-storage": "^1.17.6",
29
30
"@react-native-camera-roll/camera-roll": "^5.1.0",
30
31
"@react-native-clipboard/clipboard": "^1.10.0",
···
45
46
"react-dom": "17.0.2",
46
47
"react-native": "0.71.0",
47
48
"react-native-appstate-hook": "^1.0.6",
49
+
"react-native-background-fetch": "^4.1.8",
48
50
"react-native-fs": "^2.20.0",
49
51
"react-native-gesture-handler": "^2.5.0",
50
52
"react-native-haptic-feedback": "^1.14.0",
+8
src/App.native.tsx
+8
src/App.native.tsx
···
16
16
import {RootStoreModel, setupState, RootStoreProvider} from './state'
17
17
import {MobileShell} from './view/shell/mobile'
18
18
import {s} from './view/lib/styles'
19
+
import notifee, {EventType} from '@notifee/react-native'
19
20
20
21
const App = observer(() => {
21
22
const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
···
42
43
})
43
44
Linking.addEventListener('url', ({url}) => {
44
45
store.nav.handleLink(url)
46
+
})
47
+
notifee.onForegroundEvent(async ({type}: {type: EventType}) => {
48
+
store.log.debug('Notifee foreground event', {type})
49
+
if (type === EventType.PRESS) {
50
+
store.log.debug('User pressed a notifee, opening notifications')
51
+
store.nav.switchTo(1, true)
52
+
}
45
53
})
46
54
})
47
55
}, [])
+18
-2
src/state/models/me.ts
+18
-2
src/state/models/me.ts
···
1
1
import {makeAutoObservable, runInAction} from 'mobx'
2
+
import notifee from '@notifee/react-native'
2
3
import {RootStoreModel} from './root-store'
3
4
import {FeedModel} from './feed-view'
4
5
import {NotificationsViewModel} from './notifications-view'
···
104
105
this.rootStore.log.error('Failed to setup notifications model', e)
105
106
}),
106
107
])
108
+
109
+
// request notifications permission once the user has logged in
110
+
notifee.requestPermission()
107
111
} else {
108
112
this.clear()
109
113
}
···
111
115
112
116
clearNotificationCount() {
113
117
this.notificationCount = 0
118
+
notifee.setBadgeCount(0)
114
119
}
115
120
116
-
async fetchStateUpdate() {
121
+
async fetchNotifications() {
117
122
const res = await this.rootStore.api.app.bsky.notification.getCount()
118
123
runInAction(() => {
119
124
const newNotifications = this.notificationCount !== res.data.count
120
125
this.notificationCount = res.data.count
126
+
notifee.setBadgeCount(this.notificationCount)
121
127
if (newNotifications) {
122
128
// trigger pre-emptive fetch on new notifications
123
-
this.notifications.refresh()
129
+
let oldMostRecent = this.notifications.mostRecentNotification
130
+
this.notifications.refresh().then(() => {
131
+
// if a new most recent notification is found, trigger a notification card
132
+
const mostRecent = this.notifications.mostRecentNotification
133
+
if (mostRecent && oldMostRecent?.uri !== mostRecent?.uri) {
134
+
const notifeeOpts = mostRecent.toNotifeeOpts()
135
+
if (notifeeOpts) {
136
+
notifee.displayNotification(notifeeOpts)
137
+
}
138
+
}
139
+
})
124
140
}
125
141
})
126
142
}
+50
src/state/models/notifications-view.ts
+50
src/state/models/notifications-view.ts
···
7
7
AppBskyFeedVote,
8
8
AppBskyGraphAssertion,
9
9
AppBskyGraphFollow,
10
+
AppBskyEmbedImages,
10
11
} from '@atproto/api'
11
12
import {RootStoreModel} from './root-store'
12
13
import {PostThreadViewModel} from './post-thread-view'
···
179
180
})
180
181
}
181
182
}
183
+
184
+
toNotifeeOpts() {
185
+
let author = this.author.displayName || this.author.handle
186
+
let title: string
187
+
let body: string = ''
188
+
if (this.isUpvote) {
189
+
title = `${author} liked your post`
190
+
body = this.additionalPost?.thread?.postRecord?.text || ''
191
+
} else if (this.isRepost) {
192
+
title = `${author} reposted your post`
193
+
body = this.additionalPost?.thread?.postRecord?.text || ''
194
+
} else if (this.isReply) {
195
+
title = `${author} replied to your post`
196
+
body = this.additionalPost?.thread?.postRecord?.text || ''
197
+
} else if (this.isFollow) {
198
+
title = `${author} followed you`
199
+
} else {
200
+
return undefined
201
+
}
202
+
let ios
203
+
if (
204
+
AppBskyEmbedImages.isPresented(this.additionalPost?.thread?.post.embed) &&
205
+
this.additionalPost?.thread?.post.embed.images[0]?.thumb
206
+
) {
207
+
ios = {
208
+
attachments: [
209
+
{url: this.additionalPost.thread.post.embed.images[0].thumb},
210
+
],
211
+
}
212
+
}
213
+
return {
214
+
title,
215
+
body,
216
+
ios,
217
+
}
218
+
}
182
219
}
183
220
184
221
export class NotificationsViewModel {
···
196
233
197
234
// data
198
235
notifications: NotificationsViewItemModel[] = []
236
+
237
+
// this is used to trigger push notifications
238
+
mostRecentNotification: NotificationsViewItemModel | undefined
199
239
200
240
constructor(
201
241
public rootStore: RootStoreModel,
···
388
428
}
389
429
390
430
private async _replaceAll(res: ListNotifications.Response) {
431
+
if (res.data.notifications[0]) {
432
+
this.mostRecentNotification = new NotificationsViewItemModel(
433
+
this.rootStore,
434
+
'mostRecent',
435
+
res.data.notifications[0],
436
+
)
437
+
await this.mostRecentNotification.fetchAdditionalData()
438
+
} else {
439
+
this.mostRecentNotification = undefined
440
+
}
391
441
return this._appendAll(res, true)
392
442
}
393
443
+36
-2
src/state/models/root-store.ts
+36
-2
src/state/models/root-store.ts
···
6
6
import {sessionClient as AtpApi, SessionServiceClient} from '@atproto/api'
7
7
import {createContext, useContext} from 'react'
8
8
import {DeviceEventEmitter, EmitterSubscription} from 'react-native'
9
+
import BackgroundFetch from 'react-native-background-fetch'
9
10
import {isObj, hasProp} from '../lib/type-guards'
10
11
import {LogModel} from './log'
11
12
import {SessionModel} from './session'
···
34
35
serialize: false,
35
36
hydrate: false,
36
37
})
38
+
this.initBgFetch()
37
39
}
38
40
39
41
async resolveName(didOrHandle: string) {
···
55
57
if (!this.session.online) {
56
58
await this.session.connect()
57
59
}
58
-
await this.me.fetchStateUpdate()
60
+
await this.me.fetchNotifications()
59
61
} catch (e: any) {
60
62
if (isNetworkError(e)) {
61
63
this.session.setOnline(false) // connection lost
···
109
111
}
110
112
111
113
emitPostDeleted(uri: string) {
112
-
console.log('emit')
113
114
DeviceEventEmitter.emit('post-deleted', uri)
115
+
}
116
+
117
+
// background fetch
118
+
// =
119
+
// - we use this to poll for unread notifications, which is not "ideal" behavior but
120
+
// gives us a solution for push-notifications that work against any pds
121
+
122
+
initBgFetch() {
123
+
// NOTE
124
+
// background fetch runs every 15 minutes *at most* and will get slowed down
125
+
// based on some heuristics run by iOS, meaning it is not a reliable form of delivery
126
+
// -prf
127
+
BackgroundFetch.configure(
128
+
{minimumFetchInterval: 15},
129
+
this.onBgFetch.bind(this),
130
+
this.onBgFetchTimeout.bind(this),
131
+
).then(status => {
132
+
this.log.debug(`Background fetch initiated, status: ${status}`)
133
+
})
134
+
}
135
+
136
+
async onBgFetch(taskId: string) {
137
+
this.log.debug(`Background fetch fired for task ${taskId}`)
138
+
if (this.session.hasSession) {
139
+
// grab notifications
140
+
await this.me.fetchNotifications()
141
+
}
142
+
BackgroundFetch.finish(taskId)
143
+
}
144
+
145
+
onBgFetchTimeout(taskId: string) {
146
+
this.log.debug(`Background fetch timed out for task ${taskId}`)
147
+
BackgroundFetch.finish(taskId)
114
148
}
115
149
}
116
150
+27
-11
src/state/models/session.ts
+27
-11
src/state/models/session.ts
···
286
286
* Attempt to resume a session that we still have access tokens for.
287
287
*/
288
288
async resumeSession(account: AccountData): Promise<boolean> {
289
-
if (account.accessJwt && account.refreshJwt) {
290
-
this.setState({
291
-
service: account.service,
292
-
accessJwt: account.accessJwt,
293
-
refreshJwt: account.refreshJwt,
294
-
handle: account.handle,
295
-
did: account.did,
296
-
})
297
-
} else {
289
+
if (!(account.accessJwt && account.refreshJwt && account.service)) {
298
290
return false
299
291
}
292
+
293
+
// test that the session is good
294
+
const api = AtpApi.service(account.service)
295
+
api.sessionManager.set({
296
+
refreshJwt: account.refreshJwt,
297
+
accessJwt: account.accessJwt,
298
+
})
299
+
try {
300
+
const sess = await api.com.atproto.session.get()
301
+
if (!sess.success || sess.data.did !== account.did) {
302
+
return false
303
+
}
304
+
} catch (_e) {
305
+
return false
306
+
}
307
+
308
+
// session is good, connect
309
+
this.setState({
310
+
service: account.service,
311
+
accessJwt: account.accessJwt,
312
+
refreshJwt: account.refreshJwt,
313
+
handle: account.handle,
314
+
did: account.did,
315
+
})
300
316
return this.connect()
301
317
}
302
318
···
345
361
* Close all sessions across all accounts.
346
362
*/
347
363
async logout() {
348
-
if (this.hasSession) {
364
+
/*if (this.hasSession) {
349
365
this.rootStore.api.com.atproto.session.delete().catch((e: any) => {
350
366
this.rootStore.log.warn(
351
367
'(Minor issue) Failed to delete session on the server',
352
368
e,
353
369
)
354
370
})
355
-
}
371
+
}*/
356
372
this.clearSessionTokensFromAccounts()
357
373
this.rootStore.clearAll()
358
374
}
+10
yarn.lock
+10
yarn.lock
···
2109
2109
"@nodelib/fs.scandir" "2.1.5"
2110
2110
fastq "^1.6.0"
2111
2111
2112
+
"@notifee/react-native@^7.4.0":
2113
+
version "7.4.0"
2114
+
resolved "https://registry.yarnpkg.com/@notifee/react-native/-/react-native-7.4.0.tgz#0f20744307bf3b800f7b56eb2d0bbdd474748d09"
2115
+
integrity sha512-c8pkxDQFRbw0JlUmTb07OTG/4LQHRj8MBodMLwEcO+SvqIxK8ya8zSUEzfdcdWsSVqdoym0v3zpSNroR3Quj/w==
2116
+
2112
2117
"@pmmmwh/react-refresh-webpack-plugin@^0.5.3":
2113
2118
version "0.5.10"
2114
2119
resolved "https://registry.yarnpkg.com/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.10.tgz#2eba163b8e7dbabb4ce3609ab5e32ab63dda3ef8"
···
11163
11168
version "1.0.6"
11164
11169
resolved "https://registry.yarnpkg.com/react-native-appstate-hook/-/react-native-appstate-hook-1.0.6.tgz#cbc16e7b89cfaea034cabd999f00e99053cabd06"
11165
11170
integrity sha512-0hPVyf5yLxCSVrrNEuGqN1ZnSSj3Ye2gZex0NtcK/AHYwMc0rXWFNZjBKOoZSouspqu3hXBbQ6NOUSTzrME1AQ==
11171
+
11172
+
react-native-background-fetch@^4.1.8:
11173
+
version "4.1.8"
11174
+
resolved "https://registry.yarnpkg.com/react-native-background-fetch/-/react-native-background-fetch-4.1.8.tgz#a21858e5d876de8d9d15a37f40714b244f73713c"
11175
+
integrity sha512-/qe86laa0n4AbD6mrLL8SCGR+K5693URX95e02/bTJh3UkdS3+sU1Jyc/XTlz4MQwlquI929/lm5EZh8AOUqzQ==
11166
11176
11167
11177
react-native-codegen@^0.71.3:
11168
11178
version "0.71.3"