Compare commits
337 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7086b4a68c | |||
| cd5175c663 | |||
| ed56365cc0 | |||
| afea10e5dd | |||
| 10360ecded | |||
| fe239abd26 | |||
| 50818d73f6 | |||
| 7021ea2745 | |||
| 441dd29685 | |||
| 89ddcdfa52 | |||
| a4422f1819 | |||
| 68dd96128a | |||
| e28093ec35 | |||
| 6e55149281 | |||
| 3aca890da6 | |||
| 4798d44110 | |||
| 37b2b40aa1 | |||
| f9e287b12c | |||
| 96dc9e398b | |||
| 2f0157dd33 | |||
| f608712308 | |||
| 3807f216fe | |||
| 6b1a5374ae | |||
| 71b7158ff9 | |||
| 26f06d768c | |||
| 383a40bc03 | |||
| 8413fccdb5 | |||
| 3161703d8a | |||
| 4c097d89c5 | |||
| 0b50ae3e61 | |||
| 063583bb6f | |||
| f2da9f2d0a | |||
| fc7fa8a354 | |||
| 709a970cdc | |||
| 5d7814897c | |||
| 1e2ee001a8 | |||
| 11423fc25c | |||
| 160fc31b47 | |||
| 3389e0dc4a | |||
| ae8162974e | |||
| b33269a535 | |||
| d8280b6ad9 | |||
| 935cc8f675 | |||
| 3bd6e9c84c | |||
| f1b9114af0 | |||
| 95e5b169c8 | |||
| 09791a3b3c | |||
| c7a5642e48 | |||
| 6c23972620 | |||
| 211af98aaf | |||
| bc404572cb | |||
| b824ac710b | |||
| 372cd80175 | |||
| d0c9f95c7e | |||
| 91a06f2390 | |||
| ffd333164b | |||
| a6e8bb39b8 | |||
| 8d87fbd56a | |||
| a129ddba27 | |||
| 211b9fc2f2 | |||
| 8b6bb6999d | |||
| 524cb062a2 | |||
| be71989e45 | |||
| edb5a8ddb2 | |||
| fb461c0344 | |||
| a8608d0170 | |||
| 628f69605c | |||
| 537770b39d | |||
| 44eca8a550 | |||
| 3a79e46a4a | |||
| 6f56a8c539 | |||
| 2ac28aa2de | |||
| 53c55b2d7a | |||
| 31e0fa898a | |||
| 1ec46a00e2 | |||
| d98fbbbe4d | |||
| ef02ec9ab1 | |||
| 72fe122005 | |||
| 4b960bc320 | |||
| 090ea1a3f4 | |||
| 983f2349e0 | |||
| 8af98b4a52 | |||
| d821803050 | |||
| 35ead0155e | |||
| 5b908f5358 | |||
| 099fea6624 | |||
| a2ef0b5a68 | |||
| 38c17e06bb | |||
| c7c0c04b66 | |||
| bc7364acca | |||
| 1e9d265403 | |||
| 02689c5533 | |||
| d0f6da2a51 | |||
| 2e9a858e3d | |||
| 8e46204617 | |||
| b844e479a1 | |||
| f7ccf14211 | |||
| 814ddb194a | |||
| d7103412e5 | |||
| ad4c413ae4 | |||
| f0ed5d7c28 | |||
| e8304bb77d | |||
| 5ae10f17c5 | |||
| 4b8de11046 | |||
| 27f94052e9 | |||
| 8004d038ab | |||
| ff9d7346ec | |||
| c0298dc7b7 | |||
| ab0f4f8288 | |||
| 148fa29875 | |||
| f13c42c078 | |||
| a079605049 | |||
| 56d98abe64 | |||
| dab5a36a58 | |||
| 65e80556e8 | |||
| b60406f960 | |||
| 4d2c2c5f2a | |||
| deb7c1bba7 | |||
| 8b1134dc75 | |||
| a83da7367c | |||
| ec3ba2ebc8 | |||
| ab4f828865 | |||
| c72e1b6635 | |||
| 4e245f8471 | |||
| 652362b0c2 | |||
| abd6ffdbf5 | |||
| 0dc1c904ed | |||
| a8bdb4a288 | |||
| 9ec2712728 | |||
| 64609575e7 | |||
| 09411967e8 | |||
| fbec25412b | |||
| ffe51591e8 | |||
| f11ef7ba0b | |||
| 0439674aa9 | |||
| e609f8dadb | |||
| 6af2e1d162 | |||
| 35e46a5b60 | |||
| 14ca7602e9 | |||
| 7c719f3af8 | |||
| 547965b528 | |||
| 85a425bf3e | |||
| d9d9344fcc | |||
| 67fb6b23da | |||
| 2a4391af97 | |||
| fec3aa2e2f | |||
| 4fcc0accc7 | |||
| 3da90128f0 | |||
| 038556cc58 | |||
| f42ff8e4cf | |||
| d073049ed2 | |||
| b165d076f1 | |||
| c16e1b8fb1 | |||
| a527c1086a | |||
| 6be082e728 | |||
| f99cdba6dd | |||
| 397ace5c11 | |||
| 924c76f11b | |||
| d875933412 | |||
| c5a81dcc71 | |||
| 28a9fe85d5 | |||
| 29d5cef912 | |||
| 0e935a50e7 | |||
| 61de9aaea6 | |||
| 83a533a0dc | |||
| 2e7da95e5b | |||
| 93a7667c83 | |||
| 51f78cef9a | |||
| bf7581e8e3 | |||
| aa7a5964f2 | |||
| 30c0a5f4b1 | |||
| a3c3b1cd27 | |||
| cc775dcdc0 | |||
| 5d9c21c2a5 | |||
| 9a19607f3f | |||
| 3d0eeb01f8 | |||
| 310ec3a574 | |||
| 96d12df021 | |||
| f382d6f3a5 | |||
| 6de99fd964 | |||
| bc76c7ffb8 | |||
| 20ed8da853 | |||
| aaf10bfdbf | |||
| 36b9020f36 | |||
| 5ec8fcf350 | |||
| 194c6dc0fd | |||
| aadec58087 | |||
| 1b1883aa30 | |||
| 020a0bc882 | |||
| f2bf75e85e | |||
| 957f42698e | |||
| fd436dcd9b | |||
| 9e32b64d57 | |||
| 7d39cb5f6b | |||
| a8cd419b08 | |||
| c20d941a77 | |||
| 93741ac50e | |||
| f83739fdb9 | |||
| daf91112f5 | |||
| d406b3a5c2 | |||
| 09b8ae0fed | |||
| 683a456b5c | |||
| 265af0d720 | |||
| 30f6636a95 | |||
| 03980afe98 | |||
| a3f2c28c13 | |||
| 2850887c71 | |||
| 8a403407cb | |||
| 73f1f464d8 | |||
| 16c06cd997 | |||
| 0f1a59641a | |||
| bd62ab2f92 | |||
| 4727fb25ce | |||
| 0947970514 | |||
| 04b081bbd7 | |||
| 3a6bb19cad | |||
| 77650f328b | |||
| 424a08c4a0 | |||
| 18d96ce0ce | |||
| 60f4d64af6 | |||
| 5664ecbd0c | |||
| 4fa74c0d38 | |||
| 6b3780f6d1 | |||
| 7d90638188 | |||
| 28c2591e0b | |||
| 2b58550d36 | |||
| 22e619d0ea | |||
| a32b067057 | |||
| 482f848281 | |||
| 6f97f8a84b | |||
| 0168efd737 | |||
| bb1d0ab0d9 | |||
| 0fae45e8df | |||
| 34203fcbce | |||
| e64fe9d36e | |||
| 11c5000b5b | |||
| 1e5046c679 | |||
| 9344f7f20d | |||
| b724b0d3eb | |||
| 6703f0c068 | |||
| 88345d7cb5 | |||
| 266cd69348 | |||
| 1ae5318af0 | |||
| 992b79384f | |||
| f9fa6229b2 | |||
| 2c5b988de0 | |||
| feac39e657 | |||
| 1fa4419da1 | |||
| f49f8b6495 | |||
| c122f8a9d7 | |||
| 91dd25f7b1 | |||
| 0d1cdb7a38 | |||
| 9238504db9 | |||
| 774a3a653e | |||
| 497155b84c | |||
| b03cc5862b | |||
| b76a6109cf | |||
| 97d8b7cf23 | |||
| 48158e05cb | |||
| 20890227c9 | |||
| ed86125d87 | |||
| 4b8795bfea | |||
| 07a234e0f2 | |||
| bf42123576 | |||
| 37016ccf9f | |||
| 47b8b93c2c | |||
| c5f744870f | |||
| 993a273d76 | |||
| 6ee93cdbc4 | |||
| 9a57f5de5e | |||
| 94f365f564 | |||
| 01515de17d | |||
| f074d6017d | |||
| 5b96be04b4 | |||
| de7951074a | |||
| 990b3c7084 | |||
| 7de013f467 | |||
| ba417935f0 | |||
| a77988a984 | |||
| 5b8d0957b3 | |||
| a913745b8f | |||
| 2f7e3da138 | |||
| 13ce232455 | |||
| a67b4847ca | |||
| 49beea6cc8 | |||
| ee81540c64 | |||
| ccd3492e1e | |||
| 0a37c34704 | |||
| 60c2805c01 | |||
| debfb16520 | |||
| 76782b6ff1 | |||
| 1ccb0e8123 | |||
| d9cf32f0ac | |||
| 521e691138 | |||
| be9d89e0cc | |||
| b93b57c83e | |||
| 6bad17e353 | |||
| eaabfb8250 | |||
| 1001c5b4c4 | |||
| 2441cbfde6 | |||
| b2b6df7a7e | |||
| d954f6d905 | |||
| 3269f048b3 | |||
| 5c07a74c55 | |||
| 744a21ec57 | |||
| c55711a1d9 | |||
| ddcc804518 | |||
| 56f983fd4e | |||
| d85751caf0 | |||
| 79a5064072 | |||
| cb083f3836 | |||
| 3677f3be4b | |||
| fc835be1e6 | |||
| 40fdd27698 | |||
| d46de5b819 | |||
| 9627e9981d | |||
| 5852f17bed | |||
| 80c4566baf | |||
| 8b286a2179 | |||
| 9abeb86168 | |||
| 0963e67325 | |||
| 8dba18b67c | |||
| 4e56f11d06 | |||
| 39f13f01bc | |||
| 2ed9ea85c6 | |||
| 61b5a0cbe9 | |||
| c3831bd799 | |||
| 53deb48e2d | |||
| 2eac309f86 | |||
| 558f8010eb | |||
| 36e3697fd6 | |||
| 42f1c5d45d | |||
| 00c4940352 | |||
| 3075ded77f | |||
| 94fd697148 | |||
| a3df278f7d | |||
| 135a0456a2 |
@@ -10,11 +10,6 @@ Uses:
|
||||
- [MobX](https://mobx.js.org/README.html)
|
||||
- [Async Storage](https://github.com/react-native-async-storage/async-storage)
|
||||
|
||||
## TODOs
|
||||
|
||||
- Handle the "unauthed" state better than changing route definitions
|
||||
- Currently it's possible to get a 404 if the auth state changes
|
||||
|
||||
## Build instructions
|
||||
|
||||
- Setup your environment [using the react native instructions](https://reactnative.dev/docs/environment-setup).
|
||||
@@ -37,34 +32,8 @@ Uses:
|
||||
|
||||
## Various notes
|
||||
|
||||
### Env vars
|
||||
|
||||
Set using the `.env` file or using bash.
|
||||
|
||||
```
|
||||
REACT_APP_AUTH_LOBBY = 'http://localhost:3001'
|
||||
```
|
||||
|
||||
### Build behaviors
|
||||
|
||||
The `metro.config.js` file rewrites a couple of imports. This is partly to work around missing features in Metro, and partly to patch the bundle. Affected imports include:
|
||||
|
||||
- ucans
|
||||
- one-webcrypto
|
||||
|
||||
### Cryptography
|
||||
|
||||
For native builds, we must provide a polyfill of `webcrypto`. We use a custom native module AppSecureRandom (based on [react-native-securerandom](https://github.com/robhogan/react-native-securerandom)) for the CRNG and [msrcrypto](https://github.com/microsoft/MSR-JavaScript-Crypto) for the cryptography.
|
||||
|
||||
**NOTE** Keys are not currently stored securely.
|
||||
|
||||
### Polyfills
|
||||
|
||||
`./platform/polyfills.*.ts` adds polyfills to the environment. Currently this includes:
|
||||
|
||||
- webcrypto
|
||||
- TextEncoder / TextDecoder
|
||||
|
||||
### Auth flow
|
||||
|
||||
The auth flow is based on a browser app which is specified by the `REACT_APP_AUTH_LOBBY` env var. The app redirects to that location with the UCAN request, and then waits for a redirect back. In the native platforms with proper support, it will do this using an in-app browser. In native without in-app browser, or in the Web platform, it will handle this with redirects. The ucan is extracted from the hash fragment of the "return url" which is provided either by the in-app browser in response or detected during initial setup in the case of redirects.
|
||||
@@ -0,0 +1 @@
|
||||
export default from '@react-native-async-storage/async-storage/jest/async-storage-mock'
|
||||
@@ -1,16 +1,16 @@
|
||||
/**
|
||||
* @format
|
||||
*/
|
||||
// /**
|
||||
// * @format
|
||||
// */
|
||||
|
||||
import 'react-native'
|
||||
import React from 'react'
|
||||
import App from '../src/App'
|
||||
// import 'react-native'
|
||||
// import React from 'react'
|
||||
// import App from '../src/App'
|
||||
|
||||
// Note: test renderer must be required after react-native.
|
||||
import renderer from 'react-test-renderer'
|
||||
// // Note: test renderer must be required after react-native.
|
||||
// import renderer from 'react-test-renderer'
|
||||
|
||||
it('renders correctly', () => {
|
||||
renderer.act(() => {
|
||||
renderer.create(<App />)
|
||||
})
|
||||
})
|
||||
// it('renders correctly', () => {
|
||||
// renderer.act(() => {
|
||||
// renderer.create(<App />)
|
||||
// })
|
||||
// })
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import {LikelyType, getLinkMeta} from '../src/lib/link-meta'
|
||||
|
||||
const exampleComHtml = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Example Domain</title>
|
||||
<meta name="description" content="An example website">
|
||||
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style type="text/css">
|
||||
body {
|
||||
background-color: #f0f0f2;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
|
||||
}
|
||||
div {
|
||||
width: 600px;
|
||||
margin: 5em auto;
|
||||
padding: 2em;
|
||||
background-color: #fdfdff;
|
||||
border-radius: 0.5em;
|
||||
box-shadow: 2px 3px 7px 2px rgba(0,0,0,0.02);
|
||||
}
|
||||
a:link, a:visited {
|
||||
color: #38488f;
|
||||
text-decoration: none;
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
div {
|
||||
margin: 0 auto;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div>
|
||||
<h1>Example Domain</h1>
|
||||
<p>This domain is for use in illustrative examples in documents. You may use this
|
||||
domain in literature without prior coordination or asking for permission.</p>
|
||||
<p><a href="https://www.iana.org/domains/example">More information...</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
describe('getLinkMeta', () => {
|
||||
const inputs = [
|
||||
'',
|
||||
'httpbadurl',
|
||||
'https://example.com',
|
||||
'https://example.com/index.html',
|
||||
'https://example.com/image.png',
|
||||
'https://example.com/video.avi',
|
||||
'https://example.com/audio.ogg',
|
||||
'https://example.com/javascript.js',
|
||||
]
|
||||
const outputs = [
|
||||
{
|
||||
error: 'Invalid URL',
|
||||
likelyType: LikelyType.Other,
|
||||
url: '',
|
||||
},
|
||||
{
|
||||
error: 'Invalid URL',
|
||||
likelyType: LikelyType.Other,
|
||||
url: 'httpbadurl',
|
||||
},
|
||||
{
|
||||
likelyType: LikelyType.HTML,
|
||||
url: 'https://example.com',
|
||||
title: 'Example Domain',
|
||||
description: 'An example website',
|
||||
},
|
||||
{
|
||||
likelyType: LikelyType.HTML,
|
||||
url: 'https://example.com/index.html',
|
||||
title: 'Example Domain',
|
||||
description: 'An example website',
|
||||
},
|
||||
{
|
||||
likelyType: LikelyType.Image,
|
||||
url: 'https://example.com/image.png',
|
||||
},
|
||||
{
|
||||
likelyType: LikelyType.Video,
|
||||
url: 'https://example.com/video.avi',
|
||||
},
|
||||
{
|
||||
likelyType: LikelyType.Audio,
|
||||
url: 'https://example.com/audio.ogg',
|
||||
},
|
||||
{
|
||||
likelyType: LikelyType.Other,
|
||||
url: 'https://example.com/javascript.js',
|
||||
},
|
||||
]
|
||||
it('correctly handles a set of text inputs', async () => {
|
||||
for (let i = 0; i < inputs.length; i++) {
|
||||
global.fetch = jest.fn().mockImplementationOnce(() => {
|
||||
return new Promise((resolve, reject) => {
|
||||
resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => exampleComHtml,
|
||||
})
|
||||
})
|
||||
})
|
||||
const input = inputs[i]
|
||||
const output = await getLinkMeta(input)
|
||||
expect(output).toEqual(outputs[i])
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,319 @@
|
||||
import {
|
||||
extractEntities,
|
||||
detectLinkables,
|
||||
extractHtmlMeta,
|
||||
} from '../src/lib/strings'
|
||||
|
||||
describe('extractEntities', () => {
|
||||
const knownHandles = new Set(['handle.com', 'full123.test-of-chars'])
|
||||
const inputs = [
|
||||
'no mention',
|
||||
'@handle.com middle end',
|
||||
'start @handle.com end',
|
||||
'start middle @handle.com',
|
||||
'@handle.com @handle.com @handle.com',
|
||||
'@full123.test-of-chars',
|
||||
'not@right',
|
||||
'@handle.com!@#$chars',
|
||||
'@handle.com\n@handle.com',
|
||||
'parenthetical (@handle.com)',
|
||||
'start https://middle.com end',
|
||||
'start https://middle.com/foo/bar end',
|
||||
'start https://middle.com/foo/bar?baz=bux end',
|
||||
'start https://middle.com/foo/bar?baz=bux#hash end',
|
||||
'https://start.com/foo/bar?baz=bux#hash middle end',
|
||||
'start middle https://end.com/foo/bar?baz=bux#hash',
|
||||
'https://newline1.com\nhttps://newline2.com',
|
||||
'start middle.com end',
|
||||
'start middle.com/foo/bar end',
|
||||
'start middle.com/foo/bar?baz=bux end',
|
||||
'start middle.com/foo/bar?baz=bux#hash end',
|
||||
'start.com/foo/bar?baz=bux#hash middle end',
|
||||
'start middle end.com/foo/bar?baz=bux#hash',
|
||||
'newline1.com\nnewline2.com',
|
||||
'not.. a..url ..here',
|
||||
'e.g.',
|
||||
'something-cool.jpg',
|
||||
'website.com.jpg',
|
||||
'e.g./foo',
|
||||
'website.com.jpg/foo',
|
||||
'Classic article https://socket3.wordpress.com/2018/02/03/designing-windows-95s-user-interface/',
|
||||
'Classic article https://socket3.wordpress.com/2018/02/03/designing-windows-95s-user-interface/ ',
|
||||
'https://foo.com https://bar.com/whatever https://baz.com',
|
||||
'punctuation https://foo.com, https://bar.com/whatever; https://baz.com.',
|
||||
'parenthentical (https://foo.com)',
|
||||
'except for https://foo.com/thing_(cool)',
|
||||
]
|
||||
interface Output {
|
||||
type: string
|
||||
value: string
|
||||
noScheme?: boolean
|
||||
}
|
||||
const outputs: Output[][] = [
|
||||
[],
|
||||
[{type: 'mention', value: 'handle.com'}],
|
||||
[{type: 'mention', value: 'handle.com'}],
|
||||
[{type: 'mention', value: 'handle.com'}],
|
||||
[
|
||||
{type: 'mention', value: 'handle.com'},
|
||||
{type: 'mention', value: 'handle.com'},
|
||||
{type: 'mention', value: 'handle.com'},
|
||||
],
|
||||
[
|
||||
{
|
||||
type: 'mention',
|
||||
value: 'full123.test-of-chars',
|
||||
},
|
||||
],
|
||||
[],
|
||||
[{type: 'mention', value: 'handle.com'}],
|
||||
[
|
||||
{type: 'mention', value: 'handle.com'},
|
||||
{type: 'mention', value: 'handle.com'},
|
||||
],
|
||||
[{type: 'mention', value: 'handle.com'}],
|
||||
[{type: 'link', value: 'https://middle.com'}],
|
||||
[{type: 'link', value: 'https://middle.com/foo/bar'}],
|
||||
[{type: 'link', value: 'https://middle.com/foo/bar?baz=bux'}],
|
||||
[{type: 'link', value: 'https://middle.com/foo/bar?baz=bux#hash'}],
|
||||
[{type: 'link', value: 'https://start.com/foo/bar?baz=bux#hash'}],
|
||||
[{type: 'link', value: 'https://end.com/foo/bar?baz=bux#hash'}],
|
||||
[
|
||||
{type: 'link', value: 'https://newline1.com'},
|
||||
{type: 'link', value: 'https://newline2.com'},
|
||||
],
|
||||
[{type: 'link', value: 'middle.com', noScheme: true}],
|
||||
[{type: 'link', value: 'middle.com/foo/bar', noScheme: true}],
|
||||
[{type: 'link', value: 'middle.com/foo/bar?baz=bux', noScheme: true}],
|
||||
[{type: 'link', value: 'middle.com/foo/bar?baz=bux#hash', noScheme: true}],
|
||||
[{type: 'link', value: 'start.com/foo/bar?baz=bux#hash', noScheme: true}],
|
||||
[{type: 'link', value: 'end.com/foo/bar?baz=bux#hash', noScheme: true}],
|
||||
[
|
||||
{type: 'link', value: 'newline1.com', noScheme: true},
|
||||
{type: 'link', value: 'newline2.com', noScheme: true},
|
||||
],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[
|
||||
{
|
||||
type: 'link',
|
||||
value:
|
||||
'https://socket3.wordpress.com/2018/02/03/designing-windows-95s-user-interface/',
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
type: 'link',
|
||||
value:
|
||||
'https://socket3.wordpress.com/2018/02/03/designing-windows-95s-user-interface/',
|
||||
},
|
||||
],
|
||||
[
|
||||
{type: 'link', value: 'https://foo.com'},
|
||||
{type: 'link', value: 'https://bar.com/whatever'},
|
||||
{type: 'link', value: 'https://baz.com'},
|
||||
],
|
||||
[
|
||||
{type: 'link', value: 'https://foo.com'},
|
||||
{type: 'link', value: 'https://bar.com/whatever'},
|
||||
{type: 'link', value: 'https://baz.com'},
|
||||
],
|
||||
[{type: 'link', value: 'https://foo.com'}],
|
||||
[{type: 'link', value: 'https://foo.com/thing_(cool)'}],
|
||||
]
|
||||
it('correctly handles a set of text inputs', () => {
|
||||
for (let i = 0; i < inputs.length; i++) {
|
||||
const input = inputs[i]
|
||||
const result = extractEntities(input, knownHandles)
|
||||
if (!outputs[i].length) {
|
||||
expect(result).toBeFalsy()
|
||||
} else if (outputs[i].length && !result) {
|
||||
expect(result).toBeTruthy()
|
||||
} else if (result) {
|
||||
expect(result.length).toBe(outputs[i].length)
|
||||
for (let j = 0; j < outputs[i].length; j++) {
|
||||
expect(result[j].type).toEqual(outputs[i][j].type)
|
||||
if (outputs[i][j].noScheme) {
|
||||
expect(result[j].value).toEqual(`https://${outputs[i][j].value}`)
|
||||
} else {
|
||||
expect(result[j].value).toEqual(outputs[i][j].value)
|
||||
}
|
||||
if (outputs[i]?.[j].type === 'mention') {
|
||||
expect(
|
||||
input.slice(result[j].index.start, result[j].index.end),
|
||||
).toBe(`@${result[j].value}`)
|
||||
} else {
|
||||
if (!outputs[i]?.[j].noScheme) {
|
||||
expect(
|
||||
input.slice(result[j].index.start, result[j].index.end),
|
||||
).toBe(result[j].value)
|
||||
} else {
|
||||
expect(
|
||||
input.slice(result[j].index.start, result[j].index.end),
|
||||
).toBe(result[j].value.slice('https://'.length))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectLinkables', () => {
|
||||
const inputs = [
|
||||
'no linkable',
|
||||
'@start middle end',
|
||||
'start @middle end',
|
||||
'start middle @end',
|
||||
'@start @middle @end',
|
||||
'@full123.test-of-chars',
|
||||
'not@right',
|
||||
'@bad!@#$chars',
|
||||
'@newline1\n@newline2',
|
||||
'parenthetical (@handle)',
|
||||
'start https://middle.com end',
|
||||
'start https://middle.com/foo/bar end',
|
||||
'start https://middle.com/foo/bar?baz=bux end',
|
||||
'start https://middle.com/foo/bar?baz=bux#hash end',
|
||||
'https://start.com/foo/bar?baz=bux#hash middle end',
|
||||
'start middle https://end.com/foo/bar?baz=bux#hash',
|
||||
'https://newline1.com\nhttps://newline2.com',
|
||||
'start middle.com end',
|
||||
'start middle.com/foo/bar end',
|
||||
'start middle.com/foo/bar?baz=bux end',
|
||||
'start middle.com/foo/bar?baz=bux#hash end',
|
||||
'start.com/foo/bar?baz=bux#hash middle end',
|
||||
'start middle end.com/foo/bar?baz=bux#hash',
|
||||
'newline1.com\nnewline2.com',
|
||||
'not.. a..url ..here',
|
||||
'e.g.',
|
||||
'e.g. real.com fake.notreal',
|
||||
'something-cool.jpg',
|
||||
'website.com.jpg',
|
||||
'e.g./foo',
|
||||
'website.com.jpg/foo',
|
||||
'Classic article https://socket3.wordpress.com/2018/02/03/designing-windows-95s-user-interface/',
|
||||
'Classic article https://socket3.wordpress.com/2018/02/03/designing-windows-95s-user-interface/ ',
|
||||
'https://foo.com https://bar.com/whatever https://baz.com',
|
||||
'punctuation https://foo.com, https://bar.com/whatever; https://baz.com.',
|
||||
'parenthentical (https://foo.com)',
|
||||
'except for https://foo.com/thing_(cool)',
|
||||
]
|
||||
const outputs = [
|
||||
['no linkable'],
|
||||
[{link: '@start'}, ' middle end'],
|
||||
['start ', {link: '@middle'}, ' end'],
|
||||
['start middle ', {link: '@end'}],
|
||||
[{link: '@start'}, ' ', {link: '@middle'}, ' ', {link: '@end'}],
|
||||
[{link: '@full123.test-of-chars'}],
|
||||
['not@right'],
|
||||
[{link: '@bad'}, '!@#$chars'],
|
||||
[{link: '@newline1'}, '\n', {link: '@newline2'}],
|
||||
['parenthetical (', {link: '@handle'}, ')'],
|
||||
['start ', {link: 'https://middle.com'}, ' end'],
|
||||
['start ', {link: 'https://middle.com/foo/bar'}, ' end'],
|
||||
['start ', {link: 'https://middle.com/foo/bar?baz=bux'}, ' end'],
|
||||
['start ', {link: 'https://middle.com/foo/bar?baz=bux#hash'}, ' end'],
|
||||
[{link: 'https://start.com/foo/bar?baz=bux#hash'}, ' middle end'],
|
||||
['start middle ', {link: 'https://end.com/foo/bar?baz=bux#hash'}],
|
||||
[{link: 'https://newline1.com'}, '\n', {link: 'https://newline2.com'}],
|
||||
['start ', {link: 'middle.com'}, ' end'],
|
||||
['start ', {link: 'middle.com/foo/bar'}, ' end'],
|
||||
['start ', {link: 'middle.com/foo/bar?baz=bux'}, ' end'],
|
||||
['start ', {link: 'middle.com/foo/bar?baz=bux#hash'}, ' end'],
|
||||
[{link: 'start.com/foo/bar?baz=bux#hash'}, ' middle end'],
|
||||
['start middle ', {link: 'end.com/foo/bar?baz=bux#hash'}],
|
||||
[{link: 'newline1.com'}, '\n', {link: 'newline2.com'}],
|
||||
['not.. a..url ..here'],
|
||||
['e.g.'],
|
||||
['e.g. ', {link: 'real.com'}, ' fake.notreal'],
|
||||
['something-cool.jpg'],
|
||||
['website.com.jpg'],
|
||||
['e.g./foo'],
|
||||
['website.com.jpg/foo'],
|
||||
[
|
||||
'Classic article ',
|
||||
{
|
||||
link: 'https://socket3.wordpress.com/2018/02/03/designing-windows-95s-user-interface/',
|
||||
},
|
||||
],
|
||||
[
|
||||
'Classic article ',
|
||||
{
|
||||
link: 'https://socket3.wordpress.com/2018/02/03/designing-windows-95s-user-interface/',
|
||||
},
|
||||
' ',
|
||||
],
|
||||
[
|
||||
{link: 'https://foo.com'},
|
||||
' ',
|
||||
{link: 'https://bar.com/whatever'},
|
||||
' ',
|
||||
{link: 'https://baz.com'},
|
||||
],
|
||||
[
|
||||
'punctuation ',
|
||||
{link: 'https://foo.com'},
|
||||
', ',
|
||||
{link: 'https://bar.com/whatever'},
|
||||
'; ',
|
||||
{link: 'https://baz.com'},
|
||||
'.',
|
||||
],
|
||||
['parenthentical (', {link: 'https://foo.com'}, ')'],
|
||||
['except for ', {link: 'https://foo.com/thing_(cool)'}],
|
||||
]
|
||||
it('correctly handles a set of text inputs', () => {
|
||||
for (let i = 0; i < inputs.length; i++) {
|
||||
const input = inputs[i]
|
||||
const output = detectLinkables(input)
|
||||
expect(output).toEqual(outputs[i])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractHtmlMeta', () => {
|
||||
const inputs = [
|
||||
'',
|
||||
'nothing',
|
||||
'<title>title</title>',
|
||||
'<title> aSd!@#AC </title>',
|
||||
'<title>\n title\n </title>',
|
||||
'<meta name="title" content="meta title">',
|
||||
'<meta name="description" content="meta description">',
|
||||
'<meta property="og:title" content="og title">',
|
||||
'<meta property="og:description" content="og description">',
|
||||
'<meta property="og:image" content="https://ogimage.com/foo.png">',
|
||||
'<meta property="twitter:title" content="twitter title">',
|
||||
'<meta property="twitter:description" content="twitter description">',
|
||||
'<meta property="twitter:image" content="https://twitterimage.com/foo.png">',
|
||||
'<meta\n name="title"\n content="meta title"\n>',
|
||||
]
|
||||
const outputs = [
|
||||
{},
|
||||
{},
|
||||
{title: 'title'},
|
||||
{title: 'aSd!@#AC'},
|
||||
{title: 'title'},
|
||||
{title: 'meta title'},
|
||||
{description: 'meta description'},
|
||||
{title: 'og title'},
|
||||
{description: 'og description'},
|
||||
{image: 'https://ogimage.com/foo.png'},
|
||||
{title: 'twitter title'},
|
||||
{description: 'twitter description'},
|
||||
{image: 'https://twitterimage.com/foo.png'},
|
||||
{title: 'meta title'},
|
||||
]
|
||||
it('correctly handles a set of text inputs', () => {
|
||||
for (let i = 0; i < inputs.length; i++) {
|
||||
const input = inputs[i]
|
||||
const output = extractHtmlMeta(input)
|
||||
expect(output).toEqual(outputs[i])
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -35,12 +35,12 @@ android_library(
|
||||
|
||||
android_build_config(
|
||||
name = "build_config",
|
||||
package = "xyz.blueskyweb.pubsq",
|
||||
package = "xyz.blueskyweb.app",
|
||||
)
|
||||
|
||||
android_resource(
|
||||
name = "res",
|
||||
package = "xyz.blueskyweb.pubsq",
|
||||
package = "xyz.blueskyweb.app",
|
||||
res = "src/main/res",
|
||||
)
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ android {
|
||||
compileSdkVersion rootProject.ext.compileSdkVersion
|
||||
|
||||
defaultConfig {
|
||||
applicationId "xyz.blueskyweb.pubsq"
|
||||
applicationId "xyz.blueskyweb.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="xyz.blueskyweb.pubsq">
|
||||
package="xyz.blueskyweb.app">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
@@ -26,17 +26,17 @@
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<!-- debug host: https://pubsq.pfrazee.com -->
|
||||
<!-- debug host: https://bsky.pfrazee.com -->
|
||||
<data android:scheme="https" />
|
||||
<data android:host="pubsq.pfrazee.com" />
|
||||
<data android:host="bsky.pfrazee.com" />
|
||||
</intent-filter>
|
||||
<!-- deep linking via custom links -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<!-- debug scheme: pubsq://app -->
|
||||
<data android:scheme="pubsq" />
|
||||
<!-- debug scheme: bsky://app -->
|
||||
<data android:scheme="bsky" />
|
||||
<data android:host="app" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
package xyz.blueskyweb.pubsq;
|
||||
|
||||
import com.facebook.react.bridge.ReactApplicationContext;
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule;
|
||||
import com.facebook.react.bridge.ReactMethod;
|
||||
import com.facebook.react.bridge.Promise;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import android.util.Base64;
|
||||
|
||||
public class AppSecureRandomModule extends ReactContextBaseJavaModule {
|
||||
public AppSecureRandomModule(ReactApplicationContext context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
public void generateSecureRandomAsBase64(int length, Promise promise) {
|
||||
SecureRandom secureRandom = new SecureRandom();
|
||||
byte[] buffer = new byte[length];
|
||||
secureRandom.nextBytes(buffer);
|
||||
promise.resolve(Base64.encodeToString(buffer, Base64.NO_WRAP));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "AppSecureRandomModule";
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package xyz.blueskyweb.pubsq;
|
||||
|
||||
import com.facebook.react.ReactPackage;
|
||||
import com.facebook.react.bridge.NativeModule;
|
||||
import com.facebook.react.bridge.ReactApplicationContext;
|
||||
import com.facebook.react.uimanager.ViewManager;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
public class AppSecureRandomPackage implements ReactPackage {
|
||||
|
||||
@Override
|
||||
public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<NativeModule> createNativeModules(ReactApplicationContext reactContext) {
|
||||
List<NativeModule> modules = new ArrayList<>();
|
||||
modules.add(new AppSecureRandomModule(reactContext));
|
||||
return modules;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package xyz.blueskyweb.pubsq;
|
||||
package xyz.blueskyweb.app;
|
||||
|
||||
import com.facebook.react.ReactActivity;
|
||||
import com.facebook.react.ReactActivityDelegate;
|
||||
@@ -13,7 +13,7 @@ public class MainActivity extends ReactActivity {
|
||||
*/
|
||||
@Override
|
||||
protected String getMainComponentName() {
|
||||
return "xyz.blueskyweb.pubsq";
|
||||
return "xyz.blueskyweb.app";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package xyz.blueskyweb.pubsq;
|
||||
package xyz.blueskyweb.app;
|
||||
|
||||
import android.app.Application;
|
||||
import android.content.Context;
|
||||
@@ -9,7 +9,7 @@ import com.facebook.react.ReactNativeHost;
|
||||
import com.facebook.react.ReactPackage;
|
||||
import com.facebook.react.config.ReactFeatureFlags;
|
||||
import com.facebook.soloader.SoLoader;
|
||||
import xyz.blueskyweb.pubsq.newarchitecture.MainApplicationReactNativeHost;
|
||||
import xyz.blueskyweb.app.newarchitecture.MainApplicationReactNativeHost;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.util.List;
|
||||
|
||||
@@ -28,7 +28,6 @@ public class MainApplication extends Application implements ReactApplication {
|
||||
List<ReactPackage> packages = new PackageList(this).getPackages();
|
||||
// Packages that cannot be autolinked yet can be added manually here, for example:
|
||||
// packages.add(new MyReactNativePackage());
|
||||
packages.add(new AppSecureRandomPackage());
|
||||
return packages;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package xyz.blueskyweb.pubsq.newarchitecture;
|
||||
package xyz.blueskyweb.app.newarchitecture;
|
||||
|
||||
import android.app.Application;
|
||||
import androidx.annotation.NonNull;
|
||||
@@ -19,9 +19,9 @@ import com.facebook.react.fabric.CoreComponentsRegistry;
|
||||
import com.facebook.react.fabric.EmptyReactNativeConfig;
|
||||
import com.facebook.react.fabric.FabricJSIModuleProvider;
|
||||
import com.facebook.react.uimanager.ViewManagerRegistry;
|
||||
import xyz.blueskyweb.pubsq.BuildConfig;
|
||||
import xyz.blueskyweb.pubsq.newarchitecture.components.MainComponentsRegistry;
|
||||
import xyz.blueskyweb.pubsq.newarchitecture.modules.MainApplicationTurboModuleManagerDelegate;
|
||||
import xyz.blueskyweb.app.BuildConfig;
|
||||
import xyz.blueskyweb.app.newarchitecture.components.MainComponentsRegistry;
|
||||
import xyz.blueskyweb.app.newarchitecture.modules.MainApplicationTurboModuleManagerDelegate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package xyz.blueskyweb.pubsq.newarchitecture.components;
|
||||
package xyz.blueskyweb.app.newarchitecture.components;
|
||||
|
||||
import com.facebook.jni.HybridData;
|
||||
import com.facebook.proguard.annotations.DoNotStrip;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package xyz.blueskyweb.pubsq.newarchitecture.modules;
|
||||
package xyz.blueskyweb.app.newarchitecture.modules;
|
||||
|
||||
import com.facebook.jni.HybridData;
|
||||
import com.facebook.react.ReactPackage;
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">pubsq</string>
|
||||
<string name="app_name">bsky</string>
|
||||
</resources>
|
||||
|
||||
@@ -4,7 +4,7 @@ module.exports = {
|
||||
[
|
||||
'module:react-native-dotenv',
|
||||
{
|
||||
// envName: 'APP_ENV',
|
||||
envName: 'APP_ENV',
|
||||
moduleName: '@env',
|
||||
path: '.env',
|
||||
blocklist: null,
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
#import <React/RCTBridgeModule.h>
|
||||
|
||||
@interface AppSecureRandomModule : NSObject <RCTBridgeModule>
|
||||
|
||||
@end
|
||||
@@ -1,27 +0,0 @@
|
||||
#import "AppSecureRandomModule.h"
|
||||
|
||||
@implementation AppSecureRandomModule
|
||||
|
||||
RCT_EXPORT_MODULE();
|
||||
|
||||
+ (BOOL)requiresMainQueueSetup
|
||||
{
|
||||
return NO;
|
||||
}
|
||||
|
||||
RCT_REMAP_METHOD(generateSecureRandomAsBase64,
|
||||
withLength:(int)length
|
||||
resolver:(RCTPromiseResolveBlock)resolve
|
||||
rejecter:(RCTPromiseRejectBlock)reject)
|
||||
{
|
||||
NSMutableData* bytes = [NSMutableData dataWithLength:length];
|
||||
int result = SecRandomCopyBytes(kSecRandomDefault,length, [bytes mutableBytes]);
|
||||
if (result == errSecSuccess) {
|
||||
resolve([bytes base64EncodedStringWithOptions:0]);
|
||||
} else {
|
||||
NSError *error = [NSError errorWithDomain:@"RNSecureRandom" code:result userInfo: nil];
|
||||
reject(@"randombytes_error", @"Error generating random bytes", error);
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -1,5 +1,7 @@
|
||||
PODS:
|
||||
- boost (1.76.0)
|
||||
- BVLinearGradient (2.6.2):
|
||||
- React-Core
|
||||
- DoubleConversion (1.1.6)
|
||||
- FBLazyVector (0.68.2)
|
||||
- FBReactNativeSpec (0.68.2):
|
||||
@@ -218,12 +220,20 @@ PODS:
|
||||
- React-jsinspector (0.68.2)
|
||||
- React-logger (0.68.2):
|
||||
- glog
|
||||
- react-native-safe-area-context (4.3.1):
|
||||
- react-native-cameraroll (5.1.0):
|
||||
- React-Core
|
||||
- react-native-pager-view (6.0.2):
|
||||
- React-Core
|
||||
- react-native-safe-area-context (4.4.1):
|
||||
- RCT-Folly
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
- React
|
||||
- React-Core
|
||||
- ReactCommon/turbomodule/core
|
||||
- react-native-splash-screen (3.3.0):
|
||||
- React-Core
|
||||
- react-native-version-number (0.3.6):
|
||||
- React
|
||||
- React-perflogger (0.68.2)
|
||||
- React-RCTActionSheet (0.68.2):
|
||||
- React-Core/RCTActionSheetHeaders (= 0.68.2)
|
||||
@@ -289,15 +299,24 @@ PODS:
|
||||
- React-jsi (= 0.68.2)
|
||||
- React-logger (= 0.68.2)
|
||||
- React-perflogger (= 0.68.2)
|
||||
- RNCAsyncStorage (1.17.6):
|
||||
- RNCAsyncStorage (1.17.10):
|
||||
- React-Core
|
||||
- RNCClipboard (1.10.0):
|
||||
- RNCClipboard (1.11.1):
|
||||
- React-Core
|
||||
- RNGestureHandler (2.5.0):
|
||||
- RNGestureHandler (2.6.1):
|
||||
- React-Core
|
||||
- RNInAppBrowser (3.6.3):
|
||||
- RNImageCropPicker (0.38.1):
|
||||
- React-Core
|
||||
- RNReanimated (2.9.1):
|
||||
- React-RCTImage
|
||||
- RNImageCropPicker/QBImagePickerController (= 0.38.1)
|
||||
- TOCropViewController
|
||||
- RNImageCropPicker/QBImagePickerController (0.38.1):
|
||||
- React-Core
|
||||
- React-RCTImage
|
||||
- TOCropViewController
|
||||
- RNInAppBrowser (3.7.0):
|
||||
- React-Core
|
||||
- RNReanimated (2.10.0):
|
||||
- DoubleConversion
|
||||
- FBLazyVector
|
||||
- FBReactNativeSpec
|
||||
@@ -324,15 +343,17 @@ PODS:
|
||||
- React-RCTText
|
||||
- ReactCommon/turbomodule/core
|
||||
- Yoga
|
||||
- RNScreens (3.13.1):
|
||||
- RNScreens (3.17.0):
|
||||
- React-Core
|
||||
- React-RCTImage
|
||||
- RNSVG (12.4.0):
|
||||
- RNSVG (12.4.4):
|
||||
- React-Core
|
||||
- TOCropViewController (2.6.1)
|
||||
- Yoga (1.14.0)
|
||||
|
||||
DEPENDENCIES:
|
||||
- boost (from `../node_modules/react-native/third-party-podspecs/boost.podspec`)
|
||||
- BVLinearGradient (from `../node_modules/react-native-linear-gradient`)
|
||||
- DoubleConversion (from `../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`)
|
||||
- FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`)
|
||||
- FBReactNativeSpec (from `../node_modules/react-native/React/FBReactNativeSpec`)
|
||||
@@ -352,7 +373,11 @@ DEPENDENCIES:
|
||||
- React-jsiexecutor (from `../node_modules/react-native/ReactCommon/jsiexecutor`)
|
||||
- React-jsinspector (from `../node_modules/react-native/ReactCommon/jsinspector`)
|
||||
- React-logger (from `../node_modules/react-native/ReactCommon/logger`)
|
||||
- "react-native-cameraroll (from `../node_modules/@react-native-camera-roll/camera-roll`)"
|
||||
- react-native-pager-view (from `../node_modules/react-native-pager-view`)
|
||||
- react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
|
||||
- react-native-splash-screen (from `../node_modules/react-native-splash-screen`)
|
||||
- react-native-version-number (from `../node_modules/react-native-version-number`)
|
||||
- React-perflogger (from `../node_modules/react-native/ReactCommon/reactperflogger`)
|
||||
- React-RCTActionSheet (from `../node_modules/react-native/Libraries/ActionSheetIOS`)
|
||||
- React-RCTAnimation (from `../node_modules/react-native/Libraries/NativeAnimation`)
|
||||
@@ -368,6 +393,7 @@ DEPENDENCIES:
|
||||
- "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)"
|
||||
- "RNCClipboard (from `../node_modules/@react-native-clipboard/clipboard`)"
|
||||
- RNGestureHandler (from `../node_modules/react-native-gesture-handler`)
|
||||
- RNImageCropPicker (from `../node_modules/react-native-image-crop-picker`)
|
||||
- RNInAppBrowser (from `../node_modules/react-native-inappbrowser-reborn`)
|
||||
- RNReanimated (from `../node_modules/react-native-reanimated`)
|
||||
- RNScreens (from `../node_modules/react-native-screens`)
|
||||
@@ -377,10 +403,13 @@ DEPENDENCIES:
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- fmt
|
||||
- TOCropViewController
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
boost:
|
||||
:podspec: "../node_modules/react-native/third-party-podspecs/boost.podspec"
|
||||
BVLinearGradient:
|
||||
:path: "../node_modules/react-native-linear-gradient"
|
||||
DoubleConversion:
|
||||
:podspec: "../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec"
|
||||
FBLazyVector:
|
||||
@@ -415,8 +444,16 @@ EXTERNAL SOURCES:
|
||||
:path: "../node_modules/react-native/ReactCommon/jsinspector"
|
||||
React-logger:
|
||||
:path: "../node_modules/react-native/ReactCommon/logger"
|
||||
react-native-cameraroll:
|
||||
:path: "../node_modules/@react-native-camera-roll/camera-roll"
|
||||
react-native-pager-view:
|
||||
:path: "../node_modules/react-native-pager-view"
|
||||
react-native-safe-area-context:
|
||||
:path: "../node_modules/react-native-safe-area-context"
|
||||
react-native-splash-screen:
|
||||
:path: "../node_modules/react-native-splash-screen"
|
||||
react-native-version-number:
|
||||
:path: "../node_modules/react-native-version-number"
|
||||
React-perflogger:
|
||||
:path: "../node_modules/react-native/ReactCommon/reactperflogger"
|
||||
React-RCTActionSheet:
|
||||
@@ -447,6 +484,8 @@ EXTERNAL SOURCES:
|
||||
:path: "../node_modules/@react-native-clipboard/clipboard"
|
||||
RNGestureHandler:
|
||||
:path: "../node_modules/react-native-gesture-handler"
|
||||
RNImageCropPicker:
|
||||
:path: "../node_modules/react-native-image-crop-picker"
|
||||
RNInAppBrowser:
|
||||
:path: "../node_modules/react-native-inappbrowser-reborn"
|
||||
RNReanimated:
|
||||
@@ -460,6 +499,7 @@ EXTERNAL SOURCES:
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
boost: a7c83b31436843459a1961bfd74b96033dc77234
|
||||
BVLinearGradient: 34a999fda29036898a09c6a6b728b0b4189e1a44
|
||||
DoubleConversion: 831926d9b8bf8166fd87886c4abab286c2422662
|
||||
FBLazyVector: a7a655862f6b09625d11c772296b01cd5164b648
|
||||
FBReactNativeSpec: 81ce99032d5b586fddd6a38d450f8595f7e04be4
|
||||
@@ -478,7 +518,11 @@ SPEC CHECKSUMS:
|
||||
React-jsiexecutor: b7b553412f2ec768fe6c8f27cd6bafdb9d8719e6
|
||||
React-jsinspector: c5989c77cb89ae6a69561095a61cce56a44ae8e8
|
||||
React-logger: a0833912d93b36b791b7a521672d8ee89107aff1
|
||||
react-native-safe-area-context: 6c12e3859b6f27b25de4fee8201cfb858432d8de
|
||||
react-native-cameraroll: a40b082318eb1ecd0336a2f29d9f74b7f2c8cae8
|
||||
react-native-pager-view: 592421df0259bf7a7a4fe85b74c24f3f39905605
|
||||
react-native-safe-area-context: 99b24a0c5acd0d5dcac2b1a7f18c49ea317be99a
|
||||
react-native-splash-screen: 4312f786b13a81b5169ef346d76d33bc0c6dc457
|
||||
react-native-version-number: b415bbec6a13f2df62bf978e85bc0d699462f37f
|
||||
React-perflogger: a18b4f0bd933b8b24ecf9f3c54f9bf65180f3fe6
|
||||
React-RCTActionSheet: 547fe42fdb4b6089598d79f8e1d855d7c23e2162
|
||||
React-RCTAnimation: bc9440a1c37b06ae9ebbb532d244f607805c6034
|
||||
@@ -491,13 +535,15 @@ SPEC CHECKSUMS:
|
||||
React-RCTVibration: 79040b92bfa9c3c2d2cb4f57e981164ec7ab9374
|
||||
React-runtimeexecutor: b960b687d2dfef0d3761fbb187e01812ebab8b23
|
||||
ReactCommon: 095366164a276d91ea704ce53cb03825c487a3f2
|
||||
RNCAsyncStorage: 466b9df1a14bccda91da86e0b7d9a345d78e1673
|
||||
RNCClipboard: f1736c75ab85b627a4d57587edb4b60999c4dd80
|
||||
RNGestureHandler: bad495418bcbd3ab47017a38d93d290ebd406f50
|
||||
RNInAppBrowser: 3ff3a3b8f458aaf25aaee879d057352862edf357
|
||||
RNReanimated: 5c8c17e26787fd8984cd5accdc70fef2ca70aafd
|
||||
RNScreens: 40a2cb40a02a609938137a1e0acfbf8fc9eebf19
|
||||
RNSVG: 3dd44d99d1c18e1342aee4bfa53ab3f6a8c4865f
|
||||
RNCAsyncStorage: 0c357f3156fcb16c8589ede67cc036330b6698ca
|
||||
RNCClipboard: 2834e1c4af68697089cdd455ee4a4cdd198fa7dd
|
||||
RNGestureHandler: 28ad20bf02257791f7f137b31beef34b9549f54b
|
||||
RNImageCropPicker: 648356d68fbf9911a1016b3e3723885d28373eda
|
||||
RNInAppBrowser: e36d6935517101ccba0e875bac8ad7b0cb655364
|
||||
RNReanimated: 5bdcbcc3a72aedeee7bb099604262403aa75a1e5
|
||||
RNScreens: 0df01424e9e0ed7827200d6ed1087ddd06c493f9
|
||||
RNSVG: ecd661f380a07ba690c9c5929c475a44f432d674
|
||||
TOCropViewController: edfd4f25713d56905ad1e0b9f5be3fbe0f59c863
|
||||
Yoga: 99652481fcd320aefa4a7ef90095b95acd181952
|
||||
|
||||
PODFILE CHECKSUM: cf94853ebcb0d8e0d027dca9ab7a4ede886a8f20
|
||||
|
||||
@@ -11,11 +11,10 @@
|
||||
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.mm */; };
|
||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
|
||||
13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; };
|
||||
5CEAE7B7A55582F96F1D5952 /* libPods-app.a in Frameworks */ = {isa = PBXBuildFile; fileRef = FCB672808307A6013805A3FE /* libPods-app.a */; };
|
||||
379AA1F602EF704C84D7A02B /* libPods-app-appTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 05B90550DA1387B5C44FB80A /* libPods-app-appTests.a */; };
|
||||
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; };
|
||||
E4BD704B285AD57E00A8FED9 /* AppSecureRandomModule.m in Sources */ = {isa = PBXBuildFile; fileRef = E4BD704A285AD57E00A8FED9 /* AppSecureRandomModule.m */; };
|
||||
E4BD704C285AD57E00A8FED9 /* AppSecureRandomModule.m in Sources */ = {isa = PBXBuildFile; fileRef = E4BD704A285AD57E00A8FED9 /* AppSecureRandomModule.m */; };
|
||||
FEB90D21557517F9279AECA4 /* libPods-app-appTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = BAD3BC60FA05CF2D4F6F9BA2 /* libPods-app-appTests.a */; };
|
||||
8896F504D7727F539A45F5E3 /* libPods-app.a in Frameworks */ = {isa = PBXBuildFile; fileRef = FC3B8692C2BCABAF5171340D /* libPods-app.a */; };
|
||||
E4BBD590292C1F5200296224 /* app.entitlements in Resources */ = {isa = PBXBuildFile; fileRef = E4437C9E28581FA7006DA9E7 /* app.entitlements */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -32,23 +31,21 @@
|
||||
00E356EE1AD99517003FC87E /* appTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = appTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
00E356F11AD99517003FC87E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
00E356F21AD99517003FC87E /* appTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = appTests.m; sourceTree = "<group>"; };
|
||||
03F7088487057F17D25D2F4B /* Pods-app-appTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-app-appTests.release.xcconfig"; path = "Target Support Files/Pods-app-appTests/Pods-app-appTests.release.xcconfig"; sourceTree = "<group>"; };
|
||||
05B90550DA1387B5C44FB80A /* libPods-app-appTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-app-appTests.a"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
13B07F961A680F5B00A75B9A /* app.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = app.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = app/AppDelegate.h; sourceTree = "<group>"; };
|
||||
13B07FB01A68108700A75B9A /* AppDelegate.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = AppDelegate.mm; path = app/AppDelegate.mm; sourceTree = "<group>"; };
|
||||
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = app/Images.xcassets; sourceTree = "<group>"; };
|
||||
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = app/Info.plist; sourceTree = "<group>"; };
|
||||
13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = app/main.m; sourceTree = "<group>"; };
|
||||
19C43486502F2DE23E091FE3 /* Pods-app-appTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-app-appTests.debug.xcconfig"; path = "Target Support Files/Pods-app-appTests/Pods-app-appTests.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = app/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
970005155A83960D1D13380F /* Pods-app.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-app.release.xcconfig"; path = "Target Support Files/Pods-app/Pods-app.release.xcconfig"; sourceTree = "<group>"; };
|
||||
BAD3BC60FA05CF2D4F6F9BA2 /* libPods-app-appTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-app-appTests.a"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
98E4A0BAD72B0C24AF5986D9 /* Pods-app.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-app.debug.xcconfig"; path = "Target Support Files/Pods-app/Pods-app.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
9FA2BF4F6574C986E6B102F0 /* Pods-app.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-app.release.xcconfig"; path = "Target Support Files/Pods-app/Pods-app.release.xcconfig"; sourceTree = "<group>"; };
|
||||
BC19D4073A1A93D07CD74999 /* Pods-app-appTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-app-appTests.release.xcconfig"; path = "Target Support Files/Pods-app-appTests/Pods-app-appTests.release.xcconfig"; sourceTree = "<group>"; };
|
||||
E1EFCDF82E4E346591FBB028 /* Pods-app-appTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-app-appTests.debug.xcconfig"; path = "Target Support Files/Pods-app-appTests/Pods-app-appTests.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
E4437C9E28581FA7006DA9E7 /* app.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = app.entitlements; path = app/app.entitlements; sourceTree = "<group>"; };
|
||||
E4BD7049285AD54000A8FED9 /* AppSecureRandomModule.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AppSecureRandomModule.h; sourceTree = "<group>"; };
|
||||
E4BD704A285AD57E00A8FED9 /* AppSecureRandomModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AppSecureRandomModule.m; sourceTree = "<group>"; };
|
||||
ED22CAA45207BC18E75DB44B /* Pods-app.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-app.debug.xcconfig"; path = "Target Support Files/Pods-app/Pods-app.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
|
||||
FCB672808307A6013805A3FE /* libPods-app.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-app.a"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
FC3B8692C2BCABAF5171340D /* libPods-app.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-app.a"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -56,7 +53,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
FEB90D21557517F9279AECA4 /* libPods-app-appTests.a in Frameworks */,
|
||||
379AA1F602EF704C84D7A02B /* libPods-app-appTests.a in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -64,7 +61,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
5CEAE7B7A55582F96F1D5952 /* libPods-app.a in Frameworks */,
|
||||
8896F504D7727F539A45F5E3 /* libPods-app.a in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -98,8 +95,6 @@
|
||||
13B07FB61A68108700A75B9A /* Info.plist */,
|
||||
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */,
|
||||
13B07FB71A68108700A75B9A /* main.m */,
|
||||
E4BD7049285AD54000A8FED9 /* AppSecureRandomModule.h */,
|
||||
E4BD704A285AD57E00A8FED9 /* AppSecureRandomModule.m */,
|
||||
);
|
||||
name = app;
|
||||
sourceTree = "<group>";
|
||||
@@ -108,8 +103,8 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
|
||||
FCB672808307A6013805A3FE /* libPods-app.a */,
|
||||
BAD3BC60FA05CF2D4F6F9BA2 /* libPods-app-appTests.a */,
|
||||
FC3B8692C2BCABAF5171340D /* libPods-app.a */,
|
||||
05B90550DA1387B5C44FB80A /* libPods-app-appTests.a */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
@@ -148,10 +143,10 @@
|
||||
BBD78D7AC51CEA395F1C20DB /* Pods */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
ED22CAA45207BC18E75DB44B /* Pods-app.debug.xcconfig */,
|
||||
970005155A83960D1D13380F /* Pods-app.release.xcconfig */,
|
||||
19C43486502F2DE23E091FE3 /* Pods-app-appTests.debug.xcconfig */,
|
||||
03F7088487057F17D25D2F4B /* Pods-app-appTests.release.xcconfig */,
|
||||
98E4A0BAD72B0C24AF5986D9 /* Pods-app.debug.xcconfig */,
|
||||
9FA2BF4F6574C986E6B102F0 /* Pods-app.release.xcconfig */,
|
||||
E1EFCDF82E4E346591FBB028 /* Pods-app-appTests.debug.xcconfig */,
|
||||
BC19D4073A1A93D07CD74999 /* Pods-app-appTests.release.xcconfig */,
|
||||
);
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
@@ -163,11 +158,11 @@
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 00E357021AD99517003FC87E /* Build configuration list for PBXNativeTarget "appTests" */;
|
||||
buildPhases = (
|
||||
F309585457BFF312EF73FD95 /* [CP] Check Pods Manifest.lock */,
|
||||
420689A074E4E7426A392B09 /* [CP] Check Pods Manifest.lock */,
|
||||
00E356EA1AD99517003FC87E /* Sources */,
|
||||
00E356EB1AD99517003FC87E /* Frameworks */,
|
||||
00E356EC1AD99517003FC87E /* Resources */,
|
||||
2772CBCCE24036517E86445D /* [CP] Copy Pods Resources */,
|
||||
C5618360E9363F58ED30C68E /* [CP] Copy Pods Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -183,13 +178,13 @@
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "app" */;
|
||||
buildPhases = (
|
||||
4D45C29570A58E7A8A8D177D /* [CP] Check Pods Manifest.lock */,
|
||||
1C451C95EB728E95B9CDD2FF /* [CP] Check Pods Manifest.lock */,
|
||||
FD10A7F022414F080027D42C /* Start Packager */,
|
||||
13B07F871A680F5B00A75B9A /* Sources */,
|
||||
13B07F8C1A680F5B00A75B9A /* Frameworks */,
|
||||
13B07F8E1A680F5B00A75B9A /* Resources */,
|
||||
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
|
||||
9CEFF2A5EA3D60C120442CA6 /* [CP] Copy Pods Resources */,
|
||||
BC7FC72051CB585D346FAE10 /* [CP] Copy Pods Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -248,6 +243,7 @@
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
E4BBD590292C1F5200296224 /* app.entitlements in Resources */,
|
||||
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */,
|
||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
|
||||
);
|
||||
@@ -268,26 +264,9 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "set -e\n\nexport NODE_BINARY=node\n../node_modules/react-native/scripts/react-native-xcode.sh\n";
|
||||
shellScript = "set -e\n\nexport BUNDLE_COMMAND=\"ram-bundle\"\nexport NODE_BINARY=node\n../node_modules/react-native/scripts/react-native-xcode.sh\n";
|
||||
};
|
||||
2772CBCCE24036517E86445D /* [CP] Copy Pods Resources */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-app-appTests/Pods-app-appTests-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
name = "[CP] Copy Pods Resources";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-app-appTests/Pods-app-appTests-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-app-appTests/Pods-app-appTests-resources.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
4D45C29570A58E7A8A8D177D /* [CP] Check Pods Manifest.lock */ = {
|
||||
1C451C95EB728E95B9CDD2FF /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
@@ -309,24 +288,7 @@
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
9CEFF2A5EA3D60C120442CA6 /* [CP] Copy Pods Resources */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-app/Pods-app-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
name = "[CP] Copy Pods Resources";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-app/Pods-app-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-app/Pods-app-resources.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
F309585457BFF312EF73FD95 /* [CP] Check Pods Manifest.lock */ = {
|
||||
420689A074E4E7426A392B09 /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
@@ -348,6 +310,40 @@
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
BC7FC72051CB585D346FAE10 /* [CP] Copy Pods Resources */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-app/Pods-app-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
name = "[CP] Copy Pods Resources";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-app/Pods-app-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-app/Pods-app-resources.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
C5618360E9363F58ED30C68E /* [CP] Copy Pods Resources */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-app-appTests/Pods-app-appTests-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
name = "[CP] Copy Pods Resources";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-app-appTests/Pods-app-appTests-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-app-appTests/Pods-app-appTests-resources.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
FD10A7F022414F080027D42C /* Start Packager */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -375,7 +371,6 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
00E356F31AD99517003FC87E /* appTests.m in Sources */,
|
||||
E4BD704C285AD57E00A8FED9 /* AppSecureRandomModule.m in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -384,7 +379,6 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */,
|
||||
E4BD704B285AD57E00A8FED9 /* AppSecureRandomModule.m in Sources */,
|
||||
13B07FC11A68108700A75B9A /* main.m in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -402,7 +396,7 @@
|
||||
/* Begin XCBuildConfiguration section */
|
||||
00E356F61AD99517003FC87E /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 19C43486502F2DE23E091FE3 /* Pods-app-appTests.debug.xcconfig */;
|
||||
baseConfigurationReference = E1EFCDF82E4E346591FBB028 /* Pods-app-appTests.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
@@ -430,7 +424,7 @@
|
||||
};
|
||||
00E356F71AD99517003FC87E /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 03F7088487057F17D25D2F4B /* Pods-app-appTests.release.xcconfig */;
|
||||
baseConfigurationReference = BC19D4073A1A93D07CD74999 /* Pods-app-appTests.release.xcconfig */;
|
||||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
@@ -455,16 +449,17 @@
|
||||
};
|
||||
13B07F941A680F5B00A75B9A /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = ED22CAA45207BC18E75DB44B /* Pods-app.debug.xcconfig */;
|
||||
baseConfigurationReference = 98E4A0BAD72B0C24AF5986D9 /* Pods-app.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = app/app.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = A6GRZHF6BV;
|
||||
DEVELOPMENT_TEAM = B3LX46C5HS;
|
||||
ENABLE_BITCODE = NO;
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64;
|
||||
INFOPLIST_FILE = app/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -474,7 +469,7 @@
|
||||
"-ObjC",
|
||||
"-lc++",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = xyz.blueskyweb.pubsq;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = xyz.blueskyweb.app;
|
||||
PRODUCT_NAME = app;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -484,15 +479,16 @@
|
||||
};
|
||||
13B07F951A680F5B00A75B9A /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 970005155A83960D1D13380F /* Pods-app.release.xcconfig */;
|
||||
baseConfigurationReference = 9FA2BF4F6574C986E6B102F0 /* Pods-app.release.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = app/app.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = A6GRZHF6BV;
|
||||
DEVELOPMENT_TEAM = B3LX46C5HS;
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64;
|
||||
INFOPLIST_FILE = app/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -502,7 +498,7 @@
|
||||
"-ObjC",
|
||||
"-lc++",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = xyz.blueskyweb.pubsq;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = xyz.blueskyweb.app;
|
||||
PRODUCT_NAME = app;
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
// universal links
|
||||
#import <React/RCTLinkingManager.h>
|
||||
|
||||
// splash screen
|
||||
#import "RNSplashScreen.h"
|
||||
|
||||
#if RCT_NEW_ARCH_ENABLED
|
||||
#import <React/CoreModulesPlugins.h>
|
||||
#import <React/RCTCxxBridgeDelegate.h>
|
||||
@@ -44,7 +47,7 @@
|
||||
bridge.surfacePresenter = _bridgeAdapter.surfacePresenter;
|
||||
#endif
|
||||
|
||||
UIView *rootView = RCTAppSetupDefaultRootView(bridge, @"xyz.blueskyweb.pubsq", nil);
|
||||
UIView *rootView = RCTAppSetupDefaultRootView(bridge, @"xyz.blueskyweb.app", nil);
|
||||
|
||||
if (@available(iOS 13.0, *)) {
|
||||
rootView.backgroundColor = [UIColor systemBackgroundColor];
|
||||
@@ -57,6 +60,10 @@
|
||||
rootViewController.view = rootView;
|
||||
self.window.rootViewController = rootViewController;
|
||||
[self.window makeKeyAndVisible];
|
||||
|
||||
// Show the splash screen
|
||||
[RNSplashScreen show];
|
||||
|
||||
return YES;
|
||||
}
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 347 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -1,46 +1,73 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "40.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "60.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "29.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "1x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "58.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "87.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "80.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "120.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "57.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "1x",
|
||||
"size" : "57x57"
|
||||
},
|
||||
{
|
||||
"filename" : "114.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "57x57"
|
||||
},
|
||||
{
|
||||
"filename" : "120 1.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"filename" : "180.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"filename" : "1024.png",
|
||||
"idiom" : "ios-marketing",
|
||||
"scale" : "1x",
|
||||
"size" : "1024x1024"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "splash.jpg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "splash@2x.jpg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "splash@3x.jpg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 527 KiB |
@@ -5,7 +5,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>PubSQ</string>
|
||||
<string>Bluesky</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
@@ -26,10 +26,10 @@
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>xyz.blueskyweb.pubsq</string>
|
||||
<string>xyz.blueskyweb.app</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>pubsqapp</string>
|
||||
<string>bskyapp</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
@@ -48,8 +48,12 @@
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Used to take pictures and videos when composing posts, choosing avatars, and so on.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string></string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Used to upload pictures and videos when composing posts, choosing avatars, and so on.</string>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
@@ -59,8 +63,12 @@
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<false/>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="15702" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="21507" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||
<device id="retina4_7" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15704"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21505"/>
|
||||
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
@@ -16,27 +16,17 @@
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="app" textAlignment="center" lineBreakMode="middleTruncation" baselineAdjustment="alignBaselines" minimumFontSize="18" translatesAutoresizingMaskIntoConstraints="NO" id="GJd-Yh-RWb">
|
||||
<rect key="frame" x="0.0" y="202" width="375" height="43"/>
|
||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="36"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Powered by React Native" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumFontSize="9" translatesAutoresizingMaskIntoConstraints="NO" id="MN2-I3-ftu">
|
||||
<rect key="frame" x="0.0" y="626" width="375" height="21"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="17"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" image="LaunchScreen" translatesAutoresizingMaskIntoConstraints="NO" id="3xW-LZ-8bI">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
</imageView>
|
||||
</subviews>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||
<constraints>
|
||||
<constraint firstItem="Bcu-3y-fUS" firstAttribute="bottom" secondItem="MN2-I3-ftu" secondAttribute="bottom" constant="20" id="OZV-Vh-mqD"/>
|
||||
<constraint firstItem="Bcu-3y-fUS" firstAttribute="centerX" secondItem="GJd-Yh-RWb" secondAttribute="centerX" id="Q3B-4B-g5h"/>
|
||||
<constraint firstItem="MN2-I3-ftu" firstAttribute="centerX" secondItem="Bcu-3y-fUS" secondAttribute="centerX" id="akx-eg-2ui"/>
|
||||
<constraint firstItem="MN2-I3-ftu" firstAttribute="leading" secondItem="Bcu-3y-fUS" secondAttribute="leading" id="i1E-0Y-4RG"/>
|
||||
<constraint firstItem="GJd-Yh-RWb" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="bottom" multiplier="1/3" constant="1" id="moa-c2-u7t"/>
|
||||
<constraint firstItem="GJd-Yh-RWb" firstAttribute="leading" secondItem="Bcu-3y-fUS" secondAttribute="leading" symbolic="YES" id="x7j-FC-K8j"/>
|
||||
<constraint firstAttribute="bottom" secondItem="3xW-LZ-8bI" secondAttribute="bottom" id="Qjl-JH-apV"/>
|
||||
<constraint firstItem="3xW-LZ-8bI" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="c7a-bI-Hl7"/>
|
||||
<constraint firstAttribute="trailing" secondItem="3xW-LZ-8bI" secondAttribute="trailing" id="vM9-Qy-zds"/>
|
||||
<constraint firstItem="3xW-LZ-8bI" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="yhO-am-Ve0"/>
|
||||
</constraints>
|
||||
<viewLayoutGuide key="safeArea" id="Bcu-3y-fUS"/>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
@@ -44,4 +34,10 @@
|
||||
<point key="canvasLocation" x="52.173913043478265" y="375"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="LaunchScreen" width="500" height="750"/>
|
||||
<systemColor name="systemBackgroundColor">
|
||||
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</systemColor>
|
||||
</resources>
|
||||
</document>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<dict>
|
||||
<key>com.apple.developer.associated-domains</key>
|
||||
<array>
|
||||
<string>applinks:pubsq.pfrazee.com</string>
|
||||
<string>applinks:bsky.app</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
jest.mock('@react-native-async-storage/async-storage', () =>
|
||||
require('@react-native-async-storage/async-storage/jest/async-storage-mock'),
|
||||
)
|
||||
@@ -8,55 +8,6 @@ const metroResolver = require('metro-resolver')
|
||||
const path = require('path')
|
||||
|
||||
module.exports = {
|
||||
resolver: {
|
||||
resolveRequest: (context, moduleName, platform) => {
|
||||
// HACK
|
||||
// metro doesn't support the "exports" directive in package.json
|
||||
// so we have to manually fix some imports
|
||||
// see https://github.com/facebook/metro/issues/670
|
||||
// -prf
|
||||
if (moduleName.startsWith('ucans')) {
|
||||
const subpath = moduleName.split('/').slice(1)
|
||||
if (subpath.length === 0) {
|
||||
subpath.push('index.js')
|
||||
} else {
|
||||
subpath[subpath.length - 1] = `${subpath[subpath.length - 1]}.js`
|
||||
}
|
||||
const filePath = path.join(
|
||||
context.projectRoot,
|
||||
'node_modules',
|
||||
'ucans',
|
||||
'dist',
|
||||
'cjs',
|
||||
...subpath,
|
||||
)
|
||||
return {
|
||||
type: 'sourceFile',
|
||||
filePath,
|
||||
}
|
||||
}
|
||||
// HACK
|
||||
// this module has the same problem with the "exports" module
|
||||
// but also we need modules to use our version of webcrypto
|
||||
// so here we're routing to a module we define
|
||||
// -prf
|
||||
if (moduleName === 'one-webcrypto') {
|
||||
return {
|
||||
type: 'sourceFile',
|
||||
filePath: path.join(
|
||||
context.projectRoot,
|
||||
'src',
|
||||
'platform',
|
||||
'polyfills.native.ts',
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// default resolve
|
||||
delete context.resolveRequest
|
||||
return metroResolver.resolve(context, moduleName, platform)
|
||||
},
|
||||
},
|
||||
transformer: {
|
||||
getTransformOptions: async () => ({
|
||||
transform: {
|
||||
|
||||
@@ -1,62 +1,66 @@
|
||||
{
|
||||
"name": "pubsq",
|
||||
"name": "bsky.app",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android": "react-native run-android",
|
||||
"ios": "react-native run-ios",
|
||||
"ios": "react-native run-ios --simulator=\"iPhone 14\"",
|
||||
"web": "react-scripts start",
|
||||
"start": "react-native start",
|
||||
"dev-pds": "node ./scripts/testing-server.mjs",
|
||||
"dev-wallet": "cd node_modules/\\@adxp/auth-lobby && npm run start:authed",
|
||||
"clean-cache": "rm -rf node_modules/.cache/babel-loader/*",
|
||||
"test": "jest",
|
||||
"lint": "eslint . --ext .js,.jsx,.ts,.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@adxp/auth": "*",
|
||||
"@adxp/common": "*",
|
||||
"@adxp/mock-api": "git+ssh://git@github.com:bluesky-social/adx-mock-api.git#e6f9ecd510fd54fbc5af32e319342634d9446a07",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.1.1",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.1.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.1.1",
|
||||
"@fortawesome/react-native-fontawesome": "^0.3.0",
|
||||
"@gorhom/bottom-sheet": "^4",
|
||||
"@react-native-async-storage/async-storage": "^1.17.6",
|
||||
"@react-native-camera-roll/camera-roll": "^5.1.0",
|
||||
"@react-native-clipboard/clipboard": "^1.10.0",
|
||||
"@react-navigation/bottom-tabs": "^6.3.1",
|
||||
"@react-navigation/native": "^6.0.10",
|
||||
"@react-navigation/native-stack": "^6.6.2",
|
||||
"@react-navigation/stack": "^6.2.1",
|
||||
"@zxing/text-encoding": "^0.9.0",
|
||||
"base64-js": "^1.5.1",
|
||||
"email-validator": "^2.0.4",
|
||||
"he": "^1.2.0",
|
||||
"lodash.chunk": "^4.2.0",
|
||||
"lodash.omit": "^4.5.0",
|
||||
"lru_map": "^0.4.1",
|
||||
"mobx": "^6.6.1",
|
||||
"mobx-react-lite": "^3.4.0",
|
||||
"react": "17.0.2",
|
||||
"react-circular-progressbar": "^2.1.0",
|
||||
"react-dom": "17.0.2",
|
||||
"react-native": "0.68.2",
|
||||
"react-native-appstate-hook": "^1.0.6",
|
||||
"react-native-bundle-splitter": "^2.2.3",
|
||||
"react-native-gesture-handler": "^2.5.0",
|
||||
"react-native-image-crop-picker": "^0.38.1",
|
||||
"react-native-inappbrowser-reborn": "^3.6.3",
|
||||
"react-native-linear-gradient": "^2.6.2",
|
||||
"react-native-pager-view": "^6.0.2",
|
||||
"react-native-progress": "^5.0.0",
|
||||
"react-native-reanimated": "^2.9.1",
|
||||
"react-native-root-siblings": "^4.1.1",
|
||||
"react-native-root-toast": "^3.4.0",
|
||||
"react-native-safe-area-context": "^4.3.1",
|
||||
"react-native-safe-area-context": "^4.4.1",
|
||||
"react-native-screens": "^3.13.1",
|
||||
"react-native-splash-screen": "^3.3.0",
|
||||
"react-native-svg": "^12.4.0",
|
||||
"react-native-tab-view": "^3.3.0",
|
||||
"react-native-url-polyfill": "^1.3.0",
|
||||
"react-native-version-number": "^0.3.6",
|
||||
"react-native-web": "^0.17.7",
|
||||
"ucans": "0.9.1"
|
||||
"tlds": "^1.234.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@adxp/auth-lobby": "*",
|
||||
"@adxp/server": "*",
|
||||
"@adxp/ws-relay": "*",
|
||||
"@babel/core": "^7.12.9",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@react-native-community/eslint-config": "^2.0.0",
|
||||
"@types/he": "^1.1.2",
|
||||
"@types/jest": "^26.0.23",
|
||||
"@types/lodash.chunk": "^4.2.7",
|
||||
"@types/lodash.omit": "^4.5.7",
|
||||
"@types/react-native": "^0.67.3",
|
||||
"@types/react-test-renderer": "^17.0.1",
|
||||
@@ -77,6 +81,9 @@
|
||||
},
|
||||
"jest": {
|
||||
"preset": "react-native",
|
||||
"setupFiles": [
|
||||
"./jest.js"
|
||||
],
|
||||
"moduleFileExtensions": [
|
||||
"ts",
|
||||
"tsx",
|
||||
|
||||
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 78 KiB |
@@ -1,36 +0,0 @@
|
||||
import {IpldStore} from '@adxp/common'
|
||||
import PDSServer from '@adxp/server/dist/server.js'
|
||||
import PDSDatabase from '@adxp/server/dist/db/index.js'
|
||||
import WSRelayServer from '@adxp/ws-relay/dist/index.js'
|
||||
import AuthLobbyServer from '@adxp/auth-lobby'
|
||||
|
||||
const PDS_PORT = 2583
|
||||
const AUTH_LOBBY1_PORT = 3001
|
||||
const AUTH_LOBBY2_PORT = 3002
|
||||
const WSR_PORT = 3005
|
||||
|
||||
async function start() {
|
||||
console.log('Initializing...')
|
||||
|
||||
const db = PDSDatabase.memory()
|
||||
const serverBlockstore = IpldStore.createInMemory()
|
||||
await db.dropTables()
|
||||
await db.createTables()
|
||||
PDSServer(serverBlockstore, db, PDS_PORT)
|
||||
|
||||
init(AuthLobbyServer, AUTH_LOBBY1_PORT, 'Auth lobby')
|
||||
|
||||
if (process.argv.includes('--relay')) {
|
||||
init(AuthLobbyServer, AUTH_LOBBY2_PORT, 'Auth lobby 2')
|
||||
init(WSRelayServer, WSR_PORT, 'Relay server')
|
||||
} else {
|
||||
console.log('Include --relay to start the WS Relay and second auth lobby')
|
||||
}
|
||||
}
|
||||
start()
|
||||
|
||||
function init(fn, port, name) {
|
||||
const s = fn(port)
|
||||
s.on('listening', () => console.log(`✔ ${name} running on port ${port}`))
|
||||
s.on('error', e => console.log(`${name} failed to start:`, e))
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import 'react-native-url-polyfill/auto'
|
||||
import React, {useState, useEffect} from 'react'
|
||||
import {Linking} from 'react-native'
|
||||
import {RootSiblingParent} from 'react-native-root-siblings'
|
||||
import {GestureHandlerRootView} from 'react-native-gesture-handler'
|
||||
import {whenWebCrypto} from './platform/polyfills.native'
|
||||
import SplashScreen from 'react-native-splash-screen'
|
||||
import {SafeAreaProvider} from 'react-native-safe-area-context'
|
||||
import * as view from './view/index'
|
||||
import {RootStoreModel, setupState, RootStoreProvider} from './state'
|
||||
import * as Routes from './view/routes'
|
||||
import {MobileShell} from './view/shell/mobile'
|
||||
|
||||
function App() {
|
||||
const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
|
||||
@@ -14,12 +16,19 @@ function App() {
|
||||
|
||||
// init
|
||||
useEffect(() => {
|
||||
whenWebCrypto
|
||||
.then(() => {
|
||||
view.setup()
|
||||
return setupState()
|
||||
view.setup()
|
||||
setupState().then(store => {
|
||||
setRootStore(store)
|
||||
SplashScreen.hide()
|
||||
Linking.getInitialURL().then((url: string | null) => {
|
||||
if (url) {
|
||||
store.nav.handleLink(url)
|
||||
}
|
||||
})
|
||||
.then(setRootStore)
|
||||
Linking.addEventListener('url', ({url}) => {
|
||||
store.nav.handleLink(url)
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
|
||||
// show nothing prior to init
|
||||
@@ -31,7 +40,9 @@ function App() {
|
||||
<GestureHandlerRootView style={{flex: 1}}>
|
||||
<RootSiblingParent>
|
||||
<RootStoreProvider value={rootStore}>
|
||||
<Routes.Root />
|
||||
<SafeAreaProvider>
|
||||
<MobileShell />
|
||||
</SafeAreaProvider>
|
||||
</RootStoreProvider>
|
||||
</RootSiblingParent>
|
||||
</GestureHandlerRootView>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, {useState, useEffect} from 'react'
|
||||
import * as view from './view/index'
|
||||
import {RootStoreModel, setupState, RootStoreProvider} from './state'
|
||||
import * as Routes from './view/routes'
|
||||
import Toast from './view/com/util/Toast'
|
||||
import {DesktopWebShell} from './view/shell/desktop-web'
|
||||
import Toast from 'react-native-root-toast'
|
||||
|
||||
function App() {
|
||||
const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
|
||||
@@ -22,7 +22,7 @@ function App() {
|
||||
|
||||
return (
|
||||
<RootStoreProvider value={rootStore}>
|
||||
<Routes.Root />
|
||||
<DesktopWebShell />
|
||||
<Toast.ToastContainer />
|
||||
</RootStoreProvider>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"name": "xyz.blueskyweb.pubsq",
|
||||
"displayName": "pubsq"
|
||||
"name": "xyz.blueskyweb.app",
|
||||
"displayName": "Bluesky"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const LOGIN_INCLUDE_DEV_SERVERS = true
|
||||
export const TABS_ENABLED = false
|
||||
export const IMAGES_ENABLED = false
|
||||
@@ -1,8 +0,0 @@
|
||||
// @ts-ignore types not available -prf
|
||||
import {REACT_APP_AUTH_LOBBY} from '@env'
|
||||
|
||||
if (typeof REACT_APP_AUTH_LOBBY !== 'string') {
|
||||
throw new Error('ENV: No auth lobby provided')
|
||||
}
|
||||
|
||||
export const AUTH_LOBBY = REACT_APP_AUTH_LOBBY
|
||||
@@ -1,5 +0,0 @@
|
||||
if (typeof process.env.REACT_APP_AUTH_LOBBY !== 'string') {
|
||||
throw new Error('ENV: No auth lobby provided')
|
||||
}
|
||||
|
||||
export const AUTH_LOBBY = process.env.REACT_APP_AUTH_LOBBY
|
||||
@@ -0,0 +1,4 @@
|
||||
export function isNetworkError(e: unknown) {
|
||||
const str = String(e)
|
||||
return str.includes('Aborted') || str.includes('Network request failed')
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function clamp(v: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, v))
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import {AtUri} from '../third-party/uri'
|
||||
import {Entity} from '../third-party/api/src/client/types/app/bsky/feed/post'
|
||||
import {PROD_SERVICE} from '../state'
|
||||
import {isNetworkError} from './errors'
|
||||
import TLDs from 'tlds'
|
||||
|
||||
export const MAX_DISPLAY_NAME = 64
|
||||
export const MAX_DESCRIPTION = 256
|
||||
|
||||
export function pluralize(n: number, base: string, plural?: string): string {
|
||||
if (n === 1) {
|
||||
return base
|
||||
}
|
||||
if (plural) {
|
||||
return plural
|
||||
}
|
||||
return base + 's'
|
||||
}
|
||||
|
||||
export function makeRecordUri(
|
||||
didOrName: string,
|
||||
collection: string,
|
||||
rkey: string,
|
||||
) {
|
||||
const urip = new AtUri(`at://host/`)
|
||||
urip.host = didOrName
|
||||
urip.collection = collection
|
||||
urip.rkey = rkey
|
||||
return urip.toString()
|
||||
}
|
||||
|
||||
const MINUTE = 60
|
||||
const HOUR = MINUTE * 60
|
||||
const DAY = HOUR * 24
|
||||
const MONTH = DAY * 30
|
||||
const YEAR = DAY * 365
|
||||
export function ago(date: number | string | Date): string {
|
||||
let ts: number
|
||||
if (typeof date === 'string') {
|
||||
ts = Number(new Date(date))
|
||||
} else if (date instanceof Date) {
|
||||
ts = Number(date)
|
||||
} else {
|
||||
ts = date
|
||||
}
|
||||
const diffSeconds = Math.floor((Date.now() - ts) / 1e3)
|
||||
if (diffSeconds < MINUTE) {
|
||||
return `${diffSeconds}s`
|
||||
} else if (diffSeconds < HOUR) {
|
||||
return `${Math.floor(diffSeconds / MINUTE)}m`
|
||||
} else if (diffSeconds < DAY) {
|
||||
return `${Math.floor(diffSeconds / HOUR)}h`
|
||||
} else if (diffSeconds < MONTH) {
|
||||
return `${Math.floor(diffSeconds / DAY)}d`
|
||||
} else if (diffSeconds < YEAR) {
|
||||
return `${Math.floor(diffSeconds / MONTH)}mo`
|
||||
} else {
|
||||
return new Date(ts).toLocaleDateString()
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidDomain(str: string): boolean {
|
||||
return !!TLDs.find(tld => {
|
||||
let i = str.lastIndexOf(tld)
|
||||
if (i === -1) return false
|
||||
return str.charAt(i - 1) === '.' && i === str.length - tld.length
|
||||
})
|
||||
}
|
||||
|
||||
export function extractEntities(
|
||||
text: string,
|
||||
knownHandles?: Set<string>,
|
||||
): Entity[] | undefined {
|
||||
let match
|
||||
let ents: Entity[] = []
|
||||
{
|
||||
// mentions
|
||||
const re = /(^|\s|\()(@)([a-zA-Z0-9\.-]+)(\b)/dg
|
||||
while ((match = re.exec(text))) {
|
||||
if (knownHandles && !knownHandles.has(match[3])) {
|
||||
continue // not a known handle
|
||||
} else if (!match[3].includes('.')) {
|
||||
continue // probably not a handle
|
||||
}
|
||||
ents.push({
|
||||
type: 'mention',
|
||||
value: match[3],
|
||||
index: {
|
||||
start: match.indices[2][0], // skip the (^|\s) but include the '@'
|
||||
end: match.indices[3][1],
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
{
|
||||
// links
|
||||
const re =
|
||||
/(^|\s|\()((https?:\/\/[\S]+)|((?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/dgm
|
||||
while ((match = re.exec(text))) {
|
||||
let value = match[2]
|
||||
if (!value.startsWith('http')) {
|
||||
const domain = match.groups?.domain
|
||||
if (!domain || !isValidDomain(domain)) {
|
||||
continue
|
||||
}
|
||||
value = `https://${value}`
|
||||
}
|
||||
const index = {
|
||||
start: match.indices[2][0], // skip the (^|\s)
|
||||
end: match.indices[2][1],
|
||||
}
|
||||
{
|
||||
// strip ending puncuation
|
||||
if (/[.,;!?]$/.test(value)) {
|
||||
value = value.slice(0, -1)
|
||||
index.end--
|
||||
}
|
||||
if (/[)]$/.test(value) && !value.includes('(')) {
|
||||
value = value.slice(0, -1)
|
||||
index.end--
|
||||
}
|
||||
}
|
||||
ents.push({
|
||||
type: 'link',
|
||||
value,
|
||||
index,
|
||||
})
|
||||
}
|
||||
}
|
||||
return ents.length > 0 ? ents : undefined
|
||||
}
|
||||
|
||||
interface DetectedLink {
|
||||
link: string
|
||||
}
|
||||
type DetectedLinkable = string | DetectedLink
|
||||
export function detectLinkables(text: string): DetectedLinkable[] {
|
||||
const re =
|
||||
/((^|\s|\()@[a-z0-9\.-]*)|((^|\s|\()https?:\/\/[\S]+)|((^|\s|\()(?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*)/gi
|
||||
const segments = []
|
||||
let match
|
||||
let start = 0
|
||||
while ((match = re.exec(text))) {
|
||||
let matchIndex = match.index
|
||||
let matchValue = match[0]
|
||||
|
||||
if (match.groups?.domain && !isValidDomain(match.groups?.domain)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (/\s|\(/.test(matchValue)) {
|
||||
// HACK
|
||||
// skip the starting space
|
||||
// we have to do this because RN doesnt support negative lookaheads
|
||||
// -prf
|
||||
matchIndex++
|
||||
matchValue = matchValue.slice(1)
|
||||
}
|
||||
|
||||
{
|
||||
// strip ending puncuation
|
||||
if (/[.,;!?]$/.test(matchValue)) {
|
||||
matchValue = matchValue.slice(0, -1)
|
||||
}
|
||||
if (/[)]$/.test(matchValue) && !matchValue.includes('(')) {
|
||||
matchValue = matchValue.slice(0, -1)
|
||||
}
|
||||
}
|
||||
|
||||
if (start !== matchIndex) {
|
||||
segments.push(text.slice(start, matchIndex))
|
||||
}
|
||||
segments.push({link: matchValue})
|
||||
start = matchIndex + matchValue.length
|
||||
}
|
||||
if (start < text.length) {
|
||||
segments.push(text.slice(start))
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
||||
export function makeValidHandle(str: string): string {
|
||||
if (str.length > 20) {
|
||||
str = str.slice(0, 20)
|
||||
}
|
||||
str = str.toLowerCase()
|
||||
return str.replace(/^[^a-z]+/g, '').replace(/[^a-z0-9-]/g, '')
|
||||
}
|
||||
|
||||
export function createFullHandle(name: string, domain: string): string {
|
||||
name = name.replace(/[\.]+$/, '')
|
||||
domain = domain.replace(/^[\.]+/, '')
|
||||
return `${name}.${domain}`
|
||||
}
|
||||
|
||||
export function enforceLen(str: string, len: number): string {
|
||||
str = str || ''
|
||||
if (str.length > len) {
|
||||
return str.slice(0, len)
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
export function cleanError(str: string): string {
|
||||
if (isNetworkError(str)) {
|
||||
return 'Unable to connect. Please check your internet connection and try again.'
|
||||
}
|
||||
if (str.startsWith('Error: ')) {
|
||||
return str.slice('Error: '.length)
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
export function toNiceDomain(url: string): string {
|
||||
try {
|
||||
const urlp = new URL(url)
|
||||
if (`https://${urlp.host}` === PROD_SERVICE) {
|
||||
return 'Bluesky Social'
|
||||
}
|
||||
return urlp.host
|
||||
} catch (e) {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
export function toShortUrl(url: string): string {
|
||||
try {
|
||||
const urlp = new URL(url)
|
||||
const shortened =
|
||||
urlp.host +
|
||||
(urlp.pathname === '/' ? '' : urlp.pathname) +
|
||||
urlp.search +
|
||||
urlp.hash
|
||||
if (shortened.length > 30) {
|
||||
return shortened.slice(0, 27) + '...'
|
||||
}
|
||||
return shortened
|
||||
} catch (e) {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
export function toShareUrl(url: string): string {
|
||||
if (!url.startsWith('https')) {
|
||||
const urlp = new URL('https://bsky.app')
|
||||
urlp.pathname = url
|
||||
url = urlp.toString()
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
export function isBskyAppUrl(url: string): boolean {
|
||||
return url.startsWith('https://bsky.app/')
|
||||
}
|
||||
|
||||
export function convertBskyAppUrlIfNeeded(url: string): string {
|
||||
if (isBskyAppUrl(url)) {
|
||||
try {
|
||||
const urlp = new URL(url)
|
||||
return urlp.pathname
|
||||
} catch (e) {
|
||||
console.log('Unexpected error in convertBskyAppUrlIfNeeded()', e)
|
||||
}
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
const htmlTitleRegex = /<title>([^<]+)<\/title>/i
|
||||
export function extractHtmlMeta(html: string): Record<string, string> {
|
||||
const res: Record<string, string> = {}
|
||||
|
||||
{
|
||||
const match = htmlTitleRegex.exec(html)
|
||||
if (match) {
|
||||
res.title = match[1].trim()
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let metaMatch
|
||||
let propMatch
|
||||
const metaRe = /<meta[\s]([^>]+)>/gis
|
||||
while ((metaMatch = metaRe.exec(html))) {
|
||||
let propName
|
||||
let propValue
|
||||
const propRe = /(name|property|content)="([^"]+)"/gis
|
||||
while ((propMatch = propRe.exec(metaMatch[1]))) {
|
||||
if (propMatch[1] === 'content') {
|
||||
propValue = propMatch[2]
|
||||
} else {
|
||||
propName = propMatch[2]
|
||||
}
|
||||
}
|
||||
if (!propName || !propValue) {
|
||||
continue
|
||||
}
|
||||
switch (propName?.trim()) {
|
||||
case 'title':
|
||||
case 'og:title':
|
||||
case 'twitter:title':
|
||||
res.title = propValue?.trim()
|
||||
break
|
||||
case 'description':
|
||||
case 'og:description':
|
||||
case 'twitter:description':
|
||||
res.description = propValue?.trim()
|
||||
break
|
||||
case 'og:image':
|
||||
case 'twitter:image':
|
||||
res.image = propValue?.trim()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import {Linking} from 'react-native'
|
||||
import * as auth from '@adxp/auth'
|
||||
import * as ucan from 'ucans'
|
||||
import {InAppBrowser} from 'react-native-inappbrowser-reborn'
|
||||
import {isWeb} from '../platform/detection'
|
||||
import {extractHashFragment, makeAppUrl} from '../platform/urls'
|
||||
import {ReactNativeStore, parseUrlForUcan} from '../state/lib/auth'
|
||||
import * as env from '../env'
|
||||
|
||||
export async function requestAppUcan(
|
||||
authStore: ReactNativeStore,
|
||||
scope: ucan.Capability,
|
||||
) {
|
||||
const did = await authStore.getDid()
|
||||
const returnUrl = makeAppUrl()
|
||||
const fragment = auth.requestAppUcanHashFragment(did, scope, returnUrl)
|
||||
const url = `${env.AUTH_LOBBY}#${fragment}`
|
||||
|
||||
if (isWeb) {
|
||||
// @ts-ignore window is defined -prf
|
||||
window.location.href = url
|
||||
return false
|
||||
}
|
||||
|
||||
if (await InAppBrowser.isAvailable()) {
|
||||
// use in-app browser
|
||||
const res = await InAppBrowser.openAuth(url, returnUrl, {
|
||||
// iOS Properties
|
||||
ephemeralWebSession: false,
|
||||
// Android Properties
|
||||
showTitle: false,
|
||||
enableUrlBarHiding: true,
|
||||
enableDefaultShare: false,
|
||||
})
|
||||
if (res.type === 'success' && res.url) {
|
||||
const fragment = extractHashFragment(res.url)
|
||||
if (fragment) {
|
||||
const ucan = await parseUrlForUcan(fragment)
|
||||
if (ucan) {
|
||||
await authStore.addUcan(ucan)
|
||||
return true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('Not completed', res)
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
// use system browser
|
||||
Linking.openURL(url)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import * as auth from '@adxp/auth'
|
||||
import * as ucan from 'ucans'
|
||||
import {makeAppUrl} from '../platform/urls'
|
||||
import {ReactNativeStore} from '../state/lib/auth'
|
||||
import * as env from '../env'
|
||||
|
||||
export async function requestAppUcan(
|
||||
authStore: ReactNativeStore,
|
||||
scope: ucan.Capability,
|
||||
) {
|
||||
const did = await authStore.getDid()
|
||||
const returnUrl = makeAppUrl()
|
||||
const fragment = auth.requestAppUcanHashFragment(did, scope, returnUrl)
|
||||
const url = `${env.AUTH_LOBBY}#${fragment}`
|
||||
|
||||
// @ts-ignore window is defined -prf
|
||||
window.location.href = url
|
||||
return false
|
||||
}
|
||||
@@ -1,30 +1 @@
|
||||
import {NativeModules} from 'react-native'
|
||||
const {AppSecureRandomModule} = NativeModules
|
||||
import {toByteArray} from 'base64-js'
|
||||
// @ts-ignore we dont have types for this -prf
|
||||
import crypto from '../third-party/msrcrypto'
|
||||
import '@zxing/text-encoding' // TextEncoder / TextDecoder
|
||||
|
||||
async function generateSecureRandom(bytes: number) {
|
||||
return toByteArray(
|
||||
await AppSecureRandomModule.generateSecureRandomAsBase64(bytes),
|
||||
)
|
||||
}
|
||||
|
||||
export const whenWebCrypto = new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
const bytes = await generateSecureRandom(48)
|
||||
crypto.initPrng(Array.from(bytes))
|
||||
|
||||
// @ts-ignore global.window exists -prf
|
||||
if (!global.window.crypto) {
|
||||
// @ts-ignore global.window exists -prf
|
||||
global.window.crypto = crypto
|
||||
}
|
||||
resolve(true)
|
||||
} catch (e: any) {
|
||||
reject(e)
|
||||
}
|
||||
})
|
||||
|
||||
export const webcrypto = crypto
|
||||
|
||||
@@ -3,9 +3,9 @@ import {isIOS, isAndroid, isNative, isWeb} from './detection'
|
||||
|
||||
export function makeAppUrl(path = '') {
|
||||
if (isIOS) {
|
||||
return `pubsqapp://${path}`
|
||||
return `bskyapp://${path}`
|
||||
} else if (isAndroid) {
|
||||
return `pubsq://app${path}`
|
||||
return `bsky://app${path}`
|
||||
} else {
|
||||
// @ts-ignore window exists -prf
|
||||
return `${window.location.origin}${path}`
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
import {autorun} from 'mobx'
|
||||
import {AdxClient, blueskywebSchemas} from '@adxp/mock-api'
|
||||
import {sessionClient as AtpApi} from '../third-party/api'
|
||||
import type {SessionServiceClient} from '../third-party/api/src/index'
|
||||
import {RootStoreModel} from './models/root-store'
|
||||
import * as libapi from './lib/api'
|
||||
import * as storage from './lib/storage'
|
||||
// import * as auth from './auth' TODO
|
||||
|
||||
export const LOCAL_DEV_SERVICE = 'http://localhost:2583'
|
||||
export const STAGING_SERVICE = 'https://pds.staging.bsky.dev'
|
||||
export const PROD_SERVICE = 'https://bsky.social'
|
||||
export const DEFAULT_SERVICE = PROD_SERVICE
|
||||
const ROOT_STATE_STORAGE_KEY = 'root'
|
||||
const STATE_FETCH_INTERVAL = 15e3
|
||||
|
||||
export async function setupState() {
|
||||
let rootStore: RootStoreModel
|
||||
let data: any
|
||||
|
||||
const api = new AdxClient({
|
||||
pds: 'http://localhost',
|
||||
schemas: blueskywebSchemas,
|
||||
})
|
||||
await libapi.setup(api)
|
||||
libapi.doPolyfill()
|
||||
|
||||
const api = AtpApi.service(DEFAULT_SERVICE) as SessionServiceClient
|
||||
rootStore = new RootStoreModel(api)
|
||||
try {
|
||||
data = (await storage.load(ROOT_STATE_STORAGE_KEY)) || {}
|
||||
@@ -24,26 +27,36 @@ export async function setupState() {
|
||||
console.error('Failed to load state from storage', e)
|
||||
}
|
||||
|
||||
console.log('Initial hydrate', rootStore.me)
|
||||
rootStore.session
|
||||
.connect()
|
||||
.then(() => {
|
||||
console.log('Session connected', rootStore.me)
|
||||
return rootStore.fetchStateUpdate()
|
||||
})
|
||||
.catch(e => {
|
||||
console.log('Failed initial connect', e)
|
||||
})
|
||||
// @ts-ignore .on() is correct -prf
|
||||
api.sessionManager.on('session', () => {
|
||||
if (!api.sessionManager.session && rootStore.session.hasSession) {
|
||||
// reset session
|
||||
rootStore.session.clear()
|
||||
} else if (api.sessionManager.session) {
|
||||
rootStore.session.updateAuthTokens(api.sessionManager.session)
|
||||
}
|
||||
})
|
||||
|
||||
// track changes & save to storage
|
||||
autorun(() => {
|
||||
const snapshot = rootStore.serialize()
|
||||
console.log('saving', snapshot)
|
||||
storage.save(ROOT_STATE_STORAGE_KEY, snapshot)
|
||||
})
|
||||
|
||||
// TODO
|
||||
rootStore.session.setAuthed(true)
|
||||
// if (env.authStore) {
|
||||
// const isAuthed = await auth.isAuthed(env.authStore)
|
||||
// rootStore.session.setAuthed(isAuthed)
|
||||
|
||||
// // handle redirect from auth
|
||||
// if (await auth.initialLoadUcanCheck(env.authStore)) {
|
||||
// rootStore.session.setAuthed(true)
|
||||
// }
|
||||
// }
|
||||
await rootStore.me.load()
|
||||
console.log(rootStore.me)
|
||||
// periodic state fetch
|
||||
setInterval(() => {
|
||||
rootStore.fetchStateUpdate()
|
||||
}, STATE_FETCH_INTERVAL)
|
||||
|
||||
return rootStore
|
||||
}
|
||||
|
||||
@@ -4,332 +4,212 @@
|
||||
*/
|
||||
|
||||
// import {ReactNativeStore} from './auth'
|
||||
import {
|
||||
AdxClient,
|
||||
AdxRepoClient,
|
||||
AdxRepoCollectionClient,
|
||||
AdxUri,
|
||||
bsky,
|
||||
SchemaOpt,
|
||||
ListRecordsResponseValidated,
|
||||
GetRecordResponseValidated,
|
||||
} from '@adxp/mock-api'
|
||||
import * as storage from './storage'
|
||||
import {postTexts} from './mock-data/post-texts'
|
||||
import {replyTexts} from './mock-data/reply-texts'
|
||||
import {sessionClient as AtpApi} from '../../third-party/api'
|
||||
import * as Profile from '../../third-party/api/src/client/types/app/bsky/actor/profile'
|
||||
import * as Post from '../../third-party/api/src/client/types/app/bsky/feed/post'
|
||||
import {AtUri} from '../../third-party/uri'
|
||||
import {APP_BSKY_GRAPH} from '../../third-party/api'
|
||||
import {RootStoreModel} from '../models/root-store'
|
||||
import {extractEntities} from '../../lib/strings'
|
||||
|
||||
export async function setup(adx: AdxClient) {
|
||||
await adx.setupMock(
|
||||
() => storage.load('mock-root'),
|
||||
async root => {
|
||||
await storage.save('mock-root', root)
|
||||
},
|
||||
() => generateMockData(adx),
|
||||
)
|
||||
const TIMEOUT = 10e3 // 10s
|
||||
|
||||
export function doPolyfill() {
|
||||
AtpApi.xrpc.fetch = fetchHandler
|
||||
}
|
||||
|
||||
export async function post(
|
||||
adx: AdxClient,
|
||||
user: string,
|
||||
store: RootStoreModel,
|
||||
text: string,
|
||||
replyToUri?: string,
|
||||
replyTo?: Post.ReplyRef,
|
||||
knownHandles?: Set<string>,
|
||||
) {
|
||||
let reply
|
||||
if (replyToUri) {
|
||||
const replyToUrip = new AdxUri(replyToUri)
|
||||
const parentPost = await adx
|
||||
.repo(replyToUrip.host, false)
|
||||
.collection(replyToUrip.collection)
|
||||
.get('Post', replyToUrip.recordKey)
|
||||
if (replyTo) {
|
||||
const replyToUrip = new AtUri(replyTo.uri)
|
||||
const parentPost = await store.api.app.bsky.feed.post.get({
|
||||
user: replyToUrip.host,
|
||||
rkey: replyToUrip.rkey,
|
||||
})
|
||||
if (parentPost) {
|
||||
const parentRef = {
|
||||
uri: parentPost.uri,
|
||||
cid: parentPost.cid,
|
||||
}
|
||||
reply = {
|
||||
root: parentPost.value.reply?.root || parentPost.uri,
|
||||
parent: parentPost.uri,
|
||||
root: parentPost.value.reply?.root || parentRef,
|
||||
parent: parentRef,
|
||||
}
|
||||
}
|
||||
}
|
||||
return await adx
|
||||
.repo(user, true)
|
||||
.collection('blueskyweb.xyz:Posts')
|
||||
.create('Post', {
|
||||
$type: 'blueskyweb.xyz:Post',
|
||||
const entities = extractEntities(text, knownHandles)
|
||||
if (entities) {
|
||||
for (const ent of entities) {
|
||||
if (ent.type === 'mention') {
|
||||
const prof = await store.profiles.getProfile(ent.value)
|
||||
ent.value = prof.data.did
|
||||
}
|
||||
}
|
||||
}
|
||||
return await store.api.app.bsky.feed.post.create(
|
||||
{did: store.me.did || ''},
|
||||
{
|
||||
text,
|
||||
reply,
|
||||
entities,
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function like(adx: AdxClient, user: string, uri: string) {
|
||||
return await adx
|
||||
.repo(user, true)
|
||||
.collection('blueskyweb.xyz:Likes')
|
||||
.create('Like', {
|
||||
$type: 'blueskyweb.xyz:Like',
|
||||
subject: uri,
|
||||
export async function repost(store: RootStoreModel, uri: string, cid: string) {
|
||||
return await store.api.app.bsky.feed.repost.create(
|
||||
{did: store.me.did || ''},
|
||||
{
|
||||
subject: {uri, cid},
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function unlike(adx: AdxClient, user: string, uri: string) {
|
||||
const coll = adx.repo(user, true).collection('blueskyweb.xyz:Likes')
|
||||
const numDels = await deleteWhere(coll, 'Like', record => {
|
||||
return record.value.subject === uri
|
||||
export async function unrepost(store: RootStoreModel, repostUri: string) {
|
||||
const repostUrip = new AtUri(repostUri)
|
||||
return await store.api.app.bsky.feed.repost.delete({
|
||||
did: repostUrip.hostname,
|
||||
rkey: repostUrip.rkey,
|
||||
})
|
||||
return numDels > 0
|
||||
}
|
||||
|
||||
export async function repost(adx: AdxClient, user: string, uri: string) {
|
||||
return await adx
|
||||
.repo(user, true)
|
||||
.collection('blueskyweb.xyz:Posts')
|
||||
.create('Repost', {
|
||||
$type: 'blueskyweb.xyz:Repost',
|
||||
subject: uri,
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
export async function unrepost(adx: AdxClient, user: string, uri: string) {
|
||||
const coll = adx.repo(user, true).collection('blueskyweb.xyz:Posts')
|
||||
const numDels = await deleteWhere(coll, 'Repost', record => {
|
||||
return record.value.subject === uri
|
||||
})
|
||||
return numDels > 0
|
||||
}
|
||||
|
||||
export async function follow(
|
||||
adx: AdxClient,
|
||||
user: string,
|
||||
subject: {did: string; name: string},
|
||||
store: RootStoreModel,
|
||||
subjectDid: string,
|
||||
subjectDeclarationCid: string,
|
||||
) {
|
||||
return await adx
|
||||
.repo(user, true)
|
||||
.collection('blueskyweb.xyz:Follows')
|
||||
.create('Follow', {
|
||||
$type: 'blueskyweb.xyz:Follow',
|
||||
subject,
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
export async function unfollow(
|
||||
adx: AdxClient,
|
||||
user: string,
|
||||
subject: {did: string},
|
||||
) {
|
||||
const coll = adx.repo(user, true).collection('blueskyweb.xyz:Follows')
|
||||
const numDels = await deleteWhere(coll, 'Follow', record => {
|
||||
return record.value.subject.did === subject.did
|
||||
})
|
||||
return numDels > 0
|
||||
}
|
||||
|
||||
type WherePred = (_record: GetRecordResponseValidated) => Boolean
|
||||
async function deleteWhere(
|
||||
coll: AdxRepoCollectionClient,
|
||||
schema: SchemaOpt,
|
||||
cond: WherePred,
|
||||
) {
|
||||
const toDelete: string[] = []
|
||||
await iterateAll(coll, schema, record => {
|
||||
if (cond(record)) {
|
||||
toDelete.push(record.key)
|
||||
}
|
||||
})
|
||||
for (const key of toDelete) {
|
||||
await coll.del(key)
|
||||
}
|
||||
return toDelete.length
|
||||
}
|
||||
|
||||
type IterateAllCb = (_record: GetRecordResponseValidated) => void
|
||||
async function iterateAll(
|
||||
coll: AdxRepoCollectionClient,
|
||||
schema: SchemaOpt,
|
||||
cb: IterateAllCb,
|
||||
) {
|
||||
let cursor
|
||||
let res: ListRecordsResponseValidated
|
||||
do {
|
||||
res = await coll.list(schema, {after: cursor, limit: 100})
|
||||
for (const record of res.records) {
|
||||
if (record.valid) {
|
||||
cb(record)
|
||||
cursor = record.key
|
||||
}
|
||||
}
|
||||
} while (res.records.length === 100)
|
||||
}
|
||||
|
||||
// TEMPORARY
|
||||
// mock api config
|
||||
// =======
|
||||
|
||||
function* dateGen() {
|
||||
let start = 1657846031914
|
||||
while (true) {
|
||||
yield new Date(start).toISOString()
|
||||
start += 1e3
|
||||
}
|
||||
}
|
||||
const date = dateGen()
|
||||
|
||||
function repo(adx: AdxClient, didOrName: string) {
|
||||
const userDb = adx.mockDb.getUser(didOrName)
|
||||
if (!userDb) throw new Error(`User not found: ${didOrName}`)
|
||||
return adx.mainPds.repo(userDb.did, userDb.writable)
|
||||
}
|
||||
|
||||
export async function generateMockData(adx: AdxClient) {
|
||||
const rand = (n: number) => Math.floor(Math.random() * n)
|
||||
const picka = <T>(arr: Array<T>): T => {
|
||||
if (arr.length) {
|
||||
return arr[rand(arr.length)] || arr[0]
|
||||
}
|
||||
throw new Error('Not found')
|
||||
}
|
||||
|
||||
await adx.mockDb.addUser({name: 'alice.com', writable: true})
|
||||
await adx.mockDb.addUser({name: 'bob.com', writable: true})
|
||||
await adx.mockDb.addUser({name: 'carla.com', writable: true})
|
||||
|
||||
const alice = repo(adx, 'alice.com')
|
||||
const bob = repo(adx, 'bob.com')
|
||||
const carla = repo(adx, 'carla.com')
|
||||
const repos = [alice, bob, carla]
|
||||
|
||||
await alice.collection('blueskyweb.xyz:Profiles').put('Profile', 'profile', {
|
||||
$type: 'blueskyweb.xyz:Profile',
|
||||
displayName: 'Alice',
|
||||
description: 'Test user 1',
|
||||
})
|
||||
await bob.collection('blueskyweb.xyz:Profiles').put('Profile', 'profile', {
|
||||
$type: 'blueskyweb.xyz:Profile',
|
||||
displayName: 'Bob',
|
||||
description: 'Test user 2',
|
||||
})
|
||||
await carla.collection('blueskyweb.xyz:Profiles').put('Profile', 'profile', {
|
||||
$type: 'blueskyweb.xyz:Profile',
|
||||
displayName: 'Carla',
|
||||
description: 'Test user 3',
|
||||
})
|
||||
|
||||
// everybody follows everybody
|
||||
const follow = async (who: AdxRepoClient, subjectName: string) => {
|
||||
const subjectDb = adx.mockDb.getUser(subjectName)
|
||||
return who.collection('blueskyweb.xyz:Follows').create('Follow', {
|
||||
$type: 'blueskyweb.xyz:Follow',
|
||||
return await store.api.app.bsky.graph.follow.create(
|
||||
{did: store.me.did || ''},
|
||||
{
|
||||
subject: {
|
||||
did: subjectDb?.did,
|
||||
name: subjectDb?.name,
|
||||
did: subjectDid,
|
||||
declarationCid: subjectDeclarationCid,
|
||||
},
|
||||
createdAt: date.next().value,
|
||||
})
|
||||
}
|
||||
await follow(alice, 'bob.com')
|
||||
await follow(alice, 'carla.com')
|
||||
await follow(bob, 'alice.com')
|
||||
await follow(bob, 'carla.com')
|
||||
await follow(carla, 'alice.com')
|
||||
await follow(carla, 'bob.com')
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// a set of posts and reposts
|
||||
const posts: {uri: string}[] = []
|
||||
for (let i = 0; i < postTexts.length; i++) {
|
||||
const author = picka(repos)
|
||||
posts.push(
|
||||
await author.collection('blueskyweb.xyz:Posts').create('Post', {
|
||||
$type: 'blueskyweb.xyz:Post',
|
||||
text: postTexts[i],
|
||||
createdAt: date.next().value,
|
||||
}),
|
||||
)
|
||||
if (rand(10) === 0) {
|
||||
await picka(repos)
|
||||
.collection('blueskyweb.xyz:Posts')
|
||||
.create('Repost', {
|
||||
$type: 'blueskyweb.xyz:Repost',
|
||||
subject: picka(posts).uri,
|
||||
createdAt: date.next().value,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// a set of replies
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const targetUri = picka(posts).uri
|
||||
const urip = new AdxUri(targetUri)
|
||||
const target = await adx.mainPds
|
||||
.repo(urip.host, true)
|
||||
.collection(urip.collection)
|
||||
.get('Post', urip.recordKey)
|
||||
const targetRecord = target.value as bsky.Post.Record
|
||||
const author = picka(repos)
|
||||
posts.push(
|
||||
await author.collection('blueskyweb.xyz:Posts').create('Post', {
|
||||
$type: 'blueskyweb.xyz:Post',
|
||||
text: picka(replyTexts),
|
||||
reply: {
|
||||
root: targetRecord.reply ? targetRecord.reply.root : target.uri,
|
||||
parent: target.uri,
|
||||
},
|
||||
createdAt: date.next().value,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// a set of likes
|
||||
for (const post of posts) {
|
||||
for (const repo of repos) {
|
||||
if (rand(3) === 0) {
|
||||
await repo.collection('blueskyweb.xyz:Likes').create('Like', {
|
||||
$type: 'blueskyweb.xyz:Like',
|
||||
subject: post.uri,
|
||||
createdAt: date.next().value,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// give alice 3 badges, 2 from bob and 2 from carla, with one ignored
|
||||
const inviteBadge = await bob
|
||||
.collection('blueskyweb.xyz:Badges')
|
||||
.create('Badge', {
|
||||
$type: 'blueskyweb.xyz:Badge',
|
||||
subject: {did: alice.did, name: 'alice.com'},
|
||||
assertion: {type: 'invite'},
|
||||
createdAt: date.next().value,
|
||||
})
|
||||
const techTagBadge1 = await bob
|
||||
.collection('blueskyweb.xyz:Badges')
|
||||
.create('Badge', {
|
||||
$type: 'blueskyweb.xyz:Badge',
|
||||
subject: {did: alice.did, name: 'alice.com'},
|
||||
assertion: {type: 'tag', tag: 'tech'},
|
||||
createdAt: date.next().value,
|
||||
})
|
||||
const techTagBadge2 = await carla
|
||||
.collection('blueskyweb.xyz:Badges')
|
||||
.create('Badge', {
|
||||
$type: 'blueskyweb.xyz:Badge',
|
||||
subject: {did: alice.did, name: 'alice.com'},
|
||||
assertion: {type: 'tag', tag: 'tech'},
|
||||
createdAt: date.next().value,
|
||||
})
|
||||
await bob.collection('blueskyweb.xyz:Badges').create('Badge', {
|
||||
$type: 'blueskyweb.xyz:Badge',
|
||||
subject: {did: alice.did, name: 'alice.com'},
|
||||
assertion: {type: 'employee'},
|
||||
createdAt: date.next().value,
|
||||
})
|
||||
await alice.collection('blueskyweb.xyz:Profiles').put('Profile', 'profile', {
|
||||
$type: 'blueskyweb.xyz:Profile',
|
||||
displayName: 'Alice',
|
||||
description: 'Test user 1',
|
||||
badges: [
|
||||
{uri: inviteBadge.uri},
|
||||
{uri: techTagBadge1.uri},
|
||||
{uri: techTagBadge2.uri},
|
||||
],
|
||||
export async function unfollow(store: RootStoreModel, followUri: string) {
|
||||
const followUrip = new AtUri(followUri)
|
||||
return await store.api.app.bsky.graph.follow.delete({
|
||||
did: followUrip.hostname,
|
||||
rkey: followUrip.rkey,
|
||||
})
|
||||
}
|
||||
|
||||
export async function inviteToScene(
|
||||
store: RootStoreModel,
|
||||
sceneDid: string,
|
||||
subjectDid: string,
|
||||
subjectDeclarationCid: string,
|
||||
): Promise<string> {
|
||||
const res = await store.api.app.bsky.graph.assertion.create(
|
||||
{
|
||||
did: sceneDid,
|
||||
},
|
||||
{
|
||||
subject: {
|
||||
did: subjectDid,
|
||||
declarationCid: subjectDeclarationCid,
|
||||
},
|
||||
assertion: APP_BSKY_GRAPH.AssertMember,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
)
|
||||
return res.uri
|
||||
}
|
||||
|
||||
interface Confirmation {
|
||||
originator: {
|
||||
did: string
|
||||
declarationCid: string
|
||||
}
|
||||
assertion: {
|
||||
uri: string
|
||||
cid: string
|
||||
}
|
||||
}
|
||||
export async function acceptSceneInvite(
|
||||
store: RootStoreModel,
|
||||
details: Confirmation,
|
||||
): Promise<string> {
|
||||
const res = await store.api.app.bsky.graph.confirmation.create(
|
||||
{
|
||||
did: store.me.did || '',
|
||||
},
|
||||
{
|
||||
...details,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
)
|
||||
return res.uri
|
||||
}
|
||||
|
||||
interface FetchHandlerResponse {
|
||||
status: number
|
||||
headers: Record<string, string>
|
||||
body: ArrayBuffer | undefined
|
||||
}
|
||||
|
||||
async function fetchHandler(
|
||||
reqUri: string,
|
||||
reqMethod: string,
|
||||
reqHeaders: Record<string, string>,
|
||||
reqBody: any,
|
||||
): Promise<FetchHandlerResponse> {
|
||||
const reqMimeType = reqHeaders['Content-Type'] || reqHeaders['content-type']
|
||||
if (reqMimeType && reqMimeType.startsWith('application/json')) {
|
||||
reqBody = JSON.stringify(reqBody)
|
||||
} else if (
|
||||
typeof reqBody === 'string' &&
|
||||
(reqBody.startsWith('/') || reqBody.startsWith('file:'))
|
||||
) {
|
||||
// NOTE
|
||||
// React native treats bodies with {uri: string} as file uploads to pull from cache
|
||||
// -prf
|
||||
reqBody = {uri: reqBody}
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const to = setTimeout(() => controller.abort(), TIMEOUT)
|
||||
|
||||
const res = await fetch(reqUri, {
|
||||
method: reqMethod,
|
||||
headers: reqHeaders,
|
||||
body: reqBody,
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
const resStatus = res.status
|
||||
const resHeaders: Record<string, string> = {}
|
||||
res.headers.forEach((value: string, key: string) => {
|
||||
resHeaders[key] = value
|
||||
})
|
||||
const resMimeType = resHeaders['Content-Type'] || resHeaders['content-type']
|
||||
let resBody
|
||||
if (resMimeType) {
|
||||
if (resMimeType.startsWith('application/json')) {
|
||||
resBody = await res.json()
|
||||
} else if (resMimeType.startsWith('text/')) {
|
||||
resBody = await res.text()
|
||||
} else {
|
||||
throw new Error('TODO: non-textual response body')
|
||||
}
|
||||
}
|
||||
|
||||
clearTimeout(to)
|
||||
|
||||
return {
|
||||
status: resStatus,
|
||||
headers: resHeaders,
|
||||
body: resBody,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import * as auth from '@adxp/auth'
|
||||
import * as ucan from 'ucans'
|
||||
import {
|
||||
getInitialURL,
|
||||
extractHashFragment,
|
||||
clearHash,
|
||||
} from '../../platform/urls'
|
||||
import * as authFlow from '../../platform/auth-flow'
|
||||
import * as storage from './storage'
|
||||
|
||||
const SCOPE = auth.writeCap(
|
||||
'did:key:z6MkfRiFMLzCxxnw6VMrHK8pPFt4QAHS3jX3XM87y9rta6kP',
|
||||
'did:example:microblog',
|
||||
)
|
||||
|
||||
export async function isAuthed(authStore: ReactNativeStore) {
|
||||
return await authStore.hasUcan(SCOPE)
|
||||
}
|
||||
|
||||
export async function logout(authStore: ReactNativeStore) {
|
||||
await authStore.reset()
|
||||
}
|
||||
|
||||
export async function parseUrlForUcan(fragment: string) {
|
||||
try {
|
||||
return await auth.parseLobbyResponseHashFragment(fragment)
|
||||
} catch (err) {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export async function initialLoadUcanCheck(authStore: ReactNativeStore) {
|
||||
let wasAuthed = false
|
||||
const fragment = extractHashFragment(await getInitialURL())
|
||||
if (fragment) {
|
||||
const ucan = await parseUrlForUcan(fragment)
|
||||
if (ucan) {
|
||||
await authStore.addUcan(ucan)
|
||||
wasAuthed = true
|
||||
clearHash()
|
||||
}
|
||||
}
|
||||
return wasAuthed
|
||||
}
|
||||
|
||||
export async function requestAppUcan(authStore: ReactNativeStore) {
|
||||
return authFlow.requestAppUcan(authStore, SCOPE)
|
||||
}
|
||||
|
||||
export class ReactNativeStore extends auth.AuthStore {
|
||||
private keypair: ucan.EdKeypair
|
||||
private ucanStore: ucan.Store
|
||||
|
||||
constructor(keypair: ucan.EdKeypair, ucanStore: ucan.Store) {
|
||||
super()
|
||||
this.keypair = keypair
|
||||
this.ucanStore = ucanStore
|
||||
}
|
||||
|
||||
static async load(): Promise<ReactNativeStore> {
|
||||
const keypair = await ReactNativeStore.loadOrCreateKeypair()
|
||||
|
||||
const storedUcans = await ReactNativeStore.getStoredUcanStrs()
|
||||
const ucanStore = await ucan.Store.fromTokens(storedUcans)
|
||||
|
||||
return new ReactNativeStore(keypair, ucanStore)
|
||||
}
|
||||
|
||||
static async loadOrCreateKeypair(): Promise<ucan.EdKeypair> {
|
||||
const storedKey = await storage.loadString('adxKey')
|
||||
if (storedKey) {
|
||||
return ucan.EdKeypair.fromSecretKey(storedKey)
|
||||
} else {
|
||||
// @TODO: again just stand in since no actual root keys
|
||||
const keypair = await ucan.EdKeypair.create({exportable: true})
|
||||
storage.saveString('adxKey', await keypair.export())
|
||||
return keypair
|
||||
}
|
||||
}
|
||||
|
||||
static async getStoredUcanStrs(): Promise<string[]> {
|
||||
const storedStr = await storage.loadString('adxUcans')
|
||||
if (!storedStr) {
|
||||
return []
|
||||
}
|
||||
return storedStr.split(',')
|
||||
}
|
||||
|
||||
static setStoredUcanStrs(ucans: string[]): void {
|
||||
storage.saveString('adxUcans', ucans.join(','))
|
||||
}
|
||||
|
||||
protected async getKeypair(): Promise<ucan.EdKeypair> {
|
||||
return this.keypair
|
||||
}
|
||||
|
||||
async addUcan(token: ucan.Chained): Promise<void> {
|
||||
this.ucanStore.add(token)
|
||||
const storedUcans = await ReactNativeStore.getStoredUcanStrs()
|
||||
ReactNativeStore.setStoredUcanStrs([...storedUcans, token.encoded()])
|
||||
}
|
||||
|
||||
async getUcanStore(): Promise<ucan.Store> {
|
||||
return this.ucanStore
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
storage.clear()
|
||||
}
|
||||
|
||||
async reset(): Promise<void> {
|
||||
this.clear()
|
||||
this.keypair = await ReactNativeStore.loadOrCreateKeypair()
|
||||
this.ucanStore = await ucan.Store.fromTokens([])
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
// I just scraped some of my twitter timeline into here
|
||||
// hope nobody minds
|
||||
// -prf
|
||||
export const postTexts = [
|
||||
'last call! share your most offbeat home idea and you could get $100,000 USD to build it. applications to the OMG! fund are open until july 22. for rules and how to apply→ http://airbnb.com/omgfund?twclid=2-6f25jsvulsw1bpglil5jvkku0',
|
||||
'Great to get some quality time with @DavidJWest #Agile2022',
|
||||
'vive la sybil résistance',
|
||||
'Where does one mint on @tezos these days?',
|
||||
'Tame the day with the soothing and luxurious all-new 2022 INFINITI QX60.',
|
||||
"In case you're wondering how it's going in the UK, here's the person most likely to be our next PM:",
|
||||
'Really thought I was watching an SNL clip',
|
||||
'Mediocrity is like a magnet pulling organizations toward it. You can only avoid it by constant effort.',
|
||||
'Nobody in the replies talking about RegCF, DAOs, etc…\n' +
|
||||
'\n' +
|
||||
'The innovation is non, perhaps contra-hierarchical.',
|
||||
'There are times when I think DevOps is just group therapy for system administrators.',
|
||||
'Is DevOps still relevant?\n' +
|
||||
'\n' +
|
||||
'Join me tomorrow, at 11:00 AM PDT, to discuss the state of DevOps with @nathenharvey, as we attempt to answer that question. https://twitter.com/i/spaces/1dRKZlMPbWaJB…',
|
||||
'trash is love. trash is life.',
|
||||
'I want you guys to know I’ve done it. I’ve killed the legendary Michael Myers with a series of good stabs. No one could survive it. Hell, I even checked to be sure he was dead. Anyone got weekend plans?',
|
||||
'Orgs that performed timely server upgrades averaged 19% revenue growth.',
|
||||
'Seeing all of the @ mention mint scams of late…',
|
||||
"For fuck's sake, Gary.",
|
||||
'And another',
|
||||
'Ah go on, one more',
|
||||
"Wait, so there's no way to test microphone settings for Twitter Spaces without creating a Twitter Space, going 'live', and having it notify everyone? Amazing.",
|
||||
'gidge day',
|
||||
'British Defense Intelligence Ukraine Update, July 20, 2022\n' +
|
||||
'#RussiaUkraineWar',
|
||||
' We’re launching a brand new template soon on @tailwindui I’m really excited about this one! \n' +
|
||||
'\n' +
|
||||
'Here is a preview:',
|
||||
'Are you looking for a pediatrician near your home or work? ARC has pediatricians in 23 locations around Central Texas. \n' +
|
||||
'\n' +
|
||||
'Our pediatricians are ready to partner with you in the growth and development of your child and family, no matter where you live.',
|
||||
'And we’ll be making undici-fetch happen. H/t @matteocollina',
|
||||
"It's Wednesday, you know what that means....\n" +
|
||||
'\n' +
|
||||
' Office Hours - 9:30AM PDT / 12:30PM EDT / 4:30PM (16:30) GMT \n' +
|
||||
'\n' +
|
||||
' Maker Hour - 1:00PM PDT / 4:00PM EDT / 8:00PM (20:00) GMT\n' +
|
||||
'\n' +
|
||||
'Join us in @discord',
|
||||
"Europe's bleak Russian winter, made stark: \n" +
|
||||
'\n' +
|
||||
"Brussels proposes Europe cuts its gas usage by 15 per cent between now and April next year if governments literally want to keep the lights on and people's homes warm.",
|
||||
'Tips for using TypeScript via JSDoc comments',
|
||||
'My talk is today come say hi!',
|
||||
'Im giving a talk on private smart contract using homomorphic encryption on Wednesday at 2:20pm. Come say hi! #fhe #web3 #privacy #ethereum',
|
||||
'We have just showcased the new build and discussed our future plans to support developers Recording coming soon! If you are in Paris drop us a line to grab a coffee ',
|
||||
'TFW you forget to buy milk for your morning coffee and now have to get dressed and go outside at 6:30am.',
|
||||
'seeing an awful lot of articles referring to the "European heatwave" in the past tense. first of all:',
|
||||
'Is there a way to fix this issue this when using TypeScript via JSDoc comments? (Reply to the quoted tweet.)',
|
||||
'For instance in this code I don’t know how to tell TS that, no, the `res` will not be `string`. (`api` is an instance of the `got` npm package).',
|
||||
'In the latest magazine, bob kindly invited me to write about techniques for ensuring convergence in distributed systems. Article is now up!',
|
||||
"This is a reboot of the “Research for Practice” column that @alice.com used to run. The idea is to distill some key ideas from recent CS research and to make them accessible to folks in industry who don't normally have time for reading papers.",
|
||||
"This is cool: post a video to @whimful's REVERSE CONFERENCE @ DWeb so you can maximize your human connecting time at camp ",
|
||||
'New blog post: How to speed up the Rust compiler in July 2022\n' +
|
||||
'\n' +
|
||||
'https://nnethercote.github.io/2022/07/20/how-to-speed-up-the-rust-compiler-in-july-2022.html…\n' +
|
||||
'\n' +
|
||||
'PGO on Windows, MIR inlining, better derived code for builtin traits, and much more!',
|
||||
'my new favorite past time is watching older couples at the airport \n' +
|
||||
'\n' +
|
||||
'Wife: “Put your mask on. I don’t want you sick for your birthday.”\n' +
|
||||
'Husband: “Can you leave me alone for one goddamn minute?”',
|
||||
"We're all gonna die",
|
||||
'Builders building, this month at @NativeHostels on 4th Street.\n' +
|
||||
'- Coworking & coffee on Tuesdays 11am-3pm \n' +
|
||||
'- HAPPY HOUR HANGOUTS on Thursdays 7:30-10pm \n' +
|
||||
'RSVP: https://ATXDAO-Native-HH.eventbrite.com',
|
||||
'crypto meetings IRL are my favorite.\n' +
|
||||
'\n' +
|
||||
'@ATXDAO\n' +
|
||||
'@HopeCampaign\n' +
|
||||
'@NativeHostels\n' +
|
||||
'@wagmiyall \n' +
|
||||
'@evrydayresearch \n' +
|
||||
'@zcreativemedia \n' +
|
||||
'@CrystalGravy2\n' +
|
||||
'@NickCPlusPlus\n' +
|
||||
'@_ElaineAlonzo',
|
||||
'I recommend @Scroll_ZKP create and hire for a developer advocate position to deliver world class developer experience.',
|
||||
'Intelligent Tracking Prevention identifies trackers and helps prevent them from profiling or following you across the web.',
|
||||
"Big story in Norway this summer is a walrus we've named Freya has made it to our shores and is touring the country, laying around and sinking boats",
|
||||
'"the other fellows at the CIA were unequivocally against assassinating three of the greatest musicians of their generation, but I went in there and held my convictions and said my piece."',
|
||||
'hls is annoying technology',
|
||||
'This summer, prey for survival. Experience #BeastMovie only in theaters August 19.',
|
||||
"got the new twitter update. i don't like it",
|
||||
'Might just set this as a daily reminder.',
|
||||
'Gm. Today, ask yourself:\n' +
|
||||
'\n' +
|
||||
'Is my Web3/NFT/DeFi/crypto making a better life for someone other than myself?',
|
||||
'every time I add a piece to my native #webgl engine I end up crying and appreciating even more the work @mrdoob and the @threejs team is doing. y’all have no idea',
|
||||
'i don’t like having 2 plagues and no healthcare. i should have 0 plagues and 2 healthcare',
|
||||
'this is the only lotr behind the scenes pic that matters actually',
|
||||
'Photographer took pictures of a baby hippo scared of birds',
|
||||
'lol',
|
||||
"VC's explaining web3.",
|
||||
'Cyberattacks cost banks $18.3 million annually per company. We ensure that you’re not part of that statistic.#stayrelentless.',
|
||||
'In SF, you can choose kind of weather you want',
|
||||
'a day in earth 2.0 should be 26 hours',
|
||||
'who wants to start a religion',
|
||||
'We just launched our code-hosting platform!\n' +
|
||||
'\n' +
|
||||
"It's like Github, but has hyperlinked syntax-highlighted code and docs from day one \n" +
|
||||
'\n' +
|
||||
"I can't overemphasize how nice it feels to push code and immediately see it there, published, highlighted, and linked up!",
|
||||
'Now that you’ve seen them do the universe, it’s time for them to do a new series. Mike Judge’s Beavis and Butt-Head is coming to #ParamountPlus on August 4th. #beavisandbutthead',
|
||||
'Organized a nice crypto research dinner with @protocollabs and @zama_fhe - very vibrant community with a lot of open problems :)\n' +
|
||||
'\n' +
|
||||
'Planning to organize more going forward - reach out if this is your vibe!',
|
||||
"Houston drivers are crazy. Been here two days and have had multiple vehicles charging up and tailing me, then switching lanes, roaring past, and weaving through traffic. While I'm going 75mph, keeping with the flow of traffic. WTF??",
|
||||
`LIVE NOW: a mix of projects coming to #DWebCamp2022! @gvelez17 of @dsocialcommons is planning "Dogfooding Decentralization" a user-testing lab at DWeb Camp. You can sign up to present your tools. We'll send you the link tomorrow! 1/n`,
|
||||
`Fabiola of @guardianproject is bringing "Butter Box" "Inspiration Beans" & more. (They must love ) Butter Box is raspberry pi-based hotspot w/ encrypted chat. It's opensource & offline first. Come onboard & connect with others offline. (8/n)`,
|
||||
"Up Now: @guardianproject's Jack Fox Keen w/ tools to verify the authencity++ of data. They are leading a Cryptographically Verified Scavenger Hunt (9/n)",
|
||||
'Translating VC Language',
|
||||
'What would Sherlock Holmes do? Listen to true crime podcasts early on Amazon Music. #TrueCrimeonAmazonMusic',
|
||||
'How to deal with a hung Linux system',
|
||||
'ALRIGHT PELOTON HAPPY MONDAY I WANNA FEEL THAT ENERGY COMING THROUGH THE SCREEN AS WE START GETTING OUR AMAZING BODIES WARMED UP. THIS FIRST TRACK IS CALLED "DONALD DUCK GETTING A BLOWJOB" RESISTANCE 30 CADENCE 80',
|
||||
"Thank you to all who joined us at @pbailis' executive roundtable lunch at @VentureBeat's #VBTransform!\n" +
|
||||
'\n' +
|
||||
'#ICYMI, stop by the Sisu table today to meet the team & learn how to supercharge your analytics workflow with decision intelligence.',
|
||||
'Prescribing Info: http://bit.ly/2cTmItE?twclid=2-1jogj2v7zxu2rw1zmzyms8jpg \n' +
|
||||
'Med Guide: http://bit.ly/2yon94P?twclid=2-1jogj2v7zxu2rw1zmzyms8jpg\n' +
|
||||
'\n' +
|
||||
'Discover a treatment for certain patients with kidney cancer (RCC).',
|
||||
'Want to learn how to write E2EE P2P apps without even trying? Check out the new application sandbox and api in Peergos!',
|
||||
'Ageism was always a reality in tech. \n' +
|
||||
'\n' +
|
||||
'The “in-office” only crowd have found a way to make their ageism more respectable.\n' +
|
||||
'\n' +
|
||||
'PS - most startups would rather hire the 32 yo Senior FAANG engineer than a 20 something grad who needs a ton of mentorship.',
|
||||
"I don't mind jumping on a call at work. I think that should be the last resort, not the first one.",
|
||||
'If you missed attending #MSInspire, we missed you too. You can still find plenty of on-demand content available at your convenience. Take advantage of the ongoing opportunity to enrich your business prospects.',
|
||||
"TODAY AT 4 PM PT: Join for the DWeb Meetup where we'll see some of the workshops coming to DWeb Camp 2022:\n" +
|
||||
'@metagov_project\n' +
|
||||
' @ameliawb\n' +
|
||||
' @guardianproject\n' +
|
||||
' @gvelez17\n' +
|
||||
' @infrared_ether\n' +
|
||||
'@LauBenedict\n' +
|
||||
' @guardianproject\n' +
|
||||
'@byUnfinished\n' +
|
||||
' Come be inspired:',
|
||||
]
|
||||
@@ -1,24 +0,0 @@
|
||||
export const replyTexts = [
|
||||
'Wow, so true!',
|
||||
'Haha ikr',
|
||||
'lol',
|
||||
'What does this mean for democrats in the midterms?',
|
||||
'What does this mean for republicans in the midterms?',
|
||||
'What does this mean for pet owners in the midterms?',
|
||||
'is it cool if I DM?',
|
||||
"This is sort of accurate, but honestly it misses a huge part of the issue at hand. There are just so many factors and I don't think a vibe is enough to cover it.",
|
||||
'Wen token',
|
||||
'This is disgusting and you should take it down',
|
||||
'fire',
|
||||
'a/s/l?',
|
||||
'this is so much better than twitter',
|
||||
'this is so much better than tiktok',
|
||||
'this is so much better than gab',
|
||||
'this is so much better than truth social',
|
||||
'finally! decentralization!',
|
||||
'ugh when will hashtags get supported in vibe',
|
||||
'ted cruz is the deep state!',
|
||||
'aoc is the deep state!',
|
||||
'donald trump is the deep state!',
|
||||
'taco tuesdays is the deep state!',
|
||||
]
|
||||
@@ -8,3 +8,7 @@ export function hasProp<K extends PropertyKey>(
|
||||
): data is Record<K, unknown> {
|
||||
return prop in data
|
||||
}
|
||||
|
||||
export function isStrArray(v: unknown): v is string[] {
|
||||
return Array.isArray(v) && v.every(item => typeof item === 'string')
|
||||
}
|
||||
|
||||
@@ -1,117 +1,221 @@
|
||||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import {bsky} from '@adxp/mock-api'
|
||||
import {Record as PostRecord} from '../../third-party/api/src/client/types/app/bsky/feed/post'
|
||||
import * as GetTimeline from '../../third-party/api/src/client/types/app/bsky/feed/getTimeline'
|
||||
import * as ActorRef from '../../third-party/api/src/client/types/app/bsky/actor/ref'
|
||||
import * as GetAuthorFeed from '../../third-party/api/src/client/types/app/bsky/feed/getAuthorFeed'
|
||||
import {PostThreadViewModel} from './post-thread-view'
|
||||
import {AtUri} from '../../third-party/uri'
|
||||
import {RootStoreModel} from './root-store'
|
||||
import * as apilib from '../lib/api'
|
||||
import {cleanError} from '../../lib/strings'
|
||||
import {isObj, hasProp} from '../lib/type-guards'
|
||||
|
||||
export class FeedViewItemMyStateModel {
|
||||
hasLiked: boolean = false
|
||||
hasReposted: boolean = false
|
||||
const PAGE_SIZE = 30
|
||||
|
||||
type FeedItem = GetTimeline.FeedItem | GetAuthorFeed.FeedItem
|
||||
type FeedItemWithThreadMeta = FeedItem & {
|
||||
_isThreadParent?: boolean
|
||||
_isThreadChild?: boolean
|
||||
}
|
||||
|
||||
export class FeedItemMyStateModel {
|
||||
repost?: string
|
||||
upvote?: string
|
||||
downvote?: string
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this)
|
||||
}
|
||||
}
|
||||
|
||||
export class FeedViewItemModel implements bsky.FeedView.FeedItem {
|
||||
export class FeedItemModel implements GetTimeline.FeedItem {
|
||||
// ui state
|
||||
_reactKey: string = ''
|
||||
_isThreadParent: boolean = false
|
||||
_isThreadChild: boolean = false
|
||||
|
||||
// data
|
||||
uri: string = ''
|
||||
author: bsky.FeedView.User = {did: '', name: '', displayName: ''}
|
||||
repostedBy?: bsky.FeedView.User
|
||||
cid: string = ''
|
||||
author: ActorRef.WithInfo = {
|
||||
did: '',
|
||||
handle: '',
|
||||
displayName: '',
|
||||
declaration: {cid: '', actorType: ''},
|
||||
avatar: undefined,
|
||||
}
|
||||
repostedBy?: ActorRef.WithInfo
|
||||
trendedBy?: ActorRef.WithInfo
|
||||
record: Record<string, unknown> = {}
|
||||
embed?:
|
||||
| bsky.FeedView.RecordEmbed
|
||||
| bsky.FeedView.ExternalEmbed
|
||||
| bsky.FeedView.UnknownEmbed
|
||||
replyCount: number = 0
|
||||
repostCount: number = 0
|
||||
likeCount: number = 0
|
||||
upvoteCount: number = 0
|
||||
downvoteCount: number = 0
|
||||
indexedAt: string = ''
|
||||
myState = new FeedViewItemMyStateModel()
|
||||
myState = new FeedItemMyStateModel()
|
||||
|
||||
// additional data
|
||||
additionalParentPost?: PostThreadViewModel
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
reactKey: string,
|
||||
v: bsky.FeedView.FeedItem,
|
||||
v: FeedItemWithThreadMeta,
|
||||
) {
|
||||
makeAutoObservable(this, {rootStore: false})
|
||||
this._reactKey = reactKey
|
||||
this.copy(v)
|
||||
this._isThreadParent = v._isThreadParent || false
|
||||
this._isThreadChild = v._isThreadChild || false
|
||||
}
|
||||
|
||||
copy(v: bsky.FeedView.FeedItem) {
|
||||
copy(v: GetTimeline.FeedItem | GetAuthorFeed.FeedItem) {
|
||||
this.uri = v.uri
|
||||
this.cid = v.cid
|
||||
this.author = v.author
|
||||
this.repostedBy = v.repostedBy
|
||||
this.trendedBy = v.trendedBy
|
||||
this.record = v.record
|
||||
this.embed = v.embed
|
||||
this.replyCount = v.replyCount
|
||||
this.repostCount = v.repostCount
|
||||
this.likeCount = v.likeCount
|
||||
this.upvoteCount = v.upvoteCount
|
||||
this.downvoteCount = v.downvoteCount
|
||||
this.indexedAt = v.indexedAt
|
||||
if (v.myState) {
|
||||
this.myState.hasLiked = v.myState.hasLiked
|
||||
this.myState.hasReposted = v.myState.hasReposted
|
||||
this.myState.upvote = v.myState.upvote
|
||||
this.myState.downvote = v.myState.downvote
|
||||
this.myState.repost = v.myState.repost
|
||||
}
|
||||
}
|
||||
|
||||
async toggleLike() {
|
||||
if (this.myState.hasLiked) {
|
||||
await apilib.unlike(this.rootStore.api, 'alice.com', this.uri)
|
||||
runInAction(() => {
|
||||
this.likeCount--
|
||||
this.myState.hasLiked = false
|
||||
})
|
||||
} else {
|
||||
await apilib.like(this.rootStore.api, 'alice.com', this.uri)
|
||||
runInAction(() => {
|
||||
this.likeCount++
|
||||
this.myState.hasLiked = true
|
||||
})
|
||||
}
|
||||
async toggleUpvote() {
|
||||
const wasUpvoted = !!this.myState.upvote
|
||||
const wasDownvoted = !!this.myState.downvote
|
||||
const res = await this.rootStore.api.app.bsky.feed.setVote({
|
||||
subject: {
|
||||
uri: this.uri,
|
||||
cid: this.cid,
|
||||
},
|
||||
direction: wasUpvoted ? 'none' : 'up',
|
||||
})
|
||||
runInAction(() => {
|
||||
if (wasDownvoted) {
|
||||
this.downvoteCount--
|
||||
}
|
||||
if (wasUpvoted) {
|
||||
this.upvoteCount--
|
||||
} else {
|
||||
this.upvoteCount++
|
||||
}
|
||||
this.myState.upvote = res.data.upvote
|
||||
this.myState.downvote = res.data.downvote
|
||||
})
|
||||
}
|
||||
|
||||
async toggleDownvote() {
|
||||
const wasUpvoted = !!this.myState.upvote
|
||||
const wasDownvoted = !!this.myState.downvote
|
||||
const res = await this.rootStore.api.app.bsky.feed.setVote({
|
||||
subject: {
|
||||
uri: this.uri,
|
||||
cid: this.cid,
|
||||
},
|
||||
direction: wasDownvoted ? 'none' : 'down',
|
||||
})
|
||||
runInAction(() => {
|
||||
if (wasUpvoted) {
|
||||
this.upvoteCount--
|
||||
}
|
||||
if (wasDownvoted) {
|
||||
this.downvoteCount--
|
||||
} else {
|
||||
this.downvoteCount++
|
||||
}
|
||||
this.myState.upvote = res.data.upvote
|
||||
this.myState.downvote = res.data.downvote
|
||||
})
|
||||
}
|
||||
|
||||
async toggleRepost() {
|
||||
if (this.myState.hasReposted) {
|
||||
await apilib.unrepost(this.rootStore.api, 'alice.com', this.uri)
|
||||
if (this.myState.repost) {
|
||||
await apilib.unrepost(this.rootStore, this.myState.repost)
|
||||
runInAction(() => {
|
||||
this.repostCount--
|
||||
this.myState.hasReposted = false
|
||||
this.myState.repost = undefined
|
||||
})
|
||||
} else {
|
||||
await apilib.repost(this.rootStore.api, 'alice.com', this.uri)
|
||||
const res = await apilib.repost(this.rootStore, this.uri, this.cid)
|
||||
runInAction(() => {
|
||||
this.repostCount++
|
||||
this.myState.hasReposted = true
|
||||
this.myState.repost = res.uri
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async delete() {
|
||||
await this.rootStore.api.app.bsky.feed.post.delete({
|
||||
did: this.author.did,
|
||||
rkey: new AtUri(this.uri).rkey,
|
||||
})
|
||||
}
|
||||
|
||||
get needsAdditionalData() {
|
||||
if (
|
||||
(this.record as PostRecord).reply?.parent?.uri &&
|
||||
!this._isThreadChild
|
||||
) {
|
||||
return !this.additionalParentPost
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async fetchAdditionalData() {
|
||||
if (!this.needsAdditionalData) {
|
||||
return
|
||||
}
|
||||
this.additionalParentPost = new PostThreadViewModel(this.rootStore, {
|
||||
uri: (this.record as PostRecord).reply?.parent.uri,
|
||||
depth: 0,
|
||||
})
|
||||
await this.additionalParentPost.setup().catch(e => {
|
||||
console.error('Failed to load post needed by notification', e)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class FeedViewModel implements bsky.FeedView.Response {
|
||||
export class FeedModel {
|
||||
// state
|
||||
isLoading = false
|
||||
isRefreshing = false
|
||||
hasNewLatest = false
|
||||
hasLoaded = false
|
||||
error = ''
|
||||
params: bsky.FeedView.Params
|
||||
params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams
|
||||
hasMore = true
|
||||
loadMoreCursor: string | undefined
|
||||
pollCursor: string | undefined
|
||||
_loadPromise: Promise<void> | undefined
|
||||
_loadMorePromise: Promise<void> | undefined
|
||||
_loadLatestPromise: Promise<void> | undefined
|
||||
_updatePromise: Promise<void> | undefined
|
||||
|
||||
// data
|
||||
feed: FeedViewItemModel[] = []
|
||||
feed: FeedItemModel[] = []
|
||||
|
||||
constructor(public rootStore: RootStoreModel, params: bsky.FeedView.Params) {
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
public feedType: 'home' | 'author',
|
||||
params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams,
|
||||
) {
|
||||
makeAutoObservable(
|
||||
this,
|
||||
{
|
||||
rootStore: false,
|
||||
params: false,
|
||||
loadMoreCursor: false,
|
||||
_loadPromise: false,
|
||||
_loadMorePromise: false,
|
||||
_loadLatestPromise: false,
|
||||
_updatePromise: false,
|
||||
},
|
||||
{autoBind: true},
|
||||
@@ -131,11 +235,19 @@ export class FeedViewModel implements bsky.FeedView.Response {
|
||||
return this.hasLoaded && !this.hasContent
|
||||
}
|
||||
|
||||
get loadMoreCursor() {
|
||||
if (this.hasContent) {
|
||||
return this.feed[this.feed.length - 1].indexedAt
|
||||
}
|
||||
return undefined
|
||||
get nonReplyFeed() {
|
||||
return this.feed.filter(
|
||||
post =>
|
||||
!post.record.reply || // not a reply
|
||||
!!post.repostedBy || // or a repost
|
||||
!!post.trendedBy || // or a trend
|
||||
post._isThreadParent || // but allow if it's a thread by the user
|
||||
post._isThreadChild,
|
||||
)
|
||||
}
|
||||
|
||||
setHasNewLatest(v: boolean) {
|
||||
this.hasNewLatest = v
|
||||
}
|
||||
|
||||
// public api
|
||||
@@ -145,10 +257,14 @@ export class FeedViewModel implements bsky.FeedView.Response {
|
||||
* Load for first render
|
||||
*/
|
||||
async setup(isRefreshing = false) {
|
||||
if (isRefreshing) {
|
||||
this.isRefreshing = true // set optimistically for UI
|
||||
}
|
||||
if (this._loadPromise) {
|
||||
return this._loadPromise
|
||||
}
|
||||
await this._pendingWork()
|
||||
this.setHasNewLatest(false)
|
||||
this._loadPromise = this._initialLoad(isRefreshing)
|
||||
await this._loadPromise
|
||||
this._loadPromise = undefined
|
||||
@@ -174,6 +290,20 @@ export class FeedViewModel implements bsky.FeedView.Response {
|
||||
this._loadMorePromise = undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Load more posts to the start of the feed
|
||||
*/
|
||||
async loadLatest() {
|
||||
if (this._loadLatestPromise) {
|
||||
return this._loadLatestPromise
|
||||
}
|
||||
await this._pendingWork()
|
||||
this.setHasNewLatest(false)
|
||||
this._loadLatestPromise = this._loadLatest()
|
||||
await this._loadLatestPromise
|
||||
this._loadLatestPromise = undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Update content in-place
|
||||
*/
|
||||
@@ -187,6 +317,21 @@ export class FeedViewModel implements bsky.FeedView.Response {
|
||||
this._updatePromise = undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if new postrs are available
|
||||
*/
|
||||
async checkForLatest() {
|
||||
if (this.hasNewLatest) {
|
||||
return
|
||||
}
|
||||
await this._pendingWork()
|
||||
const res = await this._getFeed({limit: 1})
|
||||
this.setHasNewLatest(
|
||||
res.data.feed[0] &&
|
||||
(this.feed.length === 0 || res.data.feed[0].uri !== this.pollCursor),
|
||||
)
|
||||
}
|
||||
|
||||
// state transitions
|
||||
// =
|
||||
|
||||
@@ -200,7 +345,7 @@ export class FeedViewModel implements bsky.FeedView.Response {
|
||||
this.isLoading = false
|
||||
this.isRefreshing = false
|
||||
this.hasLoaded = true
|
||||
this.error = err
|
||||
this.error = cleanError(err)
|
||||
}
|
||||
|
||||
// loader functions
|
||||
@@ -213,6 +358,9 @@ export class FeedViewModel implements bsky.FeedView.Response {
|
||||
if (this._loadMorePromise) {
|
||||
await this._loadMorePromise
|
||||
}
|
||||
if (this._loadLatestPromise) {
|
||||
await this._loadLatestPromise
|
||||
}
|
||||
if (this._updatePromise) {
|
||||
await this._updatePromise
|
||||
}
|
||||
@@ -220,31 +368,37 @@ export class FeedViewModel implements bsky.FeedView.Response {
|
||||
|
||||
private async _initialLoad(isRefreshing = false) {
|
||||
this._xLoading(isRefreshing)
|
||||
await new Promise(r => setTimeout(r, 250)) // DEBUG
|
||||
try {
|
||||
const res = (await this.rootStore.api.mainPds.view(
|
||||
'blueskyweb.xyz:FeedView',
|
||||
this.params,
|
||||
)) as bsky.FeedView.Response
|
||||
this._replaceAll(res)
|
||||
const res = await this._getFeed({limit: PAGE_SIZE})
|
||||
await this._replaceAll(res)
|
||||
this._xIdle()
|
||||
} catch (e: any) {
|
||||
this._xIdle(`Failed to load feed: ${e.toString()}`)
|
||||
this._xIdle(e.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private async _loadLatest() {
|
||||
this._xLoading()
|
||||
try {
|
||||
const res = await this._getFeed({limit: PAGE_SIZE})
|
||||
await this._prependAll(res)
|
||||
this._xIdle()
|
||||
} catch (e: any) {
|
||||
this._xIdle(e.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private async _loadMore() {
|
||||
if (!this.hasMore) {
|
||||
return
|
||||
}
|
||||
this._xLoading()
|
||||
await new Promise(r => setTimeout(r, 250)) // DEBUG
|
||||
try {
|
||||
const params = Object.assign({}, this.params, {
|
||||
const res = await this._getFeed({
|
||||
before: this.loadMoreCursor,
|
||||
limit: PAGE_SIZE,
|
||||
})
|
||||
const res = (await this.rootStore.api.mainPds.view(
|
||||
'blueskyweb.xyz:FeedView',
|
||||
params,
|
||||
)) as bsky.FeedView.Response
|
||||
this._appendAll(res)
|
||||
await this._appendAll(res)
|
||||
this._xIdle()
|
||||
} catch (e: any) {
|
||||
this._xIdle(`Failed to load feed: ${e.toString()}`)
|
||||
@@ -252,26 +406,25 @@ export class FeedViewModel implements bsky.FeedView.Response {
|
||||
}
|
||||
|
||||
private async _update() {
|
||||
if (!this.feed.length) {
|
||||
return
|
||||
}
|
||||
this._xLoading()
|
||||
await new Promise(r => setTimeout(r, 250)) // DEBUG
|
||||
let numToFetch = this.feed.length
|
||||
let cursor = undefined
|
||||
try {
|
||||
do {
|
||||
const res = (await this.rootStore.api.mainPds.view(
|
||||
'blueskyweb.xyz:FeedView',
|
||||
{
|
||||
before: cursor,
|
||||
limit: Math.min(numToFetch, 100),
|
||||
},
|
||||
)) as bsky.FeedView.Response
|
||||
if (res.feed.length === 0) {
|
||||
const res: GetTimeline.Response = await this._getFeed({
|
||||
before: cursor,
|
||||
limit: Math.min(numToFetch, 100),
|
||||
})
|
||||
if (res.data.feed.length === 0) {
|
||||
break // sanity check
|
||||
}
|
||||
this._updateAll(res)
|
||||
numToFetch -= res.feed.length
|
||||
cursor = this.feed[res.feed.length - 1].indexedAt
|
||||
console.log(numToFetch, cursor, res.feed.length)
|
||||
numToFetch -= res.data.feed.length
|
||||
cursor = this.feed[res.data.feed.length - 1].indexedAt
|
||||
console.log(numToFetch, cursor, res.data.feed.length)
|
||||
} while (numToFetch > 0)
|
||||
this._xIdle()
|
||||
} catch (e: any) {
|
||||
@@ -279,25 +432,93 @@ export class FeedViewModel implements bsky.FeedView.Response {
|
||||
}
|
||||
}
|
||||
|
||||
private _replaceAll(res: bsky.FeedView.Response) {
|
||||
this.feed.length = 0
|
||||
this._appendAll(res)
|
||||
private async _replaceAll(
|
||||
res: GetTimeline.Response | GetAuthorFeed.Response,
|
||||
) {
|
||||
this.pollCursor = res.data.feed[0]?.uri
|
||||
return this._appendAll(res, true)
|
||||
}
|
||||
|
||||
private _appendAll(res: bsky.FeedView.Response) {
|
||||
private async _appendAll(
|
||||
res: GetTimeline.Response | GetAuthorFeed.Response,
|
||||
replace = false,
|
||||
) {
|
||||
this.loadMoreCursor = res.data.cursor
|
||||
this.hasMore = !!this.loadMoreCursor
|
||||
let counter = this.feed.length
|
||||
for (const item of res.feed) {
|
||||
this._append(counter++, item)
|
||||
|
||||
// HACK 1
|
||||
// rearrange the posts to represent threads
|
||||
// (should be done on the server)
|
||||
// -prf
|
||||
// HACK 2
|
||||
// deduplicate posts on the home feed
|
||||
// (should be done on the server)
|
||||
// -prf
|
||||
const reorgedFeed = preprocessFeed(res.data.feed, this.feedType === 'home')
|
||||
|
||||
const promises = []
|
||||
const toAppend: FeedItemModel[] = []
|
||||
for (const item of reorgedFeed) {
|
||||
const itemModel = new FeedItemModel(
|
||||
this.rootStore,
|
||||
`item-${counter++}`,
|
||||
item,
|
||||
)
|
||||
if (itemModel.needsAdditionalData) {
|
||||
promises.push(
|
||||
itemModel.fetchAdditionalData().catch(e => {
|
||||
console.error('Failure during feed-view _appendAll()', e)
|
||||
}),
|
||||
)
|
||||
}
|
||||
toAppend.push(itemModel)
|
||||
}
|
||||
await Promise.all(promises)
|
||||
runInAction(() => {
|
||||
if (replace) {
|
||||
this.feed = toAppend
|
||||
} else {
|
||||
this.feed = this.feed.concat(toAppend)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private _append(keyId: number, item: bsky.FeedView.FeedItem) {
|
||||
// TODO: validate .record
|
||||
this.feed.push(new FeedViewItemModel(this.rootStore, `item-${keyId}`, item))
|
||||
private async _prependAll(
|
||||
res: GetTimeline.Response | GetAuthorFeed.Response,
|
||||
) {
|
||||
this.pollCursor = res.data.feed[0]?.uri
|
||||
let counter = this.feed.length
|
||||
|
||||
const promises = []
|
||||
const toPrepend: FeedItemModel[] = []
|
||||
for (const item of res.data.feed) {
|
||||
if (this.feed.find(item2 => item2.uri === item.uri)) {
|
||||
break // stop here - we've hit a post we already have
|
||||
}
|
||||
|
||||
const itemModel = new FeedItemModel(
|
||||
this.rootStore,
|
||||
`item-${counter++}`,
|
||||
item,
|
||||
)
|
||||
if (itemModel.needsAdditionalData) {
|
||||
promises.push(
|
||||
itemModel.fetchAdditionalData().catch(e => {
|
||||
console.error('Failure during feed-view _prependAll()', e)
|
||||
}),
|
||||
)
|
||||
}
|
||||
toPrepend.push(itemModel)
|
||||
}
|
||||
await Promise.all(promises)
|
||||
runInAction(() => {
|
||||
this.feed = toPrepend.concat(this.feed)
|
||||
})
|
||||
}
|
||||
|
||||
private _updateAll(res: bsky.FeedView.Response) {
|
||||
for (const item of res.feed) {
|
||||
private _updateAll(res: GetTimeline.Response | GetAuthorFeed.Response) {
|
||||
for (const item of res.data.feed) {
|
||||
const existingItem = this.feed.find(
|
||||
// this find function has a key subtley- the indexedAt comparison
|
||||
// the reason for this is reposts: they set the URI of the original post, not of the repost record
|
||||
@@ -309,4 +530,66 @@ export class FeedViewModel implements bsky.FeedView.Response {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected _getFeed(
|
||||
params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams = {},
|
||||
): Promise<GetTimeline.Response | GetAuthorFeed.Response> {
|
||||
params = Object.assign({}, this.params, params)
|
||||
if (this.feedType === 'home') {
|
||||
return this.rootStore.api.app.bsky.feed.getTimeline(
|
||||
params as GetTimeline.QueryParams,
|
||||
)
|
||||
} else {
|
||||
return this.rootStore.api.app.bsky.feed.getAuthorFeed(
|
||||
params as GetAuthorFeed.QueryParams,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function preprocessFeed(
|
||||
feed: FeedItem[],
|
||||
dedup: boolean,
|
||||
): FeedItemWithThreadMeta[] {
|
||||
const reorg: FeedItemWithThreadMeta[] = []
|
||||
for (let i = feed.length - 1; i >= 0; i--) {
|
||||
const item = feed[i] as FeedItemWithThreadMeta
|
||||
|
||||
if (dedup) {
|
||||
if (reorg.find(item2 => item2.uri === item.uri)) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const selfReplyUri = getSelfReplyUri(item)
|
||||
if (selfReplyUri) {
|
||||
const parentIndex = reorg.findIndex(item2 => item2.uri === selfReplyUri)
|
||||
if (parentIndex !== -1 && !reorg[parentIndex]._isThreadParent) {
|
||||
reorg[parentIndex]._isThreadParent = true
|
||||
item._isThreadChild = true
|
||||
reorg.splice(parentIndex + 1, 0, item)
|
||||
continue
|
||||
}
|
||||
}
|
||||
reorg.unshift(item)
|
||||
}
|
||||
return reorg
|
||||
}
|
||||
|
||||
function getSelfReplyUri(
|
||||
item: GetTimeline.FeedItem | GetAuthorFeed.FeedItem,
|
||||
): string | undefined {
|
||||
if (
|
||||
isObj(item.record) &&
|
||||
hasProp(item.record, 'reply') &&
|
||||
isObj(item.record.reply) &&
|
||||
hasProp(item.record.reply, 'parent') &&
|
||||
isObj(item.record.reply.parent) &&
|
||||
hasProp(item.record.reply.parent, 'uri') &&
|
||||
typeof item.record.reply.parent.uri === 'string'
|
||||
) {
|
||||
if (new AtUri(item.record.reply.parent.uri).host === item.author.did) {
|
||||
return item.record.reply.parent.uri
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import {makeAutoObservable} from 'mobx'
|
||||
import * as GetAssertions from '../../third-party/api/src/client/types/app/bsky/graph/getAssertions'
|
||||
import {RootStoreModel} from './root-store'
|
||||
|
||||
type ResponseAssertion = GetAssertions.OutputSchema['assertions'][number]
|
||||
export type Assertion = ResponseAssertion & {
|
||||
_reactKey: string
|
||||
}
|
||||
|
||||
export class GetAssertionsView {
|
||||
// state
|
||||
isLoading = false
|
||||
isRefreshing = false
|
||||
hasLoaded = false
|
||||
error = ''
|
||||
params: GetAssertions.QueryParams
|
||||
|
||||
// data
|
||||
assertions: Assertion[] = []
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
params: GetAssertions.QueryParams,
|
||||
) {
|
||||
makeAutoObservable(
|
||||
this,
|
||||
{
|
||||
rootStore: false,
|
||||
params: false,
|
||||
},
|
||||
{autoBind: true},
|
||||
)
|
||||
this.params = params
|
||||
}
|
||||
|
||||
get hasContent() {
|
||||
return this.assertions.length > 0
|
||||
}
|
||||
|
||||
get hasError() {
|
||||
return this.error !== ''
|
||||
}
|
||||
|
||||
get isEmpty() {
|
||||
return this.hasLoaded && !this.hasContent
|
||||
}
|
||||
|
||||
getBySubject(did: string) {
|
||||
return this.assertions.find(assertion => assertion.subject.did === did)
|
||||
}
|
||||
|
||||
get confirmed() {
|
||||
return this.assertions.filter(assertion => !!assertion.confirmation)
|
||||
}
|
||||
|
||||
get unconfirmed() {
|
||||
return this.assertions.filter(assertion => !assertion.confirmation)
|
||||
}
|
||||
|
||||
// public api
|
||||
// =
|
||||
|
||||
async setup() {
|
||||
await this._fetch()
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
await this._fetch(true)
|
||||
}
|
||||
|
||||
async loadMore() {
|
||||
// TODO
|
||||
}
|
||||
|
||||
// state transitions
|
||||
// =
|
||||
|
||||
private _xLoading(isRefreshing = false) {
|
||||
this.isLoading = true
|
||||
this.isRefreshing = isRefreshing
|
||||
this.error = ''
|
||||
}
|
||||
|
||||
private _xIdle(err: string = '') {
|
||||
this.isLoading = false
|
||||
this.isRefreshing = false
|
||||
this.hasLoaded = true
|
||||
this.error = err
|
||||
}
|
||||
|
||||
// loader functions
|
||||
// =
|
||||
|
||||
private async _fetch(isRefreshing = false) {
|
||||
this._xLoading(isRefreshing)
|
||||
try {
|
||||
const res = await this.rootStore.api.app.bsky.graph.getAssertions(
|
||||
this.params,
|
||||
)
|
||||
this._replaceAll(res)
|
||||
this._xIdle()
|
||||
} catch (e: any) {
|
||||
this._xIdle(e.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private _replaceAll(res: GetAssertions.Response) {
|
||||
this.assertions.length = 0
|
||||
let counter = 0
|
||||
for (const item of res.data.assertions) {
|
||||
this._append({
|
||||
_reactKey: `item-${counter++}`,
|
||||
...item,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private _append(item: Assertion) {
|
||||
this.assertions.push(item)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import {makeAutoObservable} from 'mobx'
|
||||
import {LRUMap} from 'lru_map'
|
||||
import {RootStoreModel} from './root-store'
|
||||
import {LinkMeta, getLinkMeta} from '../../lib/link-meta'
|
||||
|
||||
type CacheValue = Promise<LinkMeta> | LinkMeta
|
||||
export class LinkMetasViewModel {
|
||||
cache: LRUMap<string, CacheValue> = new LRUMap(100)
|
||||
|
||||
constructor(public rootStore: RootStoreModel) {
|
||||
makeAutoObservable(
|
||||
this,
|
||||
{
|
||||
rootStore: false,
|
||||
cache: false,
|
||||
},
|
||||
{autoBind: true},
|
||||
)
|
||||
}
|
||||
|
||||
// public api
|
||||
// =
|
||||
|
||||
async getLinkMeta(url: string) {
|
||||
const cached = this.cache.get(url)
|
||||
if (cached) {
|
||||
try {
|
||||
return await cached
|
||||
} catch (e) {
|
||||
// ignore, we'll try again
|
||||
}
|
||||
}
|
||||
try {
|
||||
const promise = getLinkMeta(url)
|
||||
this.cache.set(url, promise)
|
||||
const res = await promise
|
||||
this.cache.set(url, res)
|
||||
return res
|
||||
} catch (e) {
|
||||
this.cache.delete(url)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +1,126 @@
|
||||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import {RootStoreModel} from './root-store'
|
||||
import {MembershipsViewModel} from './memberships-view'
|
||||
import {NotificationsViewModel} from './notifications-view'
|
||||
import {isObj, hasProp} from '../lib/type-guards'
|
||||
|
||||
export class MeModel {
|
||||
did?: string
|
||||
name?: string
|
||||
displayName?: string
|
||||
description?: string
|
||||
did: string = ''
|
||||
handle: string = ''
|
||||
displayName: string = ''
|
||||
description: string = ''
|
||||
avatar: string = ''
|
||||
notificationCount: number = 0
|
||||
memberships?: MembershipsViewModel
|
||||
notifications: NotificationsViewModel
|
||||
|
||||
constructor(public rootStore: RootStoreModel) {
|
||||
makeAutoObservable(this, {rootStore: false}, {autoBind: true})
|
||||
makeAutoObservable(
|
||||
this,
|
||||
{rootStore: false, serialize: false, hydrate: false},
|
||||
{autoBind: true},
|
||||
)
|
||||
this.notifications = new NotificationsViewModel(this.rootStore, {})
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.did = ''
|
||||
this.handle = ''
|
||||
this.displayName = ''
|
||||
this.description = ''
|
||||
this.avatar = ''
|
||||
this.notificationCount = 0
|
||||
this.memberships = undefined
|
||||
}
|
||||
|
||||
serialize(): unknown {
|
||||
return {
|
||||
did: this.did,
|
||||
handle: this.handle,
|
||||
displayName: this.displayName,
|
||||
description: this.description,
|
||||
avatar: this.avatar,
|
||||
}
|
||||
}
|
||||
|
||||
hydrate(v: unknown) {
|
||||
if (isObj(v)) {
|
||||
let did, handle, displayName, description, avatar
|
||||
if (hasProp(v, 'did') && typeof v.did === 'string') {
|
||||
did = v.did
|
||||
}
|
||||
if (hasProp(v, 'handle') && typeof v.handle === 'string') {
|
||||
handle = v.handle
|
||||
}
|
||||
if (hasProp(v, 'displayName') && typeof v.displayName === 'string') {
|
||||
displayName = v.displayName
|
||||
}
|
||||
if (hasProp(v, 'description') && typeof v.description === 'string') {
|
||||
description = v.description
|
||||
}
|
||||
if (hasProp(v, 'avatar') && typeof v.avatar === 'string') {
|
||||
avatar = v.avatar
|
||||
}
|
||||
if (did && handle) {
|
||||
this.did = did
|
||||
this.handle = handle
|
||||
this.displayName = displayName || ''
|
||||
this.description = description || ''
|
||||
this.avatar = avatar || ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async load() {
|
||||
const sess = this.rootStore.session
|
||||
if (sess.isAuthed) {
|
||||
const userDb = this.rootStore.api.mockDb.mainUser
|
||||
this.did = userDb.did
|
||||
this.name = userDb.name
|
||||
const profile = await this.rootStore.api
|
||||
.repo(this.did, true)
|
||||
.collection('blueskyweb.xyz:Profiles')
|
||||
.get('Profile', 'profile')
|
||||
.catch(_ => undefined)
|
||||
if (sess.hasSession && sess.data) {
|
||||
this.did = sess.data.did || ''
|
||||
this.handle = sess.data.handle
|
||||
const profile = await this.rootStore.api.app.bsky.actor.getProfile({
|
||||
actor: this.did,
|
||||
})
|
||||
runInAction(() => {
|
||||
if (profile?.valid) {
|
||||
this.displayName = profile.value.displayName
|
||||
this.description = profile.value.description
|
||||
if (profile?.data) {
|
||||
this.displayName = profile.data.displayName || ''
|
||||
this.description = profile.data.description || ''
|
||||
this.avatar = profile.data.avatar || ''
|
||||
} else {
|
||||
this.displayName = ''
|
||||
this.description = ''
|
||||
this.avatar = ''
|
||||
}
|
||||
})
|
||||
this.memberships = new MembershipsViewModel(this.rootStore, {
|
||||
actor: this.did,
|
||||
})
|
||||
await this.memberships?.setup().catch(e => {
|
||||
console.error('Failed to setup memberships model', e)
|
||||
})
|
||||
await this.notifications.setup().catch(e => {
|
||||
console.error('Failed to setup notifications model', e)
|
||||
})
|
||||
} else {
|
||||
this.did = undefined
|
||||
this.name = undefined
|
||||
this.displayName = undefined
|
||||
this.description = undefined
|
||||
this.clear()
|
||||
}
|
||||
}
|
||||
|
||||
clearNotificationCount() {
|
||||
this.notificationCount = 0
|
||||
}
|
||||
|
||||
async fetchStateUpdate() {
|
||||
const res = await this.rootStore.api.app.bsky.notification.getCount()
|
||||
runInAction(() => {
|
||||
const newNotifications = this.notificationCount !== res.data.count
|
||||
this.notificationCount = res.data.count
|
||||
if (newNotifications) {
|
||||
// trigger pre-emptive fetch on new notifications
|
||||
this.notifications.refresh()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async refreshMemberships() {
|
||||
return this.memberships?.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import * as GetMembers from '../../third-party/api/src/client/types/app/bsky/graph/getMembers'
|
||||
import * as ActorRef from '../../third-party/api/src/client/types/app/bsky/actor/ref'
|
||||
import {APP_BSKY_GRAPH} from '../../third-party/api'
|
||||
import {AtUri} from '../../third-party/uri'
|
||||
import {RootStoreModel} from './root-store'
|
||||
|
||||
export type MemberItem = GetMembers.Member & {
|
||||
_reactKey: string
|
||||
}
|
||||
|
||||
export class MembersViewModel {
|
||||
// state
|
||||
isLoading = false
|
||||
isRefreshing = false
|
||||
hasLoaded = false
|
||||
error = ''
|
||||
params: GetMembers.QueryParams
|
||||
|
||||
// data
|
||||
subject: ActorRef.WithInfo = {
|
||||
did: '',
|
||||
handle: '',
|
||||
displayName: '',
|
||||
declaration: {cid: '', actorType: ''},
|
||||
avatar: undefined,
|
||||
}
|
||||
members: MemberItem[] = []
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
params: GetMembers.QueryParams,
|
||||
) {
|
||||
makeAutoObservable(
|
||||
this,
|
||||
{
|
||||
rootStore: false,
|
||||
params: false,
|
||||
},
|
||||
{autoBind: true},
|
||||
)
|
||||
this.params = params
|
||||
}
|
||||
|
||||
get hasContent() {
|
||||
return this.members.length !== 0
|
||||
}
|
||||
|
||||
get hasError() {
|
||||
return this.error !== ''
|
||||
}
|
||||
|
||||
get isEmpty() {
|
||||
return this.hasLoaded && !this.hasContent
|
||||
}
|
||||
|
||||
isMember(did: string) {
|
||||
return this.members.find(member => member.did === did)
|
||||
}
|
||||
|
||||
// public api
|
||||
// =
|
||||
|
||||
async setup() {
|
||||
await this._fetch()
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
await this._fetch(true)
|
||||
}
|
||||
|
||||
async loadMore() {
|
||||
// TODO
|
||||
}
|
||||
|
||||
async removeMember(did: string) {
|
||||
const assertsRes = await this.rootStore.api.app.bsky.graph.getAssertions({
|
||||
author: this.subject.did,
|
||||
subject: did,
|
||||
assertion: APP_BSKY_GRAPH.AssertMember,
|
||||
})
|
||||
if (assertsRes.data.assertions.length < 1) {
|
||||
throw new Error('Could not find membership record')
|
||||
}
|
||||
for (const assert of assertsRes.data.assertions) {
|
||||
await this.rootStore.api.app.bsky.graph.assertion.delete({
|
||||
did: this.subject.did,
|
||||
rkey: new AtUri(assert.uri).rkey,
|
||||
})
|
||||
}
|
||||
runInAction(() => {
|
||||
this.members = this.members.filter(m => m.did !== did)
|
||||
})
|
||||
}
|
||||
|
||||
// state transitions
|
||||
// =
|
||||
|
||||
private _xLoading(isRefreshing = false) {
|
||||
this.isLoading = true
|
||||
this.isRefreshing = isRefreshing
|
||||
this.error = ''
|
||||
}
|
||||
|
||||
private _xIdle(err: string = '') {
|
||||
this.isLoading = false
|
||||
this.isRefreshing = false
|
||||
this.hasLoaded = true
|
||||
this.error = err
|
||||
}
|
||||
|
||||
// loader functions
|
||||
// =
|
||||
|
||||
private async _fetch(isRefreshing = false) {
|
||||
this._xLoading(isRefreshing)
|
||||
try {
|
||||
const res = await this.rootStore.api.app.bsky.graph.getMembers(
|
||||
this.params,
|
||||
)
|
||||
this._replaceAll(res)
|
||||
this._xIdle()
|
||||
} catch (e: any) {
|
||||
this._xIdle(`Failed to load feed: ${e.toString()}`)
|
||||
}
|
||||
}
|
||||
|
||||
private _replaceAll(res: GetMembers.Response) {
|
||||
this.subject.did = res.data.subject.did
|
||||
this.subject.handle = res.data.subject.handle
|
||||
this.subject.displayName = res.data.subject.displayName
|
||||
this.subject.declaration = res.data.subject.declaration
|
||||
this.subject.avatar = res.data.subject.avatar
|
||||
this.members.length = 0
|
||||
let counter = 0
|
||||
for (const item of res.data.members) {
|
||||
this._append({_reactKey: `item-${counter++}`, ...item})
|
||||
}
|
||||
}
|
||||
|
||||
private _append(item: MemberItem) {
|
||||
this.members.push(item)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import {makeAutoObservable} from 'mobx'
|
||||
import * as GetMemberships from '../../third-party/api/src/client/types/app/bsky/graph/getMemberships'
|
||||
import * as ActorRef from '../../third-party/api/src/client/types/app/bsky/actor/ref'
|
||||
import {RootStoreModel} from './root-store'
|
||||
|
||||
export type MembershipItem = GetMemberships.Membership & {
|
||||
_reactKey: string
|
||||
}
|
||||
|
||||
export class MembershipsViewModel {
|
||||
// state
|
||||
isLoading = false
|
||||
isRefreshing = false
|
||||
hasLoaded = false
|
||||
error = ''
|
||||
params: GetMemberships.QueryParams
|
||||
|
||||
// data
|
||||
subject: ActorRef.WithInfo = {
|
||||
did: '',
|
||||
handle: '',
|
||||
displayName: '',
|
||||
declaration: {cid: '', actorType: ''},
|
||||
avatar: undefined,
|
||||
}
|
||||
memberships: MembershipItem[] = []
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
params: GetMemberships.QueryParams,
|
||||
) {
|
||||
makeAutoObservable(
|
||||
this,
|
||||
{
|
||||
rootStore: false,
|
||||
params: false,
|
||||
},
|
||||
{autoBind: true},
|
||||
)
|
||||
this.params = params
|
||||
}
|
||||
|
||||
get hasContent() {
|
||||
return this.memberships.length !== 0
|
||||
}
|
||||
|
||||
get hasError() {
|
||||
return this.error !== ''
|
||||
}
|
||||
|
||||
get isEmpty() {
|
||||
return this.hasLoaded && !this.hasContent
|
||||
}
|
||||
|
||||
isMemberOf(did: string) {
|
||||
return !!this.memberships.find(m => m.did === did)
|
||||
}
|
||||
|
||||
// public api
|
||||
// =
|
||||
|
||||
async setup() {
|
||||
await this._fetch()
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
await this._fetch(true)
|
||||
}
|
||||
|
||||
async loadMore() {
|
||||
// TODO
|
||||
}
|
||||
|
||||
// state transitions
|
||||
// =
|
||||
|
||||
private _xLoading(isRefreshing = false) {
|
||||
this.isLoading = true
|
||||
this.isRefreshing = isRefreshing
|
||||
this.error = ''
|
||||
}
|
||||
|
||||
private _xIdle(err: string = '') {
|
||||
this.isLoading = false
|
||||
this.isRefreshing = false
|
||||
this.hasLoaded = true
|
||||
this.error = err
|
||||
}
|
||||
|
||||
// loader functions
|
||||
// =
|
||||
|
||||
private async _fetch(isRefreshing = false) {
|
||||
this._xLoading(isRefreshing)
|
||||
try {
|
||||
const res = await this.rootStore.api.app.bsky.graph.getMemberships(
|
||||
this.params,
|
||||
)
|
||||
this._replaceAll(res)
|
||||
this._xIdle()
|
||||
} catch (e: any) {
|
||||
this._xIdle(`Failed to load feed: ${e.toString()}`)
|
||||
}
|
||||
}
|
||||
|
||||
private _replaceAll(res: GetMemberships.Response) {
|
||||
this.subject.did = res.data.subject.did
|
||||
this.subject.handle = res.data.subject.handle
|
||||
this.subject.displayName = res.data.subject.displayName
|
||||
this.subject.declaration = res.data.subject.declaration
|
||||
this.subject.avatar = res.data.subject.avatar
|
||||
this.memberships.length = 0
|
||||
let counter = 0
|
||||
for (const item of res.data.memberships) {
|
||||
this._append({_reactKey: `item-${counter++}`, ...item})
|
||||
}
|
||||
}
|
||||
|
||||
private _append(item: MembershipItem) {
|
||||
this.memberships.push(item)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
import {makeAutoObservable} from 'mobx'
|
||||
import {TABS_ENABLED} from '../../build-flags'
|
||||
|
||||
let __id = 0
|
||||
function genId() {
|
||||
return ++__id
|
||||
}
|
||||
|
||||
// NOTE
|
||||
// this model was originally built for a freeform "tabs" concept like a browser
|
||||
// we've since decided to pause that idea and do something more traditional
|
||||
// until we're fully sure what that is, the tabs are being repurposed into a fixed topology
|
||||
// - Tab 0: The "Default" tab
|
||||
// - Tab 1: The "Notifications" tab
|
||||
// These tabs always retain the first item in their history.
|
||||
// The default tab is used for basically everything except notifications.
|
||||
// -prf
|
||||
export enum TabPurpose {
|
||||
Default = 0,
|
||||
Notifs = 1,
|
||||
}
|
||||
|
||||
interface HistoryItem {
|
||||
url: string
|
||||
ts: number
|
||||
title?: string
|
||||
id: number
|
||||
}
|
||||
|
||||
export type HistoryPtr = [number, number]
|
||||
|
||||
export class NavigationTabModel {
|
||||
id = genId()
|
||||
history: HistoryItem[]
|
||||
index = 0
|
||||
isNewTab = false
|
||||
|
||||
constructor(public fixedTabPurpose: TabPurpose) {
|
||||
if (fixedTabPurpose === TabPurpose.Notifs) {
|
||||
this.history = [{url: '/notifications', ts: Date.now(), id: genId()}]
|
||||
} else {
|
||||
this.history = [{url: '/', ts: Date.now(), id: genId()}]
|
||||
}
|
||||
makeAutoObservable(this, {
|
||||
serialize: false,
|
||||
hydrate: false,
|
||||
})
|
||||
}
|
||||
|
||||
// accessors
|
||||
// =
|
||||
|
||||
get current() {
|
||||
return this.history[this.index]
|
||||
}
|
||||
|
||||
get canGoBack() {
|
||||
return this.index > 0
|
||||
}
|
||||
|
||||
get canGoForward() {
|
||||
return this.index < this.history.length - 1
|
||||
}
|
||||
|
||||
getBackList(n: number) {
|
||||
const start = Math.max(this.index - n, 0)
|
||||
const end = this.index
|
||||
return this.history.slice(start, end).map((item, i) => ({
|
||||
url: item.url,
|
||||
title: item.title,
|
||||
index: start + i,
|
||||
id: item.id,
|
||||
}))
|
||||
}
|
||||
|
||||
get backTen() {
|
||||
return this.getBackList(10)
|
||||
}
|
||||
|
||||
getForwardList(n: number) {
|
||||
const start = Math.min(this.index + 1, this.history.length)
|
||||
const end = Math.min(this.index + n + 1, this.history.length)
|
||||
return this.history.slice(start, end).map((item, i) => ({
|
||||
url: item.url,
|
||||
title: item.title,
|
||||
index: start + i,
|
||||
id: item.id,
|
||||
}))
|
||||
}
|
||||
|
||||
get forwardTen() {
|
||||
return this.getForwardList(10)
|
||||
}
|
||||
|
||||
// navigation
|
||||
// =
|
||||
|
||||
navigate(url: string, title?: string) {
|
||||
if (this.current?.url === url) {
|
||||
this.refresh()
|
||||
} else {
|
||||
if (this.index < this.history.length - 1) {
|
||||
this.history.length = this.index + 1
|
||||
}
|
||||
// TEMP ensure the tab has its purpose's main view -prf
|
||||
if (this.history.length < 1) {
|
||||
const fixedUrl =
|
||||
this.fixedTabPurpose === TabPurpose.Notifs ? '/notifications' : '/'
|
||||
this.history.push({url: fixedUrl, ts: Date.now(), id: genId()})
|
||||
}
|
||||
this.history.push({url, title, ts: Date.now(), id: genId()})
|
||||
this.index = this.history.length - 1
|
||||
}
|
||||
}
|
||||
|
||||
refresh() {
|
||||
this.history = [
|
||||
...this.history.slice(0, this.index),
|
||||
{
|
||||
url: this.current.url,
|
||||
title: this.current.title,
|
||||
ts: Date.now(),
|
||||
id: this.current.id,
|
||||
},
|
||||
...this.history.slice(this.index + 1),
|
||||
]
|
||||
}
|
||||
|
||||
goBack() {
|
||||
if (this.canGoBack) {
|
||||
this.index--
|
||||
}
|
||||
}
|
||||
|
||||
// TEMP
|
||||
// a helper to bring the tab back to its base state
|
||||
// -prf
|
||||
fixedTabReset() {
|
||||
this.index = 0
|
||||
}
|
||||
|
||||
goForward() {
|
||||
if (this.canGoForward) {
|
||||
this.index++
|
||||
}
|
||||
}
|
||||
|
||||
goToIndex(index: number) {
|
||||
if (index >= 0 && index <= this.history.length - 1) {
|
||||
this.index = index
|
||||
}
|
||||
}
|
||||
|
||||
setTitle(id: number, title: string) {
|
||||
this.history = this.history.map(h => {
|
||||
if (h.id === id) {
|
||||
return {...h, title}
|
||||
}
|
||||
return h
|
||||
})
|
||||
}
|
||||
|
||||
setIsNewTab(v: boolean) {
|
||||
this.isNewTab = v
|
||||
}
|
||||
|
||||
// persistence
|
||||
// =
|
||||
|
||||
serialize(): unknown {
|
||||
return {
|
||||
history: this.history,
|
||||
index: this.index,
|
||||
}
|
||||
}
|
||||
|
||||
hydrate(v: unknown) {
|
||||
// TODO fixme
|
||||
// if (isObj(v)) {
|
||||
// if (hasProp(v, 'history') && Array.isArray(v.history)) {
|
||||
// for (const item of v.history) {
|
||||
// if (
|
||||
// isObj(item) &&
|
||||
// hasProp(item, 'url') &&
|
||||
// typeof item.url === 'string'
|
||||
// ) {
|
||||
// let copy: HistoryItem = {
|
||||
// url: item.url,
|
||||
// ts:
|
||||
// hasProp(item, 'ts') && typeof item.ts === 'number'
|
||||
// ? item.ts
|
||||
// : Date.now(),
|
||||
// }
|
||||
// if (hasProp(item, 'title') && typeof item.title === 'string') {
|
||||
// copy.title = item.title
|
||||
// }
|
||||
// this.history.push(copy)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// if (hasProp(v, 'index') && typeof v.index === 'number') {
|
||||
// this.index = v.index
|
||||
// }
|
||||
// if (this.index >= this.history.length - 1) {
|
||||
// this.index = this.history.length - 1
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
export class NavigationModel {
|
||||
tabs: NavigationTabModel[] = [
|
||||
new NavigationTabModel(TabPurpose.Default),
|
||||
new NavigationTabModel(TabPurpose.Notifs),
|
||||
]
|
||||
tabIndex = 0
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this, {
|
||||
serialize: false,
|
||||
hydrate: false,
|
||||
})
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.tabs = [
|
||||
new NavigationTabModel(TabPurpose.Default),
|
||||
new NavigationTabModel(TabPurpose.Notifs),
|
||||
]
|
||||
this.tabIndex = 0
|
||||
}
|
||||
|
||||
// accessors
|
||||
// =
|
||||
|
||||
get tab() {
|
||||
return this.tabs[this.tabIndex]
|
||||
}
|
||||
|
||||
get tabCount() {
|
||||
return this.tabs.length
|
||||
}
|
||||
|
||||
isCurrentScreen(tabId: number, index: number) {
|
||||
return this.tab.id === tabId && this.tab.index === index
|
||||
}
|
||||
|
||||
// navigation
|
||||
// =
|
||||
|
||||
navigate(url: string, title?: string) {
|
||||
this.tab.navigate(url, title)
|
||||
}
|
||||
|
||||
refresh() {
|
||||
this.tab.refresh()
|
||||
}
|
||||
|
||||
setTitle(ptr: HistoryPtr, title: string) {
|
||||
this.tabs.find(t => t.id === ptr[0])?.setTitle(ptr[1], title)
|
||||
}
|
||||
|
||||
handleLink(url: string) {
|
||||
let path
|
||||
if (url.startsWith('/')) {
|
||||
path = url
|
||||
} else if (url.startsWith('http')) {
|
||||
try {
|
||||
path = new URL(url).pathname
|
||||
} catch (e) {
|
||||
console.error('Invalid url', url, e)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
console.error('Invalid url', url)
|
||||
return
|
||||
}
|
||||
this.navigate(path)
|
||||
}
|
||||
|
||||
// tab management
|
||||
// =
|
||||
|
||||
// TEMP
|
||||
// fixed tab helper function
|
||||
// -prf
|
||||
switchTo(purpose: TabPurpose, reset: boolean) {
|
||||
if (purpose === TabPurpose.Notifs) {
|
||||
this.tabIndex = 1
|
||||
} else {
|
||||
this.tabIndex = 0
|
||||
}
|
||||
if (reset) {
|
||||
this.tab.fixedTabReset()
|
||||
}
|
||||
}
|
||||
|
||||
newTab(url: string, title?: string) {
|
||||
if (!TABS_ENABLED) {
|
||||
return this.navigate(url)
|
||||
}
|
||||
const tab = new NavigationTabModel(TabPurpose.Default)
|
||||
tab.navigate(url, title)
|
||||
tab.isNewTab = true
|
||||
this.tabs.push(tab)
|
||||
this.tabIndex = this.tabs.length - 1
|
||||
}
|
||||
|
||||
setActiveTab(tabIndex: number) {
|
||||
if (!TABS_ENABLED) {
|
||||
return
|
||||
}
|
||||
this.tabIndex = Math.max(Math.min(tabIndex, this.tabs.length - 1), 0)
|
||||
}
|
||||
|
||||
closeTab(tabIndex: number) {
|
||||
if (!TABS_ENABLED) {
|
||||
return
|
||||
}
|
||||
this.tabs = [
|
||||
...this.tabs.slice(0, tabIndex),
|
||||
...this.tabs.slice(tabIndex + 1),
|
||||
]
|
||||
if (this.tabs.length === 0) {
|
||||
this.newTab('/')
|
||||
} else if (this.tabIndex >= this.tabs.length) {
|
||||
this.tabIndex = this.tabs.length - 1
|
||||
}
|
||||
}
|
||||
|
||||
// persistence
|
||||
// =
|
||||
|
||||
serialize(): unknown {
|
||||
return {
|
||||
tabs: this.tabs.map(t => t.serialize()),
|
||||
tabIndex: this.tabIndex,
|
||||
}
|
||||
}
|
||||
|
||||
hydrate(v: unknown) {
|
||||
// TODO fixme
|
||||
this.clear()
|
||||
/*if (isObj(v)) {
|
||||
if (hasProp(v, 'tabs') && Array.isArray(v.tabs)) {
|
||||
for (const tab of v.tabs) {
|
||||
const copy = new NavigationTabModel()
|
||||
copy.hydrate(tab)
|
||||
if (copy.history.length) {
|
||||
this.tabs.push(copy)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hasProp(v, 'tabIndex') && typeof v.tabIndex === 'number') {
|
||||
this.tabIndex = v.tabIndex
|
||||
}
|
||||
}*/
|
||||
}
|
||||
}
|
||||
@@ -1,72 +1,124 @@
|
||||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import {bsky} from '@adxp/mock-api'
|
||||
import * as ListNotifications from '../../third-party/api/src/client/types/app/bsky/notification/list'
|
||||
import * as ActorRef from '../../third-party/api/src/client/types/app/bsky/actor/ref'
|
||||
import {RootStoreModel} from './root-store'
|
||||
import {PostThreadViewModel} from './post-thread-view'
|
||||
import {hasProp} from '../lib/type-guards'
|
||||
import {APP_BSKY_GRAPH} from '../../third-party/api'
|
||||
import {cleanError} from '../../lib/strings'
|
||||
|
||||
export class NotificationsViewItemModel
|
||||
implements bsky.NotificationsView.Notification
|
||||
{
|
||||
const UNGROUPABLE_REASONS = ['trend', 'assertion']
|
||||
const PAGE_SIZE = 30
|
||||
const MS_60MIN = 1e3 * 60 * 60
|
||||
|
||||
export interface GroupedNotification extends ListNotifications.Notification {
|
||||
additional?: ListNotifications.Notification[]
|
||||
}
|
||||
|
||||
export class NotificationsViewItemModel implements GroupedNotification {
|
||||
// ui state
|
||||
_reactKey: string = ''
|
||||
|
||||
// data
|
||||
uri: string = ''
|
||||
author: {
|
||||
did: string
|
||||
name: string
|
||||
displayName: string
|
||||
} = {did: '', name: '', displayName: ''}
|
||||
cid: string = ''
|
||||
author: ActorRef.WithInfo = {
|
||||
did: '',
|
||||
handle: '',
|
||||
declaration: {cid: '', actorType: ''},
|
||||
}
|
||||
reason: string = ''
|
||||
reasonSubject?: string
|
||||
record: any = {}
|
||||
isRead: boolean = false
|
||||
indexedAt: string = ''
|
||||
additional?: NotificationsViewItemModel[]
|
||||
|
||||
// additional data
|
||||
additionalPost?: PostThreadViewModel
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
reactKey: string,
|
||||
v: bsky.NotificationsView.Notification,
|
||||
v: GroupedNotification,
|
||||
) {
|
||||
makeAutoObservable(this, {rootStore: false})
|
||||
this._reactKey = reactKey
|
||||
this.copy(v)
|
||||
}
|
||||
|
||||
copy(v: bsky.NotificationsView.Notification) {
|
||||
copy(v: GroupedNotification, preserve = false) {
|
||||
this.uri = v.uri
|
||||
this.cid = v.cid
|
||||
this.author = v.author
|
||||
this.reason = v.reason
|
||||
this.reasonSubject = v.reasonSubject
|
||||
this.record = v.record
|
||||
this.isRead = v.isRead
|
||||
this.indexedAt = v.indexedAt
|
||||
if (v.additional?.length) {
|
||||
this.additional = []
|
||||
for (const add of v.additional) {
|
||||
this.additional.push(
|
||||
new NotificationsViewItemModel(this.rootStore, '', add),
|
||||
)
|
||||
}
|
||||
} else if (!preserve) {
|
||||
this.additional = undefined
|
||||
}
|
||||
}
|
||||
|
||||
get isLike() {
|
||||
return (
|
||||
hasProp(this.record, '$type') &&
|
||||
this.record.$type === 'blueskyweb.xyz:Like'
|
||||
)
|
||||
get isUpvote() {
|
||||
return this.reason === 'vote'
|
||||
}
|
||||
|
||||
get isRepost() {
|
||||
return (
|
||||
hasProp(this.record, '$type') &&
|
||||
this.record.$type === 'blueskyweb.xyz:Repost'
|
||||
)
|
||||
return this.reason === 'repost'
|
||||
}
|
||||
|
||||
get isTrend() {
|
||||
return this.reason === 'trend'
|
||||
}
|
||||
|
||||
get isMention() {
|
||||
return this.reason === 'mention'
|
||||
}
|
||||
|
||||
get isReply() {
|
||||
return (
|
||||
hasProp(this.record, '$type') &&
|
||||
this.record.$type === 'blueskyweb.xyz:Post'
|
||||
)
|
||||
return this.reason === 'reply'
|
||||
}
|
||||
|
||||
get isFollow() {
|
||||
return this.reason === 'follow'
|
||||
}
|
||||
|
||||
get isAssertion() {
|
||||
return this.reason === 'assertion'
|
||||
}
|
||||
|
||||
get needsAdditionalData() {
|
||||
if (
|
||||
this.isUpvote ||
|
||||
this.isRepost ||
|
||||
this.isTrend ||
|
||||
this.isReply ||
|
||||
this.isMention
|
||||
) {
|
||||
return !this.additionalPost
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
get isInvite() {
|
||||
return (
|
||||
hasProp(this.record, '$type') &&
|
||||
this.record.$type === 'blueskyweb.xyz:Follow'
|
||||
this.isAssertion && this.record.assertion === APP_BSKY_GRAPH.AssertMember
|
||||
)
|
||||
}
|
||||
|
||||
get subjectUri() {
|
||||
if (this.reasonSubject) {
|
||||
return this.reasonSubject
|
||||
}
|
||||
if (
|
||||
hasProp(this.record, 'subject') &&
|
||||
typeof this.record.subject === 'string'
|
||||
@@ -75,15 +127,38 @@ export class NotificationsViewItemModel
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
async fetchAdditionalData() {
|
||||
if (!this.needsAdditionalData) {
|
||||
return
|
||||
}
|
||||
let postUri
|
||||
if (this.isReply || this.isMention) {
|
||||
postUri = this.uri
|
||||
} else if (this.isUpvote || this.isRead || this.isTrend) {
|
||||
postUri = this.subjectUri
|
||||
}
|
||||
if (postUri) {
|
||||
this.additionalPost = new PostThreadViewModel(this.rootStore, {
|
||||
uri: postUri,
|
||||
depth: 0,
|
||||
})
|
||||
await this.additionalPost.setup().catch(e => {
|
||||
console.error('Failed to load post needed by notification', e)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class NotificationsViewModel implements bsky.NotificationsView.Response {
|
||||
export class NotificationsViewModel {
|
||||
// state
|
||||
isLoading = false
|
||||
isRefreshing = false
|
||||
hasLoaded = false
|
||||
error = ''
|
||||
params: bsky.NotificationsView.Params
|
||||
params: ListNotifications.QueryParams
|
||||
hasMore = true
|
||||
loadMoreCursor?: string
|
||||
_loadPromise: Promise<void> | undefined
|
||||
_loadMorePromise: Promise<void> | undefined
|
||||
_updatePromise: Promise<void> | undefined
|
||||
@@ -93,7 +168,7 @@ export class NotificationsViewModel implements bsky.NotificationsView.Response {
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
params: bsky.NotificationsView.Params,
|
||||
params: ListNotifications.QueryParams,
|
||||
) {
|
||||
makeAutoObservable(
|
||||
this,
|
||||
@@ -121,13 +196,6 @@ export class NotificationsViewModel implements bsky.NotificationsView.Response {
|
||||
return this.hasLoaded && !this.hasContent
|
||||
}
|
||||
|
||||
get loadMoreCursor() {
|
||||
if (this.hasContent) {
|
||||
return this.notifications[this.notifications.length - 1].indexedAt
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// public api
|
||||
// =
|
||||
|
||||
@@ -135,6 +203,9 @@ export class NotificationsViewModel implements bsky.NotificationsView.Response {
|
||||
* Load for first render
|
||||
*/
|
||||
async setup(isRefreshing = false) {
|
||||
if (isRefreshing) {
|
||||
this.isRefreshing = true // set optimistically for UI
|
||||
}
|
||||
if (this._loadPromise) {
|
||||
return this._loadPromise
|
||||
}
|
||||
@@ -177,6 +248,20 @@ export class NotificationsViewModel implements bsky.NotificationsView.Response {
|
||||
this._updatePromise = undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Update read/unread state
|
||||
*/
|
||||
async updateReadState() {
|
||||
try {
|
||||
await this.rootStore.api.app.bsky.notification.updateSeen({
|
||||
seenAt: new Date().toISOString(),
|
||||
})
|
||||
this.rootStore.me.clearNotificationCount()
|
||||
} catch (e) {
|
||||
console.log('Failed to update notifications read state', e)
|
||||
}
|
||||
}
|
||||
|
||||
// state transitions
|
||||
// =
|
||||
|
||||
@@ -190,7 +275,7 @@ export class NotificationsViewModel implements bsky.NotificationsView.Response {
|
||||
this.isLoading = false
|
||||
this.isRefreshing = false
|
||||
this.hasLoaded = true
|
||||
this.error = err
|
||||
this.error = cleanError(err)
|
||||
}
|
||||
|
||||
// loader functions
|
||||
@@ -210,13 +295,12 @@ export class NotificationsViewModel implements bsky.NotificationsView.Response {
|
||||
|
||||
private async _initialLoad(isRefreshing = false) {
|
||||
this._xLoading(isRefreshing)
|
||||
await new Promise(r => setTimeout(r, 250)) // DEBUG
|
||||
try {
|
||||
const res = (await this.rootStore.api.mainPds.view(
|
||||
'blueskyweb.xyz:NotificationsView',
|
||||
this.params,
|
||||
)) as bsky.NotificationsView.Response
|
||||
this._replaceAll(res)
|
||||
const params = Object.assign({}, this.params, {
|
||||
limit: PAGE_SIZE,
|
||||
})
|
||||
const res = await this.rootStore.api.app.bsky.notification.list(params)
|
||||
await this._replaceAll(res)
|
||||
this._xIdle()
|
||||
} catch (e: any) {
|
||||
this._xIdle(`Failed to load notifications: ${e.toString()}`)
|
||||
@@ -224,17 +308,17 @@ export class NotificationsViewModel implements bsky.NotificationsView.Response {
|
||||
}
|
||||
|
||||
private async _loadMore() {
|
||||
if (!this.hasMore) {
|
||||
return
|
||||
}
|
||||
this._xLoading()
|
||||
await new Promise(r => setTimeout(r, 250)) // DEBUG
|
||||
try {
|
||||
const params = Object.assign({}, this.params, {
|
||||
limit: PAGE_SIZE,
|
||||
before: this.loadMoreCursor,
|
||||
})
|
||||
const res = (await this.rootStore.api.mainPds.view(
|
||||
'blueskyweb.xyz:NotificationsView',
|
||||
params,
|
||||
)) as bsky.NotificationsView.Response
|
||||
this._appendAll(res)
|
||||
const res = await this.rootStore.api.app.bsky.notification.list(params)
|
||||
await this._appendAll(res)
|
||||
this._xIdle()
|
||||
} catch (e: any) {
|
||||
this._xIdle(`Failed to load notifications: ${e.toString()}`)
|
||||
@@ -242,26 +326,26 @@ export class NotificationsViewModel implements bsky.NotificationsView.Response {
|
||||
}
|
||||
|
||||
private async _update() {
|
||||
if (!this.notifications.length) {
|
||||
return
|
||||
}
|
||||
this._xLoading()
|
||||
await new Promise(r => setTimeout(r, 250)) // DEBUG
|
||||
let numToFetch = this.notifications.length
|
||||
let cursor = undefined
|
||||
try {
|
||||
do {
|
||||
const res = (await this.rootStore.api.mainPds.view(
|
||||
'blueskyweb.xyz:NotificationsView',
|
||||
{
|
||||
const res: ListNotifications.Response =
|
||||
await this.rootStore.api.app.bsky.notification.list({
|
||||
before: cursor,
|
||||
limit: Math.min(numToFetch, 100),
|
||||
},
|
||||
)) as bsky.NotificationsView.Response
|
||||
if (res.notifications.length === 0) {
|
||||
})
|
||||
if (res.data.notifications.length === 0) {
|
||||
break // sanity check
|
||||
}
|
||||
this._updateAll(res)
|
||||
numToFetch -= res.notifications.length
|
||||
cursor = this.notifications[res.notifications.length - 1].indexedAt
|
||||
console.log(numToFetch, cursor, res.notifications.length)
|
||||
numToFetch -= res.data.notifications.length
|
||||
cursor = this.notifications[res.data.notifications.length - 1].indexedAt
|
||||
console.log(numToFetch, cursor, res.data.notifications.length)
|
||||
} while (numToFetch > 0)
|
||||
this._xIdle()
|
||||
} catch (e: any) {
|
||||
@@ -269,36 +353,83 @@ export class NotificationsViewModel implements bsky.NotificationsView.Response {
|
||||
}
|
||||
}
|
||||
|
||||
private _replaceAll(res: bsky.NotificationsView.Response) {
|
||||
this.notifications.length = 0
|
||||
this._appendAll(res)
|
||||
private async _replaceAll(res: ListNotifications.Response) {
|
||||
return this._appendAll(res, true)
|
||||
}
|
||||
|
||||
private _appendAll(res: bsky.NotificationsView.Response) {
|
||||
private async _appendAll(res: ListNotifications.Response, replace = false) {
|
||||
this.loadMoreCursor = res.data.cursor
|
||||
this.hasMore = !!this.loadMoreCursor
|
||||
let counter = this.notifications.length
|
||||
for (const item of res.notifications) {
|
||||
this._append(counter++, item)
|
||||
const promises = []
|
||||
const itemModels: NotificationsViewItemModel[] = []
|
||||
for (const item of groupNotifications(res.data.notifications)) {
|
||||
const itemModel = new NotificationsViewItemModel(
|
||||
this.rootStore,
|
||||
`item-${counter++}`,
|
||||
item,
|
||||
)
|
||||
if (itemModel.needsAdditionalData) {
|
||||
promises.push(itemModel.fetchAdditionalData())
|
||||
}
|
||||
itemModels.push(itemModel)
|
||||
}
|
||||
await Promise.all(promises).catch(e => {
|
||||
console.error(
|
||||
'Uncaught failure during notifications-view _appendAll()',
|
||||
e,
|
||||
)
|
||||
})
|
||||
runInAction(() => {
|
||||
if (replace) {
|
||||
this.notifications = itemModels
|
||||
} else {
|
||||
this.notifications = this.notifications.concat(itemModels)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private _append(keyId: number, item: bsky.NotificationsView.Notification) {
|
||||
// TODO: validate .record
|
||||
this.notifications.push(
|
||||
new NotificationsViewItemModel(this.rootStore, `item-${keyId}`, item),
|
||||
)
|
||||
}
|
||||
|
||||
private _updateAll(res: bsky.NotificationsView.Response) {
|
||||
for (const item of res.notifications) {
|
||||
private _updateAll(res: ListNotifications.Response) {
|
||||
for (const item of res.data.notifications) {
|
||||
const existingItem = this.notifications.find(
|
||||
// this find function has a key subtley- the indexedAt comparison
|
||||
// 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) {
|
||||
existingItem.copy(item)
|
||||
existingItem.copy(item, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function groupNotifications(
|
||||
items: ListNotifications.Notification[],
|
||||
): GroupedNotification[] {
|
||||
const items2: GroupedNotification[] = []
|
||||
for (const item of items) {
|
||||
const ts = +new Date(item.indexedAt)
|
||||
let grouped = false
|
||||
if (!UNGROUPABLE_REASONS.includes(item.reason)) {
|
||||
for (const item2 of items2) {
|
||||
const ts2 = +new Date(item2.indexedAt)
|
||||
if (
|
||||
Math.abs(ts2 - ts) < MS_60MIN &&
|
||||
item.reason === item2.reason &&
|
||||
item.reasonSubject === item2.reasonSubject &&
|
||||
item.author.did !== item2.author.did
|
||||
) {
|
||||
item2.additional = item2.additional || []
|
||||
item2.additional.push(item)
|
||||
grouped = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!grouped) {
|
||||
items2.push(item)
|
||||
}
|
||||
}
|
||||
return items2
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import {makeAutoObservable} from 'mobx'
|
||||
import {isObj, hasProp} from '../lib/type-guards'
|
||||
|
||||
export const OnboardStage = {
|
||||
Explainers: 'explainers',
|
||||
Follows: 'follows',
|
||||
}
|
||||
|
||||
export const OnboardStageOrder = [OnboardStage.Explainers, OnboardStage.Follows]
|
||||
|
||||
export class OnboardModel {
|
||||
isOnboarding: boolean = false
|
||||
stage: string = OnboardStageOrder[0]
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this, {
|
||||
serialize: false,
|
||||
hydrate: false,
|
||||
})
|
||||
}
|
||||
|
||||
serialize(): unknown {
|
||||
return {
|
||||
isOnboarding: this.isOnboarding,
|
||||
stage: this.stage,
|
||||
}
|
||||
}
|
||||
|
||||
hydrate(v: unknown) {
|
||||
if (isObj(v)) {
|
||||
if (hasProp(v, 'isOnboarding') && typeof v.isOnboarding === 'boolean') {
|
||||
this.isOnboarding = v.isOnboarding
|
||||
}
|
||||
if (
|
||||
hasProp(v, 'stage') &&
|
||||
typeof v.stage === 'string' &&
|
||||
OnboardStageOrder.includes(v.stage)
|
||||
) {
|
||||
this.stage = v.stage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
start() {
|
||||
this.isOnboarding = true
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.isOnboarding = false
|
||||
}
|
||||
|
||||
next() {
|
||||
if (!this.isOnboarding) return
|
||||
let i = OnboardStageOrder.indexOf(this.stage)
|
||||
i++
|
||||
if (i >= OnboardStageOrder.length) {
|
||||
this.isOnboarding = false
|
||||
this.stage = OnboardStageOrder[0] // in case they make a new account
|
||||
} else {
|
||||
this.stage = OnboardStageOrder[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,20 @@
|
||||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import {bsky, AdxUri} from '@adxp/mock-api'
|
||||
import {AppBskyFeedGetPostThread as GetPostThread} from '../../third-party/api'
|
||||
import * as Embed from '../../third-party/api/src/client/types/app/bsky/feed/embed'
|
||||
import * as ActorRef from '../../third-party/api/src/client/types/app/bsky/actor/ref'
|
||||
import {AtUri} from '../../third-party/uri'
|
||||
import _omit from 'lodash.omit'
|
||||
import {RootStoreModel} from './root-store'
|
||||
import * as apilib from '../lib/api'
|
||||
|
||||
type MaybePost =
|
||||
| GetPostThread.Post
|
||||
| GetPostThread.NotFoundPost
|
||||
| {
|
||||
$type: string
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
function* reactKeyGenerator(): Generator<string> {
|
||||
let counter = 0
|
||||
while (true) {
|
||||
@@ -11,41 +22,61 @@ function* reactKeyGenerator(): Generator<string> {
|
||||
}
|
||||
}
|
||||
|
||||
interface ReplyingTo {
|
||||
author: {
|
||||
handle: string
|
||||
displayName?: string
|
||||
avatar?: string
|
||||
}
|
||||
text: string
|
||||
}
|
||||
interface OriginalRecord {
|
||||
text: string
|
||||
}
|
||||
|
||||
export class PostThreadViewPostMyStateModel {
|
||||
hasLiked: boolean = false
|
||||
hasReposted: boolean = false
|
||||
repost?: string
|
||||
upvote?: string
|
||||
downvote?: string
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this)
|
||||
}
|
||||
}
|
||||
|
||||
export class PostThreadViewPostModel implements bsky.PostThreadView.Post {
|
||||
export class PostThreadViewPostModel implements GetPostThread.Post {
|
||||
// ui state
|
||||
_reactKey: string = ''
|
||||
_depth = 0
|
||||
_isHighlightedPost = false
|
||||
|
||||
// data
|
||||
$type: string = ''
|
||||
uri: string = ''
|
||||
author: bsky.PostThreadView.User = {did: '', name: '', displayName: ''}
|
||||
cid: string = ''
|
||||
author: ActorRef.WithInfo = {
|
||||
did: '',
|
||||
handle: '',
|
||||
declaration: {cid: '', actorType: ''},
|
||||
}
|
||||
record: Record<string, unknown> = {}
|
||||
embed?:
|
||||
| bsky.PostThreadView.RecordEmbed
|
||||
| bsky.PostThreadView.ExternalEmbed
|
||||
| bsky.PostThreadView.UnknownEmbed
|
||||
embed?: Embed.Main = undefined
|
||||
parent?: PostThreadViewPostModel
|
||||
replyCount: number = 0
|
||||
replies?: PostThreadViewPostModel[]
|
||||
repostCount: number = 0
|
||||
likeCount: number = 0
|
||||
upvoteCount: number = 0
|
||||
downvoteCount: number = 0
|
||||
indexedAt: string = ''
|
||||
myState = new PostThreadViewPostMyStateModel()
|
||||
|
||||
// added data
|
||||
replyingTo?: ReplyingTo
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
reactKey: string,
|
||||
v?: bsky.PostThreadView.Post,
|
||||
v?: GetPostThread.Post,
|
||||
) {
|
||||
makeAutoObservable(this, {rootStore: false})
|
||||
this._reactKey = reactKey
|
||||
@@ -57,9 +88,15 @@ export class PostThreadViewPostModel implements bsky.PostThreadView.Post {
|
||||
}
|
||||
}
|
||||
|
||||
assignTreeModels(keyGen: Generator<string>, v: bsky.PostThreadView.Post) {
|
||||
assignTreeModels(
|
||||
keyGen: Generator<string>,
|
||||
v: GetPostThread.Post,
|
||||
includeParent = true,
|
||||
includeChildren = true,
|
||||
isFirstChild = true,
|
||||
) {
|
||||
// parents
|
||||
if (v.parent) {
|
||||
if (includeParent && v.parent) {
|
||||
// TODO: validate .record
|
||||
const parentModel = new PostThreadViewPostModel(
|
||||
this.rootStore,
|
||||
@@ -68,13 +105,24 @@ export class PostThreadViewPostModel implements bsky.PostThreadView.Post {
|
||||
)
|
||||
parentModel._depth = this._depth - 1
|
||||
if (v.parent.parent) {
|
||||
parentModel.assignTreeModels(keyGen, v.parent)
|
||||
parentModel.assignTreeModels(keyGen, v.parent, true, false)
|
||||
}
|
||||
this.parent = parentModel
|
||||
}
|
||||
if (!includeParent && v.parent?.author.handle && !isFirstChild) {
|
||||
this.replyingTo = {
|
||||
author: {
|
||||
handle: v.parent.author.handle,
|
||||
displayName: v.parent.author.displayName,
|
||||
avatar: v.parent.author.avatar,
|
||||
},
|
||||
text: (v.parent.record as OriginalRecord).text,
|
||||
}
|
||||
}
|
||||
// replies
|
||||
if (v.replies) {
|
||||
if (includeChildren && v.replies) {
|
||||
const replies = []
|
||||
let isChildFirstChild = true
|
||||
for (const item of v.replies) {
|
||||
// TODO: validate .record
|
||||
const itemModel = new PostThreadViewPostModel(
|
||||
@@ -84,44 +132,90 @@ export class PostThreadViewPostModel implements bsky.PostThreadView.Post {
|
||||
)
|
||||
itemModel._depth = this._depth + 1
|
||||
if (item.replies) {
|
||||
itemModel.assignTreeModels(keyGen, item)
|
||||
itemModel.assignTreeModels(
|
||||
keyGen,
|
||||
item,
|
||||
false,
|
||||
true,
|
||||
isChildFirstChild,
|
||||
)
|
||||
}
|
||||
isChildFirstChild = false
|
||||
replies.push(itemModel)
|
||||
}
|
||||
this.replies = replies
|
||||
}
|
||||
}
|
||||
|
||||
async toggleLike() {
|
||||
if (this.myState.hasLiked) {
|
||||
await apilib.unlike(this.rootStore.api, 'alice.com', this.uri)
|
||||
async toggleUpvote() {
|
||||
const wasUpvoted = !!this.myState.upvote
|
||||
const wasDownvoted = !!this.myState.downvote
|
||||
const res = await this.rootStore.api.app.bsky.feed.setVote({
|
||||
subject: {
|
||||
uri: this.uri,
|
||||
cid: this.cid,
|
||||
},
|
||||
direction: wasUpvoted ? 'none' : 'up',
|
||||
})
|
||||
runInAction(() => {
|
||||
if (wasDownvoted) {
|
||||
this.downvoteCount--
|
||||
}
|
||||
if (wasUpvoted) {
|
||||
this.upvoteCount--
|
||||
} else {
|
||||
this.upvoteCount++
|
||||
}
|
||||
this.myState.upvote = res.data.upvote
|
||||
this.myState.downvote = res.data.downvote
|
||||
})
|
||||
}
|
||||
|
||||
async toggleDownvote() {
|
||||
const wasUpvoted = !!this.myState.upvote
|
||||
const wasDownvoted = !!this.myState.downvote
|
||||
const res = await this.rootStore.api.app.bsky.feed.setVote({
|
||||
subject: {
|
||||
uri: this.uri,
|
||||
cid: this.cid,
|
||||
},
|
||||
direction: wasDownvoted ? 'none' : 'down',
|
||||
})
|
||||
runInAction(() => {
|
||||
if (wasUpvoted) {
|
||||
this.upvoteCount--
|
||||
}
|
||||
if (wasDownvoted) {
|
||||
this.downvoteCount--
|
||||
} else {
|
||||
this.downvoteCount++
|
||||
}
|
||||
this.myState.upvote = res.data.upvote
|
||||
this.myState.downvote = res.data.downvote
|
||||
})
|
||||
}
|
||||
|
||||
async toggleRepost() {
|
||||
if (this.myState.repost) {
|
||||
await apilib.unrepost(this.rootStore, this.myState.repost)
|
||||
runInAction(() => {
|
||||
this.likeCount--
|
||||
this.myState.hasLiked = false
|
||||
this.repostCount--
|
||||
this.myState.repost = undefined
|
||||
})
|
||||
} else {
|
||||
await apilib.like(this.rootStore.api, 'alice.com', this.uri)
|
||||
const res = await apilib.repost(this.rootStore, this.uri, this.cid)
|
||||
runInAction(() => {
|
||||
this.likeCount++
|
||||
this.myState.hasLiked = true
|
||||
this.repostCount++
|
||||
this.myState.repost = res.uri
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async toggleRepost() {
|
||||
if (this.myState.hasReposted) {
|
||||
await apilib.unrepost(this.rootStore.api, 'alice.com', this.uri)
|
||||
runInAction(() => {
|
||||
this.repostCount--
|
||||
this.myState.hasReposted = false
|
||||
})
|
||||
} else {
|
||||
await apilib.repost(this.rootStore.api, 'alice.com', this.uri)
|
||||
runInAction(() => {
|
||||
this.repostCount++
|
||||
this.myState.hasReposted = true
|
||||
})
|
||||
}
|
||||
async delete() {
|
||||
await this.rootStore.api.app.bsky.feed.post.delete({
|
||||
did: this.author.did,
|
||||
rkey: new AtUri(this.uri).rkey,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,15 +225,16 @@ export class PostThreadViewModel {
|
||||
isRefreshing = false
|
||||
hasLoaded = false
|
||||
error = ''
|
||||
notFound = false
|
||||
resolvedUri = ''
|
||||
params: bsky.PostThreadView.Params
|
||||
params: GetPostThread.QueryParams
|
||||
|
||||
// data
|
||||
thread?: PostThreadViewPostModel
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
params: bsky.PostThreadView.Params,
|
||||
params: GetPostThread.QueryParams,
|
||||
) {
|
||||
makeAutoObservable(
|
||||
this,
|
||||
@@ -201,20 +296,22 @@ export class PostThreadViewModel {
|
||||
this.isLoading = true
|
||||
this.isRefreshing = isRefreshing
|
||||
this.error = ''
|
||||
this.notFound = false
|
||||
}
|
||||
|
||||
private _xIdle(err: string = '') {
|
||||
private _xIdle(err: any = undefined) {
|
||||
this.isLoading = false
|
||||
this.isRefreshing = false
|
||||
this.hasLoaded = true
|
||||
this.error = err
|
||||
this.error = err ? err.toString() : ''
|
||||
this.notFound = err instanceof GetPostThread.NotFoundError
|
||||
}
|
||||
|
||||
// loader functions
|
||||
// =
|
||||
|
||||
private async _resolveUri() {
|
||||
const urip = new AdxUri(this.params.uri)
|
||||
const urip = new AtUri(this.params.uri)
|
||||
if (!urip.host.startsWith('did:')) {
|
||||
urip.host = await this.rootStore.resolveName(urip.host)
|
||||
}
|
||||
@@ -226,27 +323,58 @@ export class PostThreadViewModel {
|
||||
private async _load(isRefreshing = false) {
|
||||
this._xLoading(isRefreshing)
|
||||
try {
|
||||
const res = (await this.rootStore.api.mainPds.view(
|
||||
'blueskyweb.xyz:PostThreadView',
|
||||
const res = await this.rootStore.api.app.bsky.feed.getPostThread(
|
||||
Object.assign({}, this.params, {uri: this.resolvedUri}),
|
||||
)) as bsky.PostThreadView.Response
|
||||
)
|
||||
this._replaceAll(res)
|
||||
this._xIdle()
|
||||
} catch (e: any) {
|
||||
this._xIdle(`Failed to load thread: ${e.toString()}`)
|
||||
this._xIdle(e)
|
||||
}
|
||||
}
|
||||
|
||||
private _replaceAll(res: bsky.PostThreadView.Response) {
|
||||
private _replaceAll(res: GetPostThread.Response) {
|
||||
// TODO: validate .record
|
||||
sortThread(res.data.thread)
|
||||
const keyGen = reactKeyGenerator()
|
||||
const thread = new PostThreadViewPostModel(
|
||||
this.rootStore,
|
||||
keyGen.next().value,
|
||||
res.thread,
|
||||
res.data.thread as GetPostThread.Post,
|
||||
)
|
||||
thread._isHighlightedPost = true
|
||||
thread.assignTreeModels(keyGen, res.thread)
|
||||
thread.assignTreeModels(keyGen, res.data.thread as GetPostThread.Post)
|
||||
this.thread = thread
|
||||
}
|
||||
}
|
||||
|
||||
function sortThread(post: MaybePost) {
|
||||
if (post.notFound) {
|
||||
return
|
||||
}
|
||||
post = post as GetPostThread.Post
|
||||
if (post.replies) {
|
||||
post.replies.sort((a: MaybePost, b: MaybePost) => {
|
||||
post = post as GetPostThread.Post
|
||||
if (a.notFound) {
|
||||
return 1
|
||||
}
|
||||
if (b.notFound) {
|
||||
return -1
|
||||
}
|
||||
a = a as GetPostThread.Post
|
||||
b = b as GetPostThread.Post
|
||||
const aIsByOp = a.author.did === post.author.did
|
||||
const bIsByOp = b.author.did === post.author.did
|
||||
if (aIsByOp && bIsByOp) {
|
||||
return a.indexedAt.localeCompare(b.indexedAt) // oldest
|
||||
} else if (aIsByOp) {
|
||||
return -1 // op's own reply
|
||||
} else if (bIsByOp) {
|
||||
return 1 // op's own reply
|
||||
}
|
||||
return b.indexedAt.localeCompare(a.indexedAt) // newest
|
||||
})
|
||||
post.replies.forEach(reply => sortThread(reply))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import {makeAutoObservable} from 'mobx'
|
||||
import {bsky, AdxUri} from '@adxp/mock-api'
|
||||
import * as Post from '../../third-party/api/src/client/types/app/bsky/feed/post'
|
||||
import {AtUri} from '../../third-party/uri'
|
||||
import {RootStoreModel} from './root-store'
|
||||
import {cleanError} from '../../lib/strings'
|
||||
|
||||
export type PostEntities = bsky.Post.Record['entities']
|
||||
export type PostReply = bsky.Post.Record['reply']
|
||||
export class PostModel implements bsky.Post.Record {
|
||||
type RemoveIndex<T> = {
|
||||
[P in keyof T as string extends P
|
||||
? never
|
||||
: number extends P
|
||||
? never
|
||||
: P]: T[P]
|
||||
}
|
||||
export class PostModel implements RemoveIndex<Post.Record> {
|
||||
// state
|
||||
isLoading = false
|
||||
hasLoaded = false
|
||||
@@ -13,8 +20,8 @@ export class PostModel implements bsky.Post.Record {
|
||||
|
||||
// data
|
||||
text: string = ''
|
||||
entities?: PostEntities
|
||||
reply?: PostReply
|
||||
entities?: Post.Entity[]
|
||||
reply?: Post.ReplyRef
|
||||
createdAt: string = ''
|
||||
|
||||
constructor(public rootStore: RootStoreModel, uri: string) {
|
||||
@@ -59,7 +66,7 @@ export class PostModel implements bsky.Post.Record {
|
||||
private _xIdle(err: string = '') {
|
||||
this.isLoading = false
|
||||
this.hasLoaded = true
|
||||
this.error = err
|
||||
this.error = cleanError(err)
|
||||
}
|
||||
|
||||
// loader functions
|
||||
@@ -67,24 +74,24 @@ export class PostModel implements bsky.Post.Record {
|
||||
|
||||
private async _load() {
|
||||
this._xLoading()
|
||||
await new Promise(r => setTimeout(r, 250)) // DEBUG
|
||||
try {
|
||||
const urip = new AdxUri(this.uri)
|
||||
const res = await this.rootStore.api.mainPds
|
||||
.repo(urip.host, false)
|
||||
.collection(urip.collection)
|
||||
.get('Post', urip.recordKey)
|
||||
if (!res.valid) {
|
||||
throw new Error(res.error)
|
||||
}
|
||||
const urip = new AtUri(this.uri)
|
||||
const res = await this.rootStore.api.app.bsky.feed.post.get({
|
||||
user: urip.host,
|
||||
rkey: urip.rkey,
|
||||
})
|
||||
// TODO
|
||||
// if (!res.valid) {
|
||||
// throw new Error(res.error)
|
||||
// }
|
||||
this._replaceAll(res.value)
|
||||
this._xIdle()
|
||||
} catch (e: any) {
|
||||
this._xIdle(`Failed to load post: ${e.toString()}`)
|
||||
this._xIdle(e.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private _replaceAll(res: bsky.Post.Record) {
|
||||
private _replaceAll(res: Post.Record) {
|
||||
this.text = res.text
|
||||
this.entities = res.entities
|
||||
this.reply = res.reply
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import {makeAutoObservable} from 'mobx'
|
||||
import {RootStoreModel} from './root-store'
|
||||
import {ProfileViewModel} from './profile-view'
|
||||
import {MembersViewModel} from './members-view'
|
||||
import {MembershipsViewModel} from './memberships-view'
|
||||
import {FeedModel} from './feed-view'
|
||||
|
||||
export enum Sections {
|
||||
Posts = 'Posts',
|
||||
PostsWithReplies = 'Posts & replies',
|
||||
Scenes = 'Scenes',
|
||||
Trending = 'Trending',
|
||||
Members = 'Members',
|
||||
}
|
||||
|
||||
const USER_SELECTOR_ITEMS = [
|
||||
Sections.Posts,
|
||||
Sections.PostsWithReplies,
|
||||
Sections.Scenes,
|
||||
]
|
||||
const SCENE_SELECTOR_ITEMS = [Sections.Trending, Sections.Members]
|
||||
|
||||
export interface ProfileUiParams {
|
||||
user: string
|
||||
}
|
||||
|
||||
export class ProfileUiModel {
|
||||
// data
|
||||
profile: ProfileViewModel
|
||||
feed: FeedModel
|
||||
memberships: MembershipsViewModel
|
||||
members: MembersViewModel
|
||||
|
||||
// ui state
|
||||
selectedViewIndex = 0
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
public params: ProfileUiParams,
|
||||
) {
|
||||
makeAutoObservable(
|
||||
this,
|
||||
{
|
||||
rootStore: false,
|
||||
params: false,
|
||||
},
|
||||
{autoBind: true},
|
||||
)
|
||||
this.profile = new ProfileViewModel(rootStore, {actor: params.user})
|
||||
this.feed = new FeedModel(rootStore, 'author', {
|
||||
author: params.user,
|
||||
limit: 10,
|
||||
})
|
||||
this.memberships = new MembershipsViewModel(rootStore, {actor: params.user})
|
||||
this.members = new MembersViewModel(rootStore, {actor: params.user})
|
||||
}
|
||||
|
||||
get currentView(): FeedModel | MembershipsViewModel | MembersViewModel {
|
||||
if (
|
||||
this.selectedView === Sections.Posts ||
|
||||
this.selectedView === Sections.PostsWithReplies ||
|
||||
this.selectedView === Sections.Trending
|
||||
) {
|
||||
return this.feed
|
||||
}
|
||||
if (this.selectedView === Sections.Scenes) {
|
||||
return this.memberships
|
||||
}
|
||||
if (this.selectedView === Sections.Members) {
|
||||
return this.members
|
||||
}
|
||||
throw new Error(`Invalid selector value: ${this.selectedViewIndex}`)
|
||||
}
|
||||
|
||||
get isInitialLoading() {
|
||||
const view = this.currentView
|
||||
return view.isLoading && !view.isRefreshing && !view.hasContent
|
||||
}
|
||||
|
||||
get isRefreshing() {
|
||||
return this.profile.isRefreshing || this.currentView.isRefreshing
|
||||
}
|
||||
|
||||
get isUser() {
|
||||
return this.profile.isUser
|
||||
}
|
||||
|
||||
get isScene() {
|
||||
return this.profile.isScene
|
||||
}
|
||||
|
||||
get selectorItems() {
|
||||
if (this.isUser) {
|
||||
return USER_SELECTOR_ITEMS
|
||||
} else if (this.isScene) {
|
||||
return SCENE_SELECTOR_ITEMS
|
||||
} else {
|
||||
return USER_SELECTOR_ITEMS
|
||||
}
|
||||
}
|
||||
|
||||
get selectedView() {
|
||||
return this.selectorItems[this.selectedViewIndex]
|
||||
}
|
||||
|
||||
// public api
|
||||
// =
|
||||
|
||||
setSelectedViewIndex(index: number) {
|
||||
this.selectedViewIndex = index
|
||||
}
|
||||
|
||||
async setup() {
|
||||
await Promise.all([
|
||||
this.profile
|
||||
.setup()
|
||||
.catch(err => console.error('Failed to fetch profile', err)),
|
||||
this.feed
|
||||
.setup()
|
||||
.catch(err => console.error('Failed to fetch feed', err)),
|
||||
])
|
||||
if (this.isUser) {
|
||||
await this.memberships
|
||||
.setup()
|
||||
.catch(err => console.error('Failed to fetch members', err))
|
||||
}
|
||||
if (this.isScene) {
|
||||
await this.members
|
||||
.setup()
|
||||
.catch(err => console.error('Failed to fetch members', err))
|
||||
}
|
||||
}
|
||||
|
||||
async update() {
|
||||
const view = this.currentView
|
||||
if (view instanceof FeedModel) {
|
||||
await view.update()
|
||||
} else {
|
||||
await view.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
await Promise.all([this.profile.refresh(), this.currentView.refresh()])
|
||||
}
|
||||
|
||||
async loadMore() {
|
||||
if (
|
||||
!this.currentView.isLoading &&
|
||||
!this.currentView.hasError &&
|
||||
!this.currentView.isEmpty
|
||||
) {
|
||||
await this.currentView.loadMore()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,59 @@
|
||||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import {bsky} from '@adxp/mock-api'
|
||||
import {Image as PickedImage} from 'react-native-image-crop-picker'
|
||||
import * as GetProfile from '../../third-party/api/src/client/types/app/bsky/actor/getProfile'
|
||||
import * as Profile from '../../third-party/api/src/client/types/app/bsky/actor/profile'
|
||||
import {Main as DeclRef} from '../../third-party/api/src/client/types/app/bsky/system/declRef'
|
||||
import {Entity} from '../../third-party/api/src/client/types/app/bsky/feed/post'
|
||||
import {extractEntities} from '../../lib/strings'
|
||||
import {RootStoreModel} from './root-store'
|
||||
import * as apilib from '../lib/api'
|
||||
|
||||
export const ACTOR_TYPE_USER = 'app.bsky.system.actorUser'
|
||||
export const ACTOR_TYPE_SCENE = 'app.bsky.system.actorScene'
|
||||
|
||||
export class ProfileViewMyStateModel {
|
||||
hasFollowed: boolean = false
|
||||
follow?: string
|
||||
member?: string
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this)
|
||||
}
|
||||
}
|
||||
|
||||
export class ProfileViewModel implements bsky.ProfileView.Response {
|
||||
export class ProfileViewModel {
|
||||
// state
|
||||
isLoading = false
|
||||
isRefreshing = false
|
||||
hasLoaded = false
|
||||
error = ''
|
||||
params: bsky.ProfileView.Params
|
||||
params: GetProfile.QueryParams
|
||||
|
||||
// data
|
||||
did: string = ''
|
||||
name: string = ''
|
||||
displayName: string = ''
|
||||
description: string = ''
|
||||
handle: string = ''
|
||||
declaration: DeclRef = {
|
||||
cid: '',
|
||||
actorType: '',
|
||||
}
|
||||
creator: string = ''
|
||||
displayName?: string
|
||||
description?: string
|
||||
avatar?: string
|
||||
followersCount: number = 0
|
||||
followsCount: number = 0
|
||||
membersCount: number = 0
|
||||
postsCount: number = 0
|
||||
badges: bsky.ProfileView.Badge[] = []
|
||||
myState = new ProfileViewMyStateModel()
|
||||
|
||||
// TODO TEMP data to be implemented in the protocol
|
||||
userBanner: string | null = null
|
||||
|
||||
// added data
|
||||
descriptionEntities?: Entity[]
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
params: bsky.ProfileView.Params,
|
||||
params: GetProfile.QueryParams,
|
||||
) {
|
||||
makeAutoObservable(
|
||||
this,
|
||||
@@ -57,6 +78,14 @@ export class ProfileViewModel implements bsky.ProfileView.Response {
|
||||
return this.hasLoaded && !this.hasContent
|
||||
}
|
||||
|
||||
get isUser() {
|
||||
return this.declaration.actorType === ACTOR_TYPE_USER
|
||||
}
|
||||
|
||||
get isScene() {
|
||||
return this.declaration.actorType === ACTOR_TYPE_SCENE
|
||||
}
|
||||
|
||||
// public api
|
||||
// =
|
||||
|
||||
@@ -65,30 +94,57 @@ export class ProfileViewModel implements bsky.ProfileView.Response {
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
await this._load()
|
||||
await this._load(true)
|
||||
}
|
||||
|
||||
async toggleFollowing() {
|
||||
if (this.myState.hasFollowed) {
|
||||
await apilib.unfollow(this.rootStore.api, 'alice.com', {
|
||||
did: this.did,
|
||||
})
|
||||
if (!this.rootStore.me.did) {
|
||||
throw new Error('Not logged in')
|
||||
}
|
||||
if (this.myState.follow) {
|
||||
await apilib.unfollow(this.rootStore, this.myState.follow)
|
||||
runInAction(() => {
|
||||
this.followersCount--
|
||||
this.myState.hasFollowed = false
|
||||
this.myState.follow = undefined
|
||||
})
|
||||
} else {
|
||||
await apilib.follow(this.rootStore.api, 'alice.com', {
|
||||
did: this.did,
|
||||
name: this.name,
|
||||
})
|
||||
const res = await apilib.follow(
|
||||
this.rootStore,
|
||||
this.did,
|
||||
this.declaration.cid,
|
||||
)
|
||||
runInAction(() => {
|
||||
this.followersCount++
|
||||
this.myState.hasFollowed = true
|
||||
this.myState.follow = res.uri
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async updateProfile(
|
||||
updates: Profile.Record,
|
||||
newUserAvatar: PickedImage | undefined,
|
||||
userBanner: string | null, // TODO TEMP
|
||||
) {
|
||||
// TODO TEMP add userBanner to the protocol when suported
|
||||
this.userBanner = userBanner
|
||||
|
||||
if (newUserAvatar) {
|
||||
const res = await this.rootStore.api.com.atproto.blob.upload(
|
||||
newUserAvatar.path, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts
|
||||
{
|
||||
encoding: newUserAvatar.mime,
|
||||
},
|
||||
)
|
||||
updates.avatar = {
|
||||
cid: res.data.cid,
|
||||
mimeType: newUserAvatar.mime,
|
||||
}
|
||||
}
|
||||
await this.rootStore.api.app.bsky.actor.updateProfile(updates)
|
||||
await this.rootStore.me.load()
|
||||
await this.refresh()
|
||||
}
|
||||
|
||||
// state transitions
|
||||
// =
|
||||
|
||||
@@ -108,32 +164,36 @@ export class ProfileViewModel implements bsky.ProfileView.Response {
|
||||
// loader functions
|
||||
// =
|
||||
|
||||
private async _load() {
|
||||
this._xLoading()
|
||||
await new Promise(r => setTimeout(r, 250)) // DEBUG
|
||||
private async _load(isRefreshing = false) {
|
||||
this._xLoading(isRefreshing)
|
||||
try {
|
||||
const res = (await this.rootStore.api.mainPds.view(
|
||||
'blueskyweb.xyz:ProfileView',
|
||||
const res = await this.rootStore.api.app.bsky.actor.getProfile(
|
||||
this.params,
|
||||
)) as bsky.ProfileView.Response
|
||||
)
|
||||
this.rootStore.profiles.overwrite(this.params.actor, res) // cache invalidation
|
||||
this._replaceAll(res)
|
||||
this._xIdle()
|
||||
} catch (e: any) {
|
||||
this._xIdle(`Failed to load feed: ${e.toString()}`)
|
||||
this._xIdle(e.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private _replaceAll(res: bsky.ProfileView.Response) {
|
||||
this.did = res.did
|
||||
this.name = res.name
|
||||
this.displayName = res.displayName
|
||||
this.description = res.description
|
||||
this.followersCount = res.followersCount
|
||||
this.followsCount = res.followsCount
|
||||
this.postsCount = res.postsCount
|
||||
this.badges = res.badges
|
||||
if (res.myState) {
|
||||
Object.assign(this.myState, res.myState)
|
||||
private _replaceAll(res: GetProfile.Response) {
|
||||
console.log(res.data)
|
||||
this.did = res.data.did
|
||||
this.handle = res.data.handle
|
||||
Object.assign(this.declaration, res.data.declaration)
|
||||
this.creator = res.data.creator
|
||||
this.displayName = res.data.displayName
|
||||
this.description = res.data.description
|
||||
this.avatar = res.data.avatar
|
||||
this.followersCount = res.data.followersCount
|
||||
this.followsCount = res.data.followsCount
|
||||
this.membersCount = res.data.membersCount
|
||||
this.postsCount = res.data.postsCount
|
||||
if (res.data.myState) {
|
||||
Object.assign(this.myState, res.data.myState)
|
||||
}
|
||||
this.descriptionEntities = extractEntities(this.description || '')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import {makeAutoObservable} from 'mobx'
|
||||
import {LRUMap} from 'lru_map'
|
||||
import {RootStoreModel} from './root-store'
|
||||
import * as GetProfile from '../../third-party/api/src/client/types/app/bsky/actor/getProfile'
|
||||
|
||||
type CacheValue = Promise<GetProfile.Response> | GetProfile.Response
|
||||
export class ProfilesViewModel {
|
||||
cache: LRUMap<string, CacheValue> = new LRUMap(100)
|
||||
|
||||
constructor(public rootStore: RootStoreModel) {
|
||||
makeAutoObservable(
|
||||
this,
|
||||
{
|
||||
rootStore: false,
|
||||
cache: false,
|
||||
},
|
||||
{autoBind: true},
|
||||
)
|
||||
}
|
||||
|
||||
// public api
|
||||
// =
|
||||
|
||||
async getProfile(did: string) {
|
||||
const cached = this.cache.get(did)
|
||||
if (cached) {
|
||||
try {
|
||||
return await cached
|
||||
} catch (e) {
|
||||
// ignore, we'll try again
|
||||
}
|
||||
}
|
||||
try {
|
||||
const promise = this.rootStore.api.app.bsky.actor.getProfile({actor: did})
|
||||
this.cache.set(did, promise)
|
||||
const res = await promise
|
||||
this.cache.set(did, res)
|
||||
return res
|
||||
} catch (e) {
|
||||
this.cache.delete(did)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
overwrite(did: string, res: GetProfile.Response) {
|
||||
if (this.cache.has(did)) {
|
||||
this.cache.set(did, res)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,19 @@
|
||||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import {bsky, AdxUri} from '@adxp/mock-api'
|
||||
import {AtUri} from '../../third-party/uri'
|
||||
import * as GetRepostedBy from '../../third-party/api/src/client/types/app/bsky/feed/getRepostedBy'
|
||||
import {Main as DeclRef} from '../../third-party/api/src/client/types/app/bsky/system/declRef'
|
||||
import {RootStoreModel} from './root-store'
|
||||
|
||||
type RepostedByItem = bsky.RepostedByView.Response['repostedBy'][number]
|
||||
|
||||
export class RepostedByViewItemModel implements RepostedByItem {
|
||||
export class RepostedByViewItemModel implements GetRepostedBy.RepostedBy {
|
||||
// ui state
|
||||
_reactKey: string = ''
|
||||
|
||||
// data
|
||||
did: string = ''
|
||||
name: string = ''
|
||||
handle: string = ''
|
||||
displayName: string = ''
|
||||
avatar?: string
|
||||
declaration: DeclRef = {cid: '', actorType: ''}
|
||||
createdAt?: string
|
||||
indexedAt: string = ''
|
||||
|
||||
@@ -22,14 +24,14 @@ export class RepostedByViewItemModel implements RepostedByItem {
|
||||
}
|
||||
}
|
||||
|
||||
export class RepostedByViewModel implements bsky.RepostedByView.Response {
|
||||
export class RepostedByViewModel {
|
||||
// state
|
||||
isLoading = false
|
||||
isRefreshing = false
|
||||
hasLoaded = false
|
||||
error = ''
|
||||
resolvedUri = ''
|
||||
params: bsky.RepostedByView.Params
|
||||
params: GetRepostedBy.QueryParams
|
||||
|
||||
// data
|
||||
uri: string = ''
|
||||
@@ -37,7 +39,7 @@ export class RepostedByViewModel implements bsky.RepostedByView.Response {
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
params: bsky.RepostedByView.Params,
|
||||
params: GetRepostedBy.QueryParams,
|
||||
) {
|
||||
makeAutoObservable(
|
||||
this,
|
||||
@@ -100,7 +102,7 @@ export class RepostedByViewModel implements bsky.RepostedByView.Response {
|
||||
// =
|
||||
|
||||
private async _resolveUri() {
|
||||
const urip = new AdxUri(this.params.uri)
|
||||
const urip = new AtUri(this.params.uri)
|
||||
if (!urip.host.startsWith('did:')) {
|
||||
urip.host = await this.rootStore.resolveName(urip.host)
|
||||
}
|
||||
@@ -111,12 +113,10 @@ export class RepostedByViewModel implements bsky.RepostedByView.Response {
|
||||
|
||||
private async _fetch(isRefreshing = false) {
|
||||
this._xLoading(isRefreshing)
|
||||
await new Promise(r => setTimeout(r, 250)) // DEBUG
|
||||
try {
|
||||
const res = (await this.rootStore.api.mainPds.view(
|
||||
'blueskyweb.xyz:RepostedByView',
|
||||
const res = await this.rootStore.api.app.bsky.feed.getRepostedBy(
|
||||
Object.assign({}, this.params, {uri: this.resolvedUri}),
|
||||
)) as bsky.RepostedByView.Response
|
||||
)
|
||||
this._replaceAll(res)
|
||||
this._xIdle()
|
||||
} catch (e: any) {
|
||||
@@ -127,14 +127,13 @@ export class RepostedByViewModel implements bsky.RepostedByView.Response {
|
||||
private async _refresh() {
|
||||
this._xLoading(true)
|
||||
// TODO: refetch and update items
|
||||
await new Promise(r => setTimeout(r, 250)) // DEBUG
|
||||
this._xIdle()
|
||||
}
|
||||
|
||||
private _replaceAll(res: bsky.RepostedByView.Response) {
|
||||
private _replaceAll(res: GetRepostedBy.Response) {
|
||||
this.repostedBy.length = 0
|
||||
let counter = 0
|
||||
for (const item of res.repostedBy) {
|
||||
for (const item of res.data.repostedBy) {
|
||||
this._append(counter++, item)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,21 +3,29 @@
|
||||
*/
|
||||
|
||||
import {makeAutoObservable} from 'mobx'
|
||||
import {adx, AdxClient} from '@adxp/mock-api'
|
||||
import {sessionClient as AtpApi} from '../../third-party/api'
|
||||
import type {SessionServiceClient} from '../../third-party/api/src/index'
|
||||
import {createContext, useContext} from 'react'
|
||||
import {isObj, hasProp} from '../lib/type-guards'
|
||||
import {SessionModel} from './session'
|
||||
import {NavigationModel} from './navigation'
|
||||
import {ShellUiModel} from './shell-ui'
|
||||
import {ProfilesViewModel} from './profiles-view'
|
||||
import {LinkMetasViewModel} from './link-metas-view'
|
||||
import {MeModel} from './me'
|
||||
import {FeedViewModel} from './feed-view'
|
||||
import {NotificationsViewModel} from './notifications-view'
|
||||
import {OnboardModel} from './onboard'
|
||||
import {isNetworkError} from '../../lib/errors'
|
||||
|
||||
export class RootStoreModel {
|
||||
session = new SessionModel()
|
||||
session = new SessionModel(this)
|
||||
nav = new NavigationModel()
|
||||
shell = new ShellUiModel()
|
||||
me = new MeModel(this)
|
||||
homeFeed = new FeedViewModel(this, {})
|
||||
notesFeed = new NotificationsViewModel(this, {})
|
||||
onboard = new OnboardModel()
|
||||
profiles = new ProfilesViewModel(this)
|
||||
linkMetas = new LinkMetasViewModel(this)
|
||||
|
||||
constructor(public api: AdxClient) {
|
||||
constructor(public api: SessionServiceClient) {
|
||||
makeAutoObservable(this, {
|
||||
api: false,
|
||||
resolveName: false,
|
||||
@@ -26,15 +34,40 @@ export class RootStoreModel {
|
||||
})
|
||||
}
|
||||
|
||||
async resolveName(didOrName: string) {
|
||||
const userDb = this.api.mockDb.getUser(didOrName)
|
||||
if (!userDb) throw new Error(`User not found: ${didOrName}`)
|
||||
return userDb.did
|
||||
async resolveName(didOrHandle: string) {
|
||||
if (!didOrHandle) {
|
||||
throw new Error('Invalid handle: ""')
|
||||
}
|
||||
if (didOrHandle.startsWith('did:')) {
|
||||
return didOrHandle
|
||||
}
|
||||
const res = await this.api.com.atproto.handle.resolve({handle: didOrHandle})
|
||||
return res.data.did
|
||||
}
|
||||
|
||||
async fetchStateUpdate() {
|
||||
if (!this.session.hasSession) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (!this.session.online) {
|
||||
await this.session.connect()
|
||||
}
|
||||
await this.me.fetchStateUpdate()
|
||||
} catch (e: unknown) {
|
||||
if (isNetworkError(e)) {
|
||||
this.session.setOnline(false) // connection lost
|
||||
}
|
||||
console.error('Failed to fetch latest state', e)
|
||||
}
|
||||
}
|
||||
|
||||
serialize(): unknown {
|
||||
return {
|
||||
session: this.session.serialize(),
|
||||
me: this.me.serialize(),
|
||||
nav: this.nav.serialize(),
|
||||
onboard: this.onboard.serialize(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,11 +76,26 @@ export class RootStoreModel {
|
||||
if (hasProp(v, 'session')) {
|
||||
this.session.hydrate(v.session)
|
||||
}
|
||||
if (hasProp(v, 'me')) {
|
||||
this.me.hydrate(v.me)
|
||||
}
|
||||
if (hasProp(v, 'nav')) {
|
||||
this.nav.hydrate(v.nav)
|
||||
}
|
||||
if (hasProp(v, 'onboard')) {
|
||||
this.onboard.hydrate(v.onboard)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clearAll() {
|
||||
this.session.clear()
|
||||
this.nav.clear()
|
||||
this.me.clear()
|
||||
}
|
||||
}
|
||||
|
||||
const throwawayInst = new RootStoreModel(adx) // this will be replaced by the loader
|
||||
const throwawayInst = new RootStoreModel(AtpApi.service('http://localhost')) // this will be replaced by the loader, we just need to supply a value at init
|
||||
const RootStoreContext = createContext<RootStoreModel>(throwawayInst)
|
||||
export const RootStoreProvider = RootStoreContext.Provider
|
||||
export const useStores = () => useContext(RootStoreContext)
|
||||
|
||||
@@ -1,109 +1,254 @@
|
||||
import {makeAutoObservable} from 'mobx'
|
||||
import {sessionClient as AtpApi} from '../../third-party/api/index'
|
||||
import type {
|
||||
SessionServiceClient,
|
||||
Session,
|
||||
} from '../../third-party/api/src/index'
|
||||
import type * as GetAccountsConfig from '../../third-party/api/src/client/types/com/atproto/server/getAccountsConfig'
|
||||
import {isObj, hasProp} from '../lib/type-guards'
|
||||
// import {UserConfig} from '../../api'
|
||||
// import * as auth from '../lib/auth'
|
||||
import {RootStoreModel} from './root-store'
|
||||
import {isNetworkError} from '../../lib/errors'
|
||||
|
||||
export type ServiceDescription = GetAccountsConfig.OutputSchema
|
||||
|
||||
interface SessionData {
|
||||
service: string
|
||||
refreshJwt: string
|
||||
accessJwt: string
|
||||
handle: string
|
||||
did: string
|
||||
}
|
||||
|
||||
export class SessionModel {
|
||||
isAuthed = false
|
||||
data: SessionData | null = null
|
||||
online = false
|
||||
attemptingConnect = false
|
||||
private _connectPromise: Promise<void> | undefined
|
||||
|
||||
constructor() {
|
||||
constructor(public rootStore: RootStoreModel) {
|
||||
makeAutoObservable(this, {
|
||||
rootStore: false,
|
||||
serialize: false,
|
||||
hydrate: false,
|
||||
})
|
||||
}
|
||||
|
||||
get hasSession() {
|
||||
return this.data !== null
|
||||
}
|
||||
|
||||
serialize(): unknown {
|
||||
return {
|
||||
isAuthed: this.isAuthed,
|
||||
data: this.data,
|
||||
}
|
||||
}
|
||||
|
||||
hydrate(v: unknown) {
|
||||
if (isObj(v)) {
|
||||
if (hasProp(v, 'isAuthed') && typeof v.isAuthed === 'boolean') {
|
||||
this.isAuthed = v.isAuthed
|
||||
if (hasProp(v, 'data') && isObj(v.data)) {
|
||||
const data: SessionData = {
|
||||
service: '',
|
||||
refreshJwt: '',
|
||||
accessJwt: '',
|
||||
handle: '',
|
||||
did: '',
|
||||
}
|
||||
if (hasProp(v.data, 'service') && typeof v.data.service === 'string') {
|
||||
data.service = v.data.service
|
||||
}
|
||||
if (
|
||||
hasProp(v.data, 'refreshJwt') &&
|
||||
typeof v.data.refreshJwt === 'string'
|
||||
) {
|
||||
data.refreshJwt = v.data.refreshJwt
|
||||
}
|
||||
if (
|
||||
hasProp(v.data, 'accessJwt') &&
|
||||
typeof v.data.accessJwt === 'string'
|
||||
) {
|
||||
data.accessJwt = v.data.accessJwt
|
||||
}
|
||||
if (hasProp(v.data, 'handle') && typeof v.data.handle === 'string') {
|
||||
data.handle = v.data.handle
|
||||
}
|
||||
if (hasProp(v.data, 'did') && typeof v.data.did === 'string') {
|
||||
data.did = v.data.did
|
||||
}
|
||||
if (
|
||||
data.service &&
|
||||
data.refreshJwt &&
|
||||
data.accessJwt &&
|
||||
data.handle &&
|
||||
data.did
|
||||
) {
|
||||
this.data = data
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setAuthed(v: boolean) {
|
||||
this.isAuthed = v
|
||||
clear() {
|
||||
this.data = null
|
||||
}
|
||||
|
||||
setState(data: SessionData) {
|
||||
this.data = data
|
||||
}
|
||||
|
||||
setOnline(online: boolean, attemptingConnect?: boolean) {
|
||||
this.online = online
|
||||
if (typeof attemptingConnect === 'boolean') {
|
||||
this.attemptingConnect = attemptingConnect
|
||||
}
|
||||
}
|
||||
|
||||
updateAuthTokens(session: Session) {
|
||||
if (this.data) {
|
||||
this.setState({
|
||||
...this.data,
|
||||
accessJwt: session.accessJwt,
|
||||
refreshJwt: session.refreshJwt,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private configureApi(): boolean {
|
||||
if (!this.data) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const serviceUri = new URL(this.data.service)
|
||||
this.rootStore.api.xrpc.uri = serviceUri
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Invalid service URL: ${this.data.service}. Resetting session.`,
|
||||
)
|
||||
console.error(e)
|
||||
this.clear()
|
||||
return false
|
||||
}
|
||||
|
||||
this.rootStore.api.sessionManager.set({
|
||||
refreshJwt: this.data.refreshJwt,
|
||||
accessJwt: this.data.accessJwt,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (this._connectPromise) {
|
||||
return this._connectPromise
|
||||
}
|
||||
this._connectPromise = this._connect()
|
||||
await this._connectPromise
|
||||
this._connectPromise = undefined
|
||||
}
|
||||
|
||||
private async _connect(): Promise<void> {
|
||||
this.attemptingConnect = true
|
||||
if (!this.configureApi()) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const sess = await this.rootStore.api.com.atproto.session.get()
|
||||
if (sess.success && this.data && this.data.did === sess.data.did) {
|
||||
this.setOnline(true, false)
|
||||
if (this.rootStore.me.did !== sess.data.did) {
|
||||
this.rootStore.me.clear()
|
||||
}
|
||||
this.rootStore.me.load().catch(e => {
|
||||
console.error('Failed to fetch local user information', e)
|
||||
})
|
||||
return // success
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (isNetworkError(e)) {
|
||||
this.setOnline(false, false) // connection issue
|
||||
return
|
||||
} else {
|
||||
this.clear() // invalid session cached
|
||||
}
|
||||
}
|
||||
|
||||
this.setOnline(false, false)
|
||||
}
|
||||
|
||||
async describeService(service: string): Promise<ServiceDescription> {
|
||||
const api = AtpApi.service(service) as SessionServiceClient
|
||||
const res = await api.com.atproto.server.getAccountsConfig({})
|
||||
return res.data
|
||||
}
|
||||
|
||||
async login({
|
||||
service,
|
||||
handle,
|
||||
password,
|
||||
}: {
|
||||
service: string
|
||||
handle: string
|
||||
password: string
|
||||
}) {
|
||||
const api = AtpApi.service(service) as SessionServiceClient
|
||||
const res = await api.com.atproto.session.create({handle, password})
|
||||
if (res.data.accessJwt && res.data.refreshJwt) {
|
||||
this.setState({
|
||||
service: service,
|
||||
accessJwt: res.data.accessJwt,
|
||||
refreshJwt: res.data.refreshJwt,
|
||||
handle: res.data.handle,
|
||||
did: res.data.did,
|
||||
})
|
||||
this.configureApi()
|
||||
this.rootStore.me.load().catch(e => {
|
||||
console.error('Failed to fetch local user information', e)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async createAccount({
|
||||
service,
|
||||
email,
|
||||
password,
|
||||
handle,
|
||||
inviteCode,
|
||||
}: {
|
||||
service: string
|
||||
email: string
|
||||
password: string
|
||||
handle: string
|
||||
inviteCode?: string
|
||||
}) {
|
||||
const api = AtpApi.service(service) as SessionServiceClient
|
||||
const res = await api.com.atproto.account.create({
|
||||
handle,
|
||||
password,
|
||||
email,
|
||||
inviteCode,
|
||||
})
|
||||
if (res.data.accessJwt && res.data.refreshJwt) {
|
||||
this.setState({
|
||||
service: service,
|
||||
accessJwt: res.data.accessJwt,
|
||||
refreshJwt: res.data.refreshJwt,
|
||||
handle: res.data.handle,
|
||||
did: res.data.did,
|
||||
})
|
||||
this.rootStore.onboard.start()
|
||||
this.configureApi()
|
||||
this.rootStore.me.load().catch(e => {
|
||||
console.error('Failed to fetch local user information', e)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async logout() {
|
||||
if (this.hasSession) {
|
||||
this.rootStore.api.com.atproto.session.delete().catch((e: any) => {
|
||||
console.error('(Minor issue) Failed to delete session on the server', e)
|
||||
})
|
||||
}
|
||||
this.rootStore.clearAll()
|
||||
}
|
||||
}
|
||||
|
||||
// TODO
|
||||
/*login: flow(function* () {
|
||||
/*self.uiIsProcessing = true
|
||||
self.uiError = undefined
|
||||
try {
|
||||
if (!self.env.authStore) {
|
||||
throw new Error('Auth store not initialized')
|
||||
}
|
||||
const res = yield auth.requestAppUcan(self.env.authStore)
|
||||
self.isAuthed = res
|
||||
self.uiIsProcessing = false
|
||||
return res
|
||||
} catch (e: any) {
|
||||
console.error('Failed to request app ucan', e)
|
||||
self.uiError = e.toString()
|
||||
self.uiIsProcessing = false
|
||||
return false
|
||||
}
|
||||
}),
|
||||
logout: flow(function* () {
|
||||
self.uiIsProcessing = true
|
||||
self.uiError = undefined
|
||||
try {
|
||||
if (!self.env.authStore) {
|
||||
throw new Error('Auth store not initialized')
|
||||
}
|
||||
const res = yield auth.logout(self.env.authStore)
|
||||
self.isAuthed = false
|
||||
self.uiIsProcessing = false
|
||||
return res
|
||||
} catch (e: any) {
|
||||
console.error('Failed to log out', e)
|
||||
self.uiError = e.toString()
|
||||
self.uiIsProcessing = false
|
||||
return false
|
||||
}
|
||||
}),
|
||||
loadAccount: flow(function* () {
|
||||
self.uiIsProcessing = true
|
||||
self.uiError = undefined
|
||||
try {
|
||||
// const cfg = yield UserConfig.hydrate({
|
||||
// serverUrl: self.serverUrl,
|
||||
// secretKeyStr: self.secretKeyStr,
|
||||
// rootAuthToken: self.rootAuthToken,
|
||||
// })
|
||||
// self.env.api.setUserCfg(cfg)
|
||||
self.isAuthed = true
|
||||
self.uiIsProcessing = false
|
||||
return true
|
||||
} catch (e: any) {
|
||||
console.error('Failed to create test account', e)
|
||||
self.uiError = e.toString()
|
||||
self.uiIsProcessing = false
|
||||
return false
|
||||
}
|
||||
}),
|
||||
createTestAccount: flow(function* (_serverUrl: string) {
|
||||
self.uiIsProcessing = true
|
||||
self.uiError = undefined
|
||||
try {
|
||||
// const cfg = yield UserConfig.createTest(serverUrl)
|
||||
// const state = yield cfg.serialize()
|
||||
// self.serverUrl = state.serverUrl
|
||||
// self.secretKeyStr = state.secretKeyStr
|
||||
// self.rootAuthToken = state.rootAuthToken
|
||||
self.isAuthed = true
|
||||
// self.env.api.setUserCfg(cfg)
|
||||
} catch (e: any) {
|
||||
console.error('Failed to create test account', e)
|
||||
self.uiError = e.toString()
|
||||
}
|
||||
self.uiIsProcessing = false
|
||||
}),
|
||||
}))*/
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import {makeAutoObservable} from 'mobx'
|
||||
import {ProfileViewModel} from './profile-view'
|
||||
|
||||
export class ConfirmModel {
|
||||
name = 'confirm'
|
||||
|
||||
constructor(
|
||||
public title: string,
|
||||
public message: string | (() => JSX.Element),
|
||||
public onPressConfirm: () => void | Promise<void>,
|
||||
) {
|
||||
makeAutoObservable(this)
|
||||
}
|
||||
}
|
||||
|
||||
export class EditProfileModel {
|
||||
name = 'edit-profile'
|
||||
|
||||
constructor(
|
||||
public profileView: ProfileViewModel,
|
||||
public onUpdate?: () => void,
|
||||
) {
|
||||
makeAutoObservable(this)
|
||||
}
|
||||
}
|
||||
|
||||
export class CreateSceneModel {
|
||||
name = 'create-scene'
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this)
|
||||
}
|
||||
}
|
||||
|
||||
export class InviteToSceneModel {
|
||||
name = 'invite-to-scene'
|
||||
|
||||
constructor(public profileView: ProfileViewModel) {
|
||||
makeAutoObservable(this)
|
||||
}
|
||||
}
|
||||
|
||||
export class ServerInputModel {
|
||||
name = 'server-input'
|
||||
|
||||
constructor(
|
||||
public initialService: string,
|
||||
public onSelect: (url: string) => void,
|
||||
) {
|
||||
makeAutoObservable(this)
|
||||
}
|
||||
}
|
||||
|
||||
export interface ComposerOptsPostRef {
|
||||
uri: string
|
||||
cid: string
|
||||
text: string
|
||||
author: {
|
||||
handle: string
|
||||
displayName?: string
|
||||
avatar?: string
|
||||
}
|
||||
}
|
||||
export interface ComposerOpts {
|
||||
replyTo?: ComposerOptsPostRef
|
||||
onPost?: () => void
|
||||
}
|
||||
|
||||
export class ShellUiModel {
|
||||
isMainMenuOpen = false
|
||||
isModalActive = false
|
||||
activeModal:
|
||||
| ConfirmModel
|
||||
| EditProfileModel
|
||||
| CreateSceneModel
|
||||
| ServerInputModel
|
||||
| undefined
|
||||
isComposerActive = false
|
||||
composerOpts: ComposerOpts | undefined
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this)
|
||||
}
|
||||
|
||||
setMainMenuOpen(v: boolean) {
|
||||
this.isMainMenuOpen = v
|
||||
}
|
||||
|
||||
openModal(
|
||||
modal:
|
||||
| ConfirmModel
|
||||
| EditProfileModel
|
||||
| CreateSceneModel
|
||||
| ServerInputModel,
|
||||
) {
|
||||
this.isModalActive = true
|
||||
this.activeModal = modal
|
||||
}
|
||||
|
||||
closeModal() {
|
||||
this.isModalActive = false
|
||||
this.activeModal = undefined
|
||||
}
|
||||
|
||||
openComposer(opts: ComposerOpts) {
|
||||
this.isComposerActive = true
|
||||
this.composerOpts = opts
|
||||
}
|
||||
|
||||
closeComposer() {
|
||||
this.isComposerActive = false
|
||||
this.composerOpts = undefined
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import {makeAutoObservable} from 'mobx'
|
||||
import * as GetSuggestions from '../../third-party/api/src/client/types/app/bsky/actor/getSuggestions'
|
||||
import {RootStoreModel} from './root-store'
|
||||
|
||||
export type SuggestedActor = GetSuggestions.Actor & {
|
||||
_reactKey: string
|
||||
}
|
||||
|
||||
export class SuggestedActorsViewModel {
|
||||
// state
|
||||
isLoading = false
|
||||
isRefreshing = false
|
||||
hasLoaded = false
|
||||
error = ''
|
||||
|
||||
// data
|
||||
suggestions: SuggestedActor[] = []
|
||||
|
||||
constructor(public rootStore: RootStoreModel) {
|
||||
makeAutoObservable(
|
||||
this,
|
||||
{
|
||||
rootStore: false,
|
||||
},
|
||||
{autoBind: true},
|
||||
)
|
||||
}
|
||||
|
||||
get hasContent() {
|
||||
return this.suggestions.length > 0
|
||||
}
|
||||
|
||||
get hasError() {
|
||||
return this.error !== ''
|
||||
}
|
||||
|
||||
get isEmpty() {
|
||||
return this.hasLoaded && !this.hasContent
|
||||
}
|
||||
|
||||
// public api
|
||||
// =
|
||||
|
||||
async setup() {
|
||||
await this._fetch()
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
await this._fetch(true)
|
||||
}
|
||||
|
||||
// state transitions
|
||||
// =
|
||||
|
||||
private _xLoading(isRefreshing = false) {
|
||||
this.isLoading = true
|
||||
this.isRefreshing = isRefreshing
|
||||
this.error = ''
|
||||
}
|
||||
|
||||
private _xIdle(err: string = '') {
|
||||
this.isLoading = false
|
||||
this.isRefreshing = false
|
||||
this.hasLoaded = true
|
||||
this.error = err
|
||||
}
|
||||
|
||||
// loader functions
|
||||
// =
|
||||
|
||||
private async _fetch(isRefreshing = false) {
|
||||
this.suggestions.length = 0
|
||||
this._xLoading(isRefreshing)
|
||||
let cursor
|
||||
let res
|
||||
try {
|
||||
do {
|
||||
res = await this.rootStore.api.app.bsky.actor.getSuggestions({
|
||||
limit: 20,
|
||||
cursor,
|
||||
})
|
||||
this._appendAll(res)
|
||||
cursor = res.data.cursor
|
||||
} while (
|
||||
cursor &&
|
||||
res.data.actors.length === 20 &&
|
||||
this.suggestions.length < 20
|
||||
)
|
||||
this._xIdle()
|
||||
} catch (e: any) {
|
||||
this._xIdle(e.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private _appendAll(res: GetSuggestions.Response) {
|
||||
for (const item of res.data.actors) {
|
||||
if (item.did === this.rootStore.me.did) {
|
||||
continue // skip self
|
||||
}
|
||||
if (item.myState?.follow) {
|
||||
continue // skip already-followed users
|
||||
}
|
||||
this._append({
|
||||
_reactKey: `item-${this.suggestions.length}`,
|
||||
...item,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private _append(item: SuggestedActor) {
|
||||
this.suggestions.push(item)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import {RootStoreModel} from './root-store'
|
||||
import {UserFollowsViewModel, FollowItem} from './user-follows-view'
|
||||
import {GetAssertionsView} from './get-assertions-view'
|
||||
import {APP_BSKY_SYSTEM, APP_BSKY_GRAPH} from '../../third-party/api'
|
||||
|
||||
export interface SuggestedInvitesViewParams {
|
||||
sceneDid: string
|
||||
}
|
||||
|
||||
export class SuggestedInvitesView {
|
||||
// state
|
||||
isLoading = false
|
||||
isRefreshing = false
|
||||
hasLoaded = false
|
||||
error = ''
|
||||
params: SuggestedInvitesViewParams
|
||||
sceneAssertionsView: GetAssertionsView
|
||||
myFollowsView: UserFollowsViewModel
|
||||
|
||||
// data
|
||||
suggestions: FollowItem[] = []
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
params: SuggestedInvitesViewParams,
|
||||
) {
|
||||
makeAutoObservable(
|
||||
this,
|
||||
{
|
||||
rootStore: false,
|
||||
params: false,
|
||||
},
|
||||
{autoBind: true},
|
||||
)
|
||||
this.params = params
|
||||
this.sceneAssertionsView = new GetAssertionsView(rootStore, {
|
||||
author: params.sceneDid,
|
||||
assertion: APP_BSKY_GRAPH.AssertMember,
|
||||
})
|
||||
this.myFollowsView = new UserFollowsViewModel(rootStore, {
|
||||
user: rootStore.me.did || '',
|
||||
})
|
||||
}
|
||||
|
||||
get hasContent() {
|
||||
return this.suggestions.length > 0
|
||||
}
|
||||
|
||||
get hasError() {
|
||||
return this.error !== ''
|
||||
}
|
||||
|
||||
get isEmpty() {
|
||||
return this.hasLoaded && !this.hasContent
|
||||
}
|
||||
|
||||
get unconfirmed() {
|
||||
return this.sceneAssertionsView.unconfirmed
|
||||
}
|
||||
|
||||
// public api
|
||||
// =
|
||||
|
||||
async setup() {
|
||||
await this._fetch(false)
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
await this._fetch(true)
|
||||
}
|
||||
|
||||
async loadMore() {
|
||||
// TODO
|
||||
}
|
||||
|
||||
// state transitions
|
||||
// =
|
||||
|
||||
private _xLoading(isRefreshing = false) {
|
||||
this.isLoading = true
|
||||
this.isRefreshing = isRefreshing
|
||||
this.error = ''
|
||||
}
|
||||
|
||||
private _xIdle(err: string = '') {
|
||||
this.isLoading = false
|
||||
this.isRefreshing = false
|
||||
this.hasLoaded = true
|
||||
this.error = err
|
||||
}
|
||||
|
||||
// loader functions
|
||||
// =
|
||||
|
||||
private async _fetch(isRefreshing = false) {
|
||||
this._xLoading(isRefreshing)
|
||||
try {
|
||||
// TODO need to fetch all!
|
||||
await this.sceneAssertionsView.setup()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
this._xIdle(
|
||||
'Failed to fetch the current scene members. Check your internet connection and try again.',
|
||||
)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await this.myFollowsView.setup()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
this._xIdle(
|
||||
'Failed to fetch the your current followers. Check your internet connection and try again.',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// collect all followed users that arent already in the scene
|
||||
const newSuggestions: FollowItem[] = []
|
||||
for (const follow of this.myFollowsView.follows) {
|
||||
if (follow.declaration.actorType !== APP_BSKY_SYSTEM.ActorUser) {
|
||||
continue
|
||||
}
|
||||
if (!this.sceneAssertionsView.getBySubject(follow.did)) {
|
||||
newSuggestions.push(follow)
|
||||
}
|
||||
}
|
||||
runInAction(() => {
|
||||
this.suggestions = newSuggestions
|
||||
})
|
||||
this._xIdle()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import * as GetFollows from '../../third-party/api/src/client/types/app/bsky/graph/getFollows'
|
||||
import * as SearchTypeahead from '../../third-party/api/src/client/types/app/bsky/actor/searchTypeahead'
|
||||
import {RootStoreModel} from './root-store'
|
||||
|
||||
export class UserAutocompleteViewModel {
|
||||
// state
|
||||
isLoading = false
|
||||
isActive = false
|
||||
prefix = ''
|
||||
_searchPromise: Promise<any> | undefined
|
||||
|
||||
// data
|
||||
follows: GetFollows.Follow[] = []
|
||||
searchRes: SearchTypeahead.User[] = []
|
||||
knownHandles: Set<string> = new Set()
|
||||
|
||||
constructor(public rootStore: RootStoreModel) {
|
||||
makeAutoObservable(
|
||||
this,
|
||||
{
|
||||
rootStore: false,
|
||||
knownHandles: false,
|
||||
},
|
||||
{autoBind: true},
|
||||
)
|
||||
}
|
||||
|
||||
get suggestions() {
|
||||
if (!this.isActive) {
|
||||
return []
|
||||
}
|
||||
if (this.prefix) {
|
||||
return this.searchRes.map(user => ({
|
||||
handle: user.handle,
|
||||
displayName: user.displayName,
|
||||
avatar: user.avatar,
|
||||
}))
|
||||
}
|
||||
return this.follows.map(follow => ({
|
||||
handle: follow.handle,
|
||||
displayName: follow.displayName,
|
||||
avatar: follow.avatar,
|
||||
}))
|
||||
}
|
||||
|
||||
// public api
|
||||
// =
|
||||
|
||||
async setup() {
|
||||
await this._getFollows()
|
||||
}
|
||||
|
||||
setActive(v: boolean) {
|
||||
this.isActive = v
|
||||
}
|
||||
|
||||
async setPrefix(prefix: string) {
|
||||
const origPrefix = prefix
|
||||
this.prefix = prefix.trim()
|
||||
if (this.prefix) {
|
||||
await this._searchPromise
|
||||
if (this.prefix !== origPrefix) {
|
||||
return // another prefix was set before we got our chance
|
||||
}
|
||||
this._searchPromise = this._search()
|
||||
} else {
|
||||
this.searchRes = []
|
||||
}
|
||||
}
|
||||
|
||||
// internal
|
||||
// =
|
||||
|
||||
private async _getFollows() {
|
||||
const res = await this.rootStore.api.app.bsky.graph.getFollows({
|
||||
user: this.rootStore.me.did || '',
|
||||
})
|
||||
runInAction(() => {
|
||||
this.follows = res.data.follows
|
||||
for (const f of this.follows) {
|
||||
this.knownHandles.add(f.handle)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async _search() {
|
||||
const res = await this.rootStore.api.app.bsky.actor.searchTypeahead({
|
||||
term: this.prefix,
|
||||
limit: 8,
|
||||
})
|
||||
runInAction(() => {
|
||||
this.searchRes = res.data.users
|
||||
for (const u of this.searchRes) {
|
||||
this.knownHandles.add(u.handle)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,31 @@
|
||||
import {makeAutoObservable} from 'mobx'
|
||||
import {bsky} from '@adxp/mock-api'
|
||||
import * as GetFollowers from '../../third-party/api/src/client/types/app/bsky/graph/getFollowers'
|
||||
import * as ActorRef from '../../third-party/api/src/client/types/app/bsky/actor/ref'
|
||||
import {RootStoreModel} from './root-store'
|
||||
|
||||
type Subject = bsky.UserFollowersView.Response['subject']
|
||||
export type FollowerItem =
|
||||
bsky.UserFollowersView.Response['followers'][number] & {_reactKey: string}
|
||||
export type FollowerItem = GetFollowers.Follower & {
|
||||
_reactKey: string
|
||||
}
|
||||
|
||||
export class UserFollowersViewModel implements bsky.UserFollowersView.Response {
|
||||
export class UserFollowersViewModel {
|
||||
// state
|
||||
isLoading = false
|
||||
isRefreshing = false
|
||||
hasLoaded = false
|
||||
error = ''
|
||||
params: bsky.UserFollowersView.Params
|
||||
params: GetFollowers.QueryParams
|
||||
|
||||
// data
|
||||
subject: Subject = {did: '', name: '', displayName: ''}
|
||||
subject: ActorRef.WithInfo = {
|
||||
did: '',
|
||||
handle: '',
|
||||
declaration: {cid: '', actorType: ''},
|
||||
}
|
||||
followers: FollowerItem[] = []
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
params: bsky.UserFollowersView.Params,
|
||||
params: GetFollowers.QueryParams,
|
||||
) {
|
||||
makeAutoObservable(
|
||||
this,
|
||||
@@ -81,12 +86,10 @@ export class UserFollowersViewModel implements bsky.UserFollowersView.Response {
|
||||
|
||||
private async _fetch(isRefreshing = false) {
|
||||
this._xLoading(isRefreshing)
|
||||
await new Promise(r => setTimeout(r, 250)) // DEBUG
|
||||
try {
|
||||
const res = (await this.rootStore.api.mainPds.view(
|
||||
'blueskyweb.xyz:UserFollowersView',
|
||||
const res = await this.rootStore.api.app.bsky.graph.getFollowers(
|
||||
this.params,
|
||||
)) as bsky.UserFollowersView.Response
|
||||
)
|
||||
this._replaceAll(res)
|
||||
this._xIdle()
|
||||
} catch (e: any) {
|
||||
@@ -94,13 +97,14 @@ export class UserFollowersViewModel implements bsky.UserFollowersView.Response {
|
||||
}
|
||||
}
|
||||
|
||||
private _replaceAll(res: bsky.UserFollowersView.Response) {
|
||||
this.subject.did = res.subject.did
|
||||
this.subject.name = res.subject.name
|
||||
this.subject.displayName = res.subject.displayName
|
||||
private _replaceAll(res: GetFollowers.Response) {
|
||||
this.subject.did = res.data.subject.did
|
||||
this.subject.handle = res.data.subject.handle
|
||||
this.subject.displayName = res.data.subject.displayName
|
||||
this.subject.avatar = res.data.subject.avatar
|
||||
this.followers.length = 0
|
||||
let counter = 0
|
||||
for (const item of res.followers) {
|
||||
for (const item of res.data.followers) {
|
||||
this._append({_reactKey: `item-${counter++}`, ...item})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,31 @@
|
||||
import {makeAutoObservable} from 'mobx'
|
||||
import {bsky} from '@adxp/mock-api'
|
||||
import * as GetFollows from '../../third-party/api/src/client/types/app/bsky/graph/getFollows'
|
||||
import * as ActorRef from '../../third-party/api/src/client/types/app/bsky/actor/ref'
|
||||
import {RootStoreModel} from './root-store'
|
||||
|
||||
type Subject = bsky.UserFollowsView.Response['subject']
|
||||
export type FollowItem = bsky.UserFollowsView.Response['follows'][number] & {
|
||||
export type FollowItem = GetFollows.Follow & {
|
||||
_reactKey: string
|
||||
}
|
||||
|
||||
export class UserFollowsViewModel implements bsky.UserFollowsView.Response {
|
||||
export class UserFollowsViewModel {
|
||||
// state
|
||||
isLoading = false
|
||||
isRefreshing = false
|
||||
hasLoaded = false
|
||||
error = ''
|
||||
params: bsky.UserFollowsView.Params
|
||||
params: GetFollows.QueryParams
|
||||
|
||||
// data
|
||||
subject: Subject = {did: '', name: '', displayName: ''}
|
||||
subject: ActorRef.WithInfo = {
|
||||
did: '',
|
||||
handle: '',
|
||||
declaration: {cid: '', actorType: ''},
|
||||
}
|
||||
follows: FollowItem[] = []
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
params: bsky.UserFollowsView.Params,
|
||||
params: GetFollows.QueryParams,
|
||||
) {
|
||||
makeAutoObservable(
|
||||
this,
|
||||
@@ -82,12 +86,10 @@ export class UserFollowsViewModel implements bsky.UserFollowsView.Response {
|
||||
|
||||
private async _fetch(isRefreshing = false) {
|
||||
this._xLoading(isRefreshing)
|
||||
await new Promise(r => setTimeout(r, 250)) // DEBUG
|
||||
try {
|
||||
const res = (await this.rootStore.api.mainPds.view(
|
||||
'blueskyweb.xyz:UserFollowsView',
|
||||
const res = await this.rootStore.api.app.bsky.graph.getFollows(
|
||||
this.params,
|
||||
)) as bsky.UserFollowsView.Response
|
||||
)
|
||||
this._replaceAll(res)
|
||||
this._xIdle()
|
||||
} catch (e: any) {
|
||||
@@ -95,13 +97,14 @@ export class UserFollowsViewModel implements bsky.UserFollowsView.Response {
|
||||
}
|
||||
}
|
||||
|
||||
private _replaceAll(res: bsky.UserFollowsView.Response) {
|
||||
this.subject.did = res.subject.did
|
||||
this.subject.name = res.subject.name
|
||||
this.subject.displayName = res.subject.displayName
|
||||
private _replaceAll(res: GetFollows.Response) {
|
||||
this.subject.did = res.data.subject.did
|
||||
this.subject.handle = res.data.subject.handle
|
||||
this.subject.displayName = res.data.subject.displayName
|
||||
this.subject.avatar = res.data.subject.avatar
|
||||
this.follows.length = 0
|
||||
let counter = 0
|
||||
for (const item of res.follows) {
|
||||
for (const item of res.data.follows) {
|
||||
this._append({_reactKey: `item-${counter++}`, ...item})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import {PhotoIdentifier} from './../../../node_modules/@react-native-camera-roll/camera-roll/src/CameraRoll'
|
||||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import {CameraRoll} from '@react-native-camera-roll/camera-roll'
|
||||
import {RootStoreModel} from './root-store'
|
||||
|
||||
export class UserLocalPhotosModel {
|
||||
// state
|
||||
photos: PhotoIdentifier[] = []
|
||||
|
||||
constructor(public rootStore: RootStoreModel) {
|
||||
makeAutoObservable(this, {
|
||||
rootStore: false,
|
||||
})
|
||||
}
|
||||
|
||||
async setup() {
|
||||
await this._getPhotos()
|
||||
}
|
||||
|
||||
private async _getPhotos() {
|
||||
CameraRoll.getPhotos({first: 20}).then(r => {
|
||||
runInAction(() => {
|
||||
this.photos = r.edges
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,44 +1,44 @@
|
||||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import {bsky, AdxUri} from '@adxp/mock-api'
|
||||
import {AtUri} from '../../third-party/uri'
|
||||
import * as GetVotes from '../../third-party/api/src/client/types/app/bsky/feed/getVotes'
|
||||
import * as ActorRef from '../../third-party/api/src/client/types/app/bsky/actor/ref'
|
||||
import {RootStoreModel} from './root-store'
|
||||
|
||||
type LikedByItem = bsky.LikedByView.Response['likedBy'][number]
|
||||
|
||||
export class LikedByViewItemModel implements LikedByItem {
|
||||
export class VotesViewItemModel implements GetVotes.Vote {
|
||||
// ui state
|
||||
_reactKey: string = ''
|
||||
|
||||
// data
|
||||
did: string = ''
|
||||
name: string = ''
|
||||
displayName: string = ''
|
||||
createdAt?: string
|
||||
direction: 'up' | 'down' = 'up'
|
||||
indexedAt: string = ''
|
||||
createdAt: string = ''
|
||||
actor: ActorRef.WithInfo = {
|
||||
did: '',
|
||||
handle: '',
|
||||
declaration: {cid: '', actorType: ''},
|
||||
}
|
||||
|
||||
constructor(reactKey: string, v: LikedByItem) {
|
||||
constructor(reactKey: string, v: GetVotes.Vote) {
|
||||
makeAutoObservable(this)
|
||||
this._reactKey = reactKey
|
||||
Object.assign(this, v)
|
||||
}
|
||||
}
|
||||
|
||||
export class LikedByViewModel implements bsky.LikedByView.Response {
|
||||
export class VotesViewModel {
|
||||
// state
|
||||
isLoading = false
|
||||
isRefreshing = false
|
||||
hasLoaded = false
|
||||
error = ''
|
||||
resolvedUri = ''
|
||||
params: bsky.LikedByView.Params
|
||||
params: GetVotes.QueryParams
|
||||
|
||||
// data
|
||||
uri: string = ''
|
||||
likedBy: LikedByViewItemModel[] = []
|
||||
votes: VotesViewItemModel[] = []
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
params: bsky.LikedByView.Params,
|
||||
) {
|
||||
constructor(public rootStore: RootStoreModel, params: GetVotes.QueryParams) {
|
||||
makeAutoObservable(
|
||||
this,
|
||||
{
|
||||
@@ -100,7 +100,7 @@ export class LikedByViewModel implements bsky.LikedByView.Response {
|
||||
// =
|
||||
|
||||
private async _resolveUri() {
|
||||
const urip = new AdxUri(this.params.uri)
|
||||
const urip = new AtUri(this.params.uri)
|
||||
if (!urip.host.startsWith('did:')) {
|
||||
urip.host = await this.rootStore.resolveName(urip.host)
|
||||
}
|
||||
@@ -111,12 +111,10 @@ export class LikedByViewModel implements bsky.LikedByView.Response {
|
||||
|
||||
private async _fetch(isRefreshing = false) {
|
||||
this._xLoading(isRefreshing)
|
||||
await new Promise(r => setTimeout(r, 250)) // DEBUG
|
||||
try {
|
||||
const res = (await this.rootStore.api.mainPds.view(
|
||||
'blueskyweb.xyz:LikedByView',
|
||||
const res = await this.rootStore.api.app.bsky.feed.getVotes(
|
||||
Object.assign({}, this.params, {uri: this.resolvedUri}),
|
||||
)) as bsky.LikedByView.Response
|
||||
)
|
||||
this._replaceAll(res)
|
||||
this._xIdle()
|
||||
} catch (e: any) {
|
||||
@@ -124,15 +122,15 @@ export class LikedByViewModel implements bsky.LikedByView.Response {
|
||||
}
|
||||
}
|
||||
|
||||
private _replaceAll(res: bsky.LikedByView.Response) {
|
||||
this.likedBy.length = 0
|
||||
private _replaceAll(res: GetVotes.Response) {
|
||||
this.votes.length = 0
|
||||
let counter = 0
|
||||
for (const item of res.likedBy) {
|
||||
for (const item of res.data.votes) {
|
||||
this._append(counter++, item)
|
||||
}
|
||||
}
|
||||
|
||||
private _append(keyId: number, item: LikedByItem) {
|
||||
this.likedBy.push(new LikedByViewItemModel(`item-${keyId}`, item))
|
||||
private _append(keyId: number, item: GetVotes.Vote) {
|
||||
this.votes.push(new VotesViewItemModel(`item-${keyId}`, item))
|
||||
}
|
||||
}
|
||||