Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d840faacc | |||
| ded615d9f9 | |||
| 551af88f22 | |||
| a49fe13223 | |||
| 9f001526d3 | |||
| d6b8313932 | |||
| e4b4d854d6 | |||
| c352e0f8b4 | |||
| e7968bc8d7 | |||
| 6f1589971c | |||
| 2ffb98e22a | |||
| d918f8dc2a | |||
| d032734278 | |||
| 3e1f076891 | |||
| de93e8de74 | |||
| da96fb1ef5 | |||
| b02445883a | |||
| bd4703ca1e | |||
| 891b432ead | |||
| 3b55f61d5f | |||
| 8c596b61c0 | |||
| 44670c7ee2 | |||
| 8d8323421c | |||
| 21d4d5f600 | |||
| bda10510a4 | |||
| 16f295ca85 | |||
| f05aebf78e | |||
| de257a1186 | |||
| d93acb25f4 | |||
| 708a80e7a7 | |||
| b51640fbc0 | |||
| 5cda807d9d | |||
| f868821cfc | |||
| 2bb3694819 | |||
| cd3b502b34 | |||
| 22e1eb18c8 | |||
| 8eb3cebb36 | |||
| 455937dd0f | |||
| 05b55c1966 | |||
| d614f6cb71 | |||
| 89c9fd3be1 | |||
| 8569e2e389 | |||
| 9879159438 | |||
| 8e2c21a882 | |||
| d6275e98c2 | |||
| 9431201026 | |||
| 9d4622e713 | |||
| 8de028387c | |||
| 8feb2ab449 | |||
| 16925baf8d | |||
| b077cbe399 | |||
| 3bdceac2fb | |||
| 76f860dad2 | |||
| 13c08f56ba | |||
| a72f55a11f | |||
| cd497a3974 | |||
| d92036f2c5 | |||
| c4abaa1abc | |||
| fba4a9ca02 | |||
| e48f8e15eb | |||
| 5fe1e171f0 | |||
| 4cc55f05c2 | |||
| 9edb487949 | |||
| 9628070e52 | |||
| eb6f44853d | |||
| 165feedb86 |
@@ -120,6 +120,32 @@ jobs:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
|
||||
|
||||
- name: 🏗️ Build Production APK
|
||||
if: ${{ inputs.profile == 'production' }}
|
||||
run: yarn use-build-number-with-bump eas build -p android --profile production-apk --local --output build.apk --non-interactive
|
||||
|
||||
- name: 🚀 Upload Production APK Artifact
|
||||
id: upload-artifact-production-apk
|
||||
if: ${{ inputs.profile == 'production' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
retention-days: 30
|
||||
compression-level: 6
|
||||
name: build-${{ steps.timestamp.outputs.time }}.apk
|
||||
path: build.apk
|
||||
|
||||
- name: 🔔 Notify Slack of Production APK Build
|
||||
if: ${{ inputs.profile == 'production' }}
|
||||
uses: slackapi/slack-github-action@v1.25.0
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"text": "Android production APK build is ready for download. This is a production build, and you should add it to the GitHub release! Download the artifact here: ${{ steps.upload-artifact-production-apk.outputs.artifact-url }}"
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
|
||||
|
||||
- name: ⬇️ Restore Cache
|
||||
id: get-base-commit
|
||||
uses: actions/cache@v4
|
||||
|
||||
@@ -142,7 +142,8 @@ describe('makeRecordUri', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('ago', () => {
|
||||
// FIXME: Reenable after fixing non-deterministic test.
|
||||
describe.skip('ago', () => {
|
||||
const oneYearDate = new Date(
|
||||
new Date().setMonth(new Date().getMonth() - 11),
|
||||
).setDate(new Date().getDate() - 28)
|
||||
@@ -479,6 +480,26 @@ describe('parseEmbedPlayerFromUrl', () => {
|
||||
'https://media.tenor.com/someID/someName.gif',
|
||||
'https://media.tenor.com/someID',
|
||||
'https://media.tenor.com',
|
||||
|
||||
'https://www.flickr.com/photos/username/albums/72177720308493661',
|
||||
'https://flickr.com/photos/username/albums/72177720308493661',
|
||||
'https://flickr.com/photos/username/albums/72177720308493661/',
|
||||
'https://flickr.com/photos/username/albums/72177720308493661//',
|
||||
'https://flic.kr/s/aHBqjAES3i',
|
||||
|
||||
'https://flickr.com/foetoes/username/albums/3903',
|
||||
'https://flickr.com/albums/3903',
|
||||
'https://flic.kr/s/OolI',
|
||||
'https://flic.kr/t/aHBqjAES3i',
|
||||
|
||||
'https://www.flickr.com/groups/898944@N23/pool',
|
||||
'https://flickr.com/groups/898944@N23/pool',
|
||||
'https://flickr.com/groups/898944@N23/pool/',
|
||||
'https://flickr.com/groups/898944@N23/pool//',
|
||||
'https://flic.kr/go/8WJtR',
|
||||
|
||||
'https://www.flickr.com/groups/898944@N23/',
|
||||
'https://www.flickr.com/groups',
|
||||
]
|
||||
|
||||
const outputs = [
|
||||
@@ -776,6 +797,66 @@ describe('parseEmbedPlayerFromUrl', () => {
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
|
||||
{
|
||||
type: 'flickr_album',
|
||||
source: 'flickr',
|
||||
playerUri: 'https://embedr.flickr.com/photosets/72177720308493661',
|
||||
},
|
||||
{
|
||||
type: 'flickr_album',
|
||||
source: 'flickr',
|
||||
playerUri: 'https://embedr.flickr.com/photosets/72177720308493661',
|
||||
},
|
||||
{
|
||||
type: 'flickr_album',
|
||||
source: 'flickr',
|
||||
playerUri: 'https://embedr.flickr.com/photosets/72177720308493661',
|
||||
},
|
||||
{
|
||||
type: 'flickr_album',
|
||||
source: 'flickr',
|
||||
playerUri: 'https://embedr.flickr.com/photosets/72177720308493661',
|
||||
},
|
||||
{
|
||||
type: 'flickr_album',
|
||||
source: 'flickr',
|
||||
playerUri: 'https://embedr.flickr.com/photosets/72177720308493661',
|
||||
},
|
||||
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
|
||||
{
|
||||
type: 'flickr_album',
|
||||
source: 'flickr',
|
||||
playerUri: 'https://embedr.flickr.com/groups/898944@N23',
|
||||
},
|
||||
{
|
||||
type: 'flickr_album',
|
||||
source: 'flickr',
|
||||
playerUri: 'https://embedr.flickr.com/groups/898944@N23',
|
||||
},
|
||||
{
|
||||
type: 'flickr_album',
|
||||
source: 'flickr',
|
||||
playerUri: 'https://embedr.flickr.com/groups/898944@N23',
|
||||
},
|
||||
{
|
||||
type: 'flickr_album',
|
||||
source: 'flickr',
|
||||
playerUri: 'https://embedr.flickr.com/groups/898944@N23',
|
||||
},
|
||||
{
|
||||
type: 'flickr_album',
|
||||
source: 'flickr',
|
||||
playerUri: 'https://embedr.flickr.com/groups/898944@N23',
|
||||
},
|
||||
|
||||
undefined,
|
||||
undefined,
|
||||
]
|
||||
|
||||
it('correctly grabs the correct id from uri', () => {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M3.004 4a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h2v5a1 1 0 0 0 .43.822c.57.395 1.176.031 1.685-.255.428-.24.998-.614 1.569-1.15 1.154-1.082 2.316-2.832 2.316-5.417V5a1 1 0 0 0-1-1h-7ZM14.004 4a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h2v5a1 1 0 0 0 .43.822c.57.395 1.176.031 1.685-.255.428-.24.998-.614 1.569-1.15 1.154-1.082 2.316-2.832 2.316-5.417V5a1 1 0 0 0-1-1h-7Z"/></svg>
|
||||
|
After Width: | Height: | Size: 450 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M2.004 5a1 1 0 0 1 1-1h7a1 1 0 0 1 1 1v8c0 2.585-1.162 4.335-2.316 5.417-.571.536-1.14.91-1.569 1.15a7.01 7.01 0 0 1-.738.36l-.016.006-.006.002h-.002l-.001.001L6.004 19l.351.936A1 1 0 0 1 5.004 19v-5h-2a1 1 0 0 1-1-1V5Zm5 12.234c.104-.085.21-.177.316-.276.846-.793 1.684-2.043 1.684-3.958V6h-5v6h2a1 1 0 0 1 1 1v4.234Zm6-12.234a1 1 0 0 1 1-1h7a1 1 0 0 1 1 1v8c0 2.585-1.162 4.335-2.316 5.417-.571.536-1.14.91-1.569 1.15a7.018 7.018 0 0 1-.738.36l-.016.006-.006.002h-.002l-.001.001-.352-.936.351.936A1 1 0 0 1 16.004 19v-5h-2a1 1 0 0 1-1-1V5Zm5 12.234V13a1 1 0 0 0-1-1h-2V6h5v7c0 1.915-.838 3.165-1.684 3.958-.106.1-.212.191-.316.276Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 775 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M2.003 5.999a2 2 0 0 1 2-1.999h5c1.104 0 2 .893 2 1.999V13c0 2.585-1.16 4.335-2.315 5.417-.571.536-1.14.91-1.569 1.15a7.01 7.01 0 0 1-.738.36l-.016.006-.006.002h-.002l-.001.001L6.004 19l.351.936a1 1 0 0 1-1.351-.935L5 14H4a2 2 0 0 1-2-2.001l.002-6Zm5 11.236L7 12.999A1 1 0 0 0 6 12H4l.003-6h5v7c0 1.915-.837 3.165-1.683 3.958-.106.1-.213.192-.317.277Zm6-11.235a2 2 0 0 1 2-2h5c1.104 0 2 .893 2 1.999V13c0 2.585-1.16 4.335-2.315 5.417-.571.536-1.14.91-1.569 1.15a7.018 7.018 0 0 1-.738.36l-.016.006-.006.002h-.002l-.001.001-.352-.936.351.936A1 1 0 0 1 16.004 19v-5h-1a2 2 0 0 1-2-2V6Zm7 0h-5v6h2a1 1 0 0 1 1 1v4.234c.105-.085.211-.177.317-.276.846-.793 1.684-2.043 1.684-3.958V6Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 821 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5Zm16 0H5v7.213l1.246-.932.044-.03a3 3 0 0 1 3.863.454c1.468 1.58 2.941 2.749 4.847 2.749 1.703 0 2.855-.555 4-1.618V5Zm0 10.357c-1.112.697-2.386 1.097-4 1.097-2.81 0-4.796-1.755-6.313-3.388a1 1 0 0 0-1.269-.164L5 14.712V19h14v-3.643ZM15 8a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm-3 1a3 3 0 1 1 6 0 3 3 0 0 1-6 0Z" clip-rule="evenodd"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v7.213l1.246-.932.044-.03a3 3 0 0 1 3.863.454c1.468 1.58 2.941 2.749 4.847 2.749 1.703 0 2.855-.555 4-1.618V5H5Zm14 10.357c-1.112.697-2.386 1.097-4 1.097-2.81 0-4.796-1.755-6.313-3.388a1 1 0 0 0-1.269-.164L5 14.712V19h14v-3.643ZM15 8a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm-3 1a3 3 0 1 1 6 0 3 3 0 0 1-6 0Z" clip-rule="evenodd"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 513 B After Width: | Height: | Size: 514 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M8.004 5a1 1 0 0 0-.43-.822c-.57-.395-1.176-.031-1.685.255-.428.24-.998.614-1.569 1.15C3.166 6.665 2.004 8.415 2.004 11v8a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1v-8a1 1 0 0 0-1-1h-2V5ZM19.004 5a1 1 0 0 0-.43-.822c-.57-.395-1.176-.031-1.685.255-.428.24-.998.614-1.569 1.15-1.154 1.082-2.316 2.832-2.316 5.417v8a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1v-8a1 1 0 0 0-1-1h-2V5Z"/></svg>
|
||||
|
After Width: | Height: | Size: 455 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M7.574 4.178a1 1 0 0 1 .43.822v5h2a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1h-7a1 1 0 0 1-1-1v-8c0-2.585 1.162-4.335 2.316-5.417a8.163 8.163 0 0 1 1.569-1.15 7.029 7.029 0 0 1 .738-.36l.016-.005.005-.003h.003v-.001c.001 0 .002 0 .353.936l-.351-.936a1 1 0 0 1 .92.114Zm-1.57 2.588a5.99 5.99 0 0 0-.316.276C4.842 7.835 4.004 9.085 4.004 11v7h5v-6h-2a1 1 0 0 1-1-1V6.766Zm12.57-2.588a1 1 0 0 1 .43.822v5h2a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1h-7a1 1 0 0 1-1-1v-8c0-2.585 1.162-4.335 2.316-5.417a8.166 8.166 0 0 1 1.569-1.15 7.038 7.038 0 0 1 .738-.36l.016-.005.005-.003h.003v-.001c.001 0 .002 0 .353.936l-.351-.936a1 1 0 0 1 .92.114Zm-1.57 2.588c-.105.085-.21.177-.316.276-.846.793-1.684 2.043-1.684 3.958v7h5v-6h-2a1 1 0 0 1-1-1V6.766Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 857 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM12 12c-4.758 0-8.083 3.521-8.496 7.906A1 1 0 0 0 4.5 21H15a3 3 0 1 1 0-6c0-.824.332-1.571.87-2.113C14.739 12.32 13.435 12 12 12Zm6 2a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2h-2a1 1 0 1 1 0-2h2v-2a1 1 0 0 1 1-1Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 412 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.678 19c.71-2.909 3.092-5 6.322-5 .621 0 1.206.077 1.748.218a1 1 0 1 0 .504-1.936A8.931 8.931 0 0 0 12 12c-4.758 0-8.083 3.521-8.496 7.906A1 1 0 0 0 4.5 21H11a1 1 0 1 0 0-2H5.678ZM18 14a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2h-2a1 1 0 1 1 0-2h2v-2a1 1 0 0 1 1-1Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 508 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M16.293 2.293a1 1 0 0 1 1.414 0l3 3a1 1 0 0 1 0 1.414l-3 3a1 1 0 0 1-1.414-1.414L17.586 7H5v4a1 1 0 1 1-2 0V6a1 1 0 0 1 1-1h13.586l-1.293-1.293a1 1 0 0 1 0-1.414ZM21 13v5a1 1 0 0 1-1 1H6.414l1.293 1.293a1 1 0 1 1-1.414 1.414l-3-3a1 1 0 0 1 0-1.414l3-3a1 1 0 0 1 1.414 1.414L6.414 17H19v-4a1 1 0 1 1 2 0Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 446 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M16.793 2.293a1 1 0 0 1 1.414 0L20.5 4.586a2 2 0 0 1 0 2.828l-2.293 2.293a1 1 0 0 1-1.414-1.414L18.086 7H7a2 2 0 0 0-2 2v2a1 1 0 1 1-2 0V9a4 4 0 0 1 4-4h11.086l-1.293-1.293a1 1 0 0 1 0-1.414ZM20 12a1 1 0 0 1 1 1v2a4 4 0 0 1-4 4H5.914l1.293 1.293a1 1 0 1 1-1.414 1.414L3.5 19.414a2 2 0 0 1 0-2.828l2.293-2.293a1 1 0 0 1 1.414 1.414L5.914 17H17a2 2 0 0 0 2-2v-2a1 1 0 0 1 1-1Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 517 B |
@@ -46,6 +46,20 @@
|
||||
"EXPO_PUBLIC_ENV": "production"
|
||||
}
|
||||
},
|
||||
"production-apk": {
|
||||
"extends": "base",
|
||||
"distribution": "internal",
|
||||
"ios": {
|
||||
"autoIncrement": false
|
||||
},
|
||||
"android": {
|
||||
"autoIncrement": false
|
||||
},
|
||||
"channel": "production",
|
||||
"env": {
|
||||
"EXPO_PUBLIC_ENV": "production"
|
||||
}
|
||||
},
|
||||
"testflight": {
|
||||
"extends": "base",
|
||||
"ios": {
|
||||
|
||||
@@ -15,7 +15,10 @@ export function NativeTranslationView() {
|
||||
return <NativeView />
|
||||
}
|
||||
|
||||
export const isAvailable = Number(Platform.Version) >= 17.4
|
||||
// can be something like "17.5.1", so just take the first two parts
|
||||
const version = String(Platform.Version).split('.').slice(0, 2).join('.')
|
||||
|
||||
export const isAvailable = Number(version) >= 17.4
|
||||
|
||||
// https://en.wikipedia.org/wiki/Translate_(Apple)#Languages
|
||||
const SUPPORTED_LANGUAGES = [
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.12.13",
|
||||
"@atproto/api": "^0.12.14",
|
||||
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||
@@ -63,7 +63,7 @@
|
||||
"@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",
|
||||
"@fortawesome/react-native-fontawesome": "^0.3.2",
|
||||
"@lingui/react": "^4.5.0",
|
||||
"@mattermost/react-native-paste-input": "^0.7.1",
|
||||
"@miblanchard/react-native-slider": "^2.3.1",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import {Platform} from 'react-native'
|
||||
import {Platform, StyleSheet} from 'react-native'
|
||||
|
||||
import * as tokens from '#/alf/tokens'
|
||||
import {native, web} from '#/alf/util/platform'
|
||||
import hairlineWidth = StyleSheet.hairlineWidth
|
||||
|
||||
export const atoms = {
|
||||
/*
|
||||
@@ -55,6 +56,13 @@ export const atoms = {
|
||||
height: '100vh',
|
||||
}),
|
||||
|
||||
/*
|
||||
* Theme-independent bg colors
|
||||
*/
|
||||
bg_transparent: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
|
||||
/*
|
||||
* Border radius
|
||||
*/
|
||||
@@ -270,19 +278,19 @@ export const atoms = {
|
||||
borderWidth: 0,
|
||||
},
|
||||
border: {
|
||||
borderWidth: 1,
|
||||
borderWidth: hairlineWidth,
|
||||
},
|
||||
border_t: {
|
||||
borderTopWidth: 1,
|
||||
borderTopWidth: hairlineWidth,
|
||||
},
|
||||
border_b: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomWidth: hairlineWidth,
|
||||
},
|
||||
border_l: {
|
||||
borderLeftWidth: 1,
|
||||
borderLeftWidth: hairlineWidth,
|
||||
},
|
||||
border_r: {
|
||||
borderRightWidth: 1,
|
||||
borderRightWidth: hairlineWidth,
|
||||
},
|
||||
|
||||
/*
|
||||
@@ -841,7 +849,7 @@ export const atoms = {
|
||||
marginRight: 'auto',
|
||||
},
|
||||
/*
|
||||
* Pointer events
|
||||
* Pointer events & user select
|
||||
*/
|
||||
pointer_events_none: {
|
||||
pointerEvents: 'none',
|
||||
@@ -849,6 +857,15 @@ export const atoms = {
|
||||
pointer_events_auto: {
|
||||
pointerEvents: 'auto',
|
||||
},
|
||||
user_select_none: {
|
||||
userSelect: 'none',
|
||||
},
|
||||
user_select_text: {
|
||||
userSelect: 'text',
|
||||
},
|
||||
user_select_all: {
|
||||
userSelect: 'all',
|
||||
},
|
||||
/*
|
||||
* Text decoration
|
||||
*/
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import React from 'react'
|
||||
import {Dimensions} from 'react-native'
|
||||
|
||||
import * as themes from '#/alf/themes'
|
||||
|
||||
export * from '#/alf/types'
|
||||
export * as tokens from '#/alf/tokens'
|
||||
export {atoms} from '#/alf/atoms'
|
||||
export * from '#/alf/util/platform'
|
||||
export * as tokens from '#/alf/tokens'
|
||||
export * from '#/alf/types'
|
||||
export * from '#/alf/util/flatten'
|
||||
export * from '#/alf/util/platform'
|
||||
export * from '#/alf/util/themeSelector'
|
||||
|
||||
type BreakpointName = keyof typeof breakpoints
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import {atoms} from '#/alf/atoms'
|
||||
import * as tokens from '#/alf/tokens'
|
||||
import type {Mutable} from '#/alf/types'
|
||||
import {atoms} from '#/alf/atoms'
|
||||
import {BLUE_HUE, GREEN_HUE, RED_HUE} from '#/alf/util/colorGeneration'
|
||||
|
||||
export type ThemeName = 'light' | 'dim' | 'dark'
|
||||
export type ReadonlyTheme = typeof light
|
||||
export type Theme = Mutable<ReadonlyTheme>
|
||||
export type Theme = Mutable<ReadonlyTheme> & {name: ThemeName}
|
||||
export type ReadonlyPalette = typeof lightPalette
|
||||
export type Palette = Mutable<ReadonlyPalette>
|
||||
|
||||
@@ -193,7 +193,7 @@ export const dimPalette: Palette = {
|
||||
} as const
|
||||
|
||||
export const light = {
|
||||
name: 'light',
|
||||
name: 'light' as ThemeName,
|
||||
palette: lightPalette,
|
||||
atoms: {
|
||||
text: {
|
||||
@@ -278,7 +278,7 @@ export const light = {
|
||||
}
|
||||
|
||||
export const dark: Theme = {
|
||||
name: 'dark',
|
||||
name: 'dark' as ThemeName,
|
||||
palette: darkPalette,
|
||||
atoms: {
|
||||
text: {
|
||||
@@ -367,7 +367,7 @@ export const dark: Theme = {
|
||||
|
||||
export const dim: Theme = {
|
||||
...dark,
|
||||
name: 'dim',
|
||||
name: 'dim' as ThemeName,
|
||||
palette: dimPalette,
|
||||
atoms: {
|
||||
...dark.atoms,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import {ThemeName} from '#/alf/themes'
|
||||
|
||||
export function select<T>(name: ThemeName, options: Record<ThemeName, T>) {
|
||||
switch (name) {
|
||||
case 'light':
|
||||
return options.light
|
||||
case 'dark':
|
||||
return options.dark || options.dim
|
||||
case 'dim':
|
||||
return options.dim || options.dark
|
||||
default:
|
||||
throw new Error(`select(theme, options) received unknown theme ${name}`)
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,27 @@
|
||||
import React from 'react'
|
||||
import {ColorSchemeName, useColorScheme} from 'react-native'
|
||||
|
||||
import {useThemePrefs} from 'state/shell'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {ThemeName, light, dark, dim} from '#/alf/themes'
|
||||
import * as SystemUI from 'expo-system-ui'
|
||||
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {useThemePrefs} from 'state/shell'
|
||||
import {dark, dim, light, ThemeName} from '#/alf/themes'
|
||||
|
||||
export function useColorModeTheme(): ThemeName {
|
||||
const theme = useThemeName()
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
updateDocument(theme)
|
||||
SystemUI.setBackgroundColorAsync(getBackgroundColor(theme))
|
||||
}, [theme])
|
||||
|
||||
return theme
|
||||
}
|
||||
|
||||
export function useThemeName(): ThemeName {
|
||||
const colorScheme = useColorScheme()
|
||||
const {colorMode, darkTheme} = useThemePrefs()
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const theme = getThemeName(colorScheme, colorMode, darkTheme)
|
||||
updateDocument(theme)
|
||||
SystemUI.setBackgroundColorAsync(getBackgroundColor(theme))
|
||||
}, [colorMode, colorScheme, darkTheme])
|
||||
|
||||
return React.useMemo(
|
||||
() => getThemeName(colorScheme, colorMode, darkTheme),
|
||||
[colorScheme, colorMode, darkTheme],
|
||||
)
|
||||
return getThemeName(colorScheme, colorMode, darkTheme)
|
||||
}
|
||||
|
||||
function getThemeName(
|
||||
@@ -53,7 +55,7 @@ function updateDocument(theme: ThemeName) {
|
||||
}
|
||||
}
|
||||
|
||||
function getBackgroundColor(theme: ThemeName): string {
|
||||
export function getBackgroundColor(theme: ThemeName): string {
|
||||
switch (theme) {
|
||||
case 'light':
|
||||
return light.atoms.bg.backgroundColor
|
||||
|
||||
@@ -37,7 +37,7 @@ export function AccountList({
|
||||
style={[
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
a.border,
|
||||
{borderWidth: 1},
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
{accounts.map(account => (
|
||||
@@ -48,7 +48,7 @@ export function AccountList({
|
||||
isCurrentAccount={account.did === currentAccount?.did}
|
||||
isPendingAccount={account.did === pendingDid}
|
||||
/>
|
||||
<View style={[a.border_b, t.atoms.border_contrast_low]} />
|
||||
<View style={[{borderBottomWidth: 1}, t.atoms.border_contrast_low]} />
|
||||
</React.Fragment>
|
||||
))}
|
||||
<Button
|
||||
|
||||
@@ -28,7 +28,7 @@ export type ButtonColor =
|
||||
| 'gradient_sunset'
|
||||
| 'gradient_nordic'
|
||||
| 'gradient_bonfire'
|
||||
export type ButtonSize = 'tiny' | 'small' | 'medium' | 'large'
|
||||
export type ButtonSize = 'tiny' | 'xsmall' | 'small' | 'medium' | 'large'
|
||||
export type ButtonShape = 'round' | 'square' | 'default'
|
||||
export type VariantProps = {
|
||||
/**
|
||||
@@ -64,13 +64,14 @@ type NonTextElements =
|
||||
|
||||
export type ButtonProps = Pick<
|
||||
PressableProps,
|
||||
'disabled' | 'onPress' | 'testID' | 'onLongPress'
|
||||
'disabled' | 'onPress' | 'testID' | 'onLongPress' | 'hitSlop'
|
||||
> &
|
||||
AccessibilityProps &
|
||||
VariantProps & {
|
||||
testID?: string
|
||||
label: string
|
||||
style?: StyleProp<ViewStyle>
|
||||
hoverStyle?: StyleProp<ViewStyle>
|
||||
children: NonTextElements | ((context: ButtonContext) => NonTextElements)
|
||||
}
|
||||
|
||||
@@ -96,6 +97,7 @@ export function Button({
|
||||
label,
|
||||
disabled = false,
|
||||
style,
|
||||
hoverStyle: hoverStyleProp,
|
||||
...rest
|
||||
}: ButtonProps) {
|
||||
const t = useTheme()
|
||||
@@ -281,6 +283,8 @@ export function Button({
|
||||
baseStyles.push({paddingVertical: 12}, a.px_2xl, a.rounded_sm, a.gap_md)
|
||||
} else if (size === 'small') {
|
||||
baseStyles.push({paddingVertical: 9}, a.px_lg, a.rounded_sm, a.gap_sm)
|
||||
} else if (size === 'xsmall') {
|
||||
baseStyles.push({paddingVertical: 6}, a.px_sm, a.rounded_sm, a.gap_sm)
|
||||
} else if (size === 'tiny') {
|
||||
baseStyles.push({paddingVertical: 4}, a.px_sm, a.rounded_xs, a.gap_xs)
|
||||
}
|
||||
@@ -293,6 +297,8 @@ export function Button({
|
||||
}
|
||||
} else if (size === 'small') {
|
||||
baseStyles.push({height: 34, width: 34})
|
||||
} else if (size === 'xsmall') {
|
||||
baseStyles.push({height: 28, width: 28})
|
||||
} else if (size === 'tiny') {
|
||||
baseStyles.push({height: 20, width: 20})
|
||||
}
|
||||
@@ -374,8 +380,10 @@ export function Button({
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
flattenedBaseStyles,
|
||||
...(state.hovered || state.pressed ? hoverStyles : []),
|
||||
flatten(style),
|
||||
...(state.hovered || state.pressed
|
||||
? [hoverStyles, flatten(hoverStyleProp)]
|
||||
: []),
|
||||
]}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={onPressOut}
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
DialogOuterProps,
|
||||
} from '#/components/Dialog/types'
|
||||
import {createInput} from '#/components/forms/TextField'
|
||||
import {FullWindowOverlay} from '#/components/FullWindowOverlay'
|
||||
import {Portal} from '#/components/Portal'
|
||||
|
||||
export {useDialogContext, useDialogControl} from '#/components/Dialog/context'
|
||||
@@ -170,47 +171,49 @@ export function Outer({
|
||||
return (
|
||||
isOpen && (
|
||||
<Portal>
|
||||
<View
|
||||
// iOS
|
||||
accessibilityViewIsModal
|
||||
// Android
|
||||
importantForAccessibility="yes"
|
||||
style={[a.absolute, a.inset_0]}
|
||||
testID={testID}
|
||||
onTouchMove={() => Keyboard.dismiss()}>
|
||||
<BottomSheet
|
||||
enableDynamicSizing={!hasSnapPoints}
|
||||
enablePanDownToClose
|
||||
keyboardBehavior="interactive"
|
||||
android_keyboardInputMode="adjustResize"
|
||||
keyboardBlurBehavior="restore"
|
||||
topInset={insets.top}
|
||||
{...sheetOptions}
|
||||
snapPoints={sheetOptions.snapPoints || ['100%']}
|
||||
ref={sheet}
|
||||
index={openIndex}
|
||||
backgroundStyle={{backgroundColor: 'transparent'}}
|
||||
backdropComponent={Backdrop}
|
||||
handleIndicatorStyle={{backgroundColor: t.palette.primary_500}}
|
||||
handleStyle={{display: 'none'}}
|
||||
onClose={onCloseAnimationComplete}>
|
||||
<Context.Provider value={context}>
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
t.atoms.bg,
|
||||
{
|
||||
borderTopLeftRadius: 40,
|
||||
borderTopRightRadius: 40,
|
||||
height: Dimensions.get('window').height * 2,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{children}
|
||||
</Context.Provider>
|
||||
</BottomSheet>
|
||||
</View>
|
||||
<FullWindowOverlay>
|
||||
<View
|
||||
// iOS
|
||||
accessibilityViewIsModal
|
||||
// Android
|
||||
importantForAccessibility="yes"
|
||||
style={[a.absolute, a.inset_0]}
|
||||
testID={testID}
|
||||
onTouchMove={() => Keyboard.dismiss()}>
|
||||
<BottomSheet
|
||||
enableDynamicSizing={!hasSnapPoints}
|
||||
enablePanDownToClose
|
||||
keyboardBehavior="interactive"
|
||||
android_keyboardInputMode="adjustResize"
|
||||
keyboardBlurBehavior="restore"
|
||||
topInset={insets.top}
|
||||
{...sheetOptions}
|
||||
snapPoints={sheetOptions.snapPoints || ['100%']}
|
||||
ref={sheet}
|
||||
index={openIndex}
|
||||
backgroundStyle={{backgroundColor: 'transparent'}}
|
||||
backdropComponent={Backdrop}
|
||||
handleIndicatorStyle={{backgroundColor: t.palette.primary_500}}
|
||||
handleStyle={{display: 'none'}}
|
||||
onClose={onCloseAnimationComplete}>
|
||||
<Context.Provider value={context}>
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
t.atoms.bg,
|
||||
{
|
||||
borderTopLeftRadius: 40,
|
||||
borderTopRightRadius: 40,
|
||||
height: Dimensions.get('window').height * 2,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{children}
|
||||
</Context.Provider>
|
||||
</BottomSheet>
|
||||
</View>
|
||||
</FullWindowOverlay>
|
||||
</Portal>
|
||||
)
|
||||
)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export {FullWindowOverlay} from 'react-native-screens'
|
||||
@@ -0,0 +1 @@
|
||||
export {Fragment as FullWindowOverlay} from 'react'
|
||||
@@ -28,6 +28,7 @@ export function RichText({
|
||||
authorHandle,
|
||||
onLinkPress,
|
||||
interactiveStyle,
|
||||
emojiMultiplier = 1.85,
|
||||
}: TextStyleProp &
|
||||
Pick<TextProps, 'selectable'> & {
|
||||
value: RichTextAPI | string
|
||||
@@ -38,6 +39,7 @@ export function RichText({
|
||||
authorHandle?: string
|
||||
onLinkPress?: LinkProps['onPress']
|
||||
interactiveStyle?: TextStyle
|
||||
emojiMultiplier?: number
|
||||
}) {
|
||||
const richText = React.useMemo(
|
||||
() =>
|
||||
@@ -57,17 +59,14 @@ export function RichText({
|
||||
const {text, facets} = richText
|
||||
|
||||
if (!facets?.length) {
|
||||
if (text.length <= 5 && /^\p{Extended_Pictographic}+$/u.test(text)) {
|
||||
if (isOnlyEmoji(text)) {
|
||||
const fontSize =
|
||||
(flattenedStyle.fontSize ?? a.text_sm.fontSize) * emojiMultiplier
|
||||
return (
|
||||
<Text
|
||||
selectable={selectable}
|
||||
testID={testID}
|
||||
style={[
|
||||
{
|
||||
fontSize: 26,
|
||||
lineHeight: 30,
|
||||
},
|
||||
]}
|
||||
style={[plainStyles, {fontSize}]}
|
||||
// @ts-ignore web only -prf
|
||||
dataSet={WORD_WRAP}>
|
||||
{text}
|
||||
@@ -247,3 +246,10 @@ function RichTextTag({
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
export function isOnlyEmoji(text: string) {
|
||||
return (
|
||||
text.length <= 15 &&
|
||||
/^[\p{Emoji_Presentation}\p{Extended_Pictographic}]+$/u.test(text)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {Modal, ScrollView, TextInput, View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {
|
||||
Gif,
|
||||
useFeaturedGifsQuery,
|
||||
useGifSearchQuery,
|
||||
} from '#/state/queries/tenor'
|
||||
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
|
||||
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
|
||||
import {FlatList_INTERNAL} from '#/view/com/util/Views'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
|
||||
import {Button, ButtonText} from '../Button'
|
||||
import {Handle} from '../Dialog'
|
||||
import {useThrottledValue} from '../hooks/useThrottledValue'
|
||||
import {ListFooter, ListMaybePlaceholder} from '../Lists'
|
||||
import {GifPreview} from './GifSelect.shared'
|
||||
|
||||
export function GifSelectDialog({
|
||||
controlRef,
|
||||
onClose,
|
||||
onSelectGif: onSelectGifProp,
|
||||
}: {
|
||||
controlRef: React.RefObject<{open: () => void}>
|
||||
onClose: () => void
|
||||
onSelectGif: (gif: Gif) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
useImperativeHandle(controlRef, () => ({
|
||||
open: () => setOpen(true),
|
||||
}))
|
||||
|
||||
const close = useCallback(() => {
|
||||
setOpen(false)
|
||||
onClose()
|
||||
}, [onClose])
|
||||
|
||||
const onSelectGif = useCallback(
|
||||
(gif: Gif) => {
|
||||
onSelectGifProp(gif)
|
||||
close()
|
||||
},
|
||||
[onSelectGifProp, close],
|
||||
)
|
||||
|
||||
const renderErrorBoundary = useCallback(
|
||||
(error: any) => <ModalError details={String(error)} close={close} />,
|
||||
[close],
|
||||
)
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={open}
|
||||
animationType="slide"
|
||||
presentationStyle="formSheet"
|
||||
onRequestClose={close}
|
||||
aria-modal
|
||||
accessibilityViewIsModal>
|
||||
<View style={[a.flex_1, t.atoms.bg]}>
|
||||
<Handle />
|
||||
<ErrorBoundary renderError={renderErrorBoundary}>
|
||||
<GifList onSelectGif={onSelectGif} close={close} />
|
||||
</ErrorBoundary>
|
||||
</View>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function GifList({
|
||||
onSelectGif,
|
||||
}: {
|
||||
close: () => void
|
||||
onSelectGif: (gif: Gif) => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const textInputRef = useRef<TextInput>(null)
|
||||
const listRef = useRef<FlatList_INTERNAL>(null)
|
||||
const [undeferredSearch, setSearch] = useState('')
|
||||
const search = useThrottledValue(undeferredSearch, 500)
|
||||
|
||||
const isSearching = search.length > 0
|
||||
|
||||
const trendingQuery = useFeaturedGifsQuery()
|
||||
const searchQuery = useGifSearchQuery(search)
|
||||
|
||||
const {
|
||||
data,
|
||||
fetchNextPage,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
error,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
} = isSearching ? searchQuery : trendingQuery
|
||||
|
||||
const flattenedData = useMemo(() => {
|
||||
return data?.pages.flatMap(page => page.results) || []
|
||||
}, [data])
|
||||
|
||||
const renderItem = useCallback(
|
||||
({item}: {item: Gif}) => {
|
||||
return <GifPreview gif={item} onSelectGif={onSelectGif} />
|
||||
},
|
||||
[onSelectGif],
|
||||
)
|
||||
|
||||
const onEndReached = React.useCallback(() => {
|
||||
if (isFetchingNextPage || !hasNextPage || error) return
|
||||
fetchNextPage()
|
||||
}, [isFetchingNextPage, hasNextPage, error, fetchNextPage])
|
||||
|
||||
const hasData = flattenedData.length > 0
|
||||
|
||||
const onGoBack = useCallback(() => {
|
||||
if (isSearching) {
|
||||
// clear the input and reset the state
|
||||
textInputRef.current?.clear()
|
||||
setSearch('')
|
||||
} else {
|
||||
close()
|
||||
}
|
||||
}, [isSearching])
|
||||
|
||||
const listHeader = useMemo(() => {
|
||||
return (
|
||||
<View style={[a.relative, a.mb_lg, a.pt_4xl, a.flex_row, a.align_center]}>
|
||||
{/* cover top corners */}
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
{
|
||||
borderBottomLeftRadius: 8,
|
||||
borderBottomRightRadius: 8,
|
||||
},
|
||||
t.atoms.bg,
|
||||
]}
|
||||
/>
|
||||
|
||||
<TextField.Root>
|
||||
<TextField.Icon icon={Search} />
|
||||
<TextField.Input
|
||||
label={_(msg`Search GIFs`)}
|
||||
placeholder={_(msg`Search Tenor`)}
|
||||
onChangeText={text => {
|
||||
setSearch(text)
|
||||
listRef.current?.scrollToOffset({offset: 0, animated: false})
|
||||
}}
|
||||
returnKeyType="search"
|
||||
clearButtonMode="while-editing"
|
||||
inputRef={textInputRef}
|
||||
maxLength={50}
|
||||
/>
|
||||
</TextField.Root>
|
||||
</View>
|
||||
)
|
||||
}, [t.atoms.bg, _])
|
||||
|
||||
return (
|
||||
<FlatList_INTERNAL
|
||||
ref={listRef}
|
||||
key={gtMobile ? '3 cols' : '2 cols'}
|
||||
data={flattenedData}
|
||||
renderItem={renderItem}
|
||||
numColumns={gtMobile ? 3 : 2}
|
||||
columnWrapperStyle={a.gap_sm}
|
||||
contentContainerStyle={a.px_lg}
|
||||
ListHeaderComponent={
|
||||
<>
|
||||
{listHeader}
|
||||
{!hasData && (
|
||||
<ListMaybePlaceholder
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
onRetry={refetch}
|
||||
onGoBack={onGoBack}
|
||||
emptyType="results"
|
||||
sideBorders={false}
|
||||
topBorder={false}
|
||||
errorTitle={_(msg`Failed to load GIFs`)}
|
||||
errorMessage={_(msg`There was an issue connecting to Tenor.`)}
|
||||
emptyMessage={
|
||||
isSearching
|
||||
? _(msg`No search results found for "${search}".`)
|
||||
: _(
|
||||
msg`No featured GIFs found. There may be an issue with Tenor.`,
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
stickyHeaderIndices={[0]}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={4}
|
||||
keyExtractor={(item: Gif) => item.id}
|
||||
keyboardDismissMode="on-drag"
|
||||
ListFooterComponent={
|
||||
hasData ? (
|
||||
<ListFooter
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
error={cleanError(error)}
|
||||
onRetry={fetchNextPage}
|
||||
style={{borderTopWidth: 0}}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ModalError({details, close}: {details?: string; close: () => void}) {
|
||||
const {_} = useLingui()
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={[a.flex_1, a.gap_md]}
|
||||
centerContent
|
||||
contentContainerStyle={a.px_lg}>
|
||||
<ErrorScreen
|
||||
title={_(msg`Oh no!`)}
|
||||
message={_(
|
||||
msg`There was an unexpected issue in the application. Please let us know if this happened to you!`,
|
||||
)}
|
||||
details={details}
|
||||
/>
|
||||
<Button
|
||||
label={_(msg`Close dialog`)}
|
||||
onPress={close}
|
||||
color="primary"
|
||||
size="medium"
|
||||
variant="solid">
|
||||
<ButtonText>
|
||||
<Trans>Close</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import {Image} from 'expo-image'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {Gif} from '#/state/queries/tenor'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button} from '../Button'
|
||||
|
||||
export function GifPreview({
|
||||
gif,
|
||||
onSelectGif,
|
||||
}: {
|
||||
gif: Gif
|
||||
onSelectGif: (gif: Gif) => void
|
||||
}) {
|
||||
const {gtTablet} = useBreakpoints()
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
logEvent('composer:gif:select', {})
|
||||
onSelectGif(gif)
|
||||
}, [onSelectGif, gif])
|
||||
|
||||
return (
|
||||
<Button
|
||||
label={_(msg`Select GIF "${gif.title}"`)}
|
||||
style={[a.flex_1, gtTablet ? {maxWidth: '33%'} : {maxWidth: '50%'}]}
|
||||
onPress={onPress}>
|
||||
{({pressed}) => (
|
||||
<Image
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.mb_sm,
|
||||
a.rounded_sm,
|
||||
{aspectRatio: 1, opacity: pressed ? 0.8 : 1},
|
||||
t.atoms.bg_contrast_25,
|
||||
]}
|
||||
source={{
|
||||
uri: gif.media_formats.tinygif.url,
|
||||
}}
|
||||
contentFit="cover"
|
||||
accessibilityLabel={gif.title}
|
||||
accessibilityHint=""
|
||||
cachePolicy="none"
|
||||
accessibilityIgnoresInvertColors
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
import React, {useCallback, useMemo, useRef, useState} from 'react'
|
||||
import React, {
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {TextInput, View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {
|
||||
@@ -23,16 +27,23 @@ import {ArrowLeft_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arr
|
||||
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
|
||||
import {Button, ButtonIcon, ButtonText} from '../Button'
|
||||
import {ListFooter, ListMaybePlaceholder} from '../Lists'
|
||||
import {GifPreview} from './GifSelect.shared'
|
||||
|
||||
export function GifSelectDialog({
|
||||
control,
|
||||
controlRef,
|
||||
onClose,
|
||||
onSelectGif: onSelectGifProp,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
controlRef: React.RefObject<{open: () => void}>
|
||||
onClose: () => void
|
||||
onSelectGif: (gif: Gif) => void
|
||||
}) {
|
||||
const control = Dialog.useDialogControl()
|
||||
|
||||
useImperativeHandle(controlRef, () => ({
|
||||
open: () => control.open(),
|
||||
}))
|
||||
|
||||
const onSelectGif = useCallback(
|
||||
(gif: Gif) => {
|
||||
control.close(() => onSelectGifProp(gif))
|
||||
@@ -233,50 +244,6 @@ function GifList({
|
||||
)
|
||||
}
|
||||
|
||||
function GifPreview({
|
||||
gif,
|
||||
onSelectGif,
|
||||
}: {
|
||||
gif: Gif
|
||||
onSelectGif: (gif: Gif) => void
|
||||
}) {
|
||||
const {gtTablet} = useBreakpoints()
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
logEvent('composer:gif:select', {})
|
||||
onSelectGif(gif)
|
||||
}, [onSelectGif, gif])
|
||||
|
||||
return (
|
||||
<Button
|
||||
label={_(msg`Select GIF "${gif.title}"`)}
|
||||
style={[a.flex_1, gtTablet ? {maxWidth: '33%'} : {maxWidth: '50%'}]}
|
||||
onPress={onPress}>
|
||||
{({pressed}) => (
|
||||
<Image
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.mb_sm,
|
||||
a.rounded_sm,
|
||||
{aspectRatio: 1, opacity: pressed ? 0.8 : 1},
|
||||
t.atoms.bg_contrast_25,
|
||||
]}
|
||||
source={{
|
||||
uri: gif.media_formats.tinygif.url,
|
||||
}}
|
||||
contentFit="cover"
|
||||
accessibilityLabel={gif.title}
|
||||
accessibilityHint=""
|
||||
cachePolicy="none"
|
||||
accessibilityIgnoresInvertColors
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogError({details}: {details?: string}) {
|
||||
const {_} = useLingui()
|
||||
const control = Dialog.useDialogContext()
|
||||
|
||||
@@ -26,9 +26,11 @@ import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeft} from '#/components
|
||||
import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
|
||||
import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag'
|
||||
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
|
||||
import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
|
||||
import {PersonCheck_Stroke2_Corner0_Rounded as PersonCheck} from '#/components/icons/PersonCheck'
|
||||
import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/PersonX'
|
||||
import {
|
||||
Person_Stroke2_Corner0_Rounded as Person,
|
||||
PersonCheck_Stroke2_Corner0_Rounded as PersonCheck,
|
||||
PersonX_Stroke2_Corner0_Rounded as PersonX,
|
||||
} from '#/components/icons/Person'
|
||||
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
|
||||
import * as Menu from '#/components/Menu'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
|
||||
@@ -6,7 +6,11 @@ import {
|
||||
TextStyle,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {ChatBskyConvoDefs, RichText as RichTextAPI} from '@atproto/api'
|
||||
import {
|
||||
AppBskyEmbedRecord,
|
||||
ChatBskyConvoDefs,
|
||||
RichText as RichTextAPI,
|
||||
} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
@@ -17,7 +21,8 @@ import {atoms as a, useTheme} from '#/alf'
|
||||
import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {RichText} from '../RichText'
|
||||
import {isOnlyEmoji, RichText} from '../RichText'
|
||||
import {MessageItemEmbed} from './MessageItemEmbed'
|
||||
|
||||
let MessageItem = ({
|
||||
item,
|
||||
@@ -77,37 +82,46 @@ let MessageItem = ({
|
||||
return (
|
||||
<View style={[isFromSelf ? a.mr_md : a.ml_md]}>
|
||||
<ActionsWrapper isFromSelf={isFromSelf} message={message}>
|
||||
<View
|
||||
style={[
|
||||
a.py_sm,
|
||||
a.my_2xs,
|
||||
a.rounded_md,
|
||||
{
|
||||
paddingLeft: 14,
|
||||
paddingRight: 14,
|
||||
backgroundColor: isFromSelf
|
||||
? isPending
|
||||
? pendingColor
|
||||
: t.palette.primary_500
|
||||
: t.palette.contrast_50,
|
||||
borderRadius: 17,
|
||||
},
|
||||
isFromSelf
|
||||
? {borderBottomRightRadius: isLastInGroup ? 2 : 17}
|
||||
: {borderBottomLeftRadius: isLastInGroup ? 2 : 17},
|
||||
]}>
|
||||
<RichText
|
||||
value={rt}
|
||||
style={[
|
||||
a.text_md,
|
||||
a.leading_snug,
|
||||
isFromSelf && {color: t.palette.white},
|
||||
isPending && t.name !== 'light' && {color: t.palette.primary_300},
|
||||
]}
|
||||
interactiveStyle={a.underline}
|
||||
enableTags
|
||||
/>
|
||||
</View>
|
||||
{AppBskyEmbedRecord.isView(message.embed) && (
|
||||
<MessageItemEmbed embed={message.embed} />
|
||||
)}
|
||||
{rt.text.length > 0 && (
|
||||
<View
|
||||
style={
|
||||
!isOnlyEmoji(message.text) && [
|
||||
a.py_sm,
|
||||
a.my_2xs,
|
||||
a.rounded_md,
|
||||
{
|
||||
paddingLeft: 14,
|
||||
paddingRight: 14,
|
||||
backgroundColor: isFromSelf
|
||||
? isPending
|
||||
? pendingColor
|
||||
: t.palette.primary_500
|
||||
: t.palette.contrast_50,
|
||||
borderRadius: 17,
|
||||
},
|
||||
isFromSelf ? a.self_end : a.self_start,
|
||||
isFromSelf
|
||||
? {borderBottomRightRadius: isLastInGroup ? 2 : 17}
|
||||
: {borderBottomLeftRadius: isLastInGroup ? 2 : 17},
|
||||
]
|
||||
}>
|
||||
<RichText
|
||||
value={rt}
|
||||
style={[
|
||||
a.text_md,
|
||||
isFromSelf && {color: t.palette.white},
|
||||
isPending &&
|
||||
t.name !== 'light' && {color: t.palette.primary_300},
|
||||
]}
|
||||
interactiveStyle={a.underline}
|
||||
enableTags
|
||||
emojiMultiplier={3}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</ActionsWrapper>
|
||||
|
||||
{isLastInGroup && (
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyEmbedRecord} from '@atproto/api'
|
||||
|
||||
import {PostEmbeds} from '#/view/com/util/post-embeds'
|
||||
import {atoms as a, native, useTheme} from '#/alf'
|
||||
|
||||
let MessageItemEmbed = ({
|
||||
embed,
|
||||
}: {
|
||||
embed: AppBskyEmbedRecord.View
|
||||
}): React.ReactNode => {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View style={[a.my_xs, t.atoms.bg, native({flexBasis: 0})]}>
|
||||
<PostEmbeds embed={embed} allowNestedQuotes />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
MessageItemEmbed = React.memo(MessageItemEmbed)
|
||||
export {MessageItemEmbed}
|
||||
@@ -6,12 +6,16 @@ import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {richTextToString} from '#/lib/strings/rich-text-helpers'
|
||||
import {getTranslatorLink} from '#/locale/helpers'
|
||||
import {useLanguagePrefs} from '#/state/preferences'
|
||||
import {useOpenLink} from '#/state/preferences/in-app-browser'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {useConvoActive} from 'state/messages/convo'
|
||||
import {useSession} from 'state/session'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {ReportDialog} from '#/components/dms/ReportDialog'
|
||||
import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble'
|
||||
import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
|
||||
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
|
||||
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
|
||||
@@ -35,10 +39,12 @@ export let MessageMenu = ({
|
||||
const convo = useConvoActive()
|
||||
const deleteControl = usePromptControl()
|
||||
const reportControl = usePromptControl()
|
||||
const langPrefs = useLanguagePrefs()
|
||||
const openLink = useOpenLink()
|
||||
|
||||
const isFromSelf = message.sender?.did === currentAccount?.did
|
||||
|
||||
const onCopyPostText = React.useCallback(() => {
|
||||
const onCopyMessage = React.useCallback(() => {
|
||||
const str = richTextToString(
|
||||
new RichText({
|
||||
text: message.text,
|
||||
@@ -51,6 +57,14 @@ export let MessageMenu = ({
|
||||
Toast.show(_(msg`Copied to clipboard`))
|
||||
}, [_, message.text, message.facets])
|
||||
|
||||
const onPressTranslateMessage = React.useCallback(() => {
|
||||
const translatorUrl = getTranslatorLink(
|
||||
message.text,
|
||||
langPrefs.primaryLanguage,
|
||||
)
|
||||
openLink(translatorUrl)
|
||||
}, [langPrefs.primaryLanguage, message.text, openLink])
|
||||
|
||||
const onDelete = React.useCallback(() => {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
convo
|
||||
@@ -81,16 +95,27 @@ export let MessageMenu = ({
|
||||
)}
|
||||
|
||||
<Menu.Outer>
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
testID="messageDropdownCopyBtn"
|
||||
label={_(msg`Copy message text`)}
|
||||
onPress={onCopyPostText}>
|
||||
<Menu.ItemText>{_(msg`Copy message text`)}</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={ClipboardIcon} position="right" />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
<Menu.Divider />
|
||||
{message.text.length > 0 && (
|
||||
<>
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
testID="messageDropdownTranslateBtn"
|
||||
label={_(msg`Translate`)}
|
||||
onPress={onPressTranslateMessage}>
|
||||
<Menu.ItemText>{_(msg`Translate`)}</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Translate} position="right" />
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
testID="messageDropdownCopyBtn"
|
||||
label={_(msg`Copy message text`)}
|
||||
onPress={onCopyMessage}>
|
||||
<Menu.ItemText>{_(msg`Copy message text`)}</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={ClipboardIcon} position="right" />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
<Menu.Divider />
|
||||
</>
|
||||
)}
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
testID="messageDropdownDeleteBtn"
|
||||
|
||||
@@ -22,6 +22,7 @@ import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {ConvoMenu} from '#/components/dms/ConvoMenu'
|
||||
import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2'
|
||||
import {Link} from '#/components/Link'
|
||||
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
const PFP_SIZE = isWeb ? 40 : 34
|
||||
@@ -58,7 +59,7 @@ export let MessagesListHeader = ({
|
||||
t.atoms.border_contrast_low,
|
||||
a.border_b,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.align_start,
|
||||
a.gap_sm,
|
||||
gtTablet ? a.pl_lg : a.pl_xl,
|
||||
a.pr_lg,
|
||||
@@ -69,7 +70,7 @@ export let MessagesListHeader = ({
|
||||
testID="conversationHeaderBackBtn"
|
||||
onPress={onPressBack}
|
||||
hitSlop={BACK_HITSLOP}
|
||||
style={{width: 30, height: 30}}
|
||||
style={{width: 30, height: 30, marginTop: isWeb ? 6 : 4}}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Back`)}
|
||||
accessibilityHint="">
|
||||
@@ -152,51 +153,71 @@ function HeaderReady({
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link
|
||||
style={[a.flex_row, a.align_center, a.gap_md, a.flex_1, a.pr_md]}
|
||||
to={makeProfileLink(profile)}>
|
||||
<PreviewableUserAvatar
|
||||
size={PFP_SIZE}
|
||||
profile={profile}
|
||||
moderation={moderation.ui('avatar')}
|
||||
disableHoverCard={moderation.blocked}
|
||||
/>
|
||||
<View style={a.flex_1}>
|
||||
<Text
|
||||
style={[a.text_md, a.font_bold, web(a.leading_normal)]}
|
||||
numberOfLines={1}>
|
||||
{displayName}
|
||||
</Text>
|
||||
{!isDeletedAccount && (
|
||||
<View style={[a.flex_1]}>
|
||||
<View style={[a.w_full, a.flex_row, a.align_center, a.justify_between]}>
|
||||
<Link
|
||||
style={[a.flex_row, a.align_start, a.gap_md, a.flex_1, a.pr_md]}
|
||||
to={makeProfileLink(profile)}>
|
||||
<View style={[a.pt_2xs]}>
|
||||
<PreviewableUserAvatar
|
||||
size={PFP_SIZE}
|
||||
profile={profile}
|
||||
moderation={moderation.ui('avatar')}
|
||||
disableHoverCard={moderation.blocked}
|
||||
/>
|
||||
</View>
|
||||
<View style={a.flex_1}>
|
||||
<Text
|
||||
style={[
|
||||
t.atoms.text_contrast_medium,
|
||||
a.text_sm,
|
||||
web([a.leading_normal, {marginTop: -2}]),
|
||||
]}
|
||||
style={[a.text_md, a.font_bold, web(a.leading_normal)]}
|
||||
numberOfLines={1}>
|
||||
@{profile.handle}
|
||||
{convoState.convo?.muted && (
|
||||
<>
|
||||
{' '}
|
||||
·{' '}
|
||||
<BellStroke size="xs" style={t.atoms.text_contrast_medium} />
|
||||
</>
|
||||
)}
|
||||
{displayName}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</Link>
|
||||
{!isDeletedAccount && (
|
||||
<Text
|
||||
style={[
|
||||
t.atoms.text_contrast_medium,
|
||||
a.text_sm,
|
||||
web([a.leading_normal, {marginTop: -2}]),
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
@{profile.handle}
|
||||
{convoState.convo?.muted && (
|
||||
<>
|
||||
{' '}
|
||||
·{' '}
|
||||
<BellStroke
|
||||
size="xs"
|
||||
style={t.atoms.text_contrast_medium}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</Link>
|
||||
|
||||
{isConvoActive(convoState) && (
|
||||
<ConvoMenu
|
||||
convo={convoState.convo}
|
||||
profile={profile}
|
||||
currentScreen="conversation"
|
||||
blockInfo={blockInfo}
|
||||
{isConvoActive(convoState) && (
|
||||
<ConvoMenu
|
||||
convo={convoState.convo}
|
||||
profile={profile}
|
||||
currentScreen="conversation"
|
||||
blockInfo={blockInfo}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
paddingLeft: PFP_SIZE + a.gap_md.gap,
|
||||
},
|
||||
]}>
|
||||
<PostAlerts
|
||||
modui={moderation.ui('contentList')}
|
||||
size="large"
|
||||
style={[a.pt_xs]}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
|
||||
import {logEvent} from 'lib/statsig/statsig'
|
||||
import {FAB} from '#/view/com/util/fab/FAB'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {useTheme} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
import {SearchablePeopleList} from './SearchablePeopleList'
|
||||
|
||||
export function NewChat({
|
||||
control,
|
||||
onNewChat,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
onNewChat: (chatId: string) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
const {mutate: createChat} = useGetConvoForMembers({
|
||||
onSuccess: data => {
|
||||
onNewChat(data.convo.id)
|
||||
|
||||
if (!data.convo.lastMessage) {
|
||||
logEvent('chat:create', {logContext: 'NewChatDialog'})
|
||||
}
|
||||
logEvent('chat:open', {logContext: 'NewChatDialog'})
|
||||
},
|
||||
onError: error => {
|
||||
Toast.show(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
const onCreateChat = useCallback(
|
||||
(did: string) => {
|
||||
control.close(() => createChat([did]))
|
||||
},
|
||||
[control, createChat],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<FAB
|
||||
testID="newChatFAB"
|
||||
onPress={control.open}
|
||||
icon={<Plus size="lg" fill={t.palette.white} />}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`New chat`)}
|
||||
accessibilityHint=""
|
||||
/>
|
||||
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
testID="newChatDialog"
|
||||
nativeOptions={{sheet: {snapPoints: ['100%']}}}>
|
||||
<SearchablePeopleList
|
||||
title={_(msg`Start a new chat`)}
|
||||
onSelectChat={onCreateChat}
|
||||
/>
|
||||
</Dialog.Outer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -16,23 +16,19 @@ import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
|
||||
import {useListConvosQuery} from '#/state/queries/messages/list-converations'
|
||||
import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
|
||||
import {useSession} from '#/state/session'
|
||||
import {logEvent} from 'lib/statsig/statsig'
|
||||
import {useActorAutocompleteQuery} from 'state/queries/actor-autocomplete'
|
||||
import {FAB} from '#/view/com/util/fab/FAB'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, native, useTheme, web} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {TextInput} from '#/components/dms/NewChatDialog/TextInput'
|
||||
import {TextInput} from '#/components/dms/dialogs/TextInput'
|
||||
import {canBeMessaged} from '#/components/dms/util'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
|
||||
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
@@ -57,55 +53,291 @@ type Item =
|
||||
key: string
|
||||
}
|
||||
|
||||
export function NewChat({
|
||||
control,
|
||||
onNewChat,
|
||||
export function SearchablePeopleList({
|
||||
title,
|
||||
onSelectChat,
|
||||
showRecentConvos,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
onNewChat: (chatId: string) => void
|
||||
title: string
|
||||
onSelectChat: (did: string) => void
|
||||
showRecentConvos?: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const control = Dialog.useDialogContext()
|
||||
const listRef = useRef<BottomSheetFlatListMethods>(null)
|
||||
const {currentAccount} = useSession()
|
||||
const inputRef = useRef<TextInputType>(null)
|
||||
|
||||
const {mutate: createChat} = useGetConvoForMembers({
|
||||
onSuccess: data => {
|
||||
onNewChat(data.convo.id)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
|
||||
if (!data.convo.lastMessage) {
|
||||
logEvent('chat:create', {logContext: 'NewChatDialog'})
|
||||
const {
|
||||
data: results,
|
||||
isError,
|
||||
isFetching,
|
||||
} = useActorAutocompleteQuery(searchText, true, 12)
|
||||
const {data: follows} = useProfileFollowsQuery(currentAccount?.did)
|
||||
const {data: convos} = useListConvosQuery({enabled: showRecentConvos})
|
||||
|
||||
const items = useMemo(() => {
|
||||
let _items: Item[] = []
|
||||
|
||||
if (isError) {
|
||||
_items.push({
|
||||
type: 'empty',
|
||||
key: 'empty',
|
||||
message: _(msg`We're having network issues, try again`),
|
||||
})
|
||||
} else if (searchText.length) {
|
||||
if (results?.length) {
|
||||
for (const profile of results) {
|
||||
if (profile.did === currentAccount?.did) continue
|
||||
_items.push({
|
||||
type: 'profile',
|
||||
key: profile.did,
|
||||
enabled: canBeMessaged(profile),
|
||||
profile,
|
||||
})
|
||||
}
|
||||
|
||||
_items = _items.sort(a => {
|
||||
// @ts-ignore
|
||||
return a.enabled ? -1 : 1
|
||||
})
|
||||
}
|
||||
logEvent('chat:open', {logContext: 'NewChatDialog'})
|
||||
},
|
||||
onError: error => {
|
||||
Toast.show(error.message)
|
||||
},
|
||||
})
|
||||
} else {
|
||||
const placeholders: Item[] = Array(10)
|
||||
.fill(0)
|
||||
.map((_, i) => ({
|
||||
type: 'placeholder',
|
||||
key: i + '',
|
||||
}))
|
||||
|
||||
const onCreateChat = useCallback(
|
||||
(did: string) => {
|
||||
control.close(() => createChat([did]))
|
||||
if (showRecentConvos) {
|
||||
if (convos && follows) {
|
||||
const usedDids = new Set()
|
||||
|
||||
for (const page of convos.pages) {
|
||||
for (const convo of page.convos) {
|
||||
const profiles = convo.members.filter(
|
||||
m => m.did !== currentAccount?.did,
|
||||
)
|
||||
|
||||
for (const profile of profiles) {
|
||||
if (usedDids.has(profile.did)) continue
|
||||
|
||||
usedDids.add(profile.did)
|
||||
|
||||
_items.push({
|
||||
type: 'profile',
|
||||
key: profile.did,
|
||||
enabled: true,
|
||||
profile,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let followsItems: typeof _items = []
|
||||
|
||||
for (const page of follows.pages) {
|
||||
for (const profile of page.follows) {
|
||||
if (usedDids.has(profile.did)) continue
|
||||
|
||||
followsItems.push({
|
||||
type: 'profile',
|
||||
key: profile.did,
|
||||
enabled: canBeMessaged(profile),
|
||||
profile,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// only sort follows
|
||||
followsItems = followsItems.sort(a => {
|
||||
// @ts-ignore
|
||||
return a.enabled ? -1 : 1
|
||||
})
|
||||
|
||||
// then append
|
||||
_items.push(...followsItems)
|
||||
} else {
|
||||
_items.push(...placeholders)
|
||||
}
|
||||
} else if (follows) {
|
||||
for (const page of follows.pages) {
|
||||
for (const profile of page.follows) {
|
||||
_items.push({
|
||||
type: 'profile',
|
||||
key: profile.did,
|
||||
enabled: canBeMessaged(profile),
|
||||
profile,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
_items = _items.sort(a => {
|
||||
// @ts-ignore
|
||||
return a.enabled ? -1 : 1
|
||||
})
|
||||
} else {
|
||||
_items.push(...placeholders)
|
||||
}
|
||||
}
|
||||
|
||||
return _items
|
||||
}, [
|
||||
_,
|
||||
searchText,
|
||||
results,
|
||||
isError,
|
||||
currentAccount?.did,
|
||||
follows,
|
||||
convos,
|
||||
showRecentConvos,
|
||||
])
|
||||
|
||||
if (searchText && !isFetching && !items.length && !isError) {
|
||||
items.push({type: 'empty', key: 'empty', message: _(msg`No results`)})
|
||||
}
|
||||
|
||||
const renderItems = useCallback(
|
||||
({item}: {item: Item}) => {
|
||||
switch (item.type) {
|
||||
case 'profile': {
|
||||
return (
|
||||
<ProfileCard
|
||||
key={item.key}
|
||||
enabled={item.enabled}
|
||||
profile={item.profile}
|
||||
moderationOpts={moderationOpts!}
|
||||
onPress={onSelectChat}
|
||||
/>
|
||||
)
|
||||
}
|
||||
case 'placeholder': {
|
||||
return <ProfileCardSkeleton key={item.key} />
|
||||
}
|
||||
case 'empty': {
|
||||
return <Empty key={item.key} message={item.message} />
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
},
|
||||
[control, createChat],
|
||||
[moderationOpts, onSelectChat],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<FAB
|
||||
testID="newChatFAB"
|
||||
onPress={control.open}
|
||||
icon={<Plus size="lg" fill={t.palette.white} />}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`New chat`)}
|
||||
accessibilityHint=""
|
||||
/>
|
||||
useLayoutEffect(() => {
|
||||
if (isWeb) {
|
||||
setImmediate(() => {
|
||||
inputRef?.current?.focus()
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
testID="newChatDialog"
|
||||
nativeOptions={{sheet: {snapPoints: ['100%']}}}>
|
||||
<SearchablePeopleList onCreateChat={onCreateChat} />
|
||||
</Dialog.Outer>
|
||||
</>
|
||||
const listHeader = useMemo(() => {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.relative,
|
||||
a.pt_md,
|
||||
a.pb_xs,
|
||||
a.px_lg,
|
||||
a.border_b,
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.bg,
|
||||
native([a.pt_lg]),
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.relative,
|
||||
native(a.align_center),
|
||||
a.justify_center,
|
||||
{height: 32},
|
||||
]}>
|
||||
<Button
|
||||
label={_(msg`Close`)}
|
||||
size="small"
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
style={[
|
||||
a.absolute,
|
||||
a.z_20,
|
||||
native({
|
||||
left: -7,
|
||||
}),
|
||||
web({
|
||||
right: -4,
|
||||
}),
|
||||
]}
|
||||
onPress={() => control.close()}>
|
||||
{isWeb ? (
|
||||
<X size="md" fill={t.palette.contrast_500} />
|
||||
) : (
|
||||
<ChevronLeft size="md" fill={t.palette.contrast_500} />
|
||||
)}
|
||||
</Button>
|
||||
<Text
|
||||
style={[
|
||||
a.z_10,
|
||||
a.text_lg,
|
||||
a.font_bold,
|
||||
a.leading_tight,
|
||||
t.atoms.text_contrast_high,
|
||||
]}>
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={[native([a.pt_sm]), web([a.pt_xs])]}>
|
||||
<SearchInput
|
||||
inputRef={inputRef}
|
||||
value={searchText}
|
||||
onChangeText={text => {
|
||||
setSearchText(text)
|
||||
listRef.current?.scrollToOffset({offset: 0, animated: false})
|
||||
}}
|
||||
onEscape={control.close}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}, [
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.bg,
|
||||
t.atoms.text_contrast_high,
|
||||
t.palette.contrast_500,
|
||||
_,
|
||||
title,
|
||||
searchText,
|
||||
control,
|
||||
])
|
||||
|
||||
return (
|
||||
<Dialog.InnerFlatList
|
||||
ref={listRef}
|
||||
data={items}
|
||||
renderItem={renderItems}
|
||||
ListHeaderComponent={listHeader}
|
||||
stickyHeaderIndices={[0]}
|
||||
keyExtractor={(item: Item) => item.key}
|
||||
style={[
|
||||
web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]),
|
||||
native({
|
||||
height: '100%',
|
||||
paddingHorizontal: 0,
|
||||
marginTop: 0,
|
||||
paddingTop: 0,
|
||||
borderTopLeftRadius: 40,
|
||||
borderTopRightRadius: 40,
|
||||
}),
|
||||
]}
|
||||
webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
|
||||
keyboardDismissMode="on-drag"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -293,217 +525,3 @@ function SearchInput({
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchablePeopleList({
|
||||
onCreateChat,
|
||||
}: {
|
||||
onCreateChat: (did: string) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const control = Dialog.useDialogContext()
|
||||
const listRef = useRef<BottomSheetFlatListMethods>(null)
|
||||
const {currentAccount} = useSession()
|
||||
const inputRef = useRef<TextInputType>(null)
|
||||
|
||||
const [searchText, setSearchText] = useState('')
|
||||
|
||||
const {
|
||||
data: results,
|
||||
isError,
|
||||
isFetching,
|
||||
} = useActorAutocompleteQuery(searchText, true, 12)
|
||||
const {data: follows} = useProfileFollowsQuery(currentAccount?.did)
|
||||
|
||||
const items = useMemo(() => {
|
||||
let _items: Item[] = []
|
||||
|
||||
if (isError) {
|
||||
_items.push({
|
||||
type: 'empty',
|
||||
key: 'empty',
|
||||
message: _(msg`We're having network issues, try again`),
|
||||
})
|
||||
} else if (searchText.length) {
|
||||
if (results?.length) {
|
||||
for (const profile of results) {
|
||||
if (profile.did === currentAccount?.did) continue
|
||||
_items.push({
|
||||
type: 'profile',
|
||||
key: profile.did,
|
||||
enabled: canBeMessaged(profile),
|
||||
profile,
|
||||
})
|
||||
}
|
||||
|
||||
_items = _items.sort(a => {
|
||||
// @ts-ignore
|
||||
return a.enabled ? -1 : 1
|
||||
})
|
||||
}
|
||||
} else {
|
||||
if (follows) {
|
||||
for (const page of follows.pages) {
|
||||
for (const profile of page.follows) {
|
||||
_items.push({
|
||||
type: 'profile',
|
||||
key: profile.did,
|
||||
enabled: canBeMessaged(profile),
|
||||
profile,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
_items = _items.sort(a => {
|
||||
// @ts-ignore
|
||||
return a.enabled ? -1 : 1
|
||||
})
|
||||
} else {
|
||||
Array(10)
|
||||
.fill(0)
|
||||
.forEach((_, i) => {
|
||||
_items.push({
|
||||
type: 'placeholder',
|
||||
key: i + '',
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return _items
|
||||
}, [_, searchText, results, isError, currentAccount?.did, follows])
|
||||
|
||||
if (searchText && !isFetching && !items.length && !isError) {
|
||||
items.push({type: 'empty', key: 'empty', message: _(msg`No results`)})
|
||||
}
|
||||
|
||||
const renderItems = useCallback(
|
||||
({item}: {item: Item}) => {
|
||||
switch (item.type) {
|
||||
case 'profile': {
|
||||
return (
|
||||
<ProfileCard
|
||||
key={item.key}
|
||||
enabled={item.enabled}
|
||||
profile={item.profile}
|
||||
moderationOpts={moderationOpts!}
|
||||
onPress={onCreateChat}
|
||||
/>
|
||||
)
|
||||
}
|
||||
case 'placeholder': {
|
||||
return <ProfileCardSkeleton key={item.key} />
|
||||
}
|
||||
case 'empty': {
|
||||
return <Empty key={item.key} message={item.message} />
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
},
|
||||
[moderationOpts, onCreateChat],
|
||||
)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isWeb) {
|
||||
setImmediate(() => {
|
||||
inputRef?.current?.focus()
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const listHeader = useMemo(() => {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.relative,
|
||||
a.pt_md,
|
||||
a.pb_xs,
|
||||
a.px_lg,
|
||||
a.border_b,
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.bg,
|
||||
native([a.pt_lg]),
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.relative,
|
||||
native(a.align_center),
|
||||
a.justify_center,
|
||||
{height: 32},
|
||||
]}>
|
||||
<Button
|
||||
label={_(msg`Close`)}
|
||||
size="small"
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
style={[
|
||||
a.absolute,
|
||||
a.z_20,
|
||||
native({
|
||||
left: -7,
|
||||
}),
|
||||
web({
|
||||
right: -4,
|
||||
}),
|
||||
]}
|
||||
onPress={() => control.close()}>
|
||||
{isWeb ? (
|
||||
<X size="md" fill={t.palette.contrast_500} />
|
||||
) : (
|
||||
<ChevronLeft size="md" fill={t.palette.contrast_500} />
|
||||
)}
|
||||
</Button>
|
||||
<Text
|
||||
style={[
|
||||
a.z_10,
|
||||
a.text_lg,
|
||||
a.font_bold,
|
||||
a.leading_tight,
|
||||
t.atoms.text_contrast_high,
|
||||
]}>
|
||||
<Trans>Start a new chat</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={[native([a.pt_sm]), web([a.pt_xs])]}>
|
||||
<SearchInput
|
||||
inputRef={inputRef}
|
||||
value={searchText}
|
||||
onChangeText={text => {
|
||||
setSearchText(text)
|
||||
listRef.current?.scrollToOffset({offset: 0, animated: false})
|
||||
}}
|
||||
onEscape={control.close}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}, [t, _, control, searchText])
|
||||
|
||||
return (
|
||||
<Dialog.InnerFlatList
|
||||
ref={listRef}
|
||||
data={items}
|
||||
renderItem={renderItems}
|
||||
ListHeaderComponent={listHeader}
|
||||
stickyHeaderIndices={[0]}
|
||||
keyExtractor={(item: Item) => item.key}
|
||||
style={[
|
||||
web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]),
|
||||
native({
|
||||
height: '100%',
|
||||
paddingHorizontal: 0,
|
||||
marginTop: 0,
|
||||
paddingTop: 0,
|
||||
borderTopLeftRadius: 40,
|
||||
borderTopRightRadius: 40,
|
||||
}),
|
||||
]}
|
||||
webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
|
||||
keyboardDismissMode="on-drag"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
|
||||
import {logEvent} from 'lib/statsig/statsig'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {SearchablePeopleList} from './SearchablePeopleList'
|
||||
|
||||
export function SendViaChatDialog({
|
||||
control,
|
||||
onSelectChat,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
onSelectChat: (chatId: string) => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
|
||||
const {mutate: createChat} = useGetConvoForMembers({
|
||||
onSuccess: data => {
|
||||
onSelectChat(data.convo.id)
|
||||
|
||||
if (!data.convo.lastMessage) {
|
||||
logEvent('chat:create', {logContext: 'SendViaChatDialog'})
|
||||
}
|
||||
logEvent('chat:open', {logContext: 'SendViaChatDialog'})
|
||||
},
|
||||
onError: error => {
|
||||
Toast.show(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
const onCreateChat = useCallback(
|
||||
(did: string) => {
|
||||
control.close(() => createChat([did]))
|
||||
},
|
||||
[control, createChat],
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
testID="sendViaChatChatDialog"
|
||||
nativeOptions={{sheet: {snapPoints: ['100%']}}}>
|
||||
<SearchablePeopleList
|
||||
title={_(msg`Send post to...`)}
|
||||
onSelectChat={onCreateChat}
|
||||
showRecentConvos
|
||||
/>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Image_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5Zm16 0H5v7.213l1.246-.932.044-.03a3 3 0 0 1 3.863.454c1.468 1.58 2.941 2.749 4.847 2.749 1.703 0 2.855-.555 4-1.618V5Zm0 10.357c-1.112.697-2.386 1.097-4 1.097-2.81 0-4.796-1.755-6.313-3.388a1 1 0 0 0-1.269-.164L5 14.712V19h14v-3.643ZM15 8a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm-3 1a3 3 0 1 1 6 0 3 3 0 0 1-6 0Z',
|
||||
path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v7.213l1.246-.932.044-.03a3 3 0 0 1 3.863.454c1.468 1.58 2.941 2.749 4.847 2.749 1.703 0 2.855-.555 4-1.618V5H5Zm14 10.357c-1.112.697-2.386 1.097-4 1.097-2.81 0-4.796-1.755-6.313-3.388a1 1 0 0 0-1.269-.164L5 14.712V19h14v-3.643ZM15 8a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm-3 1a3 3 0 1 1 6 0 3 3 0 0 1-6 0Z',
|
||||
})
|
||||
|
||||
@@ -3,3 +3,19 @@ import {createSinglePathSVG} from './TEMPLATE'
|
||||
export const Person_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.678 19h12.644c-.71-2.909-3.092-5-6.322-5s-5.613 2.091-6.322 5Zm-2.174.906C3.917 15.521 7.242 12 12 12c4.758 0 8.083 3.521 8.496 7.906A1 1 0 0 1 19.5 21h-15a1 1 0 0 1-.996-1.094Z',
|
||||
})
|
||||
|
||||
export const PersonCheck_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.679 19c.709-2.902 3.079-5 6.321-5a6.69 6.69 0 0 1 2.612.51 1 1 0 0 0 .776-1.844A8.687 8.687 0 0 0 12 12c-4.3 0-7.447 2.884-8.304 6.696-.29 1.29.767 2.304 1.902 2.304H11a1 1 0 1 0 0-2H5.679Zm14.835-4.857a1 1 0 0 1 .344 1.371l-3 5a1 1 0 0 1-1.458.286l-2-1.5a1 1 0 0 1 1.2-1.6l1.113.835 2.43-4.05a1 1 0 0 1 1.372-.342Z',
|
||||
})
|
||||
|
||||
export const PersonX_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.679 19c.709-2.902 3.079-5 6.321-5 .302 0 .595.018.878.053a1 1 0 0 0 .243-1.985A9.235 9.235 0 0 0 12 12c-4.3 0-7.447 2.884-8.304 6.696-.29 1.29.767 2.304 1.902 2.304H12a1 1 0 1 0 0-2H5.679Zm9.614-3.707a1 1 0 0 1 1.414 0L18 16.586l1.293-1.293a1 1 0 0 1 1.414 1.414L19.414 18l1.293 1.293a1 1 0 0 1-1.414 1.414L18 19.414l-1.293 1.293a1 1 0 0 1-1.414-1.414L16.586 18l-1.293-1.293a1 1 0 0 1 0-1.414Z',
|
||||
})
|
||||
|
||||
export const PersonPlus_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.678 19c.71-2.909 3.092-5 6.322-5 .621 0 1.206.077 1.748.218a1 1 0 1 0 .504-1.936A8.931 8.931 0 0 0 12 12c-4.758 0-8.083 3.521-8.496 7.906A1 1 0 0 0 4.5 21H11a1 1 0 1 0 0-2H5.678ZM18 14a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2h-2a1 1 0 1 1 0-2h2v-2a1 1 0 0 1 1-1Z',
|
||||
})
|
||||
|
||||
export const PersonPlus_Filled_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM12 12c-4.758 0-8.083 3.521-8.496 7.906A1 1 0 0 0 4.5 21H15a3 3 0 1 1 0-6c0-.824.332-1.571.87-2.113C14.739 12.32 13.435 12 12 12Zm6 2a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2h-2a1 1 0 1 1 0-2h2v-2a1 1 0 0 1 1-1Z',
|
||||
})
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const PersonCheck_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.679 19c.709-2.902 3.079-5 6.321-5a6.69 6.69 0 0 1 2.612.51 1 1 0 0 0 .776-1.844A8.687 8.687 0 0 0 12 12c-4.3 0-7.447 2.884-8.304 6.696-.29 1.29.767 2.304 1.902 2.304H11a1 1 0 1 0 0-2H5.679Zm14.835-4.857a1 1 0 0 1 .344 1.371l-3 5a1 1 0 0 1-1.458.286l-2-1.5a1 1 0 0 1 1.2-1.6l1.113.835 2.43-4.05a1 1 0 0 1 1.372-.342Z',
|
||||
})
|
||||
@@ -1,5 +0,0 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const PersonX_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.679 19c.709-2.902 3.079-5 6.321-5 .302 0 .595.018.878.053a1 1 0 0 0 .243-1.985A9.235 9.235 0 0 0 12 12c-4.3 0-7.447 2.884-8.304 6.696-.29 1.29.767 2.304 1.902 2.304H12a1 1 0 1 0 0-2H5.679Zm9.614-3.707a1 1 0 0 1 1.414 0L18 16.586l1.293-1.293a1 1 0 0 1 1.414 1.414L19.414 18l1.293 1.293a1 1 0 0 1-1.414 1.414L18 19.414l-1.293 1.293a1 1 0 0 1-1.414-1.414L16.586 18l-1.293-1.293a1 1 0 0 1 0-1.414Z',
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const OpenQuote_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M7.574 4.178a1 1 0 0 1 .43.822v5h2a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1h-7a1 1 0 0 1-1-1v-8c0-2.585 1.162-4.335 2.316-5.417a8.163 8.163 0 0 1 1.569-1.15 7.029 7.029 0 0 1 .738-.36l.016-.005.005-.003h.003v-.001c.001 0 .002 0 .353.936l-.351-.936a1 1 0 0 1 .92.114Zm-1.57 2.588a5.99 5.99 0 0 0-.316.276C4.842 7.835 4.004 9.085 4.004 11v7h5v-6h-2a1 1 0 0 1-1-1V6.766Zm12.57-2.588a1 1 0 0 1 .43.822v5h2a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1h-7a1 1 0 0 1-1-1v-8c0-2.585 1.162-4.335 2.316-5.417a8.166 8.166 0 0 1 1.569-1.15 7.038 7.038 0 0 1 .738-.36l.016-.005.005-.003h.003v-.001c.001 0 .002 0 .353.936l-.351-.936a1 1 0 0 1 .92.114Zm-1.57 2.588c-.105.085-.21.177-.316.276-.846.793-1.684 2.043-1.684 3.958v7h5v-6h-2a1 1 0 0 1-1-1V6.766Z',
|
||||
})
|
||||
|
||||
export const OpenQuote_Filled_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M8.004 5a1 1 0 0 0-.43-.822c-.57-.395-1.176-.031-1.685.255-.428.24-.998.614-1.569 1.15C3.166 6.665 2.004 8.415 2.004 11v8a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1v-8a1 1 0 0 0-1-1h-2V5ZM19.004 5a1 1 0 0 0-.43-.822c-.57-.395-1.176-.031-1.685.255-.428.24-.998.614-1.569 1.15-1.154 1.082-2.316 2.832-2.316 5.417v8a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1v-8a1 1 0 0 0-1-1h-2V5Z',
|
||||
})
|
||||
|
||||
export const CloseQuote_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M2.004 5a1 1 0 0 1 1-1h7a1 1 0 0 1 1 1v8c0 2.585-1.162 4.335-2.316 5.417-.571.536-1.14.91-1.569 1.15a7.01 7.01 0 0 1-.738.36l-.016.006-.006.002h-.002l-.001.001L6.004 19l.351.936A1 1 0 0 1 5.004 19v-5h-2a1 1 0 0 1-1-1V5Zm5 12.234c.104-.085.21-.177.316-.276.846-.793 1.684-2.043 1.684-3.958V6h-5v6h2a1 1 0 0 1 1 1v4.234Zm6-12.234a1 1 0 0 1 1-1h7a1 1 0 0 1 1 1v8c0 2.585-1.162 4.335-2.316 5.417-.571.536-1.14.91-1.569 1.15a7.018 7.018 0 0 1-.738.36l-.016.006-.006.002h-.002l-.001.001-.352-.936.351.936A1 1 0 0 1 16.004 19v-5h-2a1 1 0 0 1-1-1V5Zm5 12.234V13a1 1 0 0 0-1-1h-2V6h5v7c0 1.915-.838 3.165-1.684 3.958-.106.1-.212.191-.316.276Z',
|
||||
})
|
||||
|
||||
export const CloseQuote_Stroke2_Corner1_Rounded = createSinglePathSVG({
|
||||
path: 'M2.003 5.999a2 2 0 0 1 2-1.999h5c1.104 0 2 .893 2 1.999V13c0 2.585-1.16 4.335-2.315 5.417-.571.536-1.14.91-1.569 1.15a7.01 7.01 0 0 1-.738.36l-.016.006-.006.002h-.002l-.001.001L6.004 19l.351.936a1 1 0 0 1-1.351-.935L5 14H4a2 2 0 0 1-2-2.001l.002-6Zm5 11.236L7 12.999A1 1 0 0 0 6 12H4l.003-6h5v7c0 1.915-.837 3.165-1.683 3.958-.106.1-.213.192-.317.277Zm6-11.235a2 2 0 0 1 2-2h5c1.104 0 2 .893 2 1.999V13c0 2.585-1.16 4.335-2.315 5.417-.571.536-1.14.91-1.569 1.15a7.018 7.018 0 0 1-.738.36l-.016.006-.006.002h-.002l-.001.001-.352-.936.351.936A1 1 0 0 1 16.004 19v-5h-1a2 2 0 0 1-2-2V6Zm7 0h-5v6h2a1 1 0 0 1 1 1v4.234c.105-.085.211-.177.317-.276.846-.793 1.684-2.043 1.684-3.958V6Z',
|
||||
})
|
||||
|
||||
export const CloseQuote_Filled_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M3.004 4a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h2v5a1 1 0 0 0 .43.822c.57.395 1.176.031 1.685-.255.428-.24.998-.614 1.569-1.15 1.154-1.082 2.316-2.832 2.316-5.417V5a1 1 0 0 0-1-1h-7ZM14.004 4a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h2v5a1 1 0 0 0 .43.822c.57.395 1.176.031 1.685-.255.428-.24.998-.614 1.569-1.15 1.154-1.082 2.316-2.832 2.316-5.417V5a1 1 0 0 0-1-1h-7Z',
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Repost_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M16.293 2.293a1 1 0 0 1 1.414 0l3 3a1 1 0 0 1 0 1.414l-3 3a1 1 0 0 1-1.414-1.414L17.586 7H5v4a1 1 0 1 1-2 0V6a1 1 0 0 1 1-1h13.586l-1.293-1.293a1 1 0 0 1 0-1.414ZM21 13v5a1 1 0 0 1-1 1H6.414l1.293 1.293a1 1 0 1 1-1.414 1.414l-3-3a1 1 0 0 1 0-1.414l3-3a1 1 0 0 1 1.414 1.414L6.414 17H19v-4a1 1 0 1 1 2 0Z',
|
||||
})
|
||||
|
||||
export const Repost_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||
path: 'M17.957 2.293a1 1 0 1 0-1.414 1.414L17.836 5H6a3 3 0 0 0-3 3v3a1 1 0 1 0 2 0V8a1 1 0 0 1 1-1h11.836l-1.293 1.293a1 1 0 0 0 1.414 1.414l2.47-2.47a1.75 1.75 0 0 0 0-2.474l-2.47-2.47ZM20 12a1 1 0 0 1 1 1v3a3 3 0 0 1-3 3H6.164l1.293 1.293a1 1 0 1 1-1.414 1.414l-2.47-2.47a1.75 1.75 0 0 1 0-2.474l2.47-2.47a1 1 0 0 1 1.414 1.414L6.164 17H18a1 1 0 0 0 1-1v-3a1 1 0 0 1 1-1Z',
|
||||
})
|
||||
|
||||
export const Repost_Stroke2_Corner3_Rounded = createSinglePathSVG({
|
||||
path: 'M16.793 2.293a1 1 0 0 1 1.414 0L20.5 4.586a2 2 0 0 1 0 2.828l-2.293 2.293a1 1 0 0 1-1.414-1.414L18.086 7H7a2 2 0 0 0-2 2v2a1 1 0 1 1-2 0V9a4 4 0 0 1 4-4h11.086l-1.293-1.293a1 1 0 0 1 0-1.414ZM20 12a1 1 0 0 1 1 1v2a4 4 0 0 1-4 4H5.914l1.293 1.293a1 1 0 1 1-1.414 1.414L3.5 19.414a2 2 0 0 1 0-2.828l2.293-2.293a1 1 0 0 1 1.414 1.414L5.914 17H17a2 2 0 0 0 2-2v-2a1 1 0 0 1 1-1Z',
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import {StyleSheet, TextProps} from 'react-native'
|
||||
import type {SvgProps, PathProps} from 'react-native-svg'
|
||||
import type {PathProps, SvgProps} from 'react-native-svg'
|
||||
|
||||
import {tokens} from '#/alf'
|
||||
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
|
||||
import {ModerationUI} from '@atproto/api'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
|
||||
import {isJustAMute} from '#/lib/moderation'
|
||||
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
|
||||
import {atoms as a, useTheme, useBreakpoints, web} from '#/alf'
|
||||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {
|
||||
ModerationDetailsDialog,
|
||||
useModerationDetailsDialogControl,
|
||||
} from '#/components/moderation/ModerationDetailsDialog'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function ContentHider({
|
||||
testID,
|
||||
@@ -52,7 +51,9 @@ export function ContentHider({
|
||||
<ModerationDetailsDialog control={control} modcause={blur} />
|
||||
|
||||
<Button
|
||||
onPress={() => {
|
||||
onPress={e => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!modui.noOverride) {
|
||||
setOverride(v => !v)
|
||||
} else {
|
||||
@@ -121,7 +122,9 @@ export function ContentHider({
|
||||
|
||||
{desc.source && blur.type === 'label' && !override && (
|
||||
<Button
|
||||
onPress={() => {
|
||||
onPress={e => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
control.open()
|
||||
}}
|
||||
label={_(
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, View, ViewStyle} from 'react-native'
|
||||
import {ModerationCause, ModerationUI} from '@atproto/api'
|
||||
import {BSKY_LABELER_DID, ModerationCause, ModerationUI} from '@atproto/api'
|
||||
|
||||
import {getModerationCauseKey} from '#/lib/moderation'
|
||||
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {
|
||||
@@ -14,9 +15,11 @@ import {Text} from '#/components/Typography'
|
||||
|
||||
export function PostAlerts({
|
||||
modui,
|
||||
size,
|
||||
style,
|
||||
}: {
|
||||
modui: ModerationUI
|
||||
size?: 'medium' | 'large'
|
||||
includeMute?: boolean
|
||||
style?: StyleProp<ViewStyle>
|
||||
}) {
|
||||
@@ -28,17 +31,31 @@ export function PostAlerts({
|
||||
<View style={[a.flex_col, a.gap_xs, style]}>
|
||||
<View style={[a.flex_row, a.flex_wrap, a.gap_xs]}>
|
||||
{modui.alerts.map(cause => (
|
||||
<PostLabel key={getModerationCauseKey(cause)} cause={cause} />
|
||||
<PostLabel
|
||||
key={getModerationCauseKey(cause)}
|
||||
cause={cause}
|
||||
size={size}
|
||||
/>
|
||||
))}
|
||||
{modui.informs.map(cause => (
|
||||
<PostLabel key={getModerationCauseKey(cause)} cause={cause} />
|
||||
<PostLabel
|
||||
key={getModerationCauseKey(cause)}
|
||||
cause={cause}
|
||||
size={size}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function PostLabel({cause}: {cause: ModerationCause}) {
|
||||
function PostLabel({
|
||||
cause,
|
||||
size,
|
||||
}: {
|
||||
cause: ModerationCause
|
||||
size?: 'medium' | 'large'
|
||||
}) {
|
||||
const control = useModerationDetailsDialogControl()
|
||||
const desc = useModerationCauseDescription(cause)
|
||||
const t = useTheme()
|
||||
@@ -47,7 +64,9 @@ function PostLabel({cause}: {cause: ModerationCause}) {
|
||||
<>
|
||||
<Button
|
||||
label={desc.name}
|
||||
onPress={() => {
|
||||
onPress={e => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
control.open()
|
||||
}}>
|
||||
{({hovered, pressed}) => (
|
||||
@@ -55,24 +74,36 @@ function PostLabel({cause}: {cause: ModerationCause}) {
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
{paddingLeft: 4, paddingRight: 6, paddingVertical: 1},
|
||||
a.gap_xs,
|
||||
a.rounded_sm,
|
||||
hovered || pressed
|
||||
? t.atoms.bg_contrast_50
|
||||
: t.atoms.bg_contrast_25,
|
||||
? size === 'large'
|
||||
? t.atoms.bg_contrast_50
|
||||
: t.atoms.bg_contrast_25
|
||||
: size === 'large'
|
||||
? t.atoms.bg_contrast_25
|
||||
: undefined,
|
||||
size === 'large'
|
||||
? {paddingLeft: 4, paddingRight: 6, paddingVertical: 2}
|
||||
: {paddingRight: 4, paddingVertical: 1},
|
||||
]}>
|
||||
<desc.icon size="xs" fill={t.atoms.text_contrast_medium.color} />
|
||||
{desc.sourceType === 'labeler' &&
|
||||
desc.sourceDid !== BSKY_LABELER_DID ? (
|
||||
<UserAvatar
|
||||
avatar={desc.sourceAvi}
|
||||
size={size === 'large' ? 16 : 12}
|
||||
/>
|
||||
) : (
|
||||
<desc.icon size="sm" fill={t.atoms.text_contrast_medium.color} />
|
||||
)}
|
||||
<Text
|
||||
style={[
|
||||
a.text_left,
|
||||
a.leading_snug,
|
||||
a.text_xs,
|
||||
t.atoms.text_contrast_medium,
|
||||
a.font_semibold,
|
||||
size === 'large' ? {fontSize: 13} : a.text_xs,
|
||||
size === 'large' ? t.atoms.text : t.atoms.text_contrast_high,
|
||||
]}>
|
||||
{desc.name}
|
||||
{desc.source ? ` – ${desc.source}` : ''}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -23,6 +23,7 @@ interface Props extends ComponentProps<typeof Link> {
|
||||
iconStyles: StyleProp<ViewStyle>
|
||||
modui: ModerationUI
|
||||
profile: AppBskyActorDefs.ProfileViewBasic
|
||||
interpretFilterAsBlur?: boolean
|
||||
}
|
||||
|
||||
export function PostHider({
|
||||
@@ -35,6 +36,7 @@ export function PostHider({
|
||||
iconSize,
|
||||
iconStyles,
|
||||
profile,
|
||||
interpretFilterAsBlur,
|
||||
...props
|
||||
}: Props) {
|
||||
const queryClient = useQueryClient()
|
||||
@@ -42,7 +44,8 @@ export function PostHider({
|
||||
const {_} = useLingui()
|
||||
const [override, setOverride] = React.useState(false)
|
||||
const control = useModerationDetailsDialogControl()
|
||||
const blur = modui.blurs[0]
|
||||
const blur =
|
||||
modui.blurs[0] || (interpretFilterAsBlur ? modui.filters[0] : undefined)
|
||||
const desc = useModerationCauseDescription(blur)
|
||||
|
||||
const onBeforePress = React.useCallback(() => {
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, View, ViewStyle} from 'react-native'
|
||||
import {ModerationCause, ModerationDecision} from '@atproto/api'
|
||||
import {
|
||||
BSKY_LABELER_DID,
|
||||
ModerationCause,
|
||||
ModerationDecision,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
|
||||
import {getModerationCauseKey} from 'lib/moderation'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {
|
||||
@@ -62,7 +67,12 @@ function ProfileLabel({cause}: {cause: ModerationCause}) {
|
||||
? t.atoms.bg_contrast_50
|
||||
: t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<desc.icon size="sm" fill={t.atoms.text_contrast_medium.color} />
|
||||
{desc.sourceType === 'labeler' &&
|
||||
desc.sourceDid !== BSKY_LABELER_DID ? (
|
||||
<UserAvatar avatar={desc.sourceAvi} size={16} />
|
||||
) : (
|
||||
<desc.icon size="sm" fill={t.atoms.text_contrast_medium.color} />
|
||||
)}
|
||||
<Text
|
||||
style={[
|
||||
a.text_left,
|
||||
@@ -72,7 +82,6 @@ function ProfileLabel({cause}: {cause: ModerationCause}) {
|
||||
a.font_semibold,
|
||||
]}>
|
||||
{desc.name}
|
||||
{desc.source ? ` – ${desc.source}` : ''}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyFeedThreadgate,
|
||||
AppBskyRichtextFacet,
|
||||
BskyAgent,
|
||||
ComAtprotoLabelDefs,
|
||||
ComAtprotoRepoUploadBlob,
|
||||
@@ -15,7 +14,7 @@ import {AtUri} from '@atproto/api'
|
||||
import {logger} from '#/logger'
|
||||
import {ThreadgateSetting} from '#/state/queries/threadgate'
|
||||
import {isNetworkError} from 'lib/strings/errors'
|
||||
import {shortenLinks} from 'lib/strings/rich-text-manip'
|
||||
import {shortenLinks, stripInvalidMentions} from 'lib/strings/rich-text-manip'
|
||||
import {isNative, isWeb} from 'platform/detection'
|
||||
import {ImageModel} from 'state/models/media/image'
|
||||
import {LinkMeta} from '../link-meta/link-meta'
|
||||
@@ -81,17 +80,7 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
|
||||
opts.onStateChange?.('Processing...')
|
||||
await rt.detectFacets(agent)
|
||||
rt = shortenLinks(rt)
|
||||
|
||||
// filter out any mention facets that didn't map to a user
|
||||
rt.facets = rt.facets?.filter(facet => {
|
||||
const mention = facet.features.find(feature =>
|
||||
AppBskyRichtextFacet.isMention(feature),
|
||||
)
|
||||
if (mention && !mention.did) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
rt = stripInvalidMentions(rt)
|
||||
|
||||
// add quote embed if present
|
||||
if (opts.quote) {
|
||||
|
||||
@@ -2,98 +2,6 @@ import React from 'react'
|
||||
import {StyleProp, TextStyle, ViewStyle} from 'react-native'
|
||||
import Svg, {Ellipse, Line, Path, Rect} from 'react-native-svg'
|
||||
|
||||
export function GridIcon({
|
||||
style,
|
||||
solid,
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>
|
||||
solid?: boolean
|
||||
}) {
|
||||
const DIM = 4
|
||||
const ARC = 2
|
||||
return (
|
||||
<Svg width="24" height="24" style={style}>
|
||||
<Path
|
||||
d={`M4,1 h${DIM} a${ARC},${ARC} 0 0 1 ${ARC},${ARC} v${DIM} a${ARC},${ARC} 0 0 1 -${ARC},${ARC} h-${DIM} a${ARC},${ARC} 0 0 1 -${ARC},-${ARC} v-${DIM} a${ARC},${ARC} 0 0 1 ${ARC},-${ARC} z`}
|
||||
strokeWidth={2}
|
||||
stroke="#000"
|
||||
fill={solid ? '#000' : undefined}
|
||||
/>
|
||||
<Path
|
||||
d={`M16,1 h${DIM} a${ARC},${ARC} 0 0 1 ${ARC},${ARC} v${DIM} a${ARC},${ARC} 0 0 1 -${ARC},${ARC} h-${DIM} a${ARC},${ARC} 0 0 1 -${ARC},-${ARC} v-${DIM} a${ARC},${ARC} 0 0 1 ${ARC},-${ARC} z`}
|
||||
strokeWidth={2}
|
||||
stroke="#000"
|
||||
fill={solid ? '#000' : undefined}
|
||||
/>
|
||||
<Path
|
||||
d={`M4,13 h${DIM} a${ARC},${ARC} 0 0 1 ${ARC},${ARC} v${DIM} a${ARC},${ARC} 0 0 1 -${ARC},${ARC} h-${DIM} a${ARC},${ARC} 0 0 1 -${ARC},-${ARC} v-${DIM} a${ARC},${ARC} 0 0 1 ${ARC},-${ARC} z`}
|
||||
strokeWidth={2}
|
||||
stroke="#000"
|
||||
fill={solid ? '#000' : undefined}
|
||||
/>
|
||||
<Path
|
||||
d={`M16,13 h${DIM} a${ARC},${ARC} 0 0 1 ${ARC},${ARC} v${DIM} a${ARC},${ARC} 0 0 1 -${ARC},${ARC} h-${DIM} a${ARC},${ARC} 0 0 1 -${ARC},-${ARC} v-${DIM} a${ARC},${ARC} 0 0 1 ${ARC},-${ARC} z`}
|
||||
strokeWidth={2}
|
||||
stroke="#000"
|
||||
fill={solid ? '#000' : undefined}
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
export function GridIconSolid({style}: {style?: StyleProp<ViewStyle>}) {
|
||||
return <GridIcon style={style} solid />
|
||||
}
|
||||
|
||||
export function HomeIcon({
|
||||
style,
|
||||
size,
|
||||
strokeWidth = 4,
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>
|
||||
size?: string | number
|
||||
strokeWidth?: number
|
||||
}) {
|
||||
return (
|
||||
<Svg
|
||||
viewBox="0 0 48 48"
|
||||
width={size || 24}
|
||||
height={size || 24}
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
style={style}>
|
||||
<Path
|
||||
strokeWidth={strokeWidth}
|
||||
d="M 23.951 2 C 23.631 2.011 23.323 2.124 23.072 2.322 L 8.859 13.52 C 7.055 14.941 6 17.114 6 19.41 L 6 38.5 C 6 39.864 7.136 41 8.5 41 L 18.5 41 C 19.864 41 21 39.864 21 38.5 L 21 28.5 C 21 28.205 21.205 28 21.5 28 L 26.5 28 C 26.795 28 27 28.205 27 28.5 L 27 38.5 C 27 39.864 28.136 41 29.5 41 L 39.5 41 C 40.864 41 42 39.864 42 38.5 L 42 19.41 C 42 17.114 40.945 14.941 39.141 13.52 L 24.928 2.322 C 24.65 2.103 24.304 1.989 23.951 2 Z"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function HomeIconSolid({
|
||||
style,
|
||||
size,
|
||||
strokeWidth = 4,
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>
|
||||
size?: string | number
|
||||
strokeWidth?: number
|
||||
}) {
|
||||
return (
|
||||
<Svg
|
||||
viewBox="0 0 48 48"
|
||||
width={size || 24}
|
||||
height={size || 24}
|
||||
stroke="currentColor"
|
||||
style={style}>
|
||||
<Path
|
||||
fill="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
d="m 23.951,2 c -0.32,0.011 -0.628,0.124 -0.879,0.322 L 8.859,13.52 C 7.055,14.941 6,17.114 6,19.41 V 38.5 C 6,39.864 7.136,41 8.5,41 h 8 c 1.364,0 2.5,-1.136 2.5,-2.5 v -12 C 19,26.205 19.205,26 19.5,26 h 9 c 0.295,0 0.5,0.205 0.5,0.5 v 12 c 0,1.364 1.136,2.5 2.5,2.5 h 8 C 40.864,41 42,39.864 42,38.5 V 19.41 c 0,-2.296 -1.055,-4.469 -2.859,-5.89 L 24.928,2.322 C 24.65,2.103 24.304,1.989 23.951,2 Z"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
// Copyright (c) 2020 Refactoring UI Inc.
|
||||
// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE
|
||||
export function MagnifyingGlassIcon({
|
||||
@@ -147,90 +55,6 @@ export function MagnifyingGlassIcon2({
|
||||
)
|
||||
}
|
||||
|
||||
export function MagnifyingGlassIcon2Solid({
|
||||
style,
|
||||
size,
|
||||
strokeWidth = 2,
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>
|
||||
size?: string | number
|
||||
strokeWidth?: number
|
||||
}) {
|
||||
return (
|
||||
<Svg
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={strokeWidth}
|
||||
stroke="currentColor"
|
||||
width={size || 24}
|
||||
height={size || 24}
|
||||
style={style}>
|
||||
<Ellipse
|
||||
cx="12"
|
||||
cy="10.5"
|
||||
rx="6.5"
|
||||
ry="6.5"
|
||||
stroke="none"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<Ellipse cx="12" cy="10.5" rx="9" ry="9" />
|
||||
<Line x1="18.5" y1="17" x2="22" y2="20.5" strokeLinecap="round" />
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
// https://github.com/Remix-Design/RemixIcon/blob/master/License
|
||||
export function BellIcon({
|
||||
style,
|
||||
size,
|
||||
strokeWidth = 1.5,
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>
|
||||
size?: string | number
|
||||
strokeWidth?: number
|
||||
}) {
|
||||
return (
|
||||
<Svg
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
width={size || 24}
|
||||
height={size || 24}
|
||||
strokeWidth={strokeWidth}
|
||||
stroke="currentColor"
|
||||
style={style}>
|
||||
<Path d="M 11.642 2 H 12.442 A 8.6 8.55 0 0 1 21.042 10.55 V 18.1 A 1 1 0 0 1 20.042 19.1 H 4.042 A 1 1 0 0 1 3.042 18.1 V 10.55 A 8.6 8.55 0 0 1 11.642 2 Z" />
|
||||
<Line x1="9" y1="22" x2="15" y2="22" />
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
// https://github.com/Remix-Design/RemixIcon/blob/master/License
|
||||
export function BellIconSolid({
|
||||
style,
|
||||
size,
|
||||
strokeWidth = 1.5,
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>
|
||||
size?: string | number
|
||||
strokeWidth?: number
|
||||
}) {
|
||||
return (
|
||||
<Svg
|
||||
viewBox="0 0 24 24"
|
||||
width={size || 24}
|
||||
height={size || 24}
|
||||
strokeWidth={strokeWidth}
|
||||
stroke="currentColor"
|
||||
style={style}>
|
||||
<Path
|
||||
d="M 11.642 2 H 12.442 A 8.6 8.55 0 0 1 21.042 10.55 V 18.1 A 1 1 0 0 1 20.042 19.1 H 4.042 A 1 1 0 0 1 3.042 18.1 V 10.55 A 8.6 8.55 0 0 1 11.642 2 Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<Line x1="9" y1="22" x2="15" y2="22" />
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function CogIcon({
|
||||
style,
|
||||
size,
|
||||
@@ -263,160 +87,6 @@ export function CogIcon({
|
||||
)
|
||||
}
|
||||
|
||||
export function CogIconSolid({
|
||||
style,
|
||||
size,
|
||||
strokeWidth = 1.5,
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>
|
||||
size?: string | number
|
||||
strokeWidth: number
|
||||
}) {
|
||||
return (
|
||||
<Svg
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
width={size || 32}
|
||||
height={size || 32}
|
||||
strokeWidth={strokeWidth}
|
||||
stroke="currentColor"
|
||||
style={style}>
|
||||
<Path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M 9.594 3.94 C 9.684 3.398 10.154 3 10.704 3 L 13.297 3 C 13.847 3 14.317 3.398 14.407 3.94 L 14.62 5.221 C 14.683 5.595 14.933 5.907 15.265 6.091 C 15.339 6.131 15.412 6.174 15.485 6.218 C 15.809 6.414 16.205 6.475 16.56 6.342 L 17.777 5.886 C 18.292 5.692 18.872 5.9 19.147 6.376 L 20.443 8.623 C 20.718 9.099 20.608 9.705 20.183 10.054 L 19.18 10.881 C 18.887 11.121 18.742 11.494 18.749 11.873 C 18.751 11.958 18.751 12.043 18.749 12.128 C 18.742 12.506 18.887 12.878 19.179 13.118 L 20.184 13.946 C 20.608 14.296 20.718 14.9 20.444 15.376 L 19.146 17.623 C 18.871 18.099 18.292 18.307 17.777 18.114 L 16.56 17.658 C 16.205 17.525 15.81 17.586 15.484 17.782 C 15.412 17.826 15.338 17.869 15.264 17.91 C 14.933 18.093 14.683 18.405 14.62 18.779 L 14.407 20.059 C 14.317 20.602 13.847 21 13.297 21 L 10.703 21 C 10.153 21 9.683 20.602 9.593 20.06 L 9.38 18.779 C 9.318 18.405 9.068 18.093 8.736 17.909 C 8.662 17.868 8.589 17.826 8.516 17.782 C 8.191 17.586 7.796 17.525 7.44 17.658 L 6.223 18.114 C 5.708 18.307 5.129 18.1 4.854 17.624 L 3.557 15.377 C 3.282 14.901 3.392 14.295 3.817 13.946 L 4.821 13.119 C 5.113 12.879 5.258 12.506 5.251 12.127 C 5.249 12.042 5.249 11.957 5.251 11.872 C 5.258 11.494 5.113 11.122 4.821 10.882 L 3.817 10.054 C 3.393 9.705 3.283 9.1 3.557 8.624 L 4.854 6.377 C 5.129 5.9 5.709 5.692 6.224 5.886 L 7.44 6.342 C 7.796 6.475 8.191 6.414 8.516 6.218 C 8.588 6.174 8.662 6.131 8.736 6.09 C 9.068 5.907 9.318 5.595 9.38 5.221 Z M 13.5 9.402 C 11.5 8.247 9 9.691 9 12 C 9 13.072 9.572 14.062 10.5 14.598 C 12.5 15.753 15 14.309 15 12 C 15 10.928 14.428 9.938 13.5 9.402 Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
// Copyright (c) 2020 Refactoring UI Inc.
|
||||
// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE
|
||||
export function MoonIcon({
|
||||
style,
|
||||
size,
|
||||
strokeWidth = 1.5,
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>
|
||||
size?: string | number
|
||||
strokeWidth?: number
|
||||
}) {
|
||||
return (
|
||||
<Svg
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
width={size || 32}
|
||||
height={size || 32}
|
||||
strokeWidth={strokeWidth}
|
||||
stroke="currentColor"
|
||||
style={style}>
|
||||
<Path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
// Copyright (c) 2020 Refactoring UI Inc.
|
||||
// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE
|
||||
export function SunIcon({
|
||||
style,
|
||||
size,
|
||||
strokeWidth = 1.5,
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>
|
||||
size?: string | number
|
||||
strokeWidth?: number
|
||||
}) {
|
||||
return (
|
||||
<Svg
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
width={size || 32}
|
||||
height={size || 32}
|
||||
strokeWidth={strokeWidth}
|
||||
stroke="currentColor"
|
||||
style={style}>
|
||||
<Path
|
||||
d="M12 3V5.25M18.364 5.63604L16.773 7.22703M21 12H18.75M18.364 18.364L16.773 16.773M12 18.75V21M7.22703 16.773L5.63604 18.364M5.25 12H3M7.22703 7.22703L5.63604 5.63604M15.75 12C15.75 14.0711 14.0711 15.75 12 15.75C9.92893 15.75 8.25 14.0711 8.25 12C8.25 9.92893 9.92893 8.25 12 8.25C14.0711 8.25 15.75 9.92893 15.75 12Z"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
// Copyright (c) 2020 Refactoring UI Inc.
|
||||
// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE
|
||||
export function UserIcon({
|
||||
style,
|
||||
size,
|
||||
strokeWidth = 1.5,
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>
|
||||
size?: string | number
|
||||
strokeWidth?: number
|
||||
}) {
|
||||
return (
|
||||
<Svg
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
width={size || 32}
|
||||
height={size || 32}
|
||||
strokeWidth={strokeWidth}
|
||||
stroke="currentColor"
|
||||
style={style}>
|
||||
<Path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function UserIconSolid({
|
||||
style,
|
||||
size,
|
||||
strokeWidth = 1.5,
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>
|
||||
size?: string | number
|
||||
strokeWidth?: number
|
||||
}) {
|
||||
return (
|
||||
<Svg
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
width={size || 32}
|
||||
height={size || 32}
|
||||
strokeWidth={strokeWidth}
|
||||
stroke="currentColor"
|
||||
style={style}>
|
||||
<Path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="currentColor"
|
||||
d="M 15 9.75 C 15 12.059 12.5 13.503 10.5 12.348 C 9.572 11.812 9 10.822 9 9.75 C 9 7.441 11.5 5.997 13.5 7.152 C 14.428 7.688 15 8.678 15 9.75 Z"
|
||||
/>
|
||||
<Path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="currentColor"
|
||||
d="M 17.982 18.725 C 16.565 16.849 14.35 15.748 12 15.75 C 9.65 15.748 7.435 16.849 6.018 18.725 M 17.981 18.725 C 16.335 20.193 14.206 21.003 12 21 C 9.794 21.003 7.664 20.193 6.018 18.725"
|
||||
/>
|
||||
<Path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M 17.981 18.725 C 23.158 14.12 21.409 5.639 14.833 3.458 C 8.257 1.277 1.786 7.033 3.185 13.818 C 3.576 15.716 4.57 17.437 6.018 18.725 M 17.981 18.725 C 16.335 20.193 14.206 21.003 12 21 C 9.794 21.003 7.664 20.193 6.018 18.725"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
// Copyright (c) 2020 Refactoring UI Inc.
|
||||
// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE
|
||||
export function UserGroupIcon({
|
||||
@@ -444,208 +114,6 @@ export function UserGroupIcon({
|
||||
)
|
||||
}
|
||||
|
||||
export function RepostIcon({
|
||||
style,
|
||||
size = 24,
|
||||
strokeWidth = 1.5,
|
||||
}: {
|
||||
style?: StyleProp<TextStyle>
|
||||
size?: string | number
|
||||
strokeWidth: number
|
||||
}) {
|
||||
return (
|
||||
<Svg viewBox="0 0 24 24" width={size} height={size} style={style}>
|
||||
<Path
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
d="M 14.437 18.081 L 5.475 18.095 C 4.7 18.095 4.072 17.467 4.072 16.692 L 4.082 6.65 L 1.22 10.854 M 4.082 6.65 L 7.006 10.854 M 9.859 6.65 L 18.625 6.654 C 19.4 6.654 20.028 7.282 20.028 8.057 L 20.031 18.081 L 17.167 13.646 M 20.031 18.081 L 22.866 13.646"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
// Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc.
|
||||
export function HeartIcon({
|
||||
style,
|
||||
size = 24,
|
||||
strokeWidth = 1.5,
|
||||
}: {
|
||||
style?: StyleProp<TextStyle>
|
||||
size?: string | number
|
||||
strokeWidth: number
|
||||
}) {
|
||||
return (
|
||||
<Svg viewBox="0 0 24 24" width={size} height={size} style={style}>
|
||||
<Path
|
||||
strokeWidth={strokeWidth}
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
d="M 3.859 13.537 L 10.918 20.127 C 11.211 20.4 11.598 20.552 12 20.552 C 12.402 20.552 12.789 20.4 13.082 20.127 L 20.141 13.537 C 21.328 12.431 22 10.88 22 9.259 L 22 9.033 C 22 6.302 20.027 3.974 17.336 3.525 C 15.555 3.228 13.742 3.81 12.469 5.084 L 12 5.552 L 11.531 5.084 C 10.258 3.81 8.445 3.228 6.664 3.525 C 3.973 3.974 2 6.302 2 9.033 L 2 9.259 C 2 10.88 2.672 12.431 3.859 13.537 Z"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
// Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc.
|
||||
export function HeartIconSolid({
|
||||
style,
|
||||
size = 24,
|
||||
}: {
|
||||
style?: StyleProp<TextStyle>
|
||||
size?: string | number
|
||||
}) {
|
||||
return (
|
||||
<Svg viewBox="0 0 24 24" width={size} height={size} style={style}>
|
||||
<Path
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1}
|
||||
d="M 3.859 13.537 L 10.918 20.127 C 11.211 20.4 11.598 20.552 12 20.552 C 12.402 20.552 12.789 20.4 13.082 20.127 L 20.141 13.537 C 21.328 12.431 22 10.88 22 9.259 L 22 9.033 C 22 6.302 20.027 3.974 17.336 3.525 C 15.555 3.228 13.742 3.81 12.469 5.084 L 12 5.552 L 11.531 5.084 C 10.258 3.81 8.445 3.228 6.664 3.525 C 3.973 3.974 2 6.302 2 9.033 L 2 9.259 C 2 10.88 2.672 12.431 3.859 13.537 Z"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function UpIcon({
|
||||
style,
|
||||
size,
|
||||
strokeWidth = 1.3,
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>
|
||||
size?: string | number
|
||||
strokeWidth: number
|
||||
}) {
|
||||
return (
|
||||
<Svg
|
||||
viewBox="0 0 14 14"
|
||||
width={size || 24}
|
||||
height={size || 24}
|
||||
style={style}>
|
||||
<Path
|
||||
strokeWidth={strokeWidth}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M 7 3 L 2 8 L 4.5 8 L 4.5 11.5 L 9.5 11.5 L 9.5 8 L 12 8 L 7 3 Z"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function UpIconSolid({
|
||||
style,
|
||||
size,
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>
|
||||
size?: string | number
|
||||
}) {
|
||||
return (
|
||||
<Svg
|
||||
viewBox="0 0 14 14"
|
||||
width={size || 24}
|
||||
height={size || 24}
|
||||
style={style}>
|
||||
<Path
|
||||
strokeWidth={1.3}
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M 7 3 L 2 8 L 4.5 8 L 4.5 11.5 L 9.5 11.5 L 9.5 8 L 12 8 L 7 3 Z"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function DownIcon({
|
||||
style,
|
||||
size,
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>
|
||||
size?: string | number
|
||||
}) {
|
||||
return (
|
||||
<Svg
|
||||
viewBox="0 0 14 14"
|
||||
width={size || 24}
|
||||
height={size || 24}
|
||||
style={style}>
|
||||
<Path
|
||||
strokeWidth={1.3}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M 7 11.5 L 2 6.5 L 4.5 6.5 L 4.5 3 L 9.5 3 L 9.5 6.5 L 12 6.5 L 7 11.5 Z"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function DownIconSolid({
|
||||
style,
|
||||
size,
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>
|
||||
size?: string | number
|
||||
}) {
|
||||
return (
|
||||
<Svg
|
||||
viewBox="0 0 14 14"
|
||||
width={size || 24}
|
||||
height={size || 24}
|
||||
style={style}>
|
||||
<Path
|
||||
strokeWidth={1.3}
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M 7 11.5 L 2 6.5 L 4.5 6.5 L 4.5 3 L 9.5 3 L 9.5 6.5 L 12 6.5 L 7 11.5 Z"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
// Copyright (c) 2020 Refactoring UI Inc.
|
||||
// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE
|
||||
export function CommentBottomArrow({
|
||||
style,
|
||||
size,
|
||||
strokeWidth = 1.3,
|
||||
}: {
|
||||
style?: StyleProp<TextStyle>
|
||||
size?: string | number
|
||||
strokeWidth?: number
|
||||
}) {
|
||||
let color = 'currentColor'
|
||||
if (
|
||||
style &&
|
||||
typeof style === 'object' &&
|
||||
'color' in style &&
|
||||
typeof style.color === 'string'
|
||||
) {
|
||||
color = style.color
|
||||
}
|
||||
return (
|
||||
<Svg
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={strokeWidth || 2.5}
|
||||
stroke={color}
|
||||
width={size || 24}
|
||||
height={size || 24}
|
||||
style={style}>
|
||||
<Path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.068.157 2.148.279 3.238.364.466.037.893.281 1.153.671L12 21l2.652-3.978c.26-.39.687-.634 1.153-.67 1.09-.086 2.17-.208 3.238-.365 1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function SquareIcon({
|
||||
style,
|
||||
size,
|
||||
@@ -715,33 +183,6 @@ export function RectTallIcon({
|
||||
)
|
||||
}
|
||||
|
||||
export function ComposeIcon({
|
||||
style,
|
||||
size,
|
||||
strokeWidth = 1.5,
|
||||
}: {
|
||||
style?: StyleProp<TextStyle>
|
||||
size?: string | number
|
||||
strokeWidth?: number
|
||||
}) {
|
||||
return (
|
||||
<Svg
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={strokeWidth}
|
||||
stroke="currentColor"
|
||||
width={size || 24}
|
||||
height={size || 24}
|
||||
style={style}>
|
||||
<Path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ComposeIcon2({
|
||||
style,
|
||||
size,
|
||||
@@ -783,54 +224,6 @@ export function ComposeIcon2({
|
||||
)
|
||||
}
|
||||
|
||||
export function SquarePlusIcon({
|
||||
style,
|
||||
size,
|
||||
strokeWidth = 1.5,
|
||||
}: {
|
||||
style?: StyleProp<TextStyle>
|
||||
size?: string | number
|
||||
strokeWidth?: number
|
||||
}) {
|
||||
return (
|
||||
<Svg
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={strokeWidth}
|
||||
stroke="currentColor"
|
||||
width={size || 24}
|
||||
height={size || 24}
|
||||
style={style}>
|
||||
<Line
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
x1="12"
|
||||
y1="5.5"
|
||||
x2="12"
|
||||
y2="18.5"
|
||||
strokeWidth={strokeWidth * 1.5}
|
||||
/>
|
||||
<Line
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
x1="5.5"
|
||||
y1="12"
|
||||
x2="18.5"
|
||||
y2="12"
|
||||
strokeWidth={strokeWidth * 1.5}
|
||||
/>
|
||||
<Rect
|
||||
strokeWidth={strokeWidth}
|
||||
x="4"
|
||||
y="4"
|
||||
width="16"
|
||||
height="16"
|
||||
rx="4"
|
||||
ry="4"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function InfoCircleIcon({
|
||||
style,
|
||||
size,
|
||||
@@ -947,30 +340,3 @@ export function ShieldExclamation({
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ListIcon({
|
||||
style,
|
||||
size,
|
||||
strokeWidth = 1.5,
|
||||
}: {
|
||||
style?: StyleProp<TextStyle>
|
||||
size?: string | number
|
||||
strokeWidth?: number
|
||||
}) {
|
||||
return (
|
||||
<Svg
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={strokeWidth || 1.5}
|
||||
stroke="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
style={style}>
|
||||
<Path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zM3.75 12h.007v.008H3.75V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm-.375 5.25h.007v.008H3.75v-.008zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,15 +6,15 @@ import {
|
||||
} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {getDefinition, getLabelStrings} from './useLabelInfo'
|
||||
import {useLabelDefinitions} from '#/state/preferences'
|
||||
import {useGlobalLabelStrings} from './useGlobalLabelStrings'
|
||||
|
||||
import {Props as SVGIconProps} from '#/components/icons/common'
|
||||
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
||||
import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
|
||||
import {useLabelDefinitions} from '#/state/preferences'
|
||||
import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
||||
import {Props as SVGIconProps} from '#/components/icons/common'
|
||||
import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
|
||||
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
|
||||
import {useGlobalLabelStrings} from './useGlobalLabelStrings'
|
||||
import {getDefinition, getLabelStrings} from './useLabelInfo'
|
||||
|
||||
export interface ModerationCauseDescription {
|
||||
icon: React.ComponentType<SVGIconProps>
|
||||
@@ -22,6 +22,8 @@ export interface ModerationCauseDescription {
|
||||
description: string
|
||||
source?: string
|
||||
sourceType?: ModerationCauseSource['type']
|
||||
sourceAvi?: string
|
||||
sourceDid?: string
|
||||
}
|
||||
|
||||
export function useModerationCauseDescription(
|
||||
@@ -138,6 +140,8 @@ export function useModerationCauseDescription(
|
||||
description: strings.description,
|
||||
source,
|
||||
sourceType: cause.source.type,
|
||||
sourceAvi: labeler?.creator.avatar,
|
||||
sourceDid: cause.label.src,
|
||||
}
|
||||
}
|
||||
// should never happen
|
||||
|
||||
@@ -71,46 +71,41 @@ export function useNotificationsRegistration() {
|
||||
|
||||
export function useRequestNotificationsPermission() {
|
||||
const gate = useGate()
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
return React.useCallback(
|
||||
async (context: 'StartOnboarding' | 'AfterOnboarding' | 'Login') => {
|
||||
const permissions = await Notifications.getPermissionsAsync()
|
||||
return async (context: 'StartOnboarding' | 'AfterOnboarding' | 'Login') => {
|
||||
const permissions = await Notifications.getPermissionsAsync()
|
||||
|
||||
if (
|
||||
!currentAccount ||
|
||||
!isNative ||
|
||||
permissions?.status === 'granted' ||
|
||||
(permissions?.status === 'denied' && !permissions?.canAskAgain)
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
context === 'StartOnboarding' &&
|
||||
gate('request_notifications_permission_after_onboarding')
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
context === 'AfterOnboarding' &&
|
||||
!gate('request_notifications_permission_after_onboarding')
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
!isNative ||
|
||||
permissions?.status === 'granted' ||
|
||||
permissions?.status === 'denied'
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
context === 'StartOnboarding' &&
|
||||
gate('request_notifications_permission_after_onboarding_v2')
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
context === 'AfterOnboarding' &&
|
||||
!gate('request_notifications_permission_after_onboarding_v2')
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const res = await Notifications.requestPermissionsAsync()
|
||||
logEvent('notifications:request', {
|
||||
context: context,
|
||||
status: res.status,
|
||||
})
|
||||
const res = await Notifications.requestPermissionsAsync()
|
||||
logEvent('notifications:request', {
|
||||
context: context,
|
||||
status: res.status,
|
||||
})
|
||||
|
||||
if (res.granted) {
|
||||
// This will fire a pushTokenEvent, which will handle registration of the token
|
||||
getPushToken(true)
|
||||
}
|
||||
},
|
||||
[gate, currentAccount],
|
||||
)
|
||||
if (res.granted) {
|
||||
// This will fire a pushTokenEvent, which will handle registration of the token
|
||||
getPushToken(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function decrementBadgeCount(by: number | 'reset' = 1) {
|
||||
|
||||
@@ -38,7 +38,7 @@ export type CommonNavigatorParams = {
|
||||
AccessibilitySettings: undefined
|
||||
Search: {q?: string}
|
||||
Hashtag: {tag: string; author?: string}
|
||||
MessagesConversation: {conversation: string}
|
||||
MessagesConversation: {conversation: string; embed?: string}
|
||||
MessagesSettings: undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -13,17 +13,17 @@ export type LogEvents = {
|
||||
withPassword: boolean
|
||||
}
|
||||
'account:loggedOut': {
|
||||
logContext: 'SwitchAccount' | 'Settings' | 'Deactivated'
|
||||
logContext: 'SwitchAccount' | 'Settings' | 'SignupQueued' | 'Deactivated'
|
||||
}
|
||||
'notifications:openApp': {}
|
||||
'notifications:request': {
|
||||
context: 'StartOnboarding' | 'AfterOnboarding' | 'Login'
|
||||
status: 'granted' | 'denied' | 'undetermined'
|
||||
}
|
||||
'state:background': {
|
||||
'state:background:sampled': {
|
||||
secondsActive: number
|
||||
}
|
||||
'state:foreground': {}
|
||||
'state:foreground:sampled': {}
|
||||
'router:navigate:sampled': {}
|
||||
|
||||
// Screen events
|
||||
@@ -57,18 +57,18 @@ export type LogEvents = {
|
||||
'onboarding:finished:avatarResult': {
|
||||
avatarResult: 'default' | 'created' | 'uploaded'
|
||||
}
|
||||
'home:feedDisplayed': {
|
||||
'home:feedDisplayed:sampled': {
|
||||
feedUrl: string
|
||||
feedType: string
|
||||
index: number
|
||||
reason: 'focus' | 'tabbar-click' | 'pager-swipe' | 'desktop-sidebar-click'
|
||||
}
|
||||
'feed:endReached': {
|
||||
'feed:endReached:sampled': {
|
||||
feedUrl: string
|
||||
feedType: string
|
||||
itemCount: number
|
||||
}
|
||||
'feed:refresh': {
|
||||
'feed:refresh:sampled': {
|
||||
feedUrl: string
|
||||
feedType: string
|
||||
reason: 'pull-to-refresh' | 'soft-reset' | 'load-latest'
|
||||
@@ -115,6 +115,7 @@ export type LogEvents = {
|
||||
| 'ProfileHeaderSuggestedFollows'
|
||||
| 'ProfileMenu'
|
||||
| 'ProfileHoverCard'
|
||||
| 'AvatarButton'
|
||||
}
|
||||
'profile:unfollow': {
|
||||
logContext:
|
||||
@@ -126,12 +127,17 @@ export type LogEvents = {
|
||||
| 'ProfileMenu'
|
||||
| 'ProfileHoverCard'
|
||||
| 'Chat'
|
||||
| 'AvatarButton'
|
||||
}
|
||||
'chat:create': {
|
||||
logContext: 'ProfileHeader' | 'NewChatDialog'
|
||||
logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
|
||||
}
|
||||
'chat:open': {
|
||||
logContext: 'ProfileHeader' | 'NewChatDialog' | 'ChatsList'
|
||||
logContext:
|
||||
| 'ProfileHeader'
|
||||
| 'NewChatDialog'
|
||||
| 'ChatsList'
|
||||
| 'SendViaChatDialog'
|
||||
}
|
||||
|
||||
'test:all:always': {}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export type Gate =
|
||||
// Keep this alphabetic please.
|
||||
| 'request_notifications_permission_after_onboarding'
|
||||
| 'request_notifications_permission_after_onboarding_v2'
|
||||
| 'show_avi_follow_button'
|
||||
| 'show_follow_back_label_v2'
|
||||
|
||||
@@ -87,7 +87,14 @@ export function toClout(n: number | null | undefined): number | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
const DOWNSAMPLED_EVENTS = new Set(['router:navigate:sampled'])
|
||||
const DOWNSAMPLED_EVENTS: Set<keyof LogEvents> = new Set([
|
||||
'router:navigate:sampled',
|
||||
'state:background:sampled',
|
||||
'state:foreground:sampled',
|
||||
'home:feedDisplayed:sampled',
|
||||
'feed:endReached:sampled',
|
||||
'feed:refresh:sampled',
|
||||
])
|
||||
const isDownsampledSession = Math.random() < 0.9 // 90% likely
|
||||
|
||||
export function logEvent<E extends keyof LogEvents>(
|
||||
@@ -95,6 +102,16 @@ export function logEvent<E extends keyof LogEvents>(
|
||||
rawMetadata: LogEvents[E] & FlatJSONRecord,
|
||||
) {
|
||||
try {
|
||||
if (
|
||||
process.env.NODE_ENV === 'development' &&
|
||||
eventName.endsWith(':sampled') &&
|
||||
!DOWNSAMPLED_EVENTS.has(eventName)
|
||||
) {
|
||||
logger.error(
|
||||
'Did you forget to add ' + eventName + ' to DOWNSAMPLED_EVENTS?',
|
||||
)
|
||||
}
|
||||
|
||||
if (isDownsampledSession && DOWNSAMPLED_EVENTS.has(eventName)) {
|
||||
return
|
||||
}
|
||||
@@ -199,14 +216,14 @@ AppState.addEventListener('change', (state: AppStateStatus) => {
|
||||
lastState = state
|
||||
if (state === 'active') {
|
||||
lastActive = performance.now()
|
||||
logEvent('state:foreground', {})
|
||||
logEvent('state:foreground:sampled', {})
|
||||
} else {
|
||||
let secondsActive = 0
|
||||
if (lastActive != null) {
|
||||
secondsActive = Math.round((performance.now() - lastActive) / 1e3)
|
||||
}
|
||||
lastActive = null
|
||||
logEvent('state:background', {
|
||||
logEvent('state:background:sampled', {
|
||||
secondsActive,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export const embedPlayerSources = [
|
||||
'vimeo',
|
||||
'giphy',
|
||||
'tenor',
|
||||
'flickr',
|
||||
] as const
|
||||
|
||||
export type EmbedPlayerSource = (typeof embedPlayerSources)[number]
|
||||
@@ -42,6 +43,7 @@ export type EmbedPlayerType =
|
||||
| 'vimeo_video'
|
||||
| 'giphy_gif'
|
||||
| 'tenor_gif'
|
||||
| 'flickr_album'
|
||||
|
||||
export const externalEmbedLabels: Record<EmbedPlayerSource, string> = {
|
||||
youtube: 'YouTube',
|
||||
@@ -53,6 +55,7 @@ export const externalEmbedLabels: Record<EmbedPlayerSource, string> = {
|
||||
spotify: 'Spotify',
|
||||
appleMusic: 'Apple Music',
|
||||
soundcloud: 'SoundCloud',
|
||||
flickr: 'Flickr',
|
||||
}
|
||||
|
||||
export interface EmbedPlayerParams {
|
||||
@@ -375,6 +378,79 @@ export function parseEmbedPlayerFromUrl(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// this is a standard flickr path! we can use the embedder for albums and groups, so validate the path
|
||||
if (urlp.hostname === 'www.flickr.com' || urlp.hostname === 'flickr.com') {
|
||||
let i = urlp.pathname.length - 1
|
||||
while (i > 0 && urlp.pathname.charAt(i) === '/') {
|
||||
--i
|
||||
}
|
||||
|
||||
const path_components = urlp.pathname.slice(1, i + 1).split('/')
|
||||
if (path_components.length === 4) {
|
||||
// discard username - it's not relevant
|
||||
const [photos, _, albums, id] = path_components
|
||||
if (photos === 'photos' && albums === 'albums') {
|
||||
// this at least has the shape of a valid photo-album URL!
|
||||
return {
|
||||
type: 'flickr_album',
|
||||
source: 'flickr',
|
||||
playerUri: `https://embedr.flickr.com/photosets/${id}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (path_components.length === 3) {
|
||||
const [groups, id, pool] = path_components
|
||||
if (groups === 'groups' && pool === 'pool') {
|
||||
return {
|
||||
type: 'flickr_album',
|
||||
source: 'flickr',
|
||||
playerUri: `https://embedr.flickr.com/groups/${id}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
// not an album or a group pool, don't know what to do with this!
|
||||
return undefined
|
||||
}
|
||||
|
||||
// link shortened flickr path
|
||||
if (urlp.hostname === 'flic.kr') {
|
||||
const b58alph = '123456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ'
|
||||
let [_, type, idBase58Enc] = urlp.pathname.split('/')
|
||||
let id = 0n
|
||||
for (const char of idBase58Enc) {
|
||||
const nextIdx = b58alph.indexOf(char)
|
||||
if (nextIdx >= 0) {
|
||||
id = id * 58n + BigInt(nextIdx)
|
||||
} else {
|
||||
// not b58 encoded, ergo not a valid link to embed
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'go':
|
||||
const formattedGroupId = `${id}`
|
||||
return {
|
||||
type: 'flickr_album',
|
||||
source: 'flickr',
|
||||
playerUri: `https://embedr.flickr.com/groups/${formattedGroupId.slice(
|
||||
0,
|
||||
-2,
|
||||
)}@N${formattedGroupId.slice(-2)}`,
|
||||
}
|
||||
case 's':
|
||||
return {
|
||||
type: 'flickr_album',
|
||||
source: 'flickr',
|
||||
playerUri: `https://embedr.flickr.com/photosets/${id}`,
|
||||
}
|
||||
default:
|
||||
// we don't know what this is so we can't embed it
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getPlayerAspect({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {RichText, UnicodeString} from '@atproto/api'
|
||||
import {AppBskyRichtextFacet, RichText, UnicodeString} from '@atproto/api'
|
||||
|
||||
import {toShortUrl} from './url-helpers'
|
||||
|
||||
@@ -10,9 +10,7 @@ export function shortenLinks(rt: RichText): RichText {
|
||||
// enumerate the link facets
|
||||
if (rt.facets) {
|
||||
for (const facet of rt.facets) {
|
||||
const isLink = !!facet.features.find(
|
||||
f => f.$type === 'app.bsky.richtext.facet#link',
|
||||
)
|
||||
const isLink = !!facet.features.find(AppBskyRichtextFacet.isLink)
|
||||
if (!isLink) {
|
||||
continue
|
||||
}
|
||||
@@ -33,3 +31,21 @@ export function shortenLinks(rt: RichText): RichText {
|
||||
}
|
||||
return rt
|
||||
}
|
||||
|
||||
// filter out any mention facets that didn't map to a user
|
||||
export function stripInvalidMentions(rt: RichText): RichText {
|
||||
if (!rt.facets?.length) {
|
||||
return rt
|
||||
}
|
||||
rt = rt.clone()
|
||||
if (rt.facets) {
|
||||
rt.facets = rt.facets?.filter(facet => {
|
||||
const mention = facet.features.find(AppBskyRichtextFacet.isMention)
|
||||
if (mention && !mention.did) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
return rt
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import psl from 'psl'
|
||||
import TLDs from 'tlds'
|
||||
|
||||
import {BSKY_SERVICE} from 'lib/constants'
|
||||
import {isInvalidHandle} from 'lib/strings/handles'
|
||||
|
||||
export const BSKY_APP_HOST = 'https://bsky.app'
|
||||
const BSKY_TRUSTED_HOSTS = [
|
||||
@@ -83,6 +84,10 @@ export function toShareUrl(url: string): string {
|
||||
return url
|
||||
}
|
||||
|
||||
export function toBskyAppUrl(url: string): string {
|
||||
return new URL(url, BSKY_APP_HOST).toString()
|
||||
}
|
||||
|
||||
export function isBskyAppUrl(url: string): boolean {
|
||||
return url.startsWith('https://bsky.app/')
|
||||
}
|
||||
@@ -183,6 +188,22 @@ export function feedUriToHref(url: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function postUriToRelativePath(
|
||||
uri: string,
|
||||
options?: {handle?: string},
|
||||
): string | undefined {
|
||||
try {
|
||||
const {hostname, rkey} = new AtUri(uri)
|
||||
const handleOrDid =
|
||||
options?.handle && !isInvalidHandle(options.handle)
|
||||
? options.handle
|
||||
: hostname
|
||||
return `/profile/${handleOrDid}/post/${rkey}`
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the label in the post text matches the host of the link facet.
|
||||
*
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {msg, plural, Trans} from '@lingui/macro'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {isSessionDeactivated, useAgent, useSessionApi} from '#/state/session'
|
||||
import {useOnboardingDispatch} from '#/state/shell'
|
||||
import {type SessionAccount, useSession, useSessionApi} from '#/state/session'
|
||||
import {useSetMinimalShellMode} from '#/state/shell'
|
||||
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||
import {ScrollView} from '#/view/com/util/Views'
|
||||
import {Logo} from '#/view/icons/Logo'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {P, Text} from '#/components/Typography'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {AccountList} from '#/components/AccountList'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
const COL_WIDTH = 400
|
||||
|
||||
@@ -21,199 +24,151 @@ export function Deactivated() {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const insets = useSafeAreaInsets()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const onboardingDispatch = useOnboardingDispatch()
|
||||
const {currentAccount, accounts} = useSession()
|
||||
const {onPressSwitchAccount, pendingDid} = useAccountSwitcher()
|
||||
const {setShowLoggedOut} = useLoggedOutViewControls()
|
||||
const hasOtherAccounts = accounts.length > 1
|
||||
const setMinimalShellMode = useSetMinimalShellMode()
|
||||
const {logout} = useSessionApi()
|
||||
const agent = useAgent()
|
||||
|
||||
const [isProcessing, setProcessing] = React.useState(false)
|
||||
const [estimatedTime, setEstimatedTime] = React.useState<string | undefined>(
|
||||
undefined,
|
||||
)
|
||||
const [placeInQueue, setPlaceInQueue] = React.useState<number | undefined>(
|
||||
undefined,
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
setMinimalShellMode(true)
|
||||
}, [setMinimalShellMode]),
|
||||
)
|
||||
|
||||
const checkStatus = React.useCallback(async () => {
|
||||
setProcessing(true)
|
||||
try {
|
||||
const res = await agent.com.atproto.temp.checkSignupQueue()
|
||||
if (res.data.activated) {
|
||||
// ready to go, exchange the access token for a usable one and kick off onboarding
|
||||
await agent.refreshSession()
|
||||
if (!isSessionDeactivated(agent.session?.accessJwt)) {
|
||||
onboardingDispatch({type: 'start'})
|
||||
}
|
||||
} else {
|
||||
// not ready, update UI
|
||||
setEstimatedTime(msToString(res.data.estimatedTimeMs))
|
||||
if (typeof res.data.placeInQueue !== 'undefined') {
|
||||
setPlaceInQueue(Math.max(res.data.placeInQueue, 1))
|
||||
}
|
||||
const onSelectAccount = React.useCallback(
|
||||
(account: SessionAccount) => {
|
||||
if (account.did !== currentAccount?.did) {
|
||||
onPressSwitchAccount(account, 'SwitchAccount')
|
||||
}
|
||||
} catch (e: any) {
|
||||
logger.error('Failed to check signup queue', {err: e.toString()})
|
||||
} finally {
|
||||
setProcessing(false)
|
||||
}
|
||||
}, [
|
||||
setProcessing,
|
||||
setEstimatedTime,
|
||||
setPlaceInQueue,
|
||||
onboardingDispatch,
|
||||
agent,
|
||||
])
|
||||
|
||||
React.useEffect(() => {
|
||||
checkStatus()
|
||||
const interval = setInterval(checkStatus, 60e3)
|
||||
return () => clearInterval(interval)
|
||||
}, [checkStatus])
|
||||
|
||||
const checkBtn = (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="large"
|
||||
label={_(msg`Check my status`)}
|
||||
onPress={checkStatus}
|
||||
disabled={isProcessing}>
|
||||
<ButtonText>
|
||||
<Trans>Check my status</Trans>
|
||||
</ButtonText>
|
||||
{isProcessing && <ButtonIcon icon={Loader} />}
|
||||
</Button>
|
||||
},
|
||||
[currentAccount, onPressSwitchAccount],
|
||||
)
|
||||
|
||||
const onPressAddAccount = React.useCallback(() => {
|
||||
setShowLoggedOut(true)
|
||||
}, [setShowLoggedOut])
|
||||
|
||||
const onPressLogout = React.useCallback(() => {
|
||||
if (isWeb) {
|
||||
// We're switching accounts, which remounts the entire app.
|
||||
// On mobile, this gets us Home, but on the web we also need reset the URL.
|
||||
// We can't change the URL via a navigate() call because the navigator
|
||||
// itself is about to unmount, and it calls pushState() too late.
|
||||
// So we change the URL ourselves. The navigator will pick it up on remount.
|
||||
history.pushState(null, '', '/')
|
||||
}
|
||||
logout('Deactivated')
|
||||
}, [logout])
|
||||
|
||||
return (
|
||||
<View
|
||||
aria-modal
|
||||
role="dialog"
|
||||
aria-role="dialog"
|
||||
aria-label={_(msg`You're in line`)}
|
||||
accessibilityLabel={_(msg`You're in line`)}
|
||||
accessibilityHint=""
|
||||
style={[a.absolute, a.inset_0, a.flex_1, t.atoms.bg]}>
|
||||
<View style={[a.h_full_vh, a.flex_1, t.atoms.bg]}>
|
||||
<ScrollView
|
||||
style={[a.h_full, a.w_full]}
|
||||
contentContainerStyle={{borderWidth: 0}}>
|
||||
<View
|
||||
style={[a.flex_row, a.justify_center, gtMobile ? a.pt_4xl : a.px_xl]}>
|
||||
<View style={[a.flex_1, {maxWidth: COL_WIDTH}]}>
|
||||
<View
|
||||
style={[a.w_full, a.justify_center, a.align_center, a.my_4xl]}>
|
||||
<Logo width={120} />
|
||||
</View>
|
||||
|
||||
<Text style={[a.text_4xl, a.font_bold, a.pb_sm]}>
|
||||
<Trans>You're in line</Trans>
|
||||
</Text>
|
||||
<P style={[t.atoms.text_contrast_medium]}>
|
||||
<Trans>
|
||||
There's been a rush of new users to Bluesky! We'll activate your
|
||||
account as soon as we can.
|
||||
</Trans>
|
||||
</P>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
a.px_2xl,
|
||||
a.py_4xl,
|
||||
a.mt_2xl,
|
||||
t.atoms.bg_contrast_50,
|
||||
]}>
|
||||
{typeof placeInQueue === 'number' && (
|
||||
<Text
|
||||
style={[a.text_5xl, a.text_center, a.font_bold, a.mb_2xl]}>
|
||||
{placeInQueue}
|
||||
</Text>
|
||||
)}
|
||||
<P style={[a.text_center]}>
|
||||
{typeof placeInQueue === 'number' ? (
|
||||
<Trans>left to go.</Trans>
|
||||
) : (
|
||||
<Trans>You are in line.</Trans>
|
||||
)}{' '}
|
||||
{estimatedTime ? (
|
||||
<Trans>
|
||||
We estimate {estimatedTime} until your account is ready.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
We will let you know when your account is ready.
|
||||
</Trans>
|
||||
)}
|
||||
</P>
|
||||
</View>
|
||||
|
||||
{isWeb && gtMobile && (
|
||||
<View style={[a.w_full, a.flex_row, a.justify_between, a.pt_5xl]}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="large"
|
||||
label={_(msg`Log out`)}
|
||||
onPress={() => logout('Deactivated')}>
|
||||
<ButtonText style={[{color: t.palette.primary_500}]}>
|
||||
<Trans>Log out</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
{checkBtn}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={{height: 200}} />
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{(!isWeb || !gtMobile) && (
|
||||
<View
|
||||
style={[
|
||||
a.align_center,
|
||||
gtMobile ? a.px_5xl : a.px_xl,
|
||||
a.px_2xl,
|
||||
{
|
||||
paddingBottom: Math.max(insets.bottom, a.pb_5xl.paddingBottom),
|
||||
paddingTop: isWeb ? 64 : insets.top,
|
||||
paddingBottom: isWeb ? 64 : insets.bottom,
|
||||
},
|
||||
]}>
|
||||
<View style={[a.w_full, a.gap_sm, {maxWidth: COL_WIDTH}]}>
|
||||
{checkBtn}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="large"
|
||||
label={_(msg`Log out`)}
|
||||
onPress={() => logout('Deactivated')}>
|
||||
<ButtonText style={[{color: t.palette.primary_500}]}>
|
||||
<Trans>Log out</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<View style={[a.flex_row, a.justify_center]}>
|
||||
<View style={[a.w_full, {maxWidth: COL_WIDTH}]}>
|
||||
<View
|
||||
style={[a.w_full, a.justify_center, a.align_center, a.pb_5xl]}>
|
||||
<Logo width={40} />
|
||||
</View>
|
||||
|
||||
<View style={[a.gap_xs, a.pb_3xl]}>
|
||||
<Text style={[a.text_xl, a.font_bold, a.leading_snug]}>
|
||||
<Trans>Welcome back!</Trans>
|
||||
</Text>
|
||||
<Text style={[a.text_sm, a.leading_snug]}>
|
||||
<Trans>
|
||||
You previously deactivated @{currentAccount?.handle}.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Text style={[a.text_sm, a.leading_snug, a.pb_md]}>
|
||||
<Trans>
|
||||
You can reactivate your account to continue logging in. Your
|
||||
profile and posts will be visible to other users.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<View style={[a.gap_sm]}>
|
||||
<Button
|
||||
label={_(msg`Reactivate your account`)}
|
||||
size="medium"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onPress={() => setShowLoggedOut(true)}>
|
||||
<ButtonText>
|
||||
<Trans>Yes, reactivate my account</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<Button
|
||||
label={_(msg`Cancel reactivation and log out`)}
|
||||
size="medium"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onPress={onPressLogout}>
|
||||
<ButtonText>
|
||||
<Trans>Cancel</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[a.pb_3xl]}>
|
||||
<Divider />
|
||||
</View>
|
||||
|
||||
{hasOtherAccounts ? (
|
||||
<>
|
||||
<Text
|
||||
style={[
|
||||
t.atoms.text_contrast_medium,
|
||||
a.pb_md,
|
||||
a.leading_snug,
|
||||
]}>
|
||||
<Trans>Or, log into one of your other accounts.</Trans>
|
||||
</Text>
|
||||
<AccountList
|
||||
onSelectAccount={onSelectAccount}
|
||||
onSelectOther={onPressAddAccount}
|
||||
otherLabel={_(msg`Add account`)}
|
||||
pendingDid={pendingDid}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text
|
||||
style={[
|
||||
t.atoms.text_contrast_medium,
|
||||
a.pb_md,
|
||||
a.leading_snug,
|
||||
]}>
|
||||
<Trans>Or, continue with another account.</Trans>
|
||||
</Text>
|
||||
<Button
|
||||
label={_(msg`Log in or sign up`)}
|
||||
size="medium"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onPress={() => setShowLoggedOut(true)}>
|
||||
<ButtonText>
|
||||
<Trans>Log in or sign up</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function msToString(ms: number | undefined): string | undefined {
|
||||
if (ms && ms > 0) {
|
||||
const estimatedTimeMins = Math.ceil(ms / 60e3)
|
||||
if (estimatedTimeMins > 59) {
|
||||
const estimatedTimeHrs = Math.round(estimatedTimeMins / 60)
|
||||
if (estimatedTimeHrs > 6) {
|
||||
// dont even bother
|
||||
return undefined
|
||||
}
|
||||
// hours
|
||||
return `${estimatedTimeHrs} ${plural(estimatedTimeHrs, {
|
||||
one: 'hour',
|
||||
other: 'hours',
|
||||
})}`
|
||||
}
|
||||
// minutes
|
||||
return `${estimatedTimeMins} ${plural(estimatedTimeMins, {
|
||||
one: 'minute',
|
||||
other: 'minutes',
|
||||
})}`
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -27,13 +27,20 @@ import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {useSharedInputStyles} from '#/components/forms/TextField'
|
||||
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
|
||||
import {useExtractEmbedFromFacets} from './MessageInputEmbed'
|
||||
|
||||
const AnimatedTextInput = Animated.createAnimatedComponent(TextInput)
|
||||
|
||||
export function MessageInput({
|
||||
onSendMessage,
|
||||
hasEmbed,
|
||||
setEmbed,
|
||||
children,
|
||||
}: {
|
||||
onSendMessage: (message: string) => void
|
||||
hasEmbed: boolean
|
||||
setEmbed: (embedUrl: string | undefined) => void
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
@@ -53,9 +60,10 @@ export function MessageInput({
|
||||
const inputRef = useAnimatedRef<TextInput>()
|
||||
|
||||
useSaveMessageDraft(message)
|
||||
useExtractEmbedFromFacets(message, setEmbed)
|
||||
|
||||
const onSubmit = React.useCallback(() => {
|
||||
if (message.trim() === '') {
|
||||
if (!hasEmbed && message.trim() === '') {
|
||||
return
|
||||
}
|
||||
if (new Graphemer().countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) {
|
||||
@@ -63,16 +71,26 @@ export function MessageInput({
|
||||
return
|
||||
}
|
||||
clearDraft()
|
||||
onSendMessage(message.trimEnd())
|
||||
onSendMessage(message)
|
||||
playHaptic()
|
||||
setMessage('')
|
||||
setEmbed(undefined)
|
||||
|
||||
// Pressing the send button causes the text input to lose focus, so we need to
|
||||
// re-focus it after sending
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus()
|
||||
}, 100)
|
||||
}, [message, clearDraft, onSendMessage, playHaptic, _, inputRef])
|
||||
}, [
|
||||
hasEmbed,
|
||||
message,
|
||||
clearDraft,
|
||||
onSendMessage,
|
||||
playHaptic,
|
||||
setEmbed,
|
||||
_,
|
||||
inputRef,
|
||||
])
|
||||
|
||||
useFocusedInputHandler(
|
||||
{
|
||||
@@ -101,6 +119,7 @@ export function MessageInput({
|
||||
|
||||
return (
|
||||
<View style={[a.px_md, a.pb_sm, a.pt_xs]}>
|
||||
{children}
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
|
||||
@@ -16,11 +16,18 @@ import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {useSharedInputStyles} from '#/components/forms/TextField'
|
||||
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
|
||||
import {useExtractEmbedFromFacets} from './MessageInputEmbed'
|
||||
|
||||
export function MessageInput({
|
||||
onSendMessage,
|
||||
hasEmbed,
|
||||
setEmbed,
|
||||
children,
|
||||
}: {
|
||||
onSendMessage: (message: string) => void
|
||||
hasEmbed: boolean
|
||||
setEmbed: (embedUrl: string | undefined) => void
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
const {isTabletOrDesktop} = useWebMediaQueries()
|
||||
const {_} = useLingui()
|
||||
@@ -35,7 +42,7 @@ export function MessageInput({
|
||||
const [textAreaHeight, setTextAreaHeight] = React.useState(38)
|
||||
|
||||
const onSubmit = React.useCallback(() => {
|
||||
if (message.trim() === '') {
|
||||
if (!hasEmbed && message.trim() === '') {
|
||||
return
|
||||
}
|
||||
if (new Graphemer().countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) {
|
||||
@@ -43,9 +50,10 @@ export function MessageInput({
|
||||
return
|
||||
}
|
||||
clearDraft()
|
||||
onSendMessage(message.trimEnd())
|
||||
onSendMessage(message)
|
||||
setMessage('')
|
||||
}, [message, onSendMessage, _, clearDraft])
|
||||
setEmbed(undefined)
|
||||
}, [message, onSendMessage, _, clearDraft, hasEmbed, setEmbed])
|
||||
|
||||
const onKeyDown = React.useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
@@ -87,9 +95,11 @@ export function MessageInput({
|
||||
)
|
||||
|
||||
useSaveMessageDraft(message)
|
||||
useExtractEmbedFromFacets(message, setEmbed)
|
||||
|
||||
return (
|
||||
<View style={a.p_sm}>
|
||||
{children}
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import React, {useCallback, useEffect, useMemo, useState} from 'react'
|
||||
import {LayoutAnimation, View} from 'react-native'
|
||||
import {
|
||||
AppBskyEmbedImages,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyFeedPost,
|
||||
AppBskyRichtextFacet,
|
||||
AtUri,
|
||||
RichText as RichTextAPI,
|
||||
} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {RouteProp, useNavigation, useRoute} from '@react-navigation/native'
|
||||
|
||||
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types'
|
||||
import {
|
||||
convertBskyAppUrlIfNeeded,
|
||||
isBskyPostUrl,
|
||||
makeRecordUri,
|
||||
} from '#/lib/strings/url-helpers'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {usePostQuery} from '#/state/queries/post'
|
||||
import {ImageHorzList} from '#/view/com/util/images/ImageHorzList'
|
||||
import {PostMeta} from '#/view/com/util/PostMeta'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {ContentHider} from '#/components/moderation/ContentHider'
|
||||
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function useMessageEmbed() {
|
||||
const route =
|
||||
useRoute<RouteProp<CommonNavigatorParams, 'MessagesConversation'>>()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const embedFromParams = route.params.embed
|
||||
|
||||
const [embedUri, setEmbed] = useState(embedFromParams)
|
||||
|
||||
if (embedFromParams && embedUri !== embedFromParams) {
|
||||
setEmbed(embedFromParams)
|
||||
}
|
||||
|
||||
return {
|
||||
embedUri,
|
||||
setEmbed: useCallback(
|
||||
(embedUrl: string | undefined) => {
|
||||
if (!embedUrl) {
|
||||
navigation.setParams({embed: ''})
|
||||
setEmbed(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
if (embedFromParams) return
|
||||
|
||||
const url = convertBskyAppUrlIfNeeded(embedUrl)
|
||||
const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
|
||||
const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey)
|
||||
|
||||
setEmbed(uri)
|
||||
},
|
||||
[embedFromParams, navigation],
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function useExtractEmbedFromFacets(
|
||||
message: string,
|
||||
setEmbed: (embedUrl: string | undefined) => void,
|
||||
) {
|
||||
const rt = new RichTextAPI({text: message})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
let uriFromFacet: string | undefined
|
||||
|
||||
for (const facet of rt.facets ?? []) {
|
||||
for (const feature of facet.features) {
|
||||
if (AppBskyRichtextFacet.isLink(feature) && isBskyPostUrl(feature.uri)) {
|
||||
uriFromFacet = feature.uri
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (uriFromFacet) {
|
||||
setEmbed(uriFromFacet)
|
||||
}
|
||||
}, [uriFromFacet, setEmbed])
|
||||
}
|
||||
|
||||
export function MessageInputEmbed({
|
||||
embedUri,
|
||||
setEmbed,
|
||||
}: {
|
||||
embedUri: string | undefined
|
||||
setEmbed: (embedUrl: string | undefined) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
const {data: post, status} = usePostQuery(embedUri)
|
||||
|
||||
const moderationOpts = useModerationOpts()
|
||||
const moderation = useMemo(
|
||||
() =>
|
||||
moderationOpts && post ? moderatePost(post, moderationOpts) : undefined,
|
||||
[moderationOpts, post],
|
||||
)
|
||||
|
||||
const {rt, record} = useMemo(() => {
|
||||
if (
|
||||
post &&
|
||||
AppBskyFeedPost.isRecord(post.record) &&
|
||||
AppBskyFeedPost.validateRecord(post.record).success
|
||||
) {
|
||||
return {
|
||||
rt: new RichTextAPI({
|
||||
text: post.record.text,
|
||||
facets: post.record.facets,
|
||||
}),
|
||||
record: post.record,
|
||||
}
|
||||
}
|
||||
|
||||
return {rt: undefined, record: undefined}
|
||||
}, [post])
|
||||
|
||||
if (!embedUri) {
|
||||
return null
|
||||
}
|
||||
|
||||
let content = null
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
content = (
|
||||
<View
|
||||
style={[a.flex_1, {minHeight: 64}, a.justify_center, a.align_center]}>
|
||||
<Loader />
|
||||
</View>
|
||||
)
|
||||
break
|
||||
case 'error':
|
||||
content = (
|
||||
<View
|
||||
style={[a.flex_1, {minHeight: 64}, a.justify_center, a.align_center]}>
|
||||
<Text style={a.text_center}>Could not fetch post</Text>
|
||||
</View>
|
||||
)
|
||||
break
|
||||
case 'success':
|
||||
const itemUrip = new AtUri(post.uri)
|
||||
const itemHref = makeProfileLink(post.author, 'post', itemUrip.rkey)
|
||||
|
||||
if (!post || !moderation || !rt || !record) {
|
||||
return null
|
||||
}
|
||||
|
||||
const images = AppBskyEmbedImages.isView(post.embed)
|
||||
? post.embed.images
|
||||
: AppBskyEmbedRecordWithMedia.isView(post.embed) &&
|
||||
AppBskyEmbedImages.isView(post.embed.media)
|
||||
? post.embed.media.images
|
||||
: undefined
|
||||
|
||||
content = (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
t.atoms.bg,
|
||||
t.atoms.border_contrast_low,
|
||||
a.rounded_md,
|
||||
a.border,
|
||||
a.p_sm,
|
||||
a.mb_sm,
|
||||
]}
|
||||
pointerEvents="none">
|
||||
<PostMeta
|
||||
showAvatar
|
||||
author={post.author}
|
||||
moderation={moderation}
|
||||
authorHasWarning={!!post.author.labels?.length}
|
||||
timestamp={post.indexedAt}
|
||||
postHref={itemHref}
|
||||
style={a.flex_0}
|
||||
/>
|
||||
<ContentHider modui={moderation.ui('contentView')}>
|
||||
<PostAlerts modui={moderation.ui('contentView')} style={a.py_xs} />
|
||||
{rt.text && (
|
||||
<View style={a.mt_xs}>
|
||||
<RichText
|
||||
enableTags
|
||||
testID="postText"
|
||||
value={rt}
|
||||
style={[a.text_sm, t.atoms.text_contrast_high]}
|
||||
authorHandle={post.author.handle}
|
||||
numberOfLines={3}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{images && images?.length > 0 && (
|
||||
<ImageHorzList images={images} style={a.mt_xs} />
|
||||
)}
|
||||
</ContentHider>
|
||||
</View>
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[a.flex_row, a.gap_sm]}>
|
||||
{content}
|
||||
<Button
|
||||
label={_(msg`Remove embed`)}
|
||||
onPress={() => {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
setEmbed(undefined)
|
||||
}}
|
||||
size="tiny"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
shape="round">
|
||||
<ButtonIcon icon={X} />
|
||||
</Button>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -13,12 +13,18 @@ import {
|
||||
} from 'react-native-reanimated'
|
||||
import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/reanimated2/hook/commonTypes'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {AppBskyRichtextFacet, RichText} from '@atproto/api'
|
||||
import {AppBskyEmbedRecord, AppBskyRichtextFacet, RichText} from '@atproto/api'
|
||||
|
||||
import {shortenLinks} from '#/lib/strings/rich-text-manip'
|
||||
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
|
||||
import {
|
||||
convertBskyAppUrlIfNeeded,
|
||||
isBskyPostUrl,
|
||||
} from '#/lib/strings/url-helpers'
|
||||
import {logger} from '#/logger'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {isConvoActive, useConvoActive} from '#/state/messages/convo'
|
||||
import {ConvoItem, ConvoStatus} from '#/state/messages/convo/types'
|
||||
import {useGetPost} from '#/state/queries/post'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {clamp} from 'lib/numbers'
|
||||
import {ScrollProvider} from 'lib/ScrollContext'
|
||||
@@ -32,6 +38,7 @@ import {MessageItem} from '#/components/dms/MessageItem'
|
||||
import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {MessageInputEmbed, useMessageEmbed} from './MessageInputEmbed'
|
||||
|
||||
function MaybeLoader({isLoading}: {isLoading: boolean}) {
|
||||
return (
|
||||
@@ -80,6 +87,8 @@ export function MessagesList({
|
||||
}) {
|
||||
const convoState = useConvoActive()
|
||||
const agent = useAgent()
|
||||
const getPost = useGetPost()
|
||||
const {embedUri, setEmbed} = useMessageEmbed()
|
||||
|
||||
const flatListRef = useAnimatedRef<FlatList>()
|
||||
|
||||
@@ -264,20 +273,69 @@ export function MessagesList({
|
||||
// -- Message sending
|
||||
const onSendMessage = useCallback(
|
||||
async (text: string) => {
|
||||
let rt = new RichText({text}, {cleanNewlines: true})
|
||||
await rt.detectFacets(agent)
|
||||
rt = shortenLinks(rt)
|
||||
let rt = new RichText({text: text.trimEnd()}, {cleanNewlines: true})
|
||||
|
||||
// filter out any mention facets that didn't map to a user
|
||||
rt.facets = rt.facets?.filter(facet => {
|
||||
const mention = facet.features.find(feature =>
|
||||
AppBskyRichtextFacet.isMention(feature),
|
||||
)
|
||||
if (mention && !mention.did) {
|
||||
return false
|
||||
// detect facets without resolution first - this is used to see if there's
|
||||
// any post links in the text that we can embed. We do this first because
|
||||
// we want to remove the post link from the text, re-trim, then detect facets
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
let embed: AppBskyEmbedRecord.Main | undefined
|
||||
|
||||
if (embedUri) {
|
||||
try {
|
||||
const post = await getPost({uri: embedUri})
|
||||
if (post) {
|
||||
embed = {
|
||||
$type: 'app.bsky.embed.record',
|
||||
record: {
|
||||
uri: post.uri,
|
||||
cid: post.cid,
|
||||
},
|
||||
}
|
||||
|
||||
// look for the embed uri in the facets, so we can remove it from the text
|
||||
const postLinkFacet = rt.facets?.find(facet => {
|
||||
return facet.features.find(feature => {
|
||||
if (AppBskyRichtextFacet.isLink(feature)) {
|
||||
if (isBskyPostUrl(feature.uri)) {
|
||||
const url = convertBskyAppUrlIfNeeded(feature.uri)
|
||||
const [_0, _1, _2, rkey] = url.split('/').filter(Boolean)
|
||||
|
||||
// this might have a handle instead of a DID
|
||||
// so just compare the rkey - not particularly dangerous
|
||||
return post.uri.endsWith(rkey)
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
})
|
||||
|
||||
if (postLinkFacet) {
|
||||
const isAtStart = postLinkFacet.index.byteStart === 0
|
||||
const isAtEnd =
|
||||
postLinkFacet.index.byteEnd === rt.unicodeText.graphemeLength
|
||||
|
||||
// remove the post link from the text
|
||||
if (isAtStart || isAtEnd) {
|
||||
rt.delete(
|
||||
postLinkFacet.index.byteStart,
|
||||
postLinkFacet.index.byteEnd,
|
||||
)
|
||||
}
|
||||
|
||||
rt = new RichText({text: rt.text.trim()}, {cleanNewlines: true})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to get post as quote for DM', {error})
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
await rt.detectFacets(agent)
|
||||
|
||||
rt = shortenLinks(rt)
|
||||
rt = stripInvalidMentions(rt)
|
||||
|
||||
if (!hasScrolled) {
|
||||
setHasScrolled(true)
|
||||
@@ -286,9 +344,10 @@ export function MessagesList({
|
||||
convoState.sendMessage({
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
embed,
|
||||
})
|
||||
},
|
||||
[convoState, agent, hasScrolled, setHasScrolled],
|
||||
[agent, convoState, embedUri, getPost, hasScrolled, setHasScrolled],
|
||||
)
|
||||
|
||||
// -- List layout changes (opening emoji keyboard, etc.)
|
||||
@@ -328,6 +387,9 @@ export function MessagesList({
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
containWeb={true}
|
||||
// Prevents wrong position in Firefox when sending a message
|
||||
// as well as scroll getting stuck on Chome when scrolling upwards.
|
||||
disableContentVisibility={true}
|
||||
disableVirtualization={true}
|
||||
style={animatedListStyle}
|
||||
// The extra two items account for the header and the footer components
|
||||
@@ -360,7 +422,12 @@ export function MessagesList({
|
||||
{isConvoActive(convoState) &&
|
||||
!convoState.isFetchingHistory &&
|
||||
convoState.items.length === 0 && <ChatEmptyPill />}
|
||||
<MessageInput onSendMessage={onSendMessage} />
|
||||
<MessageInput
|
||||
onSendMessage={onSendMessage}
|
||||
hasEmbed={!!embedUri}
|
||||
setEmbed={setEmbed}>
|
||||
<MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} />
|
||||
</MessageInput>
|
||||
</>
|
||||
)}
|
||||
</KeyboardStickyView>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, {useCallback, useState} from 'react'
|
||||
import {GestureResponderEvent, View} from 'react-native'
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
AppBskyEmbedRecord,
|
||||
ChatBskyConvoDefs,
|
||||
moderateProfile,
|
||||
ModerationOpts,
|
||||
@@ -9,6 +10,11 @@ import {
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {
|
||||
postUriToRelativePath,
|
||||
toBskyAppUrl,
|
||||
toShortUrl,
|
||||
} from '#/lib/strings/url-helpers'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
@@ -23,6 +29,7 @@ import {ConvoMenu} from '#/components/dms/ConvoMenu'
|
||||
import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2'
|
||||
import {Link} from '#/components/Link'
|
||||
import {useMenuControl} from '#/components/Menu'
|
||||
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export let ChatListItem = ({
|
||||
@@ -94,21 +101,64 @@ function ChatListItemReady({
|
||||
|
||||
const isDimStyle = convo.muted || moderation.blocked || isDeletedAccount
|
||||
|
||||
let lastMessage = _(msg`No messages yet`)
|
||||
let lastMessageSentAt: string | null = null
|
||||
if (ChatBskyConvoDefs.isMessageView(convo.lastMessage)) {
|
||||
if (convo.lastMessage.sender?.did === currentAccount?.did) {
|
||||
lastMessage = _(msg`You: ${convo.lastMessage.text}`)
|
||||
} else {
|
||||
lastMessage = convo.lastMessage.text
|
||||
const {lastMessage, lastMessageSentAt} = React.useMemo(() => {
|
||||
let lastMessage = _(msg`No messages yet`)
|
||||
let lastMessageSentAt: string | null = null
|
||||
|
||||
if (ChatBskyConvoDefs.isMessageView(convo.lastMessage)) {
|
||||
const isFromMe = convo.lastMessage.sender?.did === currentAccount?.did
|
||||
|
||||
if (convo.lastMessage.text) {
|
||||
if (isFromMe) {
|
||||
lastMessage = _(msg`You: ${convo.lastMessage.text}`)
|
||||
} else {
|
||||
lastMessage = convo.lastMessage.text
|
||||
}
|
||||
} else if (convo.lastMessage.embed) {
|
||||
const defaultEmbeddedContentMessage = _(
|
||||
msg`(contains embedded content)`,
|
||||
)
|
||||
|
||||
if (AppBskyEmbedRecord.isView(convo.lastMessage.embed)) {
|
||||
const embed = convo.lastMessage.embed
|
||||
|
||||
if (AppBskyEmbedRecord.isViewRecord(embed.record)) {
|
||||
const record = embed.record
|
||||
const path = postUriToRelativePath(record.uri, {
|
||||
handle: record.author.handle,
|
||||
})
|
||||
const href = path ? toBskyAppUrl(path) : undefined
|
||||
const short = href
|
||||
? toShortUrl(href)
|
||||
: defaultEmbeddedContentMessage
|
||||
if (isFromMe) {
|
||||
lastMessage = _(msg`You: ${short}`)
|
||||
} else {
|
||||
lastMessage = short
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (isFromMe) {
|
||||
lastMessage = _(msg`You: ${defaultEmbeddedContentMessage}`)
|
||||
} else {
|
||||
lastMessage = defaultEmbeddedContentMessage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lastMessageSentAt = convo.lastMessage.sentAt
|
||||
}
|
||||
lastMessageSentAt = convo.lastMessage.sentAt
|
||||
}
|
||||
if (ChatBskyConvoDefs.isDeletedMessageView(convo.lastMessage)) {
|
||||
lastMessage = isDeletedAccount
|
||||
? _(msg`Conversation deleted`)
|
||||
: _(msg`Message deleted`)
|
||||
}
|
||||
if (ChatBskyConvoDefs.isDeletedMessageView(convo.lastMessage)) {
|
||||
lastMessage = isDeletedAccount
|
||||
? _(msg`Conversation deleted`)
|
||||
: _(msg`Message deleted`)
|
||||
}
|
||||
|
||||
return {
|
||||
lastMessage,
|
||||
lastMessageSentAt,
|
||||
}
|
||||
}, [_, convo.lastMessage, currentAccount?.did, isDeletedAccount])
|
||||
|
||||
const [showActions, setShowActions] = useState(false)
|
||||
|
||||
@@ -264,6 +314,12 @@ function ChatListItemReady({
|
||||
]}>
|
||||
{lastMessage}
|
||||
</Text>
|
||||
|
||||
<PostAlerts
|
||||
modui={moderation.ui('contentList')}
|
||||
size="large"
|
||||
style={[a.pt_xs]}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{convo.unreadCount > 0 && (
|
||||
|
||||
@@ -21,8 +21,8 @@ import {CenteredView} from '#/view/com/util/Views'
|
||||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {DialogControlProps, useDialogControl} from '#/components/Dialog'
|
||||
import {NewChat} from '#/components/dms/dialogs/NewChatDialog'
|
||||
import {MessagesNUX} from '#/components/dms/MessagesNUX'
|
||||
import {NewChat} from '#/components/dms/NewChatDialog'
|
||||
import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus'
|
||||
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Retry} from '#/components/icons/ArrowRotateCounterClockwise'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
||||
|
||||
@@ -13,6 +13,7 @@ import {RQKEY as profileRQKey} from '#/state/queries/profile'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {useOnboardingDispatch} from '#/state/shell'
|
||||
import {uploadBlob} from 'lib/api'
|
||||
import {useRequestNotificationsPermission} from 'lib/notifications/notifications'
|
||||
import {
|
||||
DescriptionText,
|
||||
OnboardingControls,
|
||||
@@ -39,6 +40,7 @@ export function StepFinished() {
|
||||
const [saving, setSaving] = React.useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
const agent = useAgent()
|
||||
const requestNotificationsPermission = useRequestNotificationsPermission()
|
||||
|
||||
const finishOnboarding = React.useCallback(async () => {
|
||||
setSaving(true)
|
||||
@@ -72,6 +74,7 @@ export function StepFinished() {
|
||||
: 'default',
|
||||
})
|
||||
})(),
|
||||
requestNotificationsPermission('AfterOnboarding'),
|
||||
])
|
||||
} catch (e: any) {
|
||||
logger.info(`onboarding: bulk save failed`)
|
||||
@@ -98,7 +101,15 @@ export function StepFinished() {
|
||||
track('OnboardingV2:StepFinished:End')
|
||||
track('OnboardingV2:Complete')
|
||||
logEvent('onboarding:finished:nextPressed', {})
|
||||
}, [state, dispatch, onboardDispatch, setSaving, track, agent, queryClient])
|
||||
}, [
|
||||
state,
|
||||
queryClient,
|
||||
agent,
|
||||
dispatch,
|
||||
onboardDispatch,
|
||||
track,
|
||||
requestNotificationsPermission,
|
||||
])
|
||||
|
||||
React.useEffect(() => {
|
||||
track('OnboardingV2:StepFinished:Start')
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {DialogOuterProps} from '#/components/Dialog'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function DeactivateAccountDialog({
|
||||
control,
|
||||
}: {
|
||||
control: DialogOuterProps['control']
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
return (
|
||||
<Prompt.Outer control={control} testID="confirmModal">
|
||||
<Prompt.TitleText>{_(msg`Deactivate account`)}</Prompt.TitleText>
|
||||
<Prompt.DescriptionText>
|
||||
<Trans>
|
||||
Your profile, posts, feeds, and lists will no longer be visible to
|
||||
other Bluesky users. You can reactivate your account at any time by
|
||||
logging in.
|
||||
</Trans>
|
||||
</Prompt.DescriptionText>
|
||||
|
||||
<View style={[a.pb_xl]}>
|
||||
<Divider />
|
||||
<View style={[a.gap_sm, a.pt_lg, a.pb_xl]}>
|
||||
<Text style={[t.atoms.text_contrast_medium, a.leading_snug]}>
|
||||
<Trans>
|
||||
There is no time limit for account deactivation, come back any
|
||||
time.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Text style={[t.atoms.text_contrast_medium, a.leading_snug]}>
|
||||
<Trans>
|
||||
If you're trying to change your handle or email, do so before you
|
||||
deactivate.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Divider />
|
||||
</View>
|
||||
<Prompt.Actions>
|
||||
<Prompt.Action
|
||||
cta={_(msg`Yes, deactivate`)}
|
||||
onPress={() => {}}
|
||||
color="negative"
|
||||
/>
|
||||
<Prompt.Cancel />
|
||||
</Prompt.Actions>
|
||||
</Prompt.Outer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {msg, plural, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {isSignupQueued, useAgent, useSessionApi} from '#/state/session'
|
||||
import {useOnboardingDispatch} from '#/state/shell'
|
||||
import {ScrollView} from '#/view/com/util/Views'
|
||||
import {Logo} from '#/view/icons/Logo'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {P, Text} from '#/components/Typography'
|
||||
|
||||
const COL_WIDTH = 400
|
||||
|
||||
export function SignupQueued() {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const insets = useSafeAreaInsets()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const onboardingDispatch = useOnboardingDispatch()
|
||||
const {logout} = useSessionApi()
|
||||
const agent = useAgent()
|
||||
|
||||
const [isProcessing, setProcessing] = React.useState(false)
|
||||
const [estimatedTime, setEstimatedTime] = React.useState<string | undefined>(
|
||||
undefined,
|
||||
)
|
||||
const [placeInQueue, setPlaceInQueue] = React.useState<number | undefined>(
|
||||
undefined,
|
||||
)
|
||||
|
||||
const checkStatus = React.useCallback(async () => {
|
||||
setProcessing(true)
|
||||
try {
|
||||
const res = await agent.com.atproto.temp.checkSignupQueue()
|
||||
if (res.data.activated) {
|
||||
// ready to go, exchange the access token for a usable one and kick off onboarding
|
||||
await agent.refreshSession()
|
||||
if (!isSignupQueued(agent.session?.accessJwt)) {
|
||||
onboardingDispatch({type: 'start'})
|
||||
}
|
||||
} else {
|
||||
// not ready, update UI
|
||||
setEstimatedTime(msToString(res.data.estimatedTimeMs))
|
||||
if (typeof res.data.placeInQueue !== 'undefined') {
|
||||
setPlaceInQueue(Math.max(res.data.placeInQueue, 1))
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
logger.error('Failed to check signup queue', {err: e.toString()})
|
||||
} finally {
|
||||
setProcessing(false)
|
||||
}
|
||||
}, [
|
||||
setProcessing,
|
||||
setEstimatedTime,
|
||||
setPlaceInQueue,
|
||||
onboardingDispatch,
|
||||
agent,
|
||||
])
|
||||
|
||||
React.useEffect(() => {
|
||||
checkStatus()
|
||||
const interval = setInterval(checkStatus, 60e3)
|
||||
return () => clearInterval(interval)
|
||||
}, [checkStatus])
|
||||
|
||||
const checkBtn = (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="large"
|
||||
label={_(msg`Check my status`)}
|
||||
onPress={checkStatus}
|
||||
disabled={isProcessing}>
|
||||
<ButtonText>
|
||||
<Trans>Check my status</Trans>
|
||||
</ButtonText>
|
||||
{isProcessing && <ButtonIcon icon={Loader} />}
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<View
|
||||
aria-modal
|
||||
role="dialog"
|
||||
aria-role="dialog"
|
||||
aria-label={_(msg`You're in line`)}
|
||||
accessibilityLabel={_(msg`You're in line`)}
|
||||
accessibilityHint=""
|
||||
style={[a.absolute, a.inset_0, a.flex_1, t.atoms.bg]}>
|
||||
<ScrollView
|
||||
style={[a.h_full, a.w_full]}
|
||||
contentContainerStyle={{borderWidth: 0}}>
|
||||
<View
|
||||
style={[a.flex_row, a.justify_center, gtMobile ? a.pt_4xl : a.px_xl]}>
|
||||
<View style={[a.flex_1, {maxWidth: COL_WIDTH}]}>
|
||||
<View
|
||||
style={[a.w_full, a.justify_center, a.align_center, a.my_4xl]}>
|
||||
<Logo width={120} />
|
||||
</View>
|
||||
|
||||
<Text style={[a.text_4xl, a.font_bold, a.pb_sm]}>
|
||||
<Trans>You're in line</Trans>
|
||||
</Text>
|
||||
<P style={[t.atoms.text_contrast_medium]}>
|
||||
<Trans>
|
||||
There's been a rush of new users to Bluesky! We'll activate your
|
||||
account as soon as we can.
|
||||
</Trans>
|
||||
</P>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
a.px_2xl,
|
||||
a.py_4xl,
|
||||
a.mt_2xl,
|
||||
t.atoms.bg_contrast_50,
|
||||
]}>
|
||||
{typeof placeInQueue === 'number' && (
|
||||
<Text
|
||||
style={[a.text_5xl, a.text_center, a.font_bold, a.mb_2xl]}>
|
||||
{placeInQueue}
|
||||
</Text>
|
||||
)}
|
||||
<P style={[a.text_center]}>
|
||||
{typeof placeInQueue === 'number' ? (
|
||||
<Trans>left to go.</Trans>
|
||||
) : (
|
||||
<Trans>You are in line.</Trans>
|
||||
)}{' '}
|
||||
{estimatedTime ? (
|
||||
<Trans>
|
||||
We estimate {estimatedTime} until your account is ready.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
We will let you know when your account is ready.
|
||||
</Trans>
|
||||
)}
|
||||
</P>
|
||||
</View>
|
||||
|
||||
{isWeb && gtMobile && (
|
||||
<View style={[a.w_full, a.flex_row, a.justify_between, a.pt_5xl]}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="large"
|
||||
label={_(msg`Log out`)}
|
||||
onPress={() => logout('SignupQueued')}>
|
||||
<ButtonText style={[{color: t.palette.primary_500}]}>
|
||||
<Trans>Log out</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
{checkBtn}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={{height: 200}} />
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{(!isWeb || !gtMobile) && (
|
||||
<View
|
||||
style={[
|
||||
a.align_center,
|
||||
gtMobile ? a.px_5xl : a.px_xl,
|
||||
{
|
||||
paddingBottom: Math.max(insets.bottom, a.pb_5xl.paddingBottom),
|
||||
},
|
||||
]}>
|
||||
<View style={[a.w_full, a.gap_sm, {maxWidth: COL_WIDTH}]}>
|
||||
{checkBtn}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="large"
|
||||
label={_(msg`Log out`)}
|
||||
onPress={() => logout('SignupQueued')}>
|
||||
<ButtonText style={[{color: t.palette.primary_500}]}>
|
||||
<Trans>Log out</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function msToString(ms: number | undefined): string | undefined {
|
||||
if (ms && ms > 0) {
|
||||
const estimatedTimeMins = Math.ceil(ms / 60e3)
|
||||
if (estimatedTimeMins > 59) {
|
||||
const estimatedTimeHrs = Math.round(estimatedTimeMins / 60)
|
||||
if (estimatedTimeHrs > 6) {
|
||||
// dont even bother
|
||||
return undefined
|
||||
}
|
||||
// hours
|
||||
return `${estimatedTimeHrs} ${plural(estimatedTimeHrs, {
|
||||
one: 'hour',
|
||||
other: 'hours',
|
||||
})}`
|
||||
}
|
||||
// minutes
|
||||
return `${estimatedTimeMins} ${plural(estimatedTimeMins, {
|
||||
one: 'minute',
|
||||
other: 'minutes',
|
||||
})}`
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -9,8 +9,10 @@ import {findAllProfilesInQueryData as findAllProfilesInListMembersQueryData} fro
|
||||
import {findAllProfilesInQueryData as findAllProfilesInListConvosQueryData} from '../queries/messages/list-converations'
|
||||
import {findAllProfilesInQueryData as findAllProfilesInMyBlockedAccountsQueryData} from '../queries/my-blocked-accounts'
|
||||
import {findAllProfilesInQueryData as findAllProfilesInMyMutedAccountsQueryData} from '../queries/my-muted-accounts'
|
||||
import {findAllProfilesInQueryData as findAllProfilesInFeedsQueryData} from '../queries/post-feed'
|
||||
import {findAllProfilesInQueryData as findAllProfilesInPostLikedByQueryData} from '../queries/post-liked-by'
|
||||
import {findAllProfilesInQueryData as findAllProfilesInPostRepostedByQueryData} from '../queries/post-reposted-by'
|
||||
import {findAllProfilesInQueryData as findAllProfilesInPostThreadQueryData} from '../queries/post-thread'
|
||||
import {findAllProfilesInQueryData as findAllProfilesInProfileQueryData} from '../queries/profile'
|
||||
import {findAllProfilesInQueryData as findAllProfilesInProfileFollowersQueryData} from '../queries/profile-followers'
|
||||
import {findAllProfilesInQueryData as findAllProfilesInProfileFollowsQueryData} from '../queries/profile-follows'
|
||||
@@ -107,4 +109,6 @@ function* findProfilesInCache(
|
||||
yield* findAllProfilesInSuggestedFollowsQueryData(queryClient, did)
|
||||
yield* findAllProfilesInActorSearchQueryData(queryClient, did)
|
||||
yield* findAllProfilesInListConvosQueryData(queryClient, did)
|
||||
yield* findAllProfilesInFeedsQueryData(queryClient, did)
|
||||
yield* findAllProfilesInPostThreadQueryData(queryClient, did)
|
||||
}
|
||||
|
||||
@@ -753,7 +753,7 @@ export class Convo {
|
||||
|
||||
sendMessage(message: ChatBskyConvoSendMessage.InputSchema['message']) {
|
||||
// Ignore empty messages for now since they have no other purpose atm
|
||||
if (!message.text.trim()) return
|
||||
if (!message.text.trim() && !message.embed) return
|
||||
|
||||
logger.debug('Convo: send message', {}, logger.DebugContext.convo)
|
||||
|
||||
@@ -1018,6 +1018,7 @@ export class Convo {
|
||||
key: m.id,
|
||||
message: {
|
||||
...m.message,
|
||||
embed: undefined,
|
||||
$type: 'chat.bsky.convo.defs#messageView',
|
||||
id: nanoid(),
|
||||
rev: '__fake__',
|
||||
|
||||
@@ -60,13 +60,6 @@ export interface DeleteAccountModal {
|
||||
name: 'delete-account'
|
||||
}
|
||||
|
||||
export interface RepostModal {
|
||||
name: 'repost'
|
||||
onRepost: () => void
|
||||
onQuote: () => void
|
||||
isReposted: boolean
|
||||
}
|
||||
|
||||
export interface SelfLabelModal {
|
||||
name: 'self-label'
|
||||
labels: string[]
|
||||
@@ -154,7 +147,6 @@ export type Modal =
|
||||
| AltTextImageModal
|
||||
| CropImageModal
|
||||
| EditImageModal
|
||||
| RepostModal
|
||||
| SelfLabelModal
|
||||
| ThreadgateModal
|
||||
|
||||
|
||||
@@ -17,7 +17,10 @@ const accountSchema = z.object({
|
||||
emailAuthFactor: z.boolean().optional(),
|
||||
refreshJwt: z.string().optional(), // optional because it can expire
|
||||
accessJwt: z.string().optional(), // optional because it can expire
|
||||
deactivated: z.boolean().optional(),
|
||||
signupQueued: z.boolean().optional(),
|
||||
status: z
|
||||
.enum(['active', 'takendown', 'suspended', 'deactivated'])
|
||||
.optional(),
|
||||
pdsUrl: z.string().optional(),
|
||||
})
|
||||
export type PersistedAccount = z.infer<typeof accountSchema>
|
||||
@@ -65,6 +68,7 @@ export const schema = z.object({
|
||||
spotify: z.enum(externalEmbedOptions).optional(),
|
||||
appleMusic: z.enum(externalEmbedOptions).optional(),
|
||||
soundcloud: z.enum(externalEmbedOptions).optional(),
|
||||
flickr: z.enum(externalEmbedOptions).optional(),
|
||||
})
|
||||
.optional(),
|
||||
mutedThreads: z.array(z.string()), // should move to server
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
|
||||
import {Schema, schema} from '#/state/persisted/schema'
|
||||
import {logger} from '#/logger'
|
||||
import {Schema, schema} from '#/state/persisted/schema'
|
||||
|
||||
const BSKY_STORAGE = 'BSKY_STORAGE'
|
||||
|
||||
@@ -13,8 +13,19 @@ export async function write(value: Schema) {
|
||||
export async function read(): Promise<Schema | undefined> {
|
||||
const rawData = await AsyncStorage.getItem(BSKY_STORAGE)
|
||||
const objData = rawData ? JSON.parse(rawData) : undefined
|
||||
if (schema.safeParse(objData).success) {
|
||||
const parsed = schema.safeParse(objData)
|
||||
if (parsed.success) {
|
||||
return objData
|
||||
} else {
|
||||
const errors =
|
||||
parsed.error?.errors?.map(e => ({
|
||||
code: e.code,
|
||||
// @ts-ignore exists on some types
|
||||
expected: e?.expected,
|
||||
path: e.path,
|
||||
})) || []
|
||||
logger.error(`persisted store: data failed validation on read`, {errors})
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,10 +26,15 @@ import {useAgent, useSession} from '#/state/session'
|
||||
export const RQKEY = ['convo-list']
|
||||
type RQPageParam = string | undefined
|
||||
|
||||
export function useListConvosQuery() {
|
||||
export function useListConvosQuery({
|
||||
enabled,
|
||||
}: {
|
||||
enabled?: boolean
|
||||
} = {}) {
|
||||
const agent = useAgent()
|
||||
|
||||
return useInfiniteQuery({
|
||||
enabled,
|
||||
queryKey: RQKEY,
|
||||
queryFn: async ({pageParam}) => {
|
||||
const {data} = await agent.api.chat.bsky.convo.listConvos(
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
|
||||
import {useEffect, useRef} from 'react'
|
||||
import {AppBskyFeedDefs} from '@atproto/api'
|
||||
import {AppBskyActorDefs, AppBskyFeedDefs, AtUri} from '@atproto/api'
|
||||
import {
|
||||
InfiniteData,
|
||||
QueryClient,
|
||||
@@ -30,7 +30,11 @@ import {useMutedThreads} from '#/state/muted-threads'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {useModerationOpts} from '../../preferences/moderation-opts'
|
||||
import {STALE} from '..'
|
||||
import {embedViewRecordToPostView, getEmbeddedPost} from '../util'
|
||||
import {
|
||||
didOrHandleUriMatches,
|
||||
embedViewRecordToPostView,
|
||||
getEmbeddedPost,
|
||||
} from '../util'
|
||||
import {FeedPage} from './types'
|
||||
import {useUnreadNotificationsApi} from './unread'
|
||||
import {fetchPage} from './util'
|
||||
@@ -142,6 +146,35 @@ export function* findAllPostsInQueryData(
|
||||
queryClient: QueryClient,
|
||||
uri: string,
|
||||
): Generator<AppBskyFeedDefs.PostView, void> {
|
||||
const atUri = new AtUri(uri)
|
||||
|
||||
const queryDatas = queryClient.getQueriesData<InfiniteData<FeedPage>>({
|
||||
queryKey: [RQKEY_ROOT],
|
||||
})
|
||||
for (const [_queryKey, queryData] of queryDatas) {
|
||||
if (!queryData?.pages) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const page of queryData?.pages) {
|
||||
for (const item of page.items) {
|
||||
if (item.subject && didOrHandleUriMatches(atUri, item.subject)) {
|
||||
yield item.subject
|
||||
}
|
||||
|
||||
const quotedPost = getEmbeddedPost(item.subject?.embed)
|
||||
if (quotedPost && didOrHandleUriMatches(atUri, quotedPost)) {
|
||||
yield embedViewRecordToPostView(quotedPost!)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function* findAllProfilesInQueryData(
|
||||
queryClient: QueryClient,
|
||||
did: string,
|
||||
): Generator<AppBskyActorDefs.ProfileView, void> {
|
||||
const queryDatas = queryClient.getQueriesData<InfiniteData<FeedPage>>({
|
||||
queryKey: [RQKEY_ROOT],
|
||||
})
|
||||
@@ -151,12 +184,12 @@ export function* findAllPostsInQueryData(
|
||||
}
|
||||
for (const page of queryData?.pages) {
|
||||
for (const item of page.items) {
|
||||
if (item.subject?.uri === uri) {
|
||||
yield item.subject
|
||||
if (item.subject?.author.did === did) {
|
||||
yield item.subject.author
|
||||
}
|
||||
const quotedPost = getEmbeddedPost(item.subject?.embed)
|
||||
if (quotedPost?.uri === uri) {
|
||||
yield embedViewRecordToPostView(quotedPost)
|
||||
if (quotedPost?.author.did === did) {
|
||||
yield quotedPost.author
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ async function fetchSubjects(
|
||||
): Promise<Map<string, AppBskyFeedDefs.PostView>> {
|
||||
const uris = new Set<string>()
|
||||
for (const notif of groupedNotifs) {
|
||||
if (notif.subjectUri && !notif.subjectUri.includes('feed.generator')) {
|
||||
if (notif.subjectUri?.includes('app.bsky.feed.post')) {
|
||||
uris.add(notif.subjectUri)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,11 @@ import {KnownError} from '#/view/com/posts/FeedErrorMessage'
|
||||
import {useFeedTuners} from '../preferences/feed-tuners'
|
||||
import {useModerationOpts} from '../preferences/moderation-opts'
|
||||
import {usePreferencesQuery} from './preferences'
|
||||
import {embedViewRecordToPostView, getEmbeddedPost} from './util'
|
||||
import {
|
||||
didOrHandleUriMatches,
|
||||
embedViewRecordToPostView,
|
||||
getEmbeddedPost,
|
||||
} from './util'
|
||||
|
||||
type ActorDid = string
|
||||
type AuthorFilter =
|
||||
@@ -448,6 +452,8 @@ export function* findAllPostsInQueryData(
|
||||
queryClient: QueryClient,
|
||||
uri: string,
|
||||
): Generator<AppBskyFeedDefs.PostView, undefined> {
|
||||
const atUri = new AtUri(uri)
|
||||
|
||||
const queryDatas = queryClient.getQueriesData<
|
||||
InfiniteData<FeedPageUnselected>
|
||||
>({
|
||||
@@ -459,24 +465,77 @@ export function* findAllPostsInQueryData(
|
||||
}
|
||||
for (const page of queryData?.pages) {
|
||||
for (const item of page.feed) {
|
||||
if (item.post.uri === uri) {
|
||||
if (didOrHandleUriMatches(atUri, item.post)) {
|
||||
yield item.post
|
||||
}
|
||||
|
||||
const quotedPost = getEmbeddedPost(item.post.embed)
|
||||
if (quotedPost?.uri === uri) {
|
||||
if (quotedPost && didOrHandleUriMatches(atUri, quotedPost)) {
|
||||
yield embedViewRecordToPostView(quotedPost)
|
||||
}
|
||||
|
||||
if (AppBskyFeedDefs.isPostView(item.reply?.parent)) {
|
||||
if (didOrHandleUriMatches(atUri, item.reply.parent)) {
|
||||
yield item.reply.parent
|
||||
}
|
||||
|
||||
const parentQuotedPost = getEmbeddedPost(item.reply.parent.embed)
|
||||
if (
|
||||
parentQuotedPost &&
|
||||
didOrHandleUriMatches(atUri, parentQuotedPost)
|
||||
) {
|
||||
yield embedViewRecordToPostView(parentQuotedPost)
|
||||
}
|
||||
}
|
||||
|
||||
if (AppBskyFeedDefs.isPostView(item.reply?.root)) {
|
||||
if (didOrHandleUriMatches(atUri, item.reply.root)) {
|
||||
yield item.reply.root
|
||||
}
|
||||
|
||||
const rootQuotedPost = getEmbeddedPost(item.reply.root.embed)
|
||||
if (rootQuotedPost && didOrHandleUriMatches(atUri, rootQuotedPost)) {
|
||||
yield embedViewRecordToPostView(rootQuotedPost)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function* findAllProfilesInQueryData(
|
||||
queryClient: QueryClient,
|
||||
did: string,
|
||||
): Generator<AppBskyActorDefs.ProfileView, undefined> {
|
||||
const queryDatas = queryClient.getQueriesData<
|
||||
InfiniteData<FeedPageUnselected>
|
||||
>({
|
||||
queryKey: [RQKEY_ROOT],
|
||||
})
|
||||
for (const [_queryKey, queryData] of queryDatas) {
|
||||
if (!queryData?.pages) {
|
||||
continue
|
||||
}
|
||||
for (const page of queryData?.pages) {
|
||||
for (const item of page.feed) {
|
||||
if (item.post.author.did === did) {
|
||||
yield item.post.author
|
||||
}
|
||||
const quotedPost = getEmbeddedPost(item.post.embed)
|
||||
if (quotedPost?.author.did === did) {
|
||||
yield quotedPost.author
|
||||
}
|
||||
if (
|
||||
AppBskyFeedDefs.isPostView(item.reply?.parent) &&
|
||||
item.reply?.parent?.uri === uri
|
||||
item.reply?.parent?.author.did === did
|
||||
) {
|
||||
yield item.reply.parent
|
||||
yield item.reply.parent.author
|
||||
}
|
||||
if (
|
||||
AppBskyFeedDefs.isPostView(item.reply?.root) &&
|
||||
item.reply?.root?.uri === uri
|
||||
item.reply?.root?.author.did === did
|
||||
) {
|
||||
yield item.reply.root
|
||||
yield item.reply.root.author
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedGetPostThread,
|
||||
AppBskyFeedPost,
|
||||
AtUri,
|
||||
ModerationDecision,
|
||||
ModerationOpts,
|
||||
} from '@atproto/api'
|
||||
@@ -11,10 +13,23 @@ import {QueryClient, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
|
||||
import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from 'state/queries/search-posts'
|
||||
import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from './notifications/feed'
|
||||
import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from './post-feed'
|
||||
import {embedViewRecordToPostView, getEmbeddedPost} from './util'
|
||||
import {
|
||||
findAllPostsInQueryData as findAllPostsInSearchQueryData,
|
||||
findAllProfilesInQueryData as findAllProfilesInSearchQueryData,
|
||||
} from 'state/queries/search-posts'
|
||||
import {
|
||||
findAllPostsInQueryData as findAllPostsInNotifsQueryData,
|
||||
findAllProfilesInQueryData as findAllProfilesInNotifsQueryData,
|
||||
} from './notifications/feed'
|
||||
import {
|
||||
findAllPostsInQueryData as findAllPostsInFeedQueryData,
|
||||
findAllProfilesInQueryData as findAllProfilesInFeedQueryData,
|
||||
} from './post-feed'
|
||||
import {
|
||||
didOrHandleUriMatches,
|
||||
embedViewRecordToPostView,
|
||||
getEmbeddedPost,
|
||||
} from './util'
|
||||
|
||||
const RQKEY_ROOT = 'post-thread'
|
||||
export const RQKEY = (uri: string) => [RQKEY_ROOT, uri]
|
||||
@@ -81,14 +96,10 @@ export function usePostThreadQuery(uri: string | undefined) {
|
||||
},
|
||||
enabled: !!uri,
|
||||
placeholderData: () => {
|
||||
if (!uri) {
|
||||
return undefined
|
||||
}
|
||||
{
|
||||
const post = findPostInQueryData(queryClient, uri)
|
||||
if (post) {
|
||||
return post
|
||||
}
|
||||
if (!uri) return
|
||||
const post = findPostInQueryData(queryClient, uri)
|
||||
if (post) {
|
||||
return post
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
@@ -261,6 +272,8 @@ export function* findAllPostsInQueryData(
|
||||
queryClient: QueryClient,
|
||||
uri: string,
|
||||
): Generator<ThreadNode, void> {
|
||||
const atUri = new AtUri(uri)
|
||||
|
||||
const queryDatas = queryClient.getQueriesData<ThreadNode>({
|
||||
queryKey: [RQKEY_ROOT],
|
||||
})
|
||||
@@ -269,7 +282,7 @@ export function* findAllPostsInQueryData(
|
||||
continue
|
||||
}
|
||||
for (const item of traverseThread(queryData)) {
|
||||
if (item.uri === uri) {
|
||||
if (item.type === 'post' && didOrHandleUriMatches(atUri, item.post)) {
|
||||
const placeholder = threadNodeToPlaceholderThread(item)
|
||||
if (placeholder) {
|
||||
yield placeholder
|
||||
@@ -277,7 +290,7 @@ export function* findAllPostsInQueryData(
|
||||
}
|
||||
const quotedPost =
|
||||
item.type === 'post' ? getEmbeddedPost(item.post.embed) : undefined
|
||||
if (quotedPost?.uri === uri) {
|
||||
if (quotedPost && didOrHandleUriMatches(atUri, quotedPost)) {
|
||||
yield embedViewRecordToPlaceholderThread(quotedPost)
|
||||
}
|
||||
}
|
||||
@@ -293,6 +306,39 @@ export function* findAllPostsInQueryData(
|
||||
}
|
||||
}
|
||||
|
||||
export function* findAllProfilesInQueryData(
|
||||
queryClient: QueryClient,
|
||||
did: string,
|
||||
): Generator<AppBskyActorDefs.ProfileView, void> {
|
||||
const queryDatas = queryClient.getQueriesData<ThreadNode>({
|
||||
queryKey: [RQKEY_ROOT],
|
||||
})
|
||||
for (const [_queryKey, queryData] of queryDatas) {
|
||||
if (!queryData) {
|
||||
continue
|
||||
}
|
||||
for (const item of traverseThread(queryData)) {
|
||||
if (item.type === 'post' && item.post.author.did === did) {
|
||||
yield item.post.author
|
||||
}
|
||||
const quotedPost =
|
||||
item.type === 'post' ? getEmbeddedPost(item.post.embed) : undefined
|
||||
if (quotedPost?.author.did === did) {
|
||||
yield quotedPost?.author
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let profile of findAllProfilesInFeedQueryData(queryClient, did)) {
|
||||
yield profile
|
||||
}
|
||||
for (let profile of findAllProfilesInNotifsQueryData(queryClient, did)) {
|
||||
yield profile
|
||||
}
|
||||
for (let profile of findAllProfilesInSearchQueryData(queryClient, did)) {
|
||||
yield profile
|
||||
}
|
||||
}
|
||||
|
||||
function* traverseThread(node: ThreadNode): Generator<ThreadNode, void> {
|
||||
if (node.type === 'post') {
|
||||
if (node.parent) {
|
||||
|
||||
@@ -18,7 +18,16 @@ export function usePostQuery(uri: string | undefined) {
|
||||
return useQuery<AppBskyFeedDefs.PostView>({
|
||||
queryKey: RQKEY(uri || ''),
|
||||
async queryFn() {
|
||||
const res = await agent.getPosts({uris: [uri!]})
|
||||
const urip = new AtUri(uri!)
|
||||
|
||||
if (!urip.host.startsWith('did:')) {
|
||||
const res = await agent.resolveHandle({
|
||||
handle: urip.host,
|
||||
})
|
||||
urip.host = res.data.did
|
||||
}
|
||||
|
||||
const res = await agent.getPosts({uris: [urip.toString()]})
|
||||
if (res.success && res.data.posts[0]) {
|
||||
return res.data.posts[0]
|
||||
}
|
||||
@@ -47,7 +56,7 @@ export function useGetPost() {
|
||||
}
|
||||
|
||||
const res = await agent.getPosts({
|
||||
uris: [urip.toString()!],
|
||||
uris: [urip.toString()],
|
||||
})
|
||||
|
||||
if (res.success && res.data.posts[0]) {
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import {AppBskyFeedDefs, AppBskyFeedSearchPosts} from '@atproto/api'
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedSearchPosts,
|
||||
AtUri,
|
||||
} from '@atproto/api'
|
||||
import {
|
||||
InfiniteData,
|
||||
QueryClient,
|
||||
@@ -7,7 +12,11 @@ import {
|
||||
} from '@tanstack/react-query'
|
||||
|
||||
import {useAgent} from '#/state/session'
|
||||
import {embedViewRecordToPostView, getEmbeddedPost} from './util'
|
||||
import {
|
||||
didOrHandleUriMatches,
|
||||
embedViewRecordToPostView,
|
||||
getEmbeddedPost,
|
||||
} from './util'
|
||||
|
||||
const searchPostsQueryKeyRoot = 'search-posts'
|
||||
const searchPostsQueryKey = ({query, sort}: {query: string; sort?: string}) => [
|
||||
@@ -58,20 +67,50 @@ export function* findAllPostsInQueryData(
|
||||
>({
|
||||
queryKey: [searchPostsQueryKeyRoot],
|
||||
})
|
||||
const atUri = new AtUri(uri)
|
||||
|
||||
for (const [_queryKey, queryData] of queryDatas) {
|
||||
if (!queryData?.pages) {
|
||||
continue
|
||||
}
|
||||
for (const page of queryData?.pages) {
|
||||
for (const post of page.posts) {
|
||||
if (post.uri === uri) {
|
||||
if (didOrHandleUriMatches(atUri, post)) {
|
||||
yield post
|
||||
}
|
||||
|
||||
const quotedPost = getEmbeddedPost(post.embed)
|
||||
if (quotedPost?.uri === uri) {
|
||||
if (quotedPost && didOrHandleUriMatches(atUri, quotedPost)) {
|
||||
yield embedViewRecordToPostView(quotedPost)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function* findAllProfilesInQueryData(
|
||||
queryClient: QueryClient,
|
||||
did: string,
|
||||
): Generator<AppBskyActorDefs.ProfileView, undefined> {
|
||||
const queryDatas = queryClient.getQueriesData<
|
||||
InfiniteData<AppBskyFeedSearchPosts.OutputSchema>
|
||||
>({
|
||||
queryKey: [searchPostsQueryKeyRoot],
|
||||
})
|
||||
for (const [_queryKey, queryData] of queryDatas) {
|
||||
if (!queryData?.pages) {
|
||||
continue
|
||||
}
|
||||
for (const page of queryData?.pages) {
|
||||
for (const post of page.posts) {
|
||||
if (post.author.did === did) {
|
||||
yield post.author
|
||||
}
|
||||
const quotedPost = getEmbeddedPost(post.embed)
|
||||
if (quotedPost?.author.did === did) {
|
||||
yield quotedPost.author
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedPost,
|
||||
AtUri,
|
||||
} from '@atproto/api'
|
||||
import {InfiniteData, QueryClient, QueryKey} from '@tanstack/react-query'
|
||||
|
||||
@@ -22,6 +24,23 @@ export function truncateAndInvalidate<T = any>(
|
||||
queryClient.invalidateQueries({queryKey})
|
||||
}
|
||||
|
||||
// Given an AtUri, this function will check if the AtUri matches a
|
||||
// hit regardless of whether the AtUri uses a DID or handle as a host.
|
||||
//
|
||||
// AtUri should be the URI that is being searched for, while currentUri
|
||||
// is the URI that is being checked. currentAuthor is the author
|
||||
// of the currentUri that is being checked.
|
||||
export function didOrHandleUriMatches(
|
||||
atUri: AtUri,
|
||||
record: {uri: string; author: AppBskyActorDefs.ProfileViewBasic},
|
||||
) {
|
||||
if (atUri.host.startsWith('did:')) {
|
||||
return atUri.href === record.uri
|
||||
}
|
||||
|
||||
return atUri.host === record.author.handle && record.uri.endsWith(atUri.rkey)
|
||||
}
|
||||
|
||||
export function getEmbeddedPost(
|
||||
v: unknown,
|
||||
): AppBskyEmbedRecord.ViewRecord | undefined {
|
||||
|
||||
@@ -50,7 +50,6 @@ describe('session', () => {
|
||||
"accounts": [
|
||||
{
|
||||
"accessJwt": "alice-access-jwt-1",
|
||||
"deactivated": false,
|
||||
"did": "alice-did",
|
||||
"email": undefined,
|
||||
"emailAuthFactor": false,
|
||||
@@ -59,6 +58,8 @@ describe('session', () => {
|
||||
"pdsUrl": undefined,
|
||||
"refreshJwt": "alice-refresh-jwt-1",
|
||||
"service": "https://alice.com/",
|
||||
"signupQueued": false,
|
||||
"status": undefined,
|
||||
},
|
||||
],
|
||||
"currentAgentState": {
|
||||
@@ -87,7 +88,6 @@ describe('session', () => {
|
||||
"accounts": [
|
||||
{
|
||||
"accessJwt": undefined,
|
||||
"deactivated": false,
|
||||
"did": "alice-did",
|
||||
"email": undefined,
|
||||
"emailAuthFactor": false,
|
||||
@@ -96,6 +96,8 @@ describe('session', () => {
|
||||
"pdsUrl": undefined,
|
||||
"refreshJwt": undefined,
|
||||
"service": "https://alice.com/",
|
||||
"signupQueued": false,
|
||||
"status": undefined,
|
||||
},
|
||||
],
|
||||
"currentAgentState": {
|
||||
@@ -136,7 +138,6 @@ describe('session', () => {
|
||||
"accounts": [
|
||||
{
|
||||
"accessJwt": "alice-access-jwt-1",
|
||||
"deactivated": false,
|
||||
"did": "alice-did",
|
||||
"email": undefined,
|
||||
"emailAuthFactor": false,
|
||||
@@ -145,6 +146,8 @@ describe('session', () => {
|
||||
"pdsUrl": undefined,
|
||||
"refreshJwt": "alice-refresh-jwt-1",
|
||||
"service": "https://alice.com/",
|
||||
"signupQueued": false,
|
||||
"status": undefined,
|
||||
},
|
||||
],
|
||||
"currentAgentState": {
|
||||
@@ -183,7 +186,6 @@ describe('session', () => {
|
||||
"accounts": [
|
||||
{
|
||||
"accessJwt": "bob-access-jwt-1",
|
||||
"deactivated": false,
|
||||
"did": "bob-did",
|
||||
"email": undefined,
|
||||
"emailAuthFactor": false,
|
||||
@@ -192,10 +194,11 @@ describe('session', () => {
|
||||
"pdsUrl": undefined,
|
||||
"refreshJwt": "bob-refresh-jwt-1",
|
||||
"service": "https://bob.com/",
|
||||
"signupQueued": false,
|
||||
"status": undefined,
|
||||
},
|
||||
{
|
||||
"accessJwt": "alice-access-jwt-1",
|
||||
"deactivated": false,
|
||||
"did": "alice-did",
|
||||
"email": undefined,
|
||||
"emailAuthFactor": false,
|
||||
@@ -204,6 +207,8 @@ describe('session', () => {
|
||||
"pdsUrl": undefined,
|
||||
"refreshJwt": "alice-refresh-jwt-1",
|
||||
"service": "https://alice.com/",
|
||||
"signupQueued": false,
|
||||
"status": undefined,
|
||||
},
|
||||
],
|
||||
"currentAgentState": {
|
||||
@@ -242,7 +247,6 @@ describe('session', () => {
|
||||
"accounts": [
|
||||
{
|
||||
"accessJwt": "alice-access-jwt-2",
|
||||
"deactivated": false,
|
||||
"did": "alice-did",
|
||||
"email": undefined,
|
||||
"emailAuthFactor": false,
|
||||
@@ -251,10 +255,11 @@ describe('session', () => {
|
||||
"pdsUrl": undefined,
|
||||
"refreshJwt": "alice-refresh-jwt-2",
|
||||
"service": "https://alice.com/",
|
||||
"signupQueued": false,
|
||||
"status": undefined,
|
||||
},
|
||||
{
|
||||
"accessJwt": "bob-access-jwt-1",
|
||||
"deactivated": false,
|
||||
"did": "bob-did",
|
||||
"email": undefined,
|
||||
"emailAuthFactor": false,
|
||||
@@ -263,6 +268,8 @@ describe('session', () => {
|
||||
"pdsUrl": undefined,
|
||||
"refreshJwt": "bob-refresh-jwt-1",
|
||||
"service": "https://bob.com/",
|
||||
"signupQueued": false,
|
||||
"status": undefined,
|
||||
},
|
||||
],
|
||||
"currentAgentState": {
|
||||
@@ -299,7 +306,6 @@ describe('session', () => {
|
||||
"accounts": [
|
||||
{
|
||||
"accessJwt": "jay-access-jwt-1",
|
||||
"deactivated": false,
|
||||
"did": "jay-did",
|
||||
"email": undefined,
|
||||
"emailAuthFactor": false,
|
||||
@@ -308,10 +314,11 @@ describe('session', () => {
|
||||
"pdsUrl": undefined,
|
||||
"refreshJwt": "jay-refresh-jwt-1",
|
||||
"service": "https://jay.com/",
|
||||
"signupQueued": false,
|
||||
"status": undefined,
|
||||
},
|
||||
{
|
||||
"accessJwt": "alice-access-jwt-2",
|
||||
"deactivated": false,
|
||||
"did": "alice-did",
|
||||
"email": undefined,
|
||||
"emailAuthFactor": false,
|
||||
@@ -320,10 +327,11 @@ describe('session', () => {
|
||||
"pdsUrl": undefined,
|
||||
"refreshJwt": "alice-refresh-jwt-2",
|
||||
"service": "https://alice.com/",
|
||||
"signupQueued": false,
|
||||
"status": undefined,
|
||||
},
|
||||
{
|
||||
"accessJwt": "bob-access-jwt-1",
|
||||
"deactivated": false,
|
||||
"did": "bob-did",
|
||||
"email": undefined,
|
||||
"emailAuthFactor": false,
|
||||
@@ -332,6 +340,8 @@ describe('session', () => {
|
||||
"pdsUrl": undefined,
|
||||
"refreshJwt": "bob-refresh-jwt-1",
|
||||
"service": "https://bob.com/",
|
||||
"signupQueued": false,
|
||||
"status": undefined,
|
||||
},
|
||||
],
|
||||
"currentAgentState": {
|
||||
@@ -364,7 +374,6 @@ describe('session', () => {
|
||||
"accounts": [
|
||||
{
|
||||
"accessJwt": undefined,
|
||||
"deactivated": false,
|
||||
"did": "jay-did",
|
||||
"email": undefined,
|
||||
"emailAuthFactor": false,
|
||||
@@ -373,10 +382,11 @@ describe('session', () => {
|
||||
"pdsUrl": undefined,
|
||||
"refreshJwt": undefined,
|
||||
"service": "https://jay.com/",
|
||||
"signupQueued": false,
|
||||
"status": undefined,
|
||||
},
|
||||
{
|
||||
"accessJwt": undefined,
|
||||
"deactivated": false,
|
||||
"did": "alice-did",
|
||||
"email": undefined,
|
||||
"emailAuthFactor": false,
|
||||
@@ -385,10 +395,11 @@ describe('session', () => {
|
||||
"pdsUrl": undefined,
|
||||
"refreshJwt": undefined,
|
||||
"service": "https://alice.com/",
|
||||
"signupQueued": false,
|
||||
"status": undefined,
|
||||
},
|
||||
{
|
||||
"accessJwt": undefined,
|
||||
"deactivated": false,
|
||||
"did": "bob-did",
|
||||
"email": undefined,
|
||||
"emailAuthFactor": false,
|
||||
@@ -397,6 +408,8 @@ describe('session', () => {
|
||||
"pdsUrl": undefined,
|
||||
"refreshJwt": undefined,
|
||||
"service": "https://bob.com/",
|
||||
"signupQueued": false,
|
||||
"status": undefined,
|
||||
},
|
||||
],
|
||||
"currentAgentState": {
|
||||
@@ -446,7 +459,6 @@ describe('session', () => {
|
||||
"accounts": [
|
||||
{
|
||||
"accessJwt": undefined,
|
||||
"deactivated": false,
|
||||
"did": "alice-did",
|
||||
"email": undefined,
|
||||
"emailAuthFactor": false,
|
||||
@@ -455,6 +467,8 @@ describe('session', () => {
|
||||
"pdsUrl": undefined,
|
||||
"refreshJwt": undefined,
|
||||
"service": "https://alice.com/",
|
||||
"signupQueued": false,
|
||||
"status": undefined,
|
||||
},
|
||||
],
|
||||
"currentAgentState": {
|
||||
@@ -490,7 +504,6 @@ describe('session', () => {
|
||||
"accounts": [
|
||||
{
|
||||
"accessJwt": "alice-access-jwt-2",
|
||||
"deactivated": false,
|
||||
"did": "alice-did",
|
||||
"email": undefined,
|
||||
"emailAuthFactor": false,
|
||||
@@ -499,6 +512,8 @@ describe('session', () => {
|
||||
"pdsUrl": undefined,
|
||||
"refreshJwt": "alice-refresh-jwt-2",
|
||||
"service": "https://alice.com/",
|
||||
"signupQueued": false,
|
||||
"status": undefined,
|
||||
},
|
||||
],
|
||||
"currentAgentState": {
|
||||
@@ -601,7 +616,6 @@ describe('session', () => {
|
||||
"accounts": [
|
||||
{
|
||||
"accessJwt": "bob-access-jwt-1",
|
||||
"deactivated": false,
|
||||
"did": "bob-did",
|
||||
"email": undefined,
|
||||
"emailAuthFactor": false,
|
||||
@@ -610,6 +624,8 @@ describe('session', () => {
|
||||
"pdsUrl": undefined,
|
||||
"refreshJwt": "bob-refresh-jwt-1",
|
||||
"service": "https://bob.com/",
|
||||
"signupQueued": false,
|
||||
"status": undefined,
|
||||
},
|
||||
],
|
||||
"currentAgentState": {
|
||||
@@ -681,7 +697,6 @@ describe('session', () => {
|
||||
"accounts": [
|
||||
{
|
||||
"accessJwt": "alice-access-jwt-2",
|
||||
"deactivated": false,
|
||||
"did": "alice-did",
|
||||
"email": "alice@foo.bar",
|
||||
"emailAuthFactor": false,
|
||||
@@ -690,6 +705,8 @@ describe('session', () => {
|
||||
"pdsUrl": undefined,
|
||||
"refreshJwt": "alice-refresh-jwt-2",
|
||||
"service": "https://alice.com/",
|
||||
"signupQueued": false,
|
||||
"status": undefined,
|
||||
},
|
||||
],
|
||||
"currentAgentState": {
|
||||
@@ -731,7 +748,6 @@ describe('session', () => {
|
||||
"accounts": [
|
||||
{
|
||||
"accessJwt": "alice-access-jwt-3",
|
||||
"deactivated": false,
|
||||
"did": "alice-did",
|
||||
"email": "alice@foo.baz",
|
||||
"emailAuthFactor": true,
|
||||
@@ -740,6 +756,8 @@ describe('session', () => {
|
||||
"pdsUrl": undefined,
|
||||
"refreshJwt": "alice-refresh-jwt-3",
|
||||
"service": "https://alice.com/",
|
||||
"signupQueued": false,
|
||||
"status": undefined,
|
||||
},
|
||||
],
|
||||
"currentAgentState": {
|
||||
@@ -781,7 +799,6 @@ describe('session', () => {
|
||||
"accounts": [
|
||||
{
|
||||
"accessJwt": "alice-access-jwt-4",
|
||||
"deactivated": false,
|
||||
"did": "alice-did",
|
||||
"email": "alice@foo.baz",
|
||||
"emailAuthFactor": false,
|
||||
@@ -790,6 +807,8 @@ describe('session', () => {
|
||||
"pdsUrl": undefined,
|
||||
"refreshJwt": "alice-refresh-jwt-4",
|
||||
"service": "https://alice.com/",
|
||||
"signupQueued": false,
|
||||
"status": undefined,
|
||||
},
|
||||
],
|
||||
"currentAgentState": {
|
||||
@@ -937,7 +956,6 @@ describe('session', () => {
|
||||
"accounts": [
|
||||
{
|
||||
"accessJwt": "bob-access-jwt-1",
|
||||
"deactivated": false,
|
||||
"did": "bob-did",
|
||||
"email": undefined,
|
||||
"emailAuthFactor": false,
|
||||
@@ -946,10 +964,11 @@ describe('session', () => {
|
||||
"pdsUrl": undefined,
|
||||
"refreshJwt": "bob-refresh-jwt-1",
|
||||
"service": "https://bob.com/",
|
||||
"signupQueued": false,
|
||||
"status": undefined,
|
||||
},
|
||||
{
|
||||
"accessJwt": "alice-access-jwt-2",
|
||||
"deactivated": false,
|
||||
"did": "alice-did",
|
||||
"email": "alice@foo.bar",
|
||||
"emailAuthFactor": false,
|
||||
@@ -958,6 +977,8 @@ describe('session', () => {
|
||||
"pdsUrl": undefined,
|
||||
"refreshJwt": "alice-refresh-jwt-2",
|
||||
"service": "https://alice.com/",
|
||||
"signupQueued": false,
|
||||
"status": undefined,
|
||||
},
|
||||
],
|
||||
"currentAgentState": {
|
||||
@@ -997,7 +1018,6 @@ describe('session', () => {
|
||||
"accounts": [
|
||||
{
|
||||
"accessJwt": "bob-access-jwt-2",
|
||||
"deactivated": false,
|
||||
"did": "bob-did",
|
||||
"email": undefined,
|
||||
"emailAuthFactor": false,
|
||||
@@ -1006,10 +1026,11 @@ describe('session', () => {
|
||||
"pdsUrl": undefined,
|
||||
"refreshJwt": "bob-refresh-jwt-2",
|
||||
"service": "https://bob.com/",
|
||||
"signupQueued": false,
|
||||
"status": undefined,
|
||||
},
|
||||
{
|
||||
"accessJwt": "alice-access-jwt-2",
|
||||
"deactivated": false,
|
||||
"did": "alice-did",
|
||||
"email": "alice@foo.bar",
|
||||
"emailAuthFactor": false,
|
||||
@@ -1018,6 +1039,8 @@ describe('session', () => {
|
||||
"pdsUrl": undefined,
|
||||
"refreshJwt": "alice-refresh-jwt-2",
|
||||
"service": "https://alice.com/",
|
||||
"signupQueued": false,
|
||||
"status": undefined,
|
||||
},
|
||||
],
|
||||
"currentAgentState": {
|
||||
@@ -1156,7 +1179,6 @@ describe('session', () => {
|
||||
"accounts": [
|
||||
{
|
||||
"accessJwt": "alice-access-jwt-1",
|
||||
"deactivated": false,
|
||||
"did": "alice-did",
|
||||
"email": undefined,
|
||||
"emailAuthFactor": false,
|
||||
@@ -1165,6 +1187,8 @@ describe('session', () => {
|
||||
"pdsUrl": undefined,
|
||||
"refreshJwt": "alice-refresh-jwt-1",
|
||||
"service": "https://alice.com/",
|
||||
"signupQueued": false,
|
||||
"status": undefined,
|
||||
},
|
||||
],
|
||||
"currentAgentState": {
|
||||
@@ -1218,7 +1242,6 @@ describe('session', () => {
|
||||
"accounts": [
|
||||
{
|
||||
"accessJwt": undefined,
|
||||
"deactivated": false,
|
||||
"did": "alice-did",
|
||||
"email": undefined,
|
||||
"emailAuthFactor": false,
|
||||
@@ -1227,6 +1250,8 @@ describe('session', () => {
|
||||
"pdsUrl": undefined,
|
||||
"refreshJwt": undefined,
|
||||
"service": "https://alice.com/",
|
||||
"signupQueued": false,
|
||||
"status": undefined,
|
||||
},
|
||||
],
|
||||
"currentAgentState": {
|
||||
@@ -1280,7 +1305,6 @@ describe('session', () => {
|
||||
"accounts": [
|
||||
{
|
||||
"accessJwt": undefined,
|
||||
"deactivated": false,
|
||||
"did": "alice-did",
|
||||
"email": undefined,
|
||||
"emailAuthFactor": false,
|
||||
@@ -1289,6 +1313,8 @@ describe('session', () => {
|
||||
"pdsUrl": undefined,
|
||||
"refreshJwt": undefined,
|
||||
"service": "https://alice.com/",
|
||||
"signupQueued": false,
|
||||
"status": undefined,
|
||||
},
|
||||
],
|
||||
"currentAgentState": {
|
||||
@@ -1371,7 +1397,6 @@ describe('session', () => {
|
||||
"accounts": [
|
||||
{
|
||||
"accessJwt": "jay-access-jwt-1",
|
||||
"deactivated": false,
|
||||
"did": "jay-did",
|
||||
"email": undefined,
|
||||
"emailAuthFactor": false,
|
||||
@@ -1380,10 +1405,11 @@ describe('session', () => {
|
||||
"pdsUrl": undefined,
|
||||
"refreshJwt": "jay-refresh-jwt-1",
|
||||
"service": "https://jay.com/",
|
||||
"signupQueued": false,
|
||||
"status": undefined,
|
||||
},
|
||||
{
|
||||
"accessJwt": "bob-access-jwt-2",
|
||||
"deactivated": false,
|
||||
"did": "bob-did",
|
||||
"email": undefined,
|
||||
"emailAuthFactor": false,
|
||||
@@ -1392,6 +1418,8 @@ describe('session', () => {
|
||||
"pdsUrl": undefined,
|
||||
"refreshJwt": "bob-refresh-jwt-2",
|
||||
"service": "https://alice.com/",
|
||||
"signupQueued": false,
|
||||
"status": undefined,
|
||||
},
|
||||
],
|
||||
"currentAgentState": {
|
||||
@@ -1429,7 +1457,6 @@ describe('session', () => {
|
||||
"accounts": [
|
||||
{
|
||||
"accessJwt": "clarence-access-jwt-2",
|
||||
"deactivated": false,
|
||||
"did": "clarence-did",
|
||||
"email": undefined,
|
||||
"emailAuthFactor": false,
|
||||
@@ -1438,6 +1465,8 @@ describe('session', () => {
|
||||
"pdsUrl": undefined,
|
||||
"refreshJwt": "clarence-refresh-jwt-2",
|
||||
"service": "https://clarence.com/",
|
||||
"signupQueued": false,
|
||||
"status": undefined,
|
||||
},
|
||||
],
|
||||
"currentAgentState": {
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
configureModerationForGuest,
|
||||
} from './moderation'
|
||||
import {SessionAccount} from './types'
|
||||
import {isSessionDeactivated, isSessionExpired} from './util'
|
||||
import {isSessionExpired, isSignupQueued} from './util'
|
||||
|
||||
export function createPublicAgent() {
|
||||
configureModerationForGuest() // Side effect but only relevant for tests
|
||||
@@ -51,7 +51,7 @@ export async function createAgentAndResume(
|
||||
await networkRetry(1, () => agent.resumeSession(prevSession))
|
||||
} else {
|
||||
agent.session = prevSession
|
||||
if (!storedAccount.deactivated) {
|
||||
if (!storedAccount.signupQueued) {
|
||||
// Intentionally not awaited to unblock the UI:
|
||||
networkRetry(3, () => agent.resumeSession(prevSession)).catch(
|
||||
(e: any) => {
|
||||
@@ -135,7 +135,7 @@ export async function createAgentAndCreateAccount(
|
||||
const account = agentToSessionAccountOrThrow(agent)
|
||||
const gates = tryFetchGates(account.did, 'prefer-fresh-gates')
|
||||
const moderation = configureModerationForAccount(agent, account)
|
||||
if (!account.deactivated) {
|
||||
if (!account.signupQueued) {
|
||||
/*dont await*/ agent.upsertProfile(_existing => {
|
||||
return {
|
||||
displayName: '',
|
||||
@@ -234,7 +234,9 @@ export function agentToSessionAccount(
|
||||
emailAuthFactor: agent.session.emailAuthFactor || false,
|
||||
refreshJwt: agent.session.refreshJwt,
|
||||
accessJwt: agent.session.accessJwt,
|
||||
deactivated: isSessionDeactivated(agent.session.accessJwt),
|
||||
signupQueued: isSignupQueued(agent.session.accessJwt),
|
||||
// @ts-expect-error TODO remove when backend is ready
|
||||
status: agent.session.status,
|
||||
pdsUrl: agent.pdsUrl?.toString(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from './agent'
|
||||
import {getInitialState, reducer} from './reducer'
|
||||
|
||||
export {isSessionDeactivated} from './util'
|
||||
export {isSignupQueued} from './util'
|
||||
export type {SessionAccount} from '#/state/session/types'
|
||||
import {SessionApiContext, SessionStateContext} from '#/state/session/types'
|
||||
|
||||
|
||||
@@ -10,11 +10,12 @@ export function readLastActiveAccount() {
|
||||
return accounts.find(a => a.did === currentAccount?.did)
|
||||
}
|
||||
|
||||
export function isSessionDeactivated(accessJwt: string | undefined) {
|
||||
export function isSignupQueued(accessJwt: string | undefined) {
|
||||
if (accessJwt) {
|
||||
const sessData = jwtDecode(accessJwt)
|
||||
return (
|
||||
hasProp(sessData, 'scope') && sessData.scope === 'com.atproto.deactivated'
|
||||
hasProp(sessData, 'scope') &&
|
||||
sessData.scope === 'com.atproto.signupQueued'
|
||||
)
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
BackHandler,
|
||||
Keyboard,
|
||||
ScrollView,
|
||||
LayoutChangeEvent,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
@@ -12,6 +18,12 @@ import {
|
||||
KeyboardAvoidingView,
|
||||
KeyboardStickyView,
|
||||
} from 'react-native-keyboard-controller'
|
||||
import Animated, {
|
||||
interpolateColor,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {LinearGradient} from 'expo-linear-gradient'
|
||||
import {RichText} from '@atproto/api'
|
||||
@@ -24,6 +36,7 @@ import {
|
||||
createGIFDescription,
|
||||
parseAltFromGIFDescription,
|
||||
} from '#/lib/gif-alt-text'
|
||||
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
|
||||
import {LikelyType} from '#/lib/link-meta/link-meta'
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
@@ -42,7 +55,7 @@ import {useAgent, useSession} from '#/state/session'
|
||||
import {useComposerControls} from '#/state/shell/composer'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import * as apilib from 'lib/api/index'
|
||||
import {MAX_GRAPHEME_LENGTH} from 'lib/constants'
|
||||
import {HITSLOP_10, MAX_GRAPHEME_LENGTH} from 'lib/constants'
|
||||
import {useIsKeyboardVisible} from 'lib/hooks/useIsKeyboardVisible'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
@@ -55,7 +68,7 @@ import {useDialogStateControlContext} from 'state/dialogs'
|
||||
import {GalleryModel} from 'state/models/media/gallery'
|
||||
import {ComposerOpts} from 'state/shell/composer'
|
||||
import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
@@ -78,6 +91,11 @@ import {SuggestedLanguage} from './select-language/SuggestedLanguage'
|
||||
import {TextInput, TextInputRef} from './text-input/TextInput'
|
||||
import {ThreadgateBtn} from './threadgate/ThreadgateBtn'
|
||||
import {useExternalLinkFetch} from './useExternalLinkFetch'
|
||||
import hairlineWidth = StyleSheet.hairlineWidth
|
||||
|
||||
type CancelRef = {
|
||||
onPressCancel: () => void
|
||||
}
|
||||
|
||||
type Props = ComposerOpts
|
||||
export const ComposePost = observer(function ComposePost({
|
||||
@@ -88,7 +106,10 @@ export const ComposePost = observer(function ComposePost({
|
||||
openPicker,
|
||||
text: initText,
|
||||
imageUris: initImageUris,
|
||||
}: Props) {
|
||||
cancelRef,
|
||||
}: Props & {
|
||||
cancelRef?: React.RefObject<CancelRef>
|
||||
}) {
|
||||
const {currentAccount} = useSession()
|
||||
const agent = useAgent()
|
||||
const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
|
||||
@@ -96,7 +117,7 @@ export const ComposePost = observer(function ComposePost({
|
||||
const {closeComposer} = useComposerControls()
|
||||
const {track} = useAnalytics()
|
||||
const pal = usePalette('default')
|
||||
const {isDesktop, isMobile} = useWebMediaQueries()
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
const {_} = useLingui()
|
||||
const requireAltTextEnabled = useRequireAltTextEnabled()
|
||||
const langPrefs = useLanguagePrefs()
|
||||
@@ -104,6 +125,7 @@ export const ComposePost = observer(function ComposePost({
|
||||
const textInput = useRef<TextInputRef>(null)
|
||||
const discardPromptControl = Prompt.usePromptControl()
|
||||
const {closeAllDialogs} = useDialogStateControlContext()
|
||||
const t = useTheme()
|
||||
|
||||
const [isKeyboardVisible] = useIsKeyboardVisible({iosUseWillEvents: true})
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
@@ -145,13 +167,12 @@ export const ComposePost = observer(function ComposePost({
|
||||
() => ({
|
||||
paddingBottom:
|
||||
isAndroid || (isIOS && !isKeyboardVisible) ? insets.bottom : 0,
|
||||
paddingTop: isAndroid ? insets.top : isMobile ? 15 : 0,
|
||||
}),
|
||||
[insets, isKeyboardVisible, isMobile],
|
||||
[insets, isKeyboardVisible],
|
||||
)
|
||||
|
||||
const onPressCancel = useCallback(() => {
|
||||
if (graphemeLength > 0 || !gallery.isEmpty) {
|
||||
if (graphemeLength > 0 || !gallery.isEmpty || extGif) {
|
||||
closeAllDialogs()
|
||||
if (Keyboard) {
|
||||
Keyboard.dismiss()
|
||||
@@ -161,29 +182,15 @@ export const ComposePost = observer(function ComposePost({
|
||||
onClose()
|
||||
}
|
||||
}, [
|
||||
extGif,
|
||||
graphemeLength,
|
||||
gallery.isEmpty,
|
||||
closeAllDialogs,
|
||||
discardPromptControl,
|
||||
onClose,
|
||||
])
|
||||
// android back button
|
||||
useEffect(() => {
|
||||
if (!isAndroid) {
|
||||
return
|
||||
}
|
||||
const backHandler = BackHandler.addEventListener(
|
||||
'hardwareBackPress',
|
||||
() => {
|
||||
onPressCancel()
|
||||
return true
|
||||
},
|
||||
)
|
||||
|
||||
return () => {
|
||||
backHandler.remove()
|
||||
}
|
||||
}, [onPressCancel])
|
||||
useImperativeHandle(cancelRef, () => ({onPressCancel}))
|
||||
|
||||
// listen to escape key on desktop web
|
||||
const onEscape = useCallback(
|
||||
@@ -374,113 +381,130 @@ export const ComposePost = observer(function ComposePost({
|
||||
[setExtLink],
|
||||
)
|
||||
|
||||
const {
|
||||
scrollHandler,
|
||||
onScrollViewContentSizeChange,
|
||||
onScrollViewLayout,
|
||||
topBarAnimatedStyle,
|
||||
bottomBarAnimatedStyle,
|
||||
} = useAnimatedBorders()
|
||||
|
||||
return (
|
||||
<>
|
||||
<KeyboardAvoidingView
|
||||
testID="composePostView"
|
||||
behavior="padding"
|
||||
style={s.flex1}
|
||||
keyboardVerticalOffset={replyTo ? 60 : isAndroid ? 120 : 100}>
|
||||
<View style={[s.flex1, viewStyles]} aria-modal accessibilityViewIsModal>
|
||||
<View style={[styles.topbar, isDesktop && styles.topbarDesktop]}>
|
||||
<TouchableOpacity
|
||||
testID="composerDiscardButton"
|
||||
onPress={onPressCancel}
|
||||
onAccessibilityEscape={onPressCancel}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Cancel`)}
|
||||
accessibilityHint={_(
|
||||
msg`Closes post composer and discards post draft`,
|
||||
)}>
|
||||
<Text style={[pal.link, s.f18]}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={s.flex1} />
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<Text style={pal.textLight}>{processingState}</Text>
|
||||
<View style={styles.postBtn}>
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LabelsBtn
|
||||
labels={labels}
|
||||
onChange={setLabels}
|
||||
hasMedia={hasMedia}
|
||||
/>
|
||||
{canPost ? (
|
||||
<TouchableOpacity
|
||||
testID="composerPublishBtn"
|
||||
onPress={onPressPublish}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
replyTo ? _(msg`Publish reply`) : _(msg`Publish post`)
|
||||
}
|
||||
accessibilityHint="">
|
||||
<LinearGradient
|
||||
colors={[
|
||||
gradients.blueLight.start,
|
||||
gradients.blueLight.end,
|
||||
]}
|
||||
start={{x: 0, y: 0}}
|
||||
end={{x: 1, y: 1}}
|
||||
style={styles.postBtn}>
|
||||
<Text style={[s.white, s.f16, s.bold]}>
|
||||
{replyTo ? (
|
||||
<Trans context="action">Reply</Trans>
|
||||
) : (
|
||||
<Trans context="action">Post</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<View style={[styles.postBtn, pal.btn]}>
|
||||
<Text style={[pal.textLight, s.f16, s.bold]}>
|
||||
<Trans context="action">Post</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
style={a.flex_1}
|
||||
keyboardVerticalOffset={replyTo ? 115 : isAndroid ? 180 : 162}>
|
||||
<View
|
||||
style={[a.flex_1, viewStyles]}
|
||||
aria-modal
|
||||
accessibilityViewIsModal>
|
||||
<Animated.View style={topBarAnimatedStyle}>
|
||||
<View style={styles.topbarInner}>
|
||||
<TouchableOpacity
|
||||
testID="composerDiscardButton"
|
||||
onPress={onPressCancel}
|
||||
onAccessibilityEscape={onPressCancel}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Cancel`)}
|
||||
accessibilityHint={_(
|
||||
msg`Closes post composer and discards post draft`,
|
||||
)}
|
||||
</>
|
||||
hitSlop={HITSLOP_10}>
|
||||
<Text style={[pal.link, s.f18]}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={a.flex_1} />
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<Text style={pal.textLight}>{processingState}</Text>
|
||||
<View style={styles.postBtn}>
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LabelsBtn
|
||||
labels={labels}
|
||||
onChange={setLabels}
|
||||
hasMedia={hasMedia}
|
||||
/>
|
||||
{canPost ? (
|
||||
<TouchableOpacity
|
||||
testID="composerPublishBtn"
|
||||
onPress={onPressPublish}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
replyTo ? _(msg`Publish reply`) : _(msg`Publish post`)
|
||||
}
|
||||
accessibilityHint="">
|
||||
<LinearGradient
|
||||
colors={[
|
||||
gradients.blueLight.start,
|
||||
gradients.blueLight.end,
|
||||
]}
|
||||
start={{x: 0, y: 0}}
|
||||
end={{x: 1, y: 1}}
|
||||
style={styles.postBtn}>
|
||||
<Text style={[s.white, s.f16, s.bold]}>
|
||||
{replyTo ? (
|
||||
<Trans context="action">Reply</Trans>
|
||||
) : (
|
||||
<Trans context="action">Post</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<View style={[styles.postBtn, pal.btn]}>
|
||||
<Text style={[pal.textLight, s.f16, s.bold]}>
|
||||
<Trans context="action">Post</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{isAltTextRequiredAndMissing && (
|
||||
<View style={[styles.reminderLine, pal.viewLight]}>
|
||||
<View style={styles.errorIcon}>
|
||||
<FontAwesomeIcon
|
||||
icon="exclamation"
|
||||
style={{color: colors.red4}}
|
||||
size={10}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[pal.text, a.flex_1]}>
|
||||
<Trans>One or more images is missing alt text.</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{isAltTextRequiredAndMissing && (
|
||||
<View style={[styles.reminderLine, pal.viewLight]}>
|
||||
<View style={styles.errorIcon}>
|
||||
<FontAwesomeIcon
|
||||
icon="exclamation"
|
||||
style={{color: colors.red4}}
|
||||
size={10}
|
||||
/>
|
||||
{error !== '' && (
|
||||
<View style={styles.errorLine}>
|
||||
<View style={styles.errorIcon}>
|
||||
<FontAwesomeIcon
|
||||
icon="exclamation"
|
||||
style={{color: colors.red4}}
|
||||
size={10}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[s.red4, a.flex_1]}>{error}</Text>
|
||||
</View>
|
||||
<Text style={[pal.text, s.flex1]}>
|
||||
<Trans>One or more images is missing alt text.</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{error !== '' && (
|
||||
<View style={styles.errorLine}>
|
||||
<View style={styles.errorIcon}>
|
||||
<FontAwesomeIcon
|
||||
icon="exclamation"
|
||||
style={{color: colors.red4}}
|
||||
size={10}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[s.red4, s.flex1]}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
<ScrollView
|
||||
)}
|
||||
</Animated.View>
|
||||
<Animated.ScrollView
|
||||
onScroll={scrollHandler}
|
||||
style={styles.scrollView}
|
||||
keyboardShouldPersistTaps="always">
|
||||
keyboardShouldPersistTaps="always"
|
||||
onContentSizeChange={onScrollViewContentSizeChange}
|
||||
onLayout={onScrollViewLayout}>
|
||||
{replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined}
|
||||
|
||||
<View
|
||||
style={[
|
||||
pal.border,
|
||||
styles.textInputLayout,
|
||||
isNative && styles.textInputLayoutMobile,
|
||||
]}>
|
||||
@@ -535,16 +559,25 @@ export const ComposePost = observer(function ComposePost({
|
||||
)}
|
||||
</View>
|
||||
) : undefined}
|
||||
</ScrollView>
|
||||
</Animated.ScrollView>
|
||||
<SuggestedLanguage text={richtext.text} />
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
<KeyboardStickyView
|
||||
offset={{closed: isIOS ? -insets.bottom : 0, opened: 0}}>
|
||||
{replyTo ? null : (
|
||||
<ThreadgateBtn threadgate={threadgate} onChange={setThreadgate} />
|
||||
<ThreadgateBtn
|
||||
threadgate={threadgate}
|
||||
onChange={setThreadgate}
|
||||
style={bottomBarAnimatedStyle}
|
||||
/>
|
||||
)}
|
||||
<View style={[pal.border, styles.bottomBar]}>
|
||||
<View
|
||||
style={[
|
||||
t.atoms.bg,
|
||||
t.atoms.border_contrast_medium,
|
||||
styles.bottomBar,
|
||||
]}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||
<SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
|
||||
<OpenCameraBtn gallery={gallery} disabled={!canSelectImages} />
|
||||
@@ -566,7 +599,7 @@ export const ComposePost = observer(function ComposePost({
|
||||
</Button>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={s.flex1} />
|
||||
<View style={a.flex_1} />
|
||||
<SelectLangBtn />
|
||||
<CharProgress count={graphemeLength} />
|
||||
</View>
|
||||
@@ -583,25 +616,119 @@ export const ComposePost = observer(function ComposePost({
|
||||
)
|
||||
})
|
||||
|
||||
export function useComposerCancelRef() {
|
||||
return useRef<CancelRef>(null)
|
||||
}
|
||||
|
||||
function useAnimatedBorders() {
|
||||
const t = useTheme()
|
||||
const hasScrolledTop = useSharedValue(0)
|
||||
const hasScrolledBottom = useSharedValue(0)
|
||||
const contentOffset = useSharedValue(0)
|
||||
const scrollViewHeight = useSharedValue(Infinity)
|
||||
const contentHeight = useSharedValue(0)
|
||||
|
||||
/**
|
||||
* Make sure to run this on the UI thread!
|
||||
*/
|
||||
const showHideBottomBorder = useCallback(
|
||||
({
|
||||
newContentHeight,
|
||||
newContentOffset,
|
||||
newScrollViewHeight,
|
||||
}: {
|
||||
newContentHeight?: number
|
||||
newContentOffset?: number
|
||||
newScrollViewHeight?: number
|
||||
}) => {
|
||||
'worklet'
|
||||
|
||||
if (typeof newContentHeight === 'number')
|
||||
contentHeight.value = Math.floor(newContentHeight)
|
||||
if (typeof newContentOffset === 'number')
|
||||
contentOffset.value = Math.floor(newContentOffset)
|
||||
if (typeof newScrollViewHeight === 'number')
|
||||
scrollViewHeight.value = Math.floor(newScrollViewHeight)
|
||||
|
||||
hasScrolledBottom.value = withTiming(
|
||||
contentHeight.value - contentOffset.value - 5 > scrollViewHeight.value
|
||||
? 1
|
||||
: 0,
|
||||
)
|
||||
},
|
||||
[contentHeight, contentOffset, scrollViewHeight, hasScrolledBottom],
|
||||
)
|
||||
|
||||
const scrollHandler = useAnimatedScrollHandler({
|
||||
onScroll: event => {
|
||||
'worklet'
|
||||
hasScrolledTop.value = withTiming(event.contentOffset.y > 0 ? 1 : 0)
|
||||
showHideBottomBorder({
|
||||
newContentOffset: event.contentOffset.y,
|
||||
newContentHeight: event.contentSize.height,
|
||||
newScrollViewHeight: event.layoutMeasurement.height,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const onScrollViewContentSizeChange = useCallback(
|
||||
(_width: number, height: number) => {
|
||||
'worklet'
|
||||
showHideBottomBorder({
|
||||
newContentHeight: height,
|
||||
})
|
||||
},
|
||||
[showHideBottomBorder],
|
||||
)
|
||||
|
||||
const onScrollViewLayout = useCallback(
|
||||
(evt: LayoutChangeEvent) => {
|
||||
'worklet'
|
||||
showHideBottomBorder({
|
||||
newScrollViewHeight: evt.nativeEvent.layout.height,
|
||||
})
|
||||
},
|
||||
[showHideBottomBorder],
|
||||
)
|
||||
|
||||
const topBarAnimatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
borderBottomWidth: hairlineWidth,
|
||||
borderColor: interpolateColor(
|
||||
hasScrolledTop.value,
|
||||
[0, 1],
|
||||
['transparent', t.atoms.border_contrast_medium.borderColor],
|
||||
),
|
||||
}
|
||||
})
|
||||
const bottomBarAnimatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
borderTopWidth: hairlineWidth,
|
||||
borderColor: interpolateColor(
|
||||
hasScrolledBottom.value,
|
||||
[0, 1],
|
||||
['transparent', t.atoms.border_contrast_medium.borderColor],
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
scrollHandler,
|
||||
onScrollViewContentSizeChange,
|
||||
onScrollViewLayout,
|
||||
topBarAnimatedStyle,
|
||||
bottomBarAnimatedStyle,
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
outer: {
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
height: '100%',
|
||||
},
|
||||
topbar: {
|
||||
topbarInner: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingTop: 6,
|
||||
paddingBottom: 4,
|
||||
paddingHorizontal: 20,
|
||||
height: 55,
|
||||
paddingHorizontal: 16,
|
||||
height: 54,
|
||||
gap: 4,
|
||||
},
|
||||
topbarDesktop: {
|
||||
paddingTop: 10,
|
||||
paddingBottom: 10,
|
||||
},
|
||||
postBtn: {
|
||||
borderRadius: 20,
|
||||
paddingHorizontal: 20,
|
||||
@@ -612,22 +739,22 @@ const styles = StyleSheet.create({
|
||||
flexDirection: 'row',
|
||||
backgroundColor: colors.red1,
|
||||
borderRadius: 6,
|
||||
marginHorizontal: 15,
|
||||
marginHorizontal: 16,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 6,
|
||||
marginVertical: 6,
|
||||
marginBottom: 8,
|
||||
},
|
||||
reminderLine: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderRadius: 6,
|
||||
marginHorizontal: 15,
|
||||
marginHorizontal: 16,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 6,
|
||||
marginBottom: 6,
|
||||
marginBottom: 8,
|
||||
},
|
||||
errorIcon: {
|
||||
borderWidth: 1,
|
||||
borderWidth: hairlineWidth,
|
||||
borderColor: colors.red4,
|
||||
color: colors.red4,
|
||||
borderRadius: 30,
|
||||
@@ -639,12 +766,11 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 15,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
textInputLayout: {
|
||||
flexDirection: 'row',
|
||||
borderTopWidth: 1,
|
||||
paddingTop: 16,
|
||||
paddingTop: 4,
|
||||
},
|
||||
textInputLayoutMobile: {
|
||||
flex: 1,
|
||||
@@ -660,9 +786,10 @@ const styles = StyleSheet.create({
|
||||
bottomBar: {
|
||||
flexDirection: 'row',
|
||||
paddingVertical: 4,
|
||||
paddingLeft: 15,
|
||||
paddingRight: 20,
|
||||
// should be 8 but due to visual alignment we have to fudge it
|
||||
paddingLeft: 7,
|
||||
paddingRight: 16,
|
||||
alignItems: 'center',
|
||||
borderTopWidth: 1,
|
||||
borderTopWidth: hairlineWidth,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -10,16 +10,16 @@ import {
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {ComposerOptsPostRef} from 'state/shell/composer'
|
||||
import {QuoteEmbed} from 'view/com/util/post-embeds/QuoteEmbed'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import {PreviewableUserAvatar} from 'view/com/util/UserAvatar'
|
||||
import {useTheme} from '#/alf'
|
||||
|
||||
export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
|
||||
const pal = usePalette('default')
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {embed} = replyTo
|
||||
|
||||
@@ -75,7 +75,7 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={[pal.border, styles.replyToLayout]}
|
||||
style={[t.atoms.border_contrast_medium, styles.replyToLayout]}
|
||||
onPress={onPress}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(
|
||||
@@ -91,7 +91,7 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
|
||||
type={replyTo.author.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
<View style={styles.replyToPost}>
|
||||
<Text type="xl-medium" style={[pal.text]}>
|
||||
<Text type="xl-medium" style={t.atoms.text}>
|
||||
{sanitizeDisplayName(
|
||||
replyTo.author.displayName || sanitizeHandle(replyTo.author.handle),
|
||||
)}
|
||||
@@ -100,7 +100,7 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
|
||||
<View style={styles.replyToText}>
|
||||
<Text
|
||||
type="post-text"
|
||||
style={pal.text}
|
||||
style={t.atoms.text}
|
||||
numberOfLines={!showFull ? 6 : undefined}>
|
||||
{replyTo.text}
|
||||
</Text>
|
||||
@@ -218,9 +218,10 @@ const styles = StyleSheet.create({
|
||||
replyToLayout: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
borderTopWidth: 1,
|
||||
paddingTop: 16,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
paddingTop: 4,
|
||||
paddingBottom: 16,
|
||||
marginBottom: 12,
|
||||
},
|
||||
replyToPost: {
|
||||
flex: 1,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {KeyboardStickyView} from 'react-native-keyboard-controller'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
|
||||
export function KeyboardAccessory({children}: {children: React.ReactNode}) {
|
||||
const t = useTheme()
|
||||
const {bottom} = useSafeAreaInsets()
|
||||
|
||||
const style = [
|
||||
a.flex_row,
|
||||
a.py_xs,
|
||||
a.pl_sm,
|
||||
a.pr_xl,
|
||||
a.align_center,
|
||||
a.border_t,
|
||||
t.atoms.border_contrast_medium,
|
||||
t.atoms.bg,
|
||||
]
|
||||
|
||||
// todo: when iPad support is added, it should also not use the KeyboardStickyView
|
||||
if (isWeb) {
|
||||
return <View style={style}>{children}</View>
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardStickyView offset={{closed: -bottom}} style={style}>
|
||||
{children}
|
||||
</KeyboardStickyView>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
import React from 'react'
|
||||
import {StyleSheet, TouchableOpacity} from 'react-native'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useProfileQuery} from '#/state/queries/profile'
|
||||
import {useSession} from '#/state/session'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useProfileQuery} from '#/state/queries/profile'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import hairlineWidth = StyleSheet.hairlineWidth
|
||||
|
||||
export function ComposePrompt({onPressCompose}: {onPressCompose: () => void}) {
|
||||
const {currentAccount} = useSession()
|
||||
@@ -47,7 +49,7 @@ const styles = StyleSheet.create({
|
||||
paddingBottom: 10,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderTopWidth: 1,
|
||||
borderTopWidth: hairlineWidth,
|
||||
},
|
||||
labelMobile: {
|
||||
paddingLeft: 12,
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import React from 'react'
|
||||
import {Keyboard, StyleSheet} from 'react-native'
|
||||
import {Button} from 'view/com/util/forms/Button'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {ShieldExclamation} from 'lib/icons'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
|
||||
import {isNative} from 'platform/detection'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {ShieldExclamation} from 'lib/icons'
|
||||
import {isNative} from 'platform/detection'
|
||||
import {Button} from 'view/com/util/forms/Button'
|
||||
|
||||
export function LabelsBtn({
|
||||
labels,
|
||||
@@ -54,6 +55,7 @@ const styles = StyleSheet.create({
|
||||
button: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 2,
|
||||
paddingHorizontal: 6,
|
||||
},
|
||||
dimmed: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import React, {useCallback, useRef} from 'react'
|
||||
import {Keyboard} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -7,7 +7,6 @@ import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {Gif} from '#/state/queries/tenor'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {GifSelectDialog} from '#/components/dialogs/GifSelect'
|
||||
import {GifSquare_Stroke2_Corner0_Rounded as GifIcon} from '#/components/icons/Gif'
|
||||
|
||||
@@ -19,14 +18,14 @@ type Props = {
|
||||
|
||||
export function SelectGifBtn({onClose, onSelectGif, disabled}: Props) {
|
||||
const {_} = useLingui()
|
||||
const control = useDialogControl()
|
||||
const ref = useRef<{open: () => void}>(null)
|
||||
const t = useTheme()
|
||||
|
||||
const onPressSelectGif = useCallback(async () => {
|
||||
logEvent('composer:gif:open', {})
|
||||
Keyboard.dismiss()
|
||||
control.open()
|
||||
}, [control])
|
||||
ref.current?.open()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -44,7 +43,7 @@ export function SelectGifBtn({onClose, onSelectGif, disabled}: Props) {
|
||||
</Button>
|
||||
|
||||
<GifSelectDialog
|
||||
control={control}
|
||||
controlRef={ref}
|
||||
onClose={onClose}
|
||||
onSelectGif={onSelectGif}
|
||||
/>
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
import React, {useCallback, useMemo} from 'react'
|
||||
import {StyleSheet, Keyboard} from 'react-native'
|
||||
import {Keyboard, StyleSheet} from 'react-native'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {
|
||||
hasPostLanguage,
|
||||
toPostLanguages,
|
||||
useLanguagePrefs,
|
||||
useLanguagePrefsApi,
|
||||
} from '#/state/preferences/languages'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {isNative} from 'platform/detection'
|
||||
import {
|
||||
DropdownButton,
|
||||
DropdownItem,
|
||||
DropdownItemButton,
|
||||
} from 'view/com/util/forms/DropdownButton'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {isNative} from 'platform/detection'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import {codeToLanguageName} from '../../../../locale/helpers'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {
|
||||
useLanguagePrefs,
|
||||
useLanguagePrefsApi,
|
||||
toPostLanguages,
|
||||
hasPostLanguage,
|
||||
} from '#/state/preferences/languages'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
export function SelectLangBtn() {
|
||||
const pal = usePalette('default')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react'
|
||||
import {Keyboard, View} from 'react-native'
|
||||
import {Keyboard, StyleProp, ViewStyle} from 'react-native'
|
||||
import Animated, {AnimatedStyle} from 'react-native-reanimated'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
@@ -7,7 +8,7 @@ import {isNative} from '#/platform/detection'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {ThreadgateSetting} from '#/state/queries/threadgate'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
|
||||
import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe'
|
||||
@@ -16,12 +17,15 @@ import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
|
||||
export function ThreadgateBtn({
|
||||
threadgate,
|
||||
onChange,
|
||||
style,
|
||||
}: {
|
||||
threadgate: ThreadgateSetting[]
|
||||
onChange: (v: ThreadgateSetting[]) => void
|
||||
style?: StyleProp<AnimatedStyle<ViewStyle>>
|
||||
}) {
|
||||
const {track} = useAnalytics()
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {openModal} = useModalControls()
|
||||
|
||||
const onPress = () => {
|
||||
@@ -45,11 +49,11 @@ export function ThreadgateBtn({
|
||||
: _(msg`Some people can reply`)
|
||||
|
||||
return (
|
||||
<View style={[a.flex_row, a.pb_sm, a.px_md]}>
|
||||
<Animated.View style={[a.flex_row, a.p_sm, t.atoms.bg, style]}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="small"
|
||||
size="xsmall"
|
||||
testID="openReplyGateButton"
|
||||
onPress={onPress}
|
||||
label={label}>
|
||||
@@ -58,6 +62,6 @@ export function ThreadgateBtn({
|
||||
/>
|
||||
<ButtonText>{label}</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ export function FeedPage({
|
||||
scrollToTop()
|
||||
truncateAndInvalidate(queryClient, FEED_RQKEY(feed))
|
||||
setHasNew(false)
|
||||
logEvent('feed:refresh', {
|
||||
logEvent('feed:refresh:sampled', {
|
||||
feedType: feed.split('|')[0],
|
||||
feedUrl: feed,
|
||||
reason: 'soft-reset',
|
||||
@@ -102,7 +102,7 @@ export function FeedPage({
|
||||
scrollToTop()
|
||||
truncateAndInvalidate(queryClient, FEED_RQKEY(feed))
|
||||
setHasNew(false)
|
||||
logEvent('feed:refresh', {
|
||||
logEvent('feed:refresh:sampled', {
|
||||
feedType: feed.split('|')[0],
|
||||
feedUrl: feed,
|
||||
reason: 'load-latest',
|
||||
|
||||
@@ -25,6 +25,7 @@ import * as Prompt from '#/components/Prompt'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import hairlineWidth = StyleSheet.hairlineWidth
|
||||
|
||||
export function FeedSourceCard({
|
||||
feedUri,
|
||||
@@ -34,6 +35,7 @@ export function FeedSourceCard({
|
||||
showLikes = false,
|
||||
pinOnSave = false,
|
||||
showMinimalPlaceholder,
|
||||
hideTopBorder,
|
||||
}: {
|
||||
feedUri: string
|
||||
style?: StyleProp<ViewStyle>
|
||||
@@ -42,6 +44,7 @@ export function FeedSourceCard({
|
||||
showLikes?: boolean
|
||||
pinOnSave?: boolean
|
||||
showMinimalPlaceholder?: boolean
|
||||
hideTopBorder?: boolean
|
||||
}) {
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const {data: feed} = useFeedSourceInfoQuery({uri: feedUri})
|
||||
@@ -57,6 +60,7 @@ export function FeedSourceCard({
|
||||
showLikes={showLikes}
|
||||
pinOnSave={pinOnSave}
|
||||
showMinimalPlaceholder={showMinimalPlaceholder}
|
||||
hideTopBorder={hideTopBorder}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -71,6 +75,7 @@ export function FeedSourceCardLoaded({
|
||||
showLikes = false,
|
||||
pinOnSave = false,
|
||||
showMinimalPlaceholder,
|
||||
hideTopBorder,
|
||||
}: {
|
||||
feedUri: string
|
||||
feed?: FeedSourceInfo
|
||||
@@ -81,6 +86,7 @@ export function FeedSourceCardLoaded({
|
||||
showLikes?: boolean
|
||||
pinOnSave?: boolean
|
||||
showMinimalPlaceholder?: boolean
|
||||
hideTopBorder?: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const pal = usePalette('default')
|
||||
@@ -149,7 +155,7 @@ export function FeedSourceCardLoaded({
|
||||
style={[
|
||||
pal.border,
|
||||
{
|
||||
borderTopWidth: showMinimalPlaceholder ? 0 : 1,
|
||||
borderTopWidth: showMinimalPlaceholder || hideTopBorder ? 0 : 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
@@ -191,7 +197,12 @@ export function FeedSourceCardLoaded({
|
||||
<Pressable
|
||||
testID={`feed-${feed.displayName}`}
|
||||
accessibilityRole="button"
|
||||
style={[styles.container, pal.border, style]}
|
||||
style={[
|
||||
styles.container,
|
||||
pal.border,
|
||||
style,
|
||||
{borderTopWidth: hideTopBorder ? 0 : hairlineWidth},
|
||||
]}
|
||||
onPress={() => {
|
||||
if (feed.type === 'feed') {
|
||||
navigation.push('ProfileFeed', {
|
||||
@@ -295,7 +306,6 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: 20,
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
borderTopWidth: 1,
|
||||
gap: 14,
|
||||
},
|
||||
headerContainer: {
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
findNodeHandle,
|
||||
ListRenderItemInfo,
|
||||
StyleProp,
|
||||
StyleSheet,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {useTheme} from '#/lib/ThemeContext'
|
||||
import {logger} from '#/logger'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {isNative, isWeb} from '#/platform/detection'
|
||||
import {hydrateFeedGenerator} from '#/state/queries/feed'
|
||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||
import {RQKEY, useProfileFeedgensQuery} from '#/state/queries/profile-feedgens'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
|
||||
import {EmptyState} from 'view/com/util/EmptyState'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {List, ListRef} from '../util/List'
|
||||
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {FeedSourceCardLoaded} from './FeedSourceCard'
|
||||
|
||||
const LOADING = {_reactKey: '__loading__'}
|
||||
@@ -51,7 +51,6 @@ export const ProfileFeedgens = React.forwardRef<
|
||||
{did, scrollElRef, headerOffset, enabled, style, testID, setScrollViewTag},
|
||||
ref,
|
||||
) {
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const theme = useTheme()
|
||||
const [isPTRing, setIsPTRing] = React.useState(false)
|
||||
@@ -134,16 +133,14 @@ export const ProfileFeedgens = React.forwardRef<
|
||||
// =
|
||||
|
||||
const renderItemInner = React.useCallback(
|
||||
({item}: {item: any}) => {
|
||||
({item, index}: ListRenderItemInfo<any>) => {
|
||||
if (item === EMPTY) {
|
||||
return (
|
||||
<View
|
||||
<EmptyState
|
||||
icon="hashtag"
|
||||
message={_(msg`You have no feeds.`)}
|
||||
testID="listsEmpty"
|
||||
style={[{padding: 18, borderTopWidth: 1}, pal.border]}>
|
||||
<Text style={pal.textLight}>
|
||||
<Trans>You have no feeds.</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
/>
|
||||
)
|
||||
} else if (item === ERROR_ITEM) {
|
||||
return (
|
||||
@@ -169,12 +166,13 @@ export const ProfileFeedgens = React.forwardRef<
|
||||
preferences={preferences}
|
||||
style={styles.item}
|
||||
showLikes
|
||||
hideTopBorder={index === 0 && !isWeb}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return null
|
||||
},
|
||||
[error, refetch, onPressRetryLoadMore, pal, preferences, _],
|
||||
[error, refetch, onPressRetryLoadMore, preferences, _],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
|
||||
import {AtUri, AppBskyGraphDefs, RichText} from '@atproto/api'
|
||||
import {Link} from '../util/Link'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {RichText as RichTextCom} from '#/components/RichText'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import {s} from 'lib/styles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {AppBskyGraphDefs, AtUri, RichText} from '@atproto/api'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
import {useSession} from '#/state/session'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {makeProfileLink} from 'lib/routes/links'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {makeProfileLink} from 'lib/routes/links'
|
||||
import {Trans} from '@lingui/macro'
|
||||
import {s} from 'lib/styles'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {RichText as RichTextCom} from '#/components/RichText'
|
||||
import {Link} from '../util/Link'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import hairlineWidth = StyleSheet.hairlineWidth
|
||||
|
||||
export const ListCard = ({
|
||||
testID,
|
||||
@@ -132,7 +134,7 @@ export const ListCard = ({
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
outer: {
|
||||
borderTopWidth: 1,
|
||||
borderTopWidth: hairlineWidth,
|
||||
paddingHorizontal: 6,
|
||||
},
|
||||
outerNoBorder: {
|
||||
|
||||