Compare commits
196 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2dbc7419f6 | |||
| c88647c8d1 | |||
| 1be2d395f8 | |||
| d0afc91620 | |||
| 91a4c97c1a | |||
| 56efebb6ab | |||
| 55fdbc7399 | |||
| becc708c61 | |||
| 03b2796976 | |||
| 17e3b946cb | |||
| 1341845537 | |||
| 2fe76333bc | |||
| 55ac287d5e | |||
| 3bac0182b5 | |||
| ce2eddca8e | |||
| 0c6bf276dd | |||
| f62b0458a7 | |||
| 701e508a1a | |||
| 750a5c899b | |||
| 7d06fb9432 | |||
| f3515e2673 | |||
| 38198fdf7f | |||
| 56f713077f | |||
| 814ec2bd7f | |||
| 0910525e2e | |||
| 4fe5a869c3 | |||
| 0c41b3188a | |||
| 31a8356aef | |||
| 165fdb7049 | |||
| 37f22ca224 | |||
| b2b1c6c040 | |||
| 53d844495b | |||
| f78126e01a | |||
| 87cb4c105e | |||
| 0625a914bd | |||
| 4678c4353b | |||
| 0658dd053d | |||
| c6d30f671a | |||
| 7d72dfb1cb | |||
| 77e6c75a2c | |||
| af77574b3c | |||
| b1a1ca98e8 | |||
| 3cca5f04d6 | |||
| 7f88ec7ca5 | |||
| 790a9c4fad | |||
| 92f6665f93 | |||
| 2ea9e2c929 | |||
| 4fad18b2fa | |||
| e264dfbb87 | |||
| 032f849092 | |||
| 0a8dc289ff | |||
| 901feba6db | |||
| 2ca4b74955 | |||
| fe82257801 | |||
| e1940983a3 | |||
| 136b0e12ab | |||
| fdb5ffff53 | |||
| b4015d10d1 | |||
| 99f3f10fe7 | |||
| 0b6ace990e | |||
| 2a1dbd2756 | |||
| a9feba4efd | |||
| 5cfe5d4f58 | |||
| c90443b37a | |||
| b34d224207 | |||
| db0b030359 | |||
| c33c3b7d1e | |||
| ae7626ce6e | |||
| bc07019911 | |||
| 594b40c3ae | |||
| 6b615f3720 | |||
| c4160c25a3 | |||
| 7448c8f785 | |||
| 91f8202549 | |||
| 9b7818d358 | |||
| 4862bc2ba8 | |||
| eb55bdf172 | |||
| c223bcdaf7 | |||
| feff55a14a | |||
| 876816675e | |||
| 6a4199febb | |||
| 55f3df5596 | |||
| 19a6bdcc86 | |||
| ce02a41163 | |||
| 4a2d4253e5 | |||
| 85b34418ef | |||
| aeed4786db | |||
| 67b0cdf649 | |||
| 997e621ce3 | |||
| a5511e3c22 | |||
| 051e897a2b | |||
| 1e484c6318 | |||
| cdf7a1957a | |||
| c9cf608f78 | |||
| c13685a0cf | |||
| 0f827c3213 | |||
| a9af83692c | |||
| 6405ad7cba | |||
| b86c3b486f | |||
| 8ba1b10ce0 | |||
| 6da18e3dcf | |||
| 96c5db3e69 | |||
| aca55cb192 | |||
| a20fe4c9a0 | |||
| 1a07e23192 | |||
| 5ec945b762 | |||
| dadf27fd2f | |||
| bff055f618 | |||
| e977c99c96 | |||
| c2062753c5 | |||
| 1d8fdbcc34 | |||
| c303df5bd6 | |||
| cc9727a844 | |||
| e19f882450 | |||
| d3fafdc066 | |||
| 5148b957b7 | |||
| e2556d9f40 | |||
| fc0eab2d03 | |||
| 333ccdad39 | |||
| 6f9993ca55 | |||
| 8304ad91ac | |||
| 538ca8dff1 | |||
| d61b366b26 | |||
| df9af92eb2 | |||
| a6061489ff | |||
| 80a1687dae | |||
| 09f3b2ae14 | |||
| b8d8bec388 | |||
| 81ae7e425d | |||
| 181e61bedb | |||
| 31cb3e5422 | |||
| 39807a8630 | |||
| 66ad5543f1 | |||
| 339f26567a | |||
| 80bcf81dc5 | |||
| db968b7610 | |||
| 268e30d21a | |||
| 611ff0c7e4 | |||
| 7b694fd860 | |||
| bcd3678067 | |||
| 2b7d796ca9 | |||
| 4de78fb69e | |||
| eb8bfd11d1 | |||
| 5d19f27052 | |||
| 42966fb49e | |||
| 28fc98d942 | |||
| 599dab1c7a | |||
| 6dd2e07ab9 | |||
| 983d9e55b3 | |||
| c8aa16e90e | |||
| c2fff6a861 | |||
| f3c32f9996 | |||
| a4e34537ce | |||
| d893fe005d | |||
| 2feea51ae3 | |||
| 5d715ae1d0 | |||
| 3c2d73909b | |||
| 388c4f79cf | |||
| dfce190cb6 | |||
| 361d255e95 | |||
| 256bb33de0 | |||
| 2a08931127 | |||
| 1dd3d6657c | |||
| 3b4848ba59 | |||
| 74cd1d4458 | |||
| bf7aee19b8 | |||
| beddddcb57 | |||
| bc956803b8 | |||
| ebd333b331 | |||
| ae26963b45 | |||
| 21e5a87fab | |||
| 61d7e6014b | |||
| 7eb1444f2c | |||
| ce85375c85 | |||
| 1af59ca8a7 | |||
| b10c96f28d | |||
| 5f9136479b | |||
| d81a373d21 | |||
| f2797218f8 | |||
| 45d354cd0c | |||
| d8c8e1e854 | |||
| e5fdbfe294 | |||
| 35005da59e | |||
| a69e567991 | |||
| ec37696034 | |||
| 282ad4b17d | |||
| 6aded4f257 | |||
| 5b82b15007 | |||
| c3fcd486b3 | |||
| 90c3ec8749 | |||
| 15055cb8c4 | |||
| 05212ca9e3 | |||
| e2a59449df | |||
| 8ec3d8c76e | |||
| b3df0b177f | |||
| 05beb1bbad |
@@ -95,8 +95,7 @@ web-build/
|
||||
/ios/
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Firebase (Android) Google services
|
||||
# INCLUDED: google-services.json
|
||||
# INCLUDED: google-services.json
|
||||
|
||||
@@ -4,3 +4,5 @@ SENTRY_AUTH_TOKEN=
|
||||
EXPO_PUBLIC_ENV=development
|
||||
EXPO_PUBLIC_LOG_LEVEL=debug
|
||||
EXPO_PUBLIC_LOG_DEBUG=
|
||||
EXPO_PUBLIC_BUNDLE_IDENTIFIER=
|
||||
EXPO_PUBLIC_BUNDLE_DATE=0
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
name: build-and-push-bskyweb-aws
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- 3p-moderators
|
||||
|
||||
env:
|
||||
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
|
||||
@@ -54,3 +54,6 @@ jobs:
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
env:
|
||||
EXPO_PUBLIC_BUNDLE_IDENTIFIER: $(git rev-parse --short HEAD)
|
||||
EXPO_PUBLIC_BUNDLE_DATE: $(date -u +"%y%m%d%H")
|
||||
|
||||
@@ -25,6 +25,8 @@ jobs:
|
||||
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 5
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -57,6 +59,8 @@ jobs:
|
||||
run: |
|
||||
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
|
||||
echo "${{ secrets.ENV_TOKEN }}" > .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
|
||||
echo "$json" > google-services.json
|
||||
|
||||
- name: 🏗️ EAS Build
|
||||
|
||||
@@ -25,6 +25,8 @@ jobs:
|
||||
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 5
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -65,6 +67,8 @@ jobs:
|
||||
- name: ✏️ Write environment variables
|
||||
run: |
|
||||
echo "${{ secrets.ENV_TOKEN }}" > .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
|
||||
echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json
|
||||
|
||||
- name: 🏗️ EAS Build
|
||||
|
||||
@@ -54,7 +54,9 @@ jobs:
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: last-successful-commit-hash.txt
|
||||
key: last-successful-deployment-commit-${{ github.ref_name }}
|
||||
key: last-successful-deployment-commit-${{ github.ref_name }}-${{ github.sha }}
|
||||
restore-keys: |
|
||||
last-successful-deployment-commit-${{ github.ref_name }}-
|
||||
|
||||
- name: Add the last successful deployment commit to the output
|
||||
id: last-successful-commit
|
||||
@@ -68,7 +70,7 @@ jobs:
|
||||
- name: 🕵️ Get the base commit
|
||||
id: base-commit
|
||||
run: |
|
||||
if [ -z "${{ inputs.channel == 'production' }}" ]; then
|
||||
if ${{ inputs.channel == 'production' }}; then
|
||||
echo base-commit=$(git show-ref -s ${{ inputs.runtimeVersion }}) >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo base-commit=${{ steps.last-successful-commit.base-commit }} >> "$GITHUB_OUTPUT"
|
||||
@@ -144,6 +146,8 @@ jobs:
|
||||
run: |
|
||||
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
|
||||
echo "${{ secrets.ENV_TOKEN }}" > .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
|
||||
echo "$json" > google-services.json
|
||||
|
||||
- name: 🏗️ Create Bundle
|
||||
@@ -182,6 +186,8 @@ jobs:
|
||||
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 5
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -222,6 +228,8 @@ jobs:
|
||||
- name: ✏️ Write environment variables
|
||||
run: |
|
||||
echo "${{ secrets.ENV_TOKEN }}" > .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
|
||||
echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json
|
||||
|
||||
- name: 🏗️ EAS Build
|
||||
@@ -251,6 +259,8 @@ jobs:
|
||||
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 5
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -283,6 +293,8 @@ jobs:
|
||||
run: |
|
||||
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
|
||||
echo "${{ secrets.ENV_TOKEN }}" > .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
|
||||
echo "$json" > google-services.json
|
||||
|
||||
- name: 🏗️ EAS Build
|
||||
|
||||
@@ -20,8 +20,8 @@ jobs:
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: '1.21'
|
||||
- name: Dummy JS File
|
||||
run: touch bskyweb/static/js/blah.js
|
||||
- name: Dummy Static Files
|
||||
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/media/blah.txt
|
||||
- name: Check
|
||||
run: cd bskyweb/ && make check
|
||||
- name: Build (binary)
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: '1.21'
|
||||
- name: Dummy JS File
|
||||
run: touch bskyweb/static/js/blah.js
|
||||
- name: Dummy Static Files
|
||||
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/media/blah.txt
|
||||
- name: Lint
|
||||
run: cd bskyweb/ && make lint
|
||||
|
||||
@@ -40,6 +40,7 @@ RUN find ./bskyweb/embedr-static && find ./bskyweb/embedr-templates && find ./bs
|
||||
|
||||
# hack around issue with empty directory and go:embed
|
||||
RUN touch bskyweb/static/js/empty.txt
|
||||
RUN touch bskyweb/static/media/empty.txt
|
||||
|
||||
#
|
||||
# Generate the embedr Go binary.
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/* eslint-env detox/detox */
|
||||
|
||||
import {describe, beforeAll, it} from '@jest/globals'
|
||||
import {beforeAll, describe, it} from '@jest/globals'
|
||||
import {expect} from 'detox'
|
||||
import {openApp, loginAsAlice, loginAsBob, createServer, sleep} from '../util'
|
||||
|
||||
import {createServer, loginAsAlice, loginAsBob, openApp, sleep} from '../util'
|
||||
|
||||
describe('Curate lists', () => {
|
||||
beforeAll(async () => {
|
||||
@@ -115,6 +116,7 @@ describe('Curate lists', () => {
|
||||
await element(by.text('About')).tap()
|
||||
await element(by.id('addUserBtn')).tap()
|
||||
await expect(element(by.id('listAddUserModal'))).toBeVisible()
|
||||
await element(by.id('searchInput')).typeText('b')
|
||||
await waitFor(element(by.id('user-bob.test-addBtn')))
|
||||
.toBeVisible()
|
||||
.withTimeout(5000)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import ImageResizer from '@bam.tech/react-native-image-resizer'
|
||||
import RNFetchBlob from 'rn-fetch-blob'
|
||||
|
||||
import {
|
||||
downloadAndResize,
|
||||
DownloadAndResizeOpts,
|
||||
} from '../../src/lib/media/manip'
|
||||
import ImageResizer from '@bam.tech/react-native-image-resizer'
|
||||
import RNFetchBlob from 'rn-fetch-blob'
|
||||
|
||||
describe('downloadAndResize', () => {
|
||||
const errorSpy = jest.spyOn(global.console, 'error')
|
||||
@@ -30,6 +31,7 @@ describe('downloadAndResize', () => {
|
||||
const mockedFetch = RNFetchBlob.fetch as jest.Mock
|
||||
mockedFetch.mockResolvedValueOnce({
|
||||
path: jest.fn().mockReturnValue('file://downloaded-image.jpg'),
|
||||
info: jest.fn().mockReturnValue({status: 200}),
|
||||
flush: jest.fn(),
|
||||
})
|
||||
|
||||
@@ -84,6 +86,7 @@ describe('downloadAndResize', () => {
|
||||
const mockedFetch = RNFetchBlob.fetch as jest.Mock
|
||||
mockedFetch.mockResolvedValueOnce({
|
||||
path: jest.fn().mockReturnValue('file://downloaded-image'),
|
||||
info: jest.fn().mockReturnValue({status: 200}),
|
||||
flush: jest.fn(),
|
||||
})
|
||||
|
||||
@@ -118,4 +121,26 @@ describe('downloadAndResize', () => {
|
||||
{mode: 'cover'},
|
||||
)
|
||||
})
|
||||
|
||||
it('should return undefined for non-200 response', async () => {
|
||||
const mockedFetch = RNFetchBlob.fetch as jest.Mock
|
||||
mockedFetch.mockResolvedValueOnce({
|
||||
path: jest.fn().mockReturnValue('file://downloaded-image'),
|
||||
info: jest.fn().mockReturnValue({status: 400}),
|
||||
flush: jest.fn(),
|
||||
})
|
||||
|
||||
const opts: DownloadAndResizeOpts = {
|
||||
uri: 'https://example.com/image',
|
||||
width: 100,
|
||||
height: 100,
|
||||
maxSize: 500000,
|
||||
mode: 'cover',
|
||||
timeout: 10000,
|
||||
}
|
||||
|
||||
const result = await downloadAndResize(opts)
|
||||
expect(errorSpy).not.toHaveBeenCalled()
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import {RichText} from '@atproto/api'
|
||||
import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player'
|
||||
import {cleanError} from '../../src/lib/strings/errors'
|
||||
import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles'
|
||||
import {enforceLen, pluralize} from '../../src/lib/strings/helpers'
|
||||
import {enforceLen} from '../../src/lib/strings/helpers'
|
||||
import {detectLinkables} from '../../src/lib/strings/rich-text-detection'
|
||||
import {shortenLinks} from '../../src/lib/strings/rich-text-manip'
|
||||
import {ago} from '../../src/lib/strings/time'
|
||||
@@ -127,35 +127,6 @@ describe('detectLinkables', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('pluralize', () => {
|
||||
const inputs: [number, string, string?][] = [
|
||||
[1, 'follower'],
|
||||
[1, 'member'],
|
||||
[100, 'post'],
|
||||
[1000, 'repost'],
|
||||
[10000, 'upvote'],
|
||||
[100000, 'other'],
|
||||
[2, 'man', 'men'],
|
||||
]
|
||||
const outputs = [
|
||||
'follower',
|
||||
'member',
|
||||
'posts',
|
||||
'reposts',
|
||||
'upvotes',
|
||||
'others',
|
||||
'men',
|
||||
]
|
||||
|
||||
it('correctly pluralizes a set of words', () => {
|
||||
for (let i = 0; i < inputs.length; i++) {
|
||||
const input = inputs[i]
|
||||
const output = pluralize(...input)
|
||||
expect(output).toEqual(outputs[i])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('makeRecordUri', () => {
|
||||
const inputs: [string, string, string][] = [
|
||||
['alice.test', 'app.bsky.feed.post', '3jk7x4irgv52r'],
|
||||
@@ -396,6 +367,7 @@ describe('parseEmbedPlayerFromUrl', () => {
|
||||
'https://youtube.com/watch?v=videoId&feature=share',
|
||||
'https://youtube.com/shorts/videoId',
|
||||
'https://m.youtube.com/watch?v=videoId',
|
||||
'https://music.youtube.com/watch?v=videoId',
|
||||
|
||||
'https://youtube.com/shorts/',
|
||||
'https://youtube.com/',
|
||||
@@ -504,6 +476,11 @@ describe('parseEmbedPlayerFromUrl', () => {
|
||||
source: 'youtube',
|
||||
playerUri: 'https://bsky.app/iframe/youtube.html?videoId=videoId&start=0',
|
||||
},
|
||||
{
|
||||
type: 'youtube_video',
|
||||
source: 'youtube',
|
||||
playerUri: 'https://bsky.app/iframe/youtube.html?videoId=videoId&start=0',
|
||||
},
|
||||
|
||||
undefined,
|
||||
undefined,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import {it, describe, expect} from '@jest/globals'
|
||||
import {describe, expect, it} from '@jest/globals'
|
||||
|
||||
import {
|
||||
linkRequiresWarning,
|
||||
isPossiblyAUrl,
|
||||
splitApexDomain,
|
||||
isTrustedUrl,
|
||||
linkRequiresWarning,
|
||||
splitApexDomain,
|
||||
} from '../../../src/lib/strings/url-helpers'
|
||||
|
||||
describe('linkRequiresWarning', () => {
|
||||
@@ -170,6 +170,7 @@ describe('isTrustedUrl', () => {
|
||||
['https://google.com', false],
|
||||
['https://docs.google.com', false],
|
||||
['https://google.com/#', false],
|
||||
['https://blueskywebxzendesk.com', false],
|
||||
]
|
||||
|
||||
it.each(cases)('given input uri %p, returns %p', (str, expected) => {
|
||||
|
||||
@@ -64,6 +64,8 @@ module.exports = function (config) {
|
||||
icon: './assets/icon.png',
|
||||
userInterfaceStyle: 'automatic',
|
||||
splash: SPLASH_CONFIG,
|
||||
// hsl(211, 99%, 53%), same as palette.default.brandText
|
||||
primaryColor: '#1083fe',
|
||||
ios: {
|
||||
supportsTablet: false,
|
||||
bundleIdentifier: 'xyz.blueskyweb.app',
|
||||
@@ -89,6 +91,29 @@ module.exports = function (config) {
|
||||
entitlements: {
|
||||
'com.apple.security.application-groups': 'group.app.bsky',
|
||||
},
|
||||
privacyManifests: {
|
||||
NSPrivacyAccessedAPITypes: [
|
||||
{
|
||||
NSPrivacyAccessedAPIType:
|
||||
'NSPrivacyAccessedAPICategoryFileTimestamp',
|
||||
NSPrivacyAccessedAPITypeReasons: ['C617.1', '3B52.1', '0A2A.1'],
|
||||
},
|
||||
{
|
||||
NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryDiskSpace',
|
||||
NSPrivacyAccessedAPITypeReasons: ['E174.1', '85F4.1'],
|
||||
},
|
||||
{
|
||||
NSPrivacyAccessedAPIType:
|
||||
'NSPrivacyAccessedAPICategorySystemBootTime',
|
||||
NSPrivacyAccessedAPITypeReasons: ['35F9.1'],
|
||||
},
|
||||
{
|
||||
NSPrivacyAccessedAPIType:
|
||||
'NSPrivacyAccessedAPICategoryUserDefaults',
|
||||
NSPrivacyAccessedAPITypeReasons: ['CA92.1'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
androidStatusBar: {
|
||||
barStyle: 'light-content',
|
||||
@@ -180,6 +205,7 @@ module.exports = function (config) {
|
||||
'./plugins/withAndroidManifestPlugin.js',
|
||||
'./plugins/withAndroidManifestFCMIconPlugin.js',
|
||||
'./plugins/withAndroidStylesWindowBackgroundPlugin.js',
|
||||
'./plugins/withAndroidStylesAccentColorPlugin.js',
|
||||
'./plugins/withAndroidSplashScreenStatusBarTranslucentPlugin.js',
|
||||
'./plugins/shareExtension/withShareExtensions.js',
|
||||
].filter(Boolean),
|
||||
|
||||
@@ -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="M3.293 3.293A1 1 0 0 1 4 3h7.25a1 1 0 1 1 0 2H5v14h6.25a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1V4a1 1 0 0 1 .293-.707Zm11.5 3.5a1 1 0 0 1 1.414 0l4.5 4.5a1 1 0 0 1 0 1.414l-4.5 4.5a1 1 0 0 1-1.414-1.414L17.586 13H8.75a1 1 0 1 1 0-2h8.836l-2.793-2.793a1 1 0 0 1 0-1.414Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 402 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 2a7.307 7.307 0 0 0-7.298 6.943l-.19 3.798-1.321 2.641A1.809 1.809 0 0 0 4.809 18H7.1a5.002 5.002 0 0 0 9.8 0h2.291a1.809 1.809 0 0 0 1.618-2.618l-1.32-2.641-.19-3.798A7.308 7.308 0 0 0 12 2Zm0 18a3.001 3.001 0 0 1-2.83-2h5.66A3.001 3.001 0 0 1 12 20Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 398 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="M4.702 8.943a7.307 7.307 0 0 1 14.596 0l.19 3.798 1.321 2.641A1.809 1.809 0 0 1 19.191 18H16.9a5.002 5.002 0 0 1-9.8 0H4.809a1.809 1.809 0 0 1-1.618-2.618l1.32-2.641.19-3.798ZM9.17 18a3.001 3.001 0 0 0 5.658 0H9.171ZM12 4a5.307 5.307 0 0 0-5.3 5.042l-.19 3.798a2 2 0 0 1-.21.795L5.119 16h13.764l-1.183-2.365a2 2 0 0 1-.208-.795l-.19-3.798A5.308 5.308 0 0 0 12 4Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 505 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 2a7.853 7.853 0 0 0-7.784 6.815l-1.207 9.053A1 1 0 0 0 4 19h3.354c.904 1.748 2.607 3 4.646 3 2.039 0 3.742-1.252 4.646-3H20a1 1 0 0 0 .991-1.132l-1.207-9.053A7.853 7.853 0 0 0 12 2Zm2.222 17H9.778c.61.637 1.399 1 2.222 1s1.613-.363 2.222-1Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 387 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="M4.216 8.815a7.853 7.853 0 0 1 15.568 0l1.207 9.053A1 1 0 0 1 20 19h-3.354c-.904 1.748-2.607 3-4.646 3-2.039 0-3.742-1.252-4.646-3H4a1 1 0 0 1-.991-1.132l1.207-9.053ZM9.778 19c.61.637 1.399 1 2.222 1s1.613-.363 2.222-1H9.778ZM12 4a5.853 5.853 0 0 0-5.802 5.08L5.142 17h13.716l-1.056-7.92A5.853 5.853 0 0 0 12 4Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 454 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.002 7a4 4 0 0 1 4-4h12a4 4 0 0 1 4 4v8a4 4 0 0 1-4 4H12.28l-4.762 2.858A1 1 0 0 1 6.002 21v-2a4 4 0 0 1-4-4V7Zm4-2a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h1a1 1 0 0 1 1 1v1.234l3.486-2.092a1 1 0 0 1 .514-.142h6a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-12Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 381 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="M3 7a3 3 0 1 1 6 0 3 3 0 0 1-6 0Zm0 10a3 3 0 1 1 6 0 3 3 0 0 1-6 0Zm10-1a1 1 0 1 0 0 2h7a1 1 0 1 0 0-2h-7Zm-1-9a1 1 0 0 1 1-1h7a1 1 0 1 1 0 2h-7a1 1 0 0 1-1-1Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 302 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="M6 6a1 1 0 1 0 0 2 1 1 0 0 0 0-2ZM3 7a3 3 0 1 1 6 0 3 3 0 0 1-6 0Zm9 0a1 1 0 0 1 1-1h7a1 1 0 1 1 0 2h-7a1 1 0 0 1-1-1Zm-6 9a1 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 0Zm9 0a1 1 0 0 1 1-1h7a1 1 0 1 1 0 2h-7a1 1 0 0 1-1-1Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 381 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="M17.293 2.293a1 1 0 0 1 1.414 0l3 3a1 1 0 0 1 0 1.414l-9 9A1 1 0 0 1 12 16H9a1 1 0 0 1-1-1v-3a1 1 0 0 1 .293-.707l9-9ZM10 12.414V14h1.586l8-8L18 4.414l-8 8ZM3 4a1 1 0 0 1 1-1h7a1 1 0 1 1 0 2H5v14h14v-6a1 1 0 1 1 2 0v7a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 393 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="M17.657 6.343A8 8 0 1 0 6.343 17.657 8 8 0 0 0 17.657 6.343ZM4.929 4.93c3.905-3.905 10.237-3.905 14.142 0 3.905 3.905 3.905 10.237 0 14.142-3.905 3.905-10.237 3.905-14.142 0-3.905-3.905-3.905-10.237 0-14.142Zm3.536 9.192a1 1 0 0 1 1.414 0 3 3 0 0 0 4.243 0 1 1 0 0 1 1.414 1.415 5 5 0 0 1-7.071 0 1 1 0 0 1 0-1.415Z M10.5 9.5c0 .828-.56 1.5-1.25 1.5S8 10.328 8 9.5 8.56 8 9.25 8s1.25.672 1.25 1.5ZM16 9.5c0 .828-.56 1.5-1.25 1.5s-1.25-.672-1.25-1.5.56-1.5 1.25-1.5S16 8.672 16 9.5Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 623 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 11.708 2.654 4.06A.998.998 0 0 1 3 4h18c.122 0 .238.022.346.061L12 11.708ZM2 19V6.11l9.367 7.664a1 1 0 0 0 1.266 0L22 6.11V19a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 303 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="M9.186 2.512a1.5 1.5 0 0 0-1.674 1.302L7.176 6.5H4a1.5 1.5 0 1 0 0 3h2.8l-.624 5H4a1.5 1.5 0 0 0 0 3h1.8l-.288 2.314a1.5 1.5 0 1 0 2.976.372l.336-2.686h4.977l-.29 2.314a1.5 1.5 0 1 0 2.977.372l.336-2.686H20a1.5 1.5 0 0 0 0-3h-2.8l.624-5H20a1.5 1.5 0 0 0 0-3h-1.8l.288-2.314a1.5 1.5 0 1 0-2.976-.372L15.176 6.5h-4.977l.29-2.314a1.5 1.5 0 0 0-1.303-1.674ZM9.2 14.5l.625-5h4.977l-.625 5H9.199Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 533 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12.63 1.724a1 1 0 0 0-1.26 0l-8 6.5A1 1 0 0 0 3 9v11a1 1 0 0 0 1 1h5a1 1 0 0 0 1-1v-6h4v6a1 1 0 0 0 1 1h5a1 1 0 0 0 1-1V9a1 1 0 0 0-.37-.776l-8-6.5Z"/></svg>
|
||||
|
After Width: | Height: | Size: 252 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="M11.37 1.724a1 1 0 0 1 1.26 0l8 6.5A1 1 0 0 1 21 9v11a1 1 0 0 1-1 1h-6a1 1 0 0 1-1-1v-5h-2v5a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V9a1 1 0 0 1 .37-.776l8-6.5ZM5 9.476V19h4v-5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v5h4V9.476l-7-5.688-7 5.688Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 367 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="M5 11a6 6 0 1 1 12 0 6 6 0 0 1-12 0Zm6-8a8 8 0 1 0 4.906 14.32l3.387 3.387a1 1 0 0 0 1.414-1.414l-3.387-3.387A8 8 0 0 0 11 3Zm4 8a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 301 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="M4 12a8 8 0 1 1 4.445 7.169 1 1 0 0 0-.629-.088l-3.537.662.7-3.415a1 1 0 0 0-.09-.66A7.961 7.961 0 0 1 4 12Zm8-10C6.477 2 2 6.477 2 12c0 1.523.341 2.968.951 4.262l-.93 4.537a1 1 0 0 0 1.163 1.184l4.68-.876A9.968 9.968 0 0 0 12 22c5.523 0 10-4.477 10-10S17.523 2 12 2ZM7.5 13.25a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Zm4.5 0a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Zm4.5 0a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 567 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 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10a9.968 9.968 0 0 1-4.136-.893l-4.68.876a1 1 0 0 1-1.164-1.184l.931-4.537A9.965 9.965 0 0 1 2 12Zm4.25 0a1.25 1.25 0 1 0 2.5 0 1.25 1.25 0 0 0-2.5 0Zm4.5 0a1.25 1.25 0 1 0 2.5 0 1.25 1.25 0 0 0-2.5 0Zm5.75 1.25a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 453 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="M3.374 3.22a1 1 0 0 1 1.073-.114l16 8a1 1 0 0 1 0 1.788l-16 8a1 1 0 0 1-1.417-1.136L4.97 12 3.03 4.243a1 1 0 0 1 .344-1.023ZM6.781 13l-1.284 5.133L17.764 12 5.497 5.867 6.781 11H9a1 1 0 1 1 0 2H6.78Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 342 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 6a1 1 0 0 1 1 1v4h4a1 1 0 1 1 0 2h-4v4a1 1 0 1 1-2 0v-4H7a1 1 0 1 1 0-2h4V7a1 1 0 0 1 1-1Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 237 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="M9.996 2.869A1.951 1.951 0 0 1 11.62 2h.76c.653 0 1.262.326 1.624.869l1.141 1.712 1.749-.404a1.951 1.951 0 0 1 1.819.522l.588.589c.476.475.673 1.162.522 1.818l-.404 1.749 1.712 1.141c.543.362.869.971.869 1.624v.76c0 .653-.326 1.262-.869 1.624l-1.712 1.141.404 1.749a1.951 1.951 0 0 1-.522 1.819l-.588.588a1.951 1.951 0 0 1-1.819.522l-1.749-.404-1.141 1.712A1.951 1.951 0 0 1 12.38 22h-.76a1.951 1.951 0 0 1-1.624-.869L8.855 19.42l-1.749.404a1.951 1.951 0 0 1-1.818-.522l-.59-.588a1.951 1.951 0 0 1-.52-1.819l.403-1.749-1.712-1.141A1.951 1.951 0 0 1 2 12.38v-.76c0-.653.326-1.262.869-1.624L4.58 8.855l-.404-1.749A1.951 1.951 0 0 1 4.7 5.288l.589-.59a1.951 1.951 0 0 1 1.818-.52l1.749.403 1.141-1.712ZM8.5 12a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 886 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 3a1 1 0 0 1 1 1v1.126a4 4 0 0 1 0 7.748V20a1 1 0 1 1-2 0v-7.126a4 4 0 0 1 0-7.748V4a1 1 0 0 1 1-1Zm10 0a1 1 0 0 1 1 1v9.126a4 4 0 1 1-2 0V4a1 1 0 0 1 1-1ZM7 7a2 2 0 1 0 0 4 2 2 0 1 0 0-4Zm10 8a2 2 0 1 0 0 4 2 2 0 1 0 0-4Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 367 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 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Zm3-12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm-3 10a7.976 7.976 0 0 1-5.714-2.4C7.618 16.004 9.605 15 12 15c2.396 0 4.383 1.005 5.714 2.6A7.976 7.976 0 0 1 12 20Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 366 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 4a8 8 0 0 0-5.935 13.365C7.56 15.895 9.612 15 12 15c2.388 0 4.44.894 5.935 2.365A8 8 0 0 0 12 4Zm4.412 14.675C15.298 17.636 13.792 17 12 17c-1.791 0-3.298.636-4.412 1.675A7.96 7.96 0 0 0 12 20a7.96 7.96 0 0 0 4.412-1.325ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10a9.98 9.98 0 0 1-3.462 7.567A9.965 9.965 0 0 1 12 22a9.965 9.965 0 0 1-6.538-2.433A9.98 9.98 0 0 1 2 12Zm10-4a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm-4 2a4 4 0 1 1 8 0 4 4 0 0 1-8 0Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 581 B |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
@@ -10,6 +10,8 @@ static/js/*.js
|
||||
static/js/*.map
|
||||
static/js/*.js.LICENSE.txt
|
||||
static/js/empty.txt
|
||||
static/media/*.png
|
||||
static/media/empty.txt
|
||||
templates/scripts.html
|
||||
templates/*-embed.html
|
||||
static/embed/*.html
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
appbsky "github.com/bluesky-social/indigo/api/bsky"
|
||||
"github.com/bluesky-social/indigo/atproto/syntax"
|
||||
@@ -13,6 +12,9 @@ import (
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// time.RFC822Z, but with four digit year. used for RSS pubData.
|
||||
var FullYearRFC822Z = "02 Jan 2006 15:04 -0700"
|
||||
|
||||
type ItemGUID struct {
|
||||
XMLName xml.Name `xml:"guid"`
|
||||
Value string `xml:",chardata"`
|
||||
@@ -107,7 +109,7 @@ func (srv *Server) WebProfileRSS(c echo.Context) error {
|
||||
pubDate := ""
|
||||
createdAt, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
|
||||
if nil == err {
|
||||
pubDate = createdAt.Time().Format(time.RFC822Z)
|
||||
pubDate = createdAt.Time().Format(FullYearRFC822Z)
|
||||
}
|
||||
posts = append(posts, Item{
|
||||
Link: fmt.Sprintf("https://%s/profile/%s/post/%s", req.Host, pv.Handle, aturi.RecordKey().String()),
|
||||
|
||||
@@ -158,7 +158,7 @@ func serve(cctx *cli.Context) error {
|
||||
|
||||
// Cache javascript and images files for 1 week, which works because
|
||||
// they're always versioned (e.g. /static/js/main.64c14927.js)
|
||||
if strings.HasPrefix(path, "/static/js/") || strings.HasPrefix(path, "/static/images/") {
|
||||
if strings.HasPrefix(path, "/static/js/") || strings.HasPrefix(path, "/static/images/") || strings.HasPrefix(path, "/static/media/") {
|
||||
maxAge = 7 * (60 * 60 * 24) // 1 week
|
||||
}
|
||||
|
||||
@@ -200,6 +200,8 @@ func serve(cctx *cli.Context) error {
|
||||
e.GET("/support/community-guidelines", server.WebGeneric)
|
||||
e.GET("/support/copyright", server.WebGeneric)
|
||||
e.GET("/intent/compose", server.WebGeneric)
|
||||
e.GET("/messages", server.WebGeneric)
|
||||
e.GET("/messages/:conversation", server.WebGeneric)
|
||||
|
||||
// profile endpoints; only first populates info
|
||||
e.GET("/profile/:handleOrDID", server.WebProfile)
|
||||
|
||||
@@ -122,14 +122,20 @@ func (srv *Server) WebOEmbed(c echo.Context) error {
|
||||
}
|
||||
|
||||
// TODO: do we actually do something with width?
|
||||
width := 550
|
||||
width := 600
|
||||
maxWidthParam := c.QueryParam("maxwidth")
|
||||
if maxWidthParam != "" {
|
||||
maxWidthInt, err := strconv.Atoi(maxWidthParam)
|
||||
if err != nil || maxWidthInt < 220 || maxWidthInt > 550 {
|
||||
return c.String(http.StatusBadRequest, "Invalid maxwidth (expected integer between 220 and 550)")
|
||||
if err != nil {
|
||||
return c.String(http.StatusBadRequest, "Invalid maxwidth (expected integer)")
|
||||
}
|
||||
if maxWidthInt < 220 {
|
||||
width = 220
|
||||
} else if maxWidthInt > 600 {
|
||||
width = 600
|
||||
} else {
|
||||
width = maxWidthInt
|
||||
}
|
||||
width = maxWidthInt
|
||||
}
|
||||
// NOTE: maxheight ignored
|
||||
|
||||
|
||||
@@ -47,6 +47,14 @@
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Liberation Sans", Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
#preload {
|
||||
width: 100px;
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
/* Buttons and inputs have a font set by UA, so we'll have to reset that */
|
||||
button, input, textarea {
|
||||
font: inherit;
|
||||
@@ -262,13 +270,19 @@
|
||||
</head>
|
||||
<body>
|
||||
{%- block body_all %}
|
||||
<div id="root"></div>
|
||||
<noscript>
|
||||
<h1 lang="en">JavaScript Required</h1>
|
||||
<p lang="en">This is a heavily interactive web application, and JavaScript is required. Simple HTML interfaces are possible, but that is not what this is.
|
||||
<p lang="en">Learn more about Bluesky at <a href="https://bsky.social">bsky.social</a> and <a href="https://atproto.com">atproto.com</a>.
|
||||
{% block noscript_extra %}{% endblock %}
|
||||
</noscript>
|
||||
<div id="root">
|
||||
<div id="preload">
|
||||
<!-- Bluesky SVG -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 360 320"><path fill="#0085ff" d="M180 142c-16.3-31.7-60.7-90.8-102-120C38.5-5.9 23.4-1 13.5 3.4 2.1 8.6 0 26.2 0 36.5c0 10.4 5.7 84.8 9.4 97.2 12.2 41 55.7 55 95.7 50.5-58.7 8.6-110.8 30-42.4 106.1 75.1 77.9 103-16.7 117.3-64.6 14.3 48 30.8 139 116 64.6 64-64.6 17.6-97.5-41.1-106.1 40 4.4 83.5-9.5 95.7-50.5 3.7-12.4 9.4-86.8 9.4-97.2 0-10.3-2-27.9-13.5-33C336.5-1 321.5-6 282 22c-41.3 29.2-85.7 88.3-102 120Z"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<noscript>
|
||||
<h1 lang="en">JavaScript Required</h1>
|
||||
<p lang="en">This is a heavily interactive web application, and JavaScript is required. Simple HTML interfaces are possible, but that is not what this is.
|
||||
<p lang="en">Learn more about Bluesky at <a href="https://bsky.social">bsky.social</a> and <a href="https://atproto.com">atproto.com</a>.
|
||||
{% block noscript_extra %}{% endblock %}
|
||||
</noscript>
|
||||
{% endblock -%}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {requireNativeViewManager} from 'expo-modules-core'
|
||||
import * as React from 'react'
|
||||
|
||||
import {ExpoScrollForwarderViewProps} from './ExpoScrollForwarder.types'
|
||||
|
||||
const NativeView: React.ComponentType<ExpoScrollForwarderViewProps> =
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.79.0",
|
||||
"version": "1.81.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -15,7 +15,7 @@
|
||||
"web": "expo start --web",
|
||||
"use-build-number": "./scripts/useBuildNumberEnv.sh",
|
||||
"use-build-number-with-bump": "./scripts/useBuildNumberEnvWithBump.sh",
|
||||
"build-web": "expo export:web && node ./scripts/post-web-build.js && cp -v ./web-build/static/js/*.* ./bskyweb/static/js/",
|
||||
"build-web": "expo export:web && node ./scripts/post-web-build.js && cp -v ./web-build/static/js/*.* ./bskyweb/static/js/ && cp -v ./web-build/static/media/*.png ./bskyweb/static/media/",
|
||||
"build-all": "yarn intl:build && yarn use-build-number-with-bump eas build --platform all",
|
||||
"build-ios": "yarn use-build-number-with-bump eas build -p ios",
|
||||
"build-android": "yarn use-build-number-with-bump eas build -p android",
|
||||
@@ -50,15 +50,18 @@
|
||||
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto-labs/api": "^0.12.8-clipclops.0",
|
||||
"@atproto/api": "^0.12.5",
|
||||
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@discord/bottom-sheet": "https://github.com/bluesky-social/react-native-bottom-sheet.git#discord-fork-4.6.1",
|
||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@expo/html-elements": "^0.4.2",
|
||||
"@expo/webpack-config": "^19.0.0",
|
||||
"@floating-ui/dom": "^1.6.3",
|
||||
"@floating-ui/react-dom": "^2.0.8",
|
||||
"@formatjs/intl-locale": "^3.4.3",
|
||||
"@formatjs/intl-pluralrules": "^5.2.10",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.1.1",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.1.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.1.1",
|
||||
@@ -97,6 +100,7 @@
|
||||
"@tiptap/react": "^2.0.0-beta.220",
|
||||
"@tiptap/suggestion": "^2.0.0-beta.220",
|
||||
"@types/invariant": "^2.2.37",
|
||||
"@types/lodash.throttle": "^4.1.9",
|
||||
"@types/node": "^18.16.2",
|
||||
"@zxing/text-encoding": "^0.9.0",
|
||||
"array.prototype.findlast": "^1.2.3",
|
||||
@@ -108,7 +112,7 @@
|
||||
"email-validator": "^2.0.4",
|
||||
"emoji-mart": "^5.5.2",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"expo": "^50.0.8",
|
||||
"expo": "^50.0.17",
|
||||
"expo-application": "^5.8.3",
|
||||
"expo-build-properties": "^0.11.1",
|
||||
"expo-camera": "~14.0.4",
|
||||
@@ -148,6 +152,7 @@
|
||||
"lodash.samplesize": "^4.2.0",
|
||||
"lodash.set": "^4.3.2",
|
||||
"lodash.shuffle": "^4.2.0",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"mobx": "^6.6.1",
|
||||
"mobx-react-lite": "^3.4.0",
|
||||
"mobx-utils": "^6.0.6",
|
||||
@@ -168,6 +173,7 @@
|
||||
"react-native-get-random-values": "~1.11.0",
|
||||
"react-native-image-crop-picker": "^0.38.1",
|
||||
"react-native-ios-context-menu": "^1.15.3",
|
||||
"react-native-keyboard-controller": "^1.11.7",
|
||||
"react-native-pager-view": "6.2.3",
|
||||
"react-native-picker-select": "^8.1.0",
|
||||
"react-native-progress": "bluesky-social/react-native-progress",
|
||||
@@ -183,6 +189,7 @@
|
||||
"react-native-web-webview": "^1.0.2",
|
||||
"react-native-webview": "13.6.4",
|
||||
"react-responsive": "^9.0.2",
|
||||
"react-textarea-autosize": "^8.5.3",
|
||||
"rn-fetch-blob": "^0.12.0",
|
||||
"sentry-expo": "~7.0.1",
|
||||
"statsig-react-native-expo": "^4.6.1",
|
||||
@@ -283,7 +290,7 @@
|
||||
"\\.[jt]sx?$": "babel-jest"
|
||||
},
|
||||
"transformIgnorePatterns": [
|
||||
"node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|nanoid|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|normalize-url|react-native-svg|@sentry/.*|sentry-expo|bcp-47-match)"
|
||||
"node_modules/(?!((jest-)?react-native|@react-native(-community)?)|@discord|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|nanoid|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|normalize-url|react-native-svg|@sentry/.*|sentry-expo|bcp-47-match)"
|
||||
],
|
||||
"modulePathIgnorePatterns": [
|
||||
"__tests__/.*/__mocks__",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
diff --git a/node_modules/@atproto/lexicon/dist/validators/complex.js b/node_modules/@atproto/lexicon/dist/validators/complex.js
|
||||
index 32d7798..9d688b7 100644
|
||||
--- a/node_modules/@atproto/lexicon/dist/validators/complex.js
|
||||
+++ b/node_modules/@atproto/lexicon/dist/validators/complex.js
|
||||
@@ -113,7 +113,22 @@ function object(lexicons, path, def, value) {
|
||||
if (value[key] === null && nullableProps.has(key)) {
|
||||
continue;
|
||||
}
|
||||
- const propDef = def.properties[key];
|
||||
+ const propDef = def.properties[key]
|
||||
+ if (typeof value[key] === 'undefined' && !requiredProps.has(key)) {
|
||||
+ // Fast path for non-required undefined props.
|
||||
+ if (
|
||||
+ propDef.type === 'integer' ||
|
||||
+ propDef.type === 'boolean' ||
|
||||
+ propDef.type === 'string'
|
||||
+ ) {
|
||||
+ if (typeof propDef.default === 'undefined') {
|
||||
+ continue
|
||||
+ }
|
||||
+ } else {
|
||||
+ // Other types have no defaults.
|
||||
+ continue
|
||||
+ }
|
||||
+ }
|
||||
const propPath = `${path}/${key}`;
|
||||
const validated = (0, util_1.validateOneOf)(lexicons, propPath, propDef, value[key]);
|
||||
const propValue = validated.success ? validated.value : value[key];
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* @file Set accent color to primaryColor from app.config.js.
|
||||
* This way we get a sane default color for spinners, text inputs, etc.
|
||||
*/
|
||||
|
||||
const {withAndroidStyles, AndroidConfig} = require('@expo/config-plugins')
|
||||
|
||||
module.exports = function withAndroidStylesAccentColorPlugin(appConfig) {
|
||||
return withAndroidStyles(appConfig, function (decoratedAppConfig) {
|
||||
try {
|
||||
decoratedAppConfig.modResults = AndroidConfig.Styles.assignStylesValue(
|
||||
decoratedAppConfig.modResults,
|
||||
{
|
||||
add: true,
|
||||
parent: AndroidConfig.Styles.getAppThemeLightNoActionBarGroup(),
|
||||
name: 'colorAccent',
|
||||
value: '@color/colorPrimary',
|
||||
},
|
||||
)
|
||||
} catch (e) {
|
||||
console.error(`withAndroidStylesAccentColorPlugin failed`, e)
|
||||
}
|
||||
return decoratedAppConfig
|
||||
})
|
||||
}
|
||||
@@ -12,14 +12,15 @@ import {
|
||||
import * as SplashScreen from 'expo-splash-screen'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
import {MessagesProvider} from '#/state/messages'
|
||||
import {init as initPersistedState} from '#/state/persisted'
|
||||
import * as persisted from '#/state/persisted'
|
||||
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
|
||||
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
|
||||
import {readLastActiveAccount} from '#/state/session/util'
|
||||
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
|
||||
import {useNotificationsListener} from 'lib/notifications/notifications'
|
||||
import {QueryProvider} from 'lib/react-query'
|
||||
import {s} from 'lib/styles'
|
||||
import {ThemeProvider} from 'lib/ThemeContext'
|
||||
@@ -32,6 +33,7 @@ import {Provider as PrefsStateProvider} from 'state/preferences'
|
||||
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
|
||||
import {
|
||||
Provider as SessionProvider,
|
||||
SessionAccount,
|
||||
useSession,
|
||||
useSessionApi,
|
||||
} from 'state/session'
|
||||
@@ -51,7 +53,8 @@ import {listenSessionDropped} from './state/events'
|
||||
SplashScreen.preventAutoHideAsync()
|
||||
|
||||
function InnerApp() {
|
||||
const {isInitialLoad, currentAccount} = useSession()
|
||||
const [isReady, setIsReady] = React.useState(false)
|
||||
const {currentAccount} = useSession()
|
||||
const {resumeSession} = useSessionApi()
|
||||
const theme = useColorModeTheme()
|
||||
const {_} = useLingui()
|
||||
@@ -60,57 +63,66 @@ function InnerApp() {
|
||||
|
||||
// init
|
||||
useEffect(() => {
|
||||
listenSessionDropped(() => {
|
||||
async function onLaunch(account?: SessionAccount) {
|
||||
try {
|
||||
if (account) {
|
||||
await resumeSession(account)
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(`session: resume failed`, {message: e})
|
||||
} finally {
|
||||
setIsReady(true)
|
||||
}
|
||||
}
|
||||
const account = readLastActiveAccount()
|
||||
onLaunch(account)
|
||||
}, [resumeSession])
|
||||
|
||||
useEffect(() => {
|
||||
return listenSessionDropped(() => {
|
||||
Toast.show(_(msg`Sorry! Your session expired. Please log in again.`))
|
||||
})
|
||||
|
||||
const account = persisted.get('session').currentAccount
|
||||
resumeSession(account)
|
||||
}, [resumeSession, _])
|
||||
}, [_])
|
||||
|
||||
return (
|
||||
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
||||
<Alf theme={theme}>
|
||||
<Splash isReady={!isInitialLoad}>
|
||||
<React.Fragment
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<PushNotificationsListener>
|
||||
<StatsigProvider>
|
||||
<LabelDefsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<ThemeProvider theme={theme}>
|
||||
{/* All components should be within this provider */}
|
||||
<RootSiblingParent>
|
||||
<GestureHandlerRootView style={s.h100pct}>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
</GestureHandlerRootView>
|
||||
</RootSiblingParent>
|
||||
</ThemeProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</LabelDefsProvider>
|
||||
</StatsigProvider>
|
||||
</PushNotificationsListener>
|
||||
</QueryProvider>
|
||||
</React.Fragment>
|
||||
</Splash>
|
||||
<ThemeProvider theme={theme}>
|
||||
<Splash isReady={isReady}>
|
||||
<RootSiblingParent>
|
||||
<React.Fragment
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<StatsigProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<GestureHandlerRootView style={s.h100pct}>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
</GestureHandlerRootView>
|
||||
</UnreadNotifsProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</StatsigProvider>
|
||||
</QueryProvider>
|
||||
</React.Fragment>
|
||||
</RootSiblingParent>
|
||||
</Splash>
|
||||
</ThemeProvider>
|
||||
</Alf>
|
||||
</SafeAreaProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function PushNotificationsListener({children}: {children: React.ReactNode}) {
|
||||
const queryClient = useQueryClient()
|
||||
useNotificationsListener(queryClient)
|
||||
return children
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [isReady, setReady] = useState(false)
|
||||
|
||||
|
||||
@@ -4,11 +4,16 @@ import 'view/icons'
|
||||
import React, {useEffect, useState} from 'react'
|
||||
import {RootSiblingParent} from 'react-native-root-siblings'
|
||||
import {SafeAreaProvider} from 'react-native-safe-area-context'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
import {MessagesProvider} from '#/state/messages'
|
||||
import {init as initPersistedState} from '#/state/persisted'
|
||||
import * as persisted from '#/state/persisted'
|
||||
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
|
||||
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
|
||||
import {readLastActiveAccount} from '#/state/session/util'
|
||||
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
|
||||
import {QueryProvider} from 'lib/react-query'
|
||||
import {ThemeProvider} from 'lib/ThemeContext'
|
||||
@@ -21,61 +26,87 @@ import {Provider as PrefsStateProvider} from 'state/preferences'
|
||||
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
|
||||
import {
|
||||
Provider as SessionProvider,
|
||||
SessionAccount,
|
||||
useSession,
|
||||
useSessionApi,
|
||||
} from 'state/session'
|
||||
import {Provider as ShellStateProvider} from 'state/shell'
|
||||
import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
|
||||
import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
import {ToastContainer} from 'view/com/util/Toast.web'
|
||||
import {Shell} from 'view/shell/index'
|
||||
import {ThemeProvider as Alf} from '#/alf'
|
||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||
import {Provider as PortalProvider} from '#/components/Portal'
|
||||
import I18nProvider from './locale/i18nProvider'
|
||||
import {listenSessionDropped} from './state/events'
|
||||
|
||||
function InnerApp() {
|
||||
const {isInitialLoad, currentAccount} = useSession()
|
||||
const [isReady, setIsReady] = React.useState(false)
|
||||
const {currentAccount} = useSession()
|
||||
const {resumeSession} = useSessionApi()
|
||||
const theme = useColorModeTheme()
|
||||
const {_} = useLingui()
|
||||
useIntentHandler()
|
||||
|
||||
// init
|
||||
useEffect(() => {
|
||||
const account = persisted.get('session').currentAccount
|
||||
resumeSession(account)
|
||||
async function onLaunch(account?: SessionAccount) {
|
||||
try {
|
||||
if (account) {
|
||||
await resumeSession(account)
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(`session: resumeSession failed`, {message: e})
|
||||
} finally {
|
||||
setIsReady(true)
|
||||
}
|
||||
}
|
||||
const account = readLastActiveAccount()
|
||||
onLaunch(account)
|
||||
}, [resumeSession])
|
||||
|
||||
useEffect(() => {
|
||||
return listenSessionDropped(() => {
|
||||
Toast.show(_(msg`Sorry! Your session expired. Please log in again.`))
|
||||
})
|
||||
}, [_])
|
||||
|
||||
// wait for session to resume
|
||||
if (isInitialLoad) return null
|
||||
if (!isReady) return null
|
||||
|
||||
return (
|
||||
<Alf theme={theme}>
|
||||
<React.Fragment
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<StatsigProvider>
|
||||
<LabelDefsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<ThemeProvider theme={theme}>
|
||||
{/* All components should be within this provider */}
|
||||
<RootSiblingParent>
|
||||
<SafeAreaProvider>
|
||||
<Shell />
|
||||
</SafeAreaProvider>
|
||||
</RootSiblingParent>
|
||||
<ToastContainer />
|
||||
</ThemeProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</LabelDefsProvider>
|
||||
</StatsigProvider>
|
||||
</QueryProvider>
|
||||
</React.Fragment>
|
||||
<ThemeProvider theme={theme}>
|
||||
<RootSiblingParent>
|
||||
<React.Fragment
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<StatsigProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<SafeAreaProvider>
|
||||
<Shell />
|
||||
</SafeAreaProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</StatsigProvider>
|
||||
</QueryProvider>
|
||||
</React.Fragment>
|
||||
<ToastContainer />
|
||||
</RootSiblingParent>
|
||||
</ThemeProvider>
|
||||
</Alf>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
FeedsTabNavigatorParams,
|
||||
FlatNavigatorParams,
|
||||
HomeTabNavigatorParams,
|
||||
MessagesTabNavigatorParams,
|
||||
MyProfileTabNavigatorParams,
|
||||
NotificationsTabNavigatorParams,
|
||||
SearchTabNavigatorParams,
|
||||
@@ -46,6 +47,9 @@ import {init as initAnalytics} from './lib/analytics/analytics'
|
||||
import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration'
|
||||
import {attachRouteToLogEvents, logEvent} from './lib/statsig/statsig'
|
||||
import {router} from './routes'
|
||||
import {MessagesConversationScreen} from './screens/Messages/Conversation'
|
||||
import {MessagesScreen} from './screens/Messages/List'
|
||||
import {MessagesSettingsScreen} from './screens/Messages/Settings'
|
||||
import {useModalControls} from './state/modals'
|
||||
import {useUnreadNotifications} from './state/queries/notifications/unread'
|
||||
import {useSession} from './state/session'
|
||||
@@ -92,6 +96,8 @@ const NotificationsTab =
|
||||
createNativeStackNavigatorWithAuth<NotificationsTabNavigatorParams>()
|
||||
const MyProfileTab =
|
||||
createNativeStackNavigatorWithAuth<MyProfileTabNavigatorParams>()
|
||||
const MessagesTab =
|
||||
createNativeStackNavigatorWithAuth<MessagesTabNavigatorParams>()
|
||||
const Flat = createNativeStackNavigatorWithAuth<FlatNavigatorParams>()
|
||||
const Tab = createBottomTabNavigator<BottomTabNavigatorParams>()
|
||||
|
||||
@@ -290,6 +296,16 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
||||
getComponent={() => HashtagScreen}
|
||||
options={{title: title(msg`Hashtag`)}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="MessagesConversation"
|
||||
getComponent={() => MessagesConversationScreen}
|
||||
options={{title: title(msg`Chat`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="MessagesSettings"
|
||||
getComponent={() => MessagesSettingsScreen}
|
||||
options={{title: title(msg`Messaging settings`), requireAuth: true}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -323,6 +339,10 @@ function TabsNavigator() {
|
||||
name="MyProfileTab"
|
||||
getComponent={() => MyProfileTabNavigator}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="MessagesTab"
|
||||
getComponent={() => MessagesTabNavigator}
|
||||
/>
|
||||
</Tab.Navigator>
|
||||
)
|
||||
}
|
||||
@@ -429,6 +449,28 @@ function MyProfileTabNavigator() {
|
||||
)
|
||||
}
|
||||
|
||||
function MessagesTabNavigator() {
|
||||
const pal = usePalette('default')
|
||||
return (
|
||||
<MessagesTab.Navigator
|
||||
screenOptions={{
|
||||
animation: isAndroid ? 'none' : undefined,
|
||||
gestureEnabled: true,
|
||||
fullScreenGestureEnabled: true,
|
||||
headerShown: false,
|
||||
animationDuration: 250,
|
||||
contentStyle: pal.view,
|
||||
}}>
|
||||
<MessagesTab.Screen
|
||||
name="Messages"
|
||||
getComponent={() => MessagesScreen}
|
||||
options={{requireAuth: true}}
|
||||
/>
|
||||
{commonScreens(MessagesTab as typeof HomeTab)}
|
||||
</MessagesTab.Navigator>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The FlatNavigator is used by Web to represent the routes
|
||||
* in a single ("flat") stack.
|
||||
@@ -469,6 +511,11 @@ const FlatNavigator = () => {
|
||||
getComponent={() => NotificationsScreen}
|
||||
options={{title: title(msg`Notifications`), requireAuth: true}}
|
||||
/>
|
||||
<Flat.Screen
|
||||
name="Messages"
|
||||
getComponent={() => MessagesScreen}
|
||||
options={{title: title(msg`Messages`), requireAuth: true}}
|
||||
/>
|
||||
{commonScreens(Flat as typeof HomeTab, numUnread)}
|
||||
</Flat.Navigator>
|
||||
)
|
||||
@@ -522,6 +569,9 @@ const LINKING = {
|
||||
if (name === 'Home') {
|
||||
return buildStateObject('HomeTab', 'Home', params)
|
||||
}
|
||||
if (name === 'Messages') {
|
||||
return buildStateObject('MessagesTab', 'Messages', params)
|
||||
}
|
||||
// if the path is something else, like a post, profile, or even settings, we need to initialize the home tab as pre-existing state otherwise the back button will not work
|
||||
return buildStateObject('HomeTab', name, params, [
|
||||
{
|
||||
|
||||
@@ -840,4 +840,22 @@ export const atoms = {
|
||||
mr_auto: {
|
||||
marginRight: 'auto',
|
||||
},
|
||||
/*
|
||||
* Pointer events
|
||||
*/
|
||||
pointer_events_none: {
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
pointer_events_auto: {
|
||||
pointerEvents: 'auto',
|
||||
},
|
||||
/*
|
||||
* Text decoration
|
||||
*/
|
||||
underline: {
|
||||
textDecorationLine: 'underline',
|
||||
},
|
||||
strike_through: {
|
||||
textDecorationLine: 'line-through',
|
||||
},
|
||||
} as const
|
||||
|
||||
@@ -16,12 +16,14 @@ export function AccountList({
|
||||
onSelectAccount,
|
||||
onSelectOther,
|
||||
otherLabel,
|
||||
pendingDid,
|
||||
}: {
|
||||
onSelectAccount: (account: SessionAccount) => void
|
||||
onSelectOther: () => void
|
||||
otherLabel?: string
|
||||
pendingDid: string | null
|
||||
}) {
|
||||
const {isSwitchingAccounts, currentAccount, accounts} = useSession()
|
||||
const {currentAccount, accounts} = useSession()
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
@@ -31,6 +33,7 @@ export function AccountList({
|
||||
|
||||
return (
|
||||
<View
|
||||
pointerEvents={pendingDid ? 'none' : 'auto'}
|
||||
style={[
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
@@ -43,6 +46,7 @@ export function AccountList({
|
||||
account={account}
|
||||
onSelect={onSelectAccount}
|
||||
isCurrentAccount={account.did === currentAccount?.did}
|
||||
isPendingAccount={account.did === pendingDid}
|
||||
/>
|
||||
<View style={[a.border_b, t.atoms.border_contrast_low]} />
|
||||
</React.Fragment>
|
||||
@@ -50,7 +54,7 @@ export function AccountList({
|
||||
<Button
|
||||
testID="chooseAddAccountBtn"
|
||||
style={[a.flex_1]}
|
||||
onPress={isSwitchingAccounts ? undefined : onPressAddAccount}
|
||||
onPress={pendingDid ? undefined : onPressAddAccount}
|
||||
label={_(msg`Login to account that is not listed`)}>
|
||||
{({hovered, pressed}) => (
|
||||
<View
|
||||
@@ -59,8 +63,7 @@ export function AccountList({
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
{height: 48},
|
||||
(hovered || pressed || isSwitchingAccounts) &&
|
||||
t.atoms.bg_contrast_25,
|
||||
(hovered || pressed) && t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
@@ -84,10 +87,12 @@ function AccountItem({
|
||||
account,
|
||||
onSelect,
|
||||
isCurrentAccount,
|
||||
isPendingAccount,
|
||||
}: {
|
||||
account: SessionAccount
|
||||
onSelect: (account: SessionAccount) => void
|
||||
isCurrentAccount: boolean
|
||||
isPendingAccount: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
@@ -115,7 +120,7 @@ function AccountItem({
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
{height: 48},
|
||||
(hovered || pressed) && t.atoms.bg_contrast_25,
|
||||
(hovered || pressed || isPendingAccount) && t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<View style={a.p_md}>
|
||||
<UserAvatar avatar={profile?.avatar} size={24} />
|
||||
|
||||
@@ -64,7 +64,7 @@ type NonTextElements =
|
||||
|
||||
export type ButtonProps = Pick<
|
||||
PressableProps,
|
||||
'disabled' | 'onPress' | 'testID'
|
||||
'disabled' | 'onPress' | 'testID' | 'onLongPress'
|
||||
> &
|
||||
AccessibilityProps &
|
||||
VariantProps & {
|
||||
|
||||
@@ -152,6 +152,12 @@ export function Outer({
|
||||
[open, close],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
setDialogIsOpen(control.id, false)
|
||||
}
|
||||
}, [control.id, setDialogIsOpen])
|
||||
|
||||
const context = React.useMemo(() => ({close}), [close])
|
||||
|
||||
return (
|
||||
|
||||
@@ -17,12 +17,14 @@ export function Error({
|
||||
message,
|
||||
onRetry,
|
||||
onGoBack: onGoBackProp,
|
||||
hideBackButton,
|
||||
sideBorders = true,
|
||||
}: {
|
||||
title?: string
|
||||
message?: string
|
||||
onRetry?: () => unknown
|
||||
onGoBack?: () => unknown
|
||||
hideBackButton?: boolean
|
||||
sideBorders?: boolean
|
||||
}) {
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
@@ -70,7 +72,7 @@ export function Error({
|
||||
a.text_center,
|
||||
t.atoms.text_contrast_high,
|
||||
{lineHeight: 1.4},
|
||||
gtMobile && {width: 450},
|
||||
gtMobile ? {width: 450} : [a.w_full, a.px_lg],
|
||||
]}>
|
||||
{message}
|
||||
</Text>
|
||||
@@ -89,17 +91,19 @@ export function Error({
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="solid"
|
||||
color={onRetry ? 'secondary' : 'primary'}
|
||||
label={_(msg`Return to previous page`)}
|
||||
onPress={onGoBack}
|
||||
size="large"
|
||||
style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}>
|
||||
<ButtonText>
|
||||
<Trans>Go Back</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
{!hideBackButton && (
|
||||
<Button
|
||||
variant="solid"
|
||||
color={onRetry ? 'secondary' : 'primary'}
|
||||
label={_(msg`Return to previous page`)}
|
||||
onPress={onGoBack}
|
||||
size="large"
|
||||
style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}>
|
||||
<ButtonText>
|
||||
<Trans>Go Back</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
</CenteredView>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {msg, Plural, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {AppBskyLabelerDefs} from '@atproto/api'
|
||||
|
||||
@@ -13,7 +13,6 @@ import {RichText} from '#/components/RichText'
|
||||
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '../icons/Chevron'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {pluralize} from '#/lib/strings/helpers'
|
||||
|
||||
type LabelingServiceProps = {
|
||||
labeler: AppBskyLabelerDefs.LabelerViewDetailed
|
||||
@@ -69,9 +68,7 @@ export function LikeCount({count}: {count: number}) {
|
||||
t.atoms.text_contrast_medium,
|
||||
{fontWeight: '500'},
|
||||
]}>
|
||||
<Trans>
|
||||
Liked by {count} {pluralize(count, 'user')}
|
||||
</Trans>
|
||||
<Plural value={count} one="Liked by # user" other="Liked by # users" />
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@ import {GestureResponderEvent} from 'react-native'
|
||||
import {sanitizeUrl} from '@braintree/sanitize-url'
|
||||
import {StackActions, useLinkProps} from '@react-navigation/native'
|
||||
|
||||
import {BSKY_DOWNLOAD_URL} from '#/lib/constants'
|
||||
import {AllNavigatorParams} from '#/lib/routes/types'
|
||||
import {shareUrl} from '#/lib/sharing'
|
||||
import {
|
||||
convertBskyAppUrlIfNeeded,
|
||||
isBskyDownloadUrl,
|
||||
isExternalUrl,
|
||||
linkRequiresWarning,
|
||||
} from '#/lib/strings/url-helpers'
|
||||
@@ -125,7 +127,9 @@ export function useLink({
|
||||
(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey)
|
||||
const shouldOpenInNewTab = isMetaKey || isMiddleClick
|
||||
|
||||
if (
|
||||
if (isBskyDownloadUrl(href)) {
|
||||
shareUrl(BSKY_DOWNLOAD_URL)
|
||||
} else if (
|
||||
shouldOpenInNewTab ||
|
||||
href.startsWith('http') ||
|
||||
href.startsWith('mailto')
|
||||
|
||||
@@ -134,6 +134,7 @@ let ListMaybePlaceholder = ({
|
||||
emptyType = 'page',
|
||||
onRetry,
|
||||
onGoBack,
|
||||
hideBackButton,
|
||||
sideBorders,
|
||||
}: {
|
||||
isLoading: boolean
|
||||
@@ -146,6 +147,7 @@ let ListMaybePlaceholder = ({
|
||||
emptyType?: 'page' | 'results'
|
||||
onRetry?: () => Promise<unknown>
|
||||
onGoBack?: () => void
|
||||
hideBackButton?: boolean
|
||||
sideBorders?: boolean
|
||||
}): React.ReactNode => {
|
||||
const t = useTheme()
|
||||
@@ -179,6 +181,7 @@ let ListMaybePlaceholder = ({
|
||||
onRetry={onRetry}
|
||||
onGoBack={onGoBack}
|
||||
sideBorders={sideBorders}
|
||||
hideBackButton={hideBackButton}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -198,6 +201,7 @@ let ListMaybePlaceholder = ({
|
||||
}
|
||||
onRetry={onRetry}
|
||||
onGoBack={onGoBack}
|
||||
hideBackButton={hideBackButton}
|
||||
sideBorders={sideBorders}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
import React from 'react'
|
||||
import {View, Pressable, ViewStyle, StyleProp} from 'react-native'
|
||||
import {Pressable, StyleProp, View, ViewStyle} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import flattenReactChildren from 'react-keyed-flatten-children'
|
||||
|
||||
import {isNative} from 'platform/detection'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
import {Context} from '#/components/Menu/context'
|
||||
import {
|
||||
ContextType,
|
||||
TriggerProps,
|
||||
ItemProps,
|
||||
GroupProps,
|
||||
ItemTextProps,
|
||||
ItemIconProps,
|
||||
ItemProps,
|
||||
ItemTextProps,
|
||||
TriggerProps,
|
||||
} from '#/components/Menu/types'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {isNative} from 'platform/detection'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export {useDialogControl as useMenuControl} from '#/components/Dialog'
|
||||
export {
|
||||
type DialogControlProps as MenuControlProps,
|
||||
useDialogControl as useMenuControl,
|
||||
} from '#/components/Dialog'
|
||||
|
||||
export function useMemoControlContext() {
|
||||
return React.useContext(Context)
|
||||
|
||||
@@ -1,27 +1,26 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
|
||||
import React from 'react'
|
||||
import {View, Pressable, ViewStyle, StyleProp} from 'react-native'
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||
import {Pressable, StyleProp, View, ViewStyle} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||
|
||||
import {atoms as a, flatten, useTheme, web} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {atoms as a, useTheme, flatten, web} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
import {Context} from '#/components/Menu/context'
|
||||
import {
|
||||
ContextType,
|
||||
TriggerProps,
|
||||
ItemProps,
|
||||
GroupProps,
|
||||
ItemTextProps,
|
||||
ItemIconProps,
|
||||
ItemProps,
|
||||
ItemTextProps,
|
||||
RadixPassThroughTriggerProps,
|
||||
TriggerProps,
|
||||
} from '#/components/Menu/types'
|
||||
import {Context} from '#/components/Menu/context'
|
||||
import {Portal} from '#/components/Portal'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function useMenuControl(): Dialog.DialogControlProps {
|
||||
const id = React.useId()
|
||||
@@ -135,10 +134,22 @@ export function Trigger({children, label}: TriggerProps) {
|
||||
},
|
||||
props: {
|
||||
...props,
|
||||
// disable on web, use `onPress`
|
||||
onPointerDown: () => false,
|
||||
onPress: () =>
|
||||
control.isOpen ? control.close() : control.open(),
|
||||
// No-op override to prevent false positive that interprets mobile scroll as a tap.
|
||||
// This requires the custom onPress handler below to compensate.
|
||||
// https://github.com/radix-ui/primitives/issues/1912
|
||||
onPointerDown: undefined,
|
||||
onPress: () => {
|
||||
if (window.event instanceof KeyboardEvent) {
|
||||
// The onPointerDown hack above is not relevant to this press, so don't do anything.
|
||||
return
|
||||
}
|
||||
// Compensate for the disabled onPointerDown above by triggering it manually.
|
||||
if (control.isOpen) {
|
||||
control.close()
|
||||
} else {
|
||||
control.open()
|
||||
}
|
||||
},
|
||||
onFocus: onFocus,
|
||||
onBlur: onBlur,
|
||||
onMouseEnter,
|
||||
|
||||
@@ -34,10 +34,17 @@ export function createPortalGroup() {
|
||||
setOutlet(<>{Object.values(map.current)}</>)
|
||||
}, [])
|
||||
|
||||
const contextValue = React.useMemo(
|
||||
() => ({
|
||||
outlet,
|
||||
append,
|
||||
remove,
|
||||
}),
|
||||
[outlet, append, remove],
|
||||
)
|
||||
|
||||
return (
|
||||
<Context.Provider value={{outlet, append, remove}}>
|
||||
{props.children}
|
||||
</Context.Provider>
|
||||
<Context.Provider value={contextValue}>{props.children}</Context.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,13 @@ import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
|
||||
import {flip, offset, shift, size, useFloating} from '@floating-ui/react-dom'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {msg, plural} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {pluralize} from '#/lib/strings/helpers'
|
||||
import {useModerationOpts} from '#/state/queries/preferences'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {usePrefetchProfileQuery, useProfileQuery} from '#/state/queries/profile'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useProfileShadow} from 'state/cache/profile-shadow'
|
||||
@@ -72,10 +71,10 @@ type Action =
|
||||
| 'unhovered-long-enough'
|
||||
| 'finished-animating-hide'
|
||||
|
||||
const SHOW_DELAY = 400
|
||||
const SHOW_DELAY = 500
|
||||
const SHOW_DURATION = 300
|
||||
const HIDE_DELAY = 150
|
||||
const HIDE_DURATION = 150
|
||||
const HIDE_DURATION = 200
|
||||
|
||||
export function ProfileHoverCardInner(props: ProfileHoverCardProps) {
|
||||
const {refs, floatingStyles} = useFloating({
|
||||
@@ -244,12 +243,20 @@ export function ProfileHoverCardInner(props: ProfileHoverCardProps) {
|
||||
}
|
||||
}, [prefetchProfileQuery, props.did])
|
||||
|
||||
const onPointerEnterTarget = React.useCallback(() => {
|
||||
const didFireHover = React.useRef(false)
|
||||
const onPointerMoveTarget = React.useCallback(() => {
|
||||
prefetchIfNeeded()
|
||||
dispatch('hovered-target')
|
||||
// Conceptually we want something like onPointerEnter,
|
||||
// but we want to ignore entering only due to scrolling.
|
||||
// So instead we hover on the first onPointerMove.
|
||||
if (!didFireHover.current) {
|
||||
didFireHover.current = true
|
||||
dispatch('hovered-target')
|
||||
}
|
||||
}, [prefetchIfNeeded])
|
||||
|
||||
const onPointerLeaveTarget = React.useCallback(() => {
|
||||
didFireHover.current = false
|
||||
dispatch('unhovered-target')
|
||||
}, [])
|
||||
|
||||
@@ -280,7 +287,7 @@ export function ProfileHoverCardInner(props: ProfileHoverCardProps) {
|
||||
return (
|
||||
<div
|
||||
ref={refs.setReference}
|
||||
onPointerEnter={onPointerEnterTarget}
|
||||
onPointerMove={onPointerMoveTarget}
|
||||
onPointerLeave={onPointerLeaveTarget}
|
||||
onMouseUp={onPress}
|
||||
style={{
|
||||
@@ -363,7 +370,14 @@ function Inner({
|
||||
const blockHide = profile.viewer?.blocking || profile.viewer?.blockedBy
|
||||
const following = formatCount(profile.followsCount || 0)
|
||||
const followers = formatCount(profile.followersCount || 0)
|
||||
const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower')
|
||||
const pluralizedFollowers = plural(profile.followersCount || 0, {
|
||||
one: 'follower',
|
||||
other: 'followers',
|
||||
})
|
||||
const pluralizedFollowings = plural(profile.followsCount || 0, {
|
||||
one: 'following',
|
||||
other: 'following',
|
||||
})
|
||||
const profileURL = makeProfileLink({
|
||||
did: profile.did,
|
||||
handle: profile.handle,
|
||||
@@ -390,7 +404,9 @@ function Inner({
|
||||
color={profileShadow.viewer?.following ? 'secondary' : 'primary'}
|
||||
variant="solid"
|
||||
label={
|
||||
profileShadow.viewer?.following ? _('Following') : _('Follow')
|
||||
profileShadow.viewer?.following
|
||||
? _(msg`Following`)
|
||||
: _(msg`Follow`)
|
||||
}
|
||||
style={[a.rounded_full]}
|
||||
onPress={profileShadow.viewer?.following ? unfollow : follow}>
|
||||
@@ -399,7 +415,9 @@ function Inner({
|
||||
icon={profileShadow.viewer?.following ? Check : Plus}
|
||||
/>
|
||||
<ButtonText>
|
||||
{profileShadow.viewer?.following ? _('Following') : _('Follow')}
|
||||
{profileShadow.viewer?.following
|
||||
? _(msg`Following`)
|
||||
: _(msg`Follow`)}
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)}
|
||||
@@ -426,22 +444,20 @@ function Inner({
|
||||
label={`${followers} ${pluralizedFollowers}`}
|
||||
style={[t.atoms.text]}
|
||||
onPress={hide}>
|
||||
<Trans>
|
||||
<Text style={[a.text_md, a.font_bold]}>{followers} </Text>
|
||||
<Text style={[t.atoms.text_contrast_medium]}>
|
||||
{pluralizedFollowers}
|
||||
</Text>
|
||||
</Trans>
|
||||
<Text style={[a.text_md, a.font_bold]}>{followers} </Text>
|
||||
<Text style={[t.atoms.text_contrast_medium]}>
|
||||
{pluralizedFollowers}
|
||||
</Text>
|
||||
</InlineLinkText>
|
||||
<InlineLinkText
|
||||
to={makeProfileLink(profile, 'follows')}
|
||||
label={_(msg`${following} following`)}
|
||||
style={[t.atoms.text]}
|
||||
onPress={hide}>
|
||||
<Trans>
|
||||
<Text style={[a.text_md, a.font_bold]}>{following} </Text>
|
||||
<Text style={[t.atoms.text_contrast_medium]}>following</Text>
|
||||
</Trans>
|
||||
<Text style={[a.text_md, a.font_bold]}>{following} </Text>
|
||||
<Text style={[t.atoms.text_contrast_medium]}>
|
||||
{pluralizedFollowings}
|
||||
</Text>
|
||||
</InlineLinkText>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -43,7 +43,9 @@ export function Outer({
|
||||
<Dialog.ScrollableInner
|
||||
accessibilityLabelledBy={titleId}
|
||||
accessibilityDescribedBy={descriptionId}
|
||||
style={[gtMobile ? {width: 'auto', maxWidth: 400} : a.w_full]}>
|
||||
style={[
|
||||
gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full,
|
||||
]}>
|
||||
{children}
|
||||
</Dialog.ScrollableInner>
|
||||
</Context.Provider>
|
||||
@@ -60,12 +62,16 @@ export function TitleText({children}: React.PropsWithChildren<{}>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function DescriptionText({children}: React.PropsWithChildren<{}>) {
|
||||
export function DescriptionText({
|
||||
children,
|
||||
selectable,
|
||||
}: React.PropsWithChildren<{selectable?: boolean}>) {
|
||||
const t = useTheme()
|
||||
const {descriptionId} = React.useContext(Context)
|
||||
return (
|
||||
<Text
|
||||
nativeID={descriptionId}
|
||||
selectable={selectable}
|
||||
style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high, a.pb_lg]}>
|
||||
{children}
|
||||
</Text>
|
||||
|
||||
@@ -6,7 +6,7 @@ import {useLingui} from '@lingui/react'
|
||||
|
||||
import {getLabelingServiceTitle} from '#/lib/moderation'
|
||||
import {ReportOption} from '#/lib/moderation/useReportOptions'
|
||||
import {getAgent} from '#/state/session'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a, native, useTheme} from '#/alf'
|
||||
@@ -35,6 +35,7 @@ export function SubmitView({
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {getAgent} = useAgent()
|
||||
const [details, setDetails] = React.useState<string>('')
|
||||
const [submitting, setSubmitting] = React.useState<boolean>(false)
|
||||
const [selectedServices, setSelectedServices] = React.useState<string[]>([
|
||||
@@ -90,6 +91,7 @@ export function SubmitView({
|
||||
selectedServices,
|
||||
onSubmitComplete,
|
||||
setError,
|
||||
getAgent,
|
||||
])
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
import {TextStyle} from 'react-native'
|
||||
import {AppBskyRichtextFacet, RichText as RichTextAPI} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -26,6 +27,7 @@ export function RichText({
|
||||
enableTags = false,
|
||||
authorHandle,
|
||||
onLinkPress,
|
||||
interactiveStyle,
|
||||
}: TextStyleProp &
|
||||
Pick<TextProps, 'selectable'> & {
|
||||
value: RichTextAPI | string
|
||||
@@ -35,13 +37,22 @@ export function RichText({
|
||||
enableTags?: boolean
|
||||
authorHandle?: string
|
||||
onLinkPress?: LinkProps['onPress']
|
||||
interactiveStyle?: TextStyle
|
||||
}) {
|
||||
const richText = React.useMemo(
|
||||
() =>
|
||||
value instanceof RichTextAPI ? value : new RichTextAPI({text: value}),
|
||||
[value],
|
||||
)
|
||||
const styles = [a.leading_snug, flatten(style)]
|
||||
|
||||
const flattenedStyle = flatten(style)
|
||||
const plainStyles = [a.leading_snug, flattenedStyle]
|
||||
const interactiveStyles = [
|
||||
a.leading_snug,
|
||||
a.pointer_events_auto,
|
||||
flatten(interactiveStyle),
|
||||
flattenedStyle,
|
||||
]
|
||||
|
||||
const {text, facets} = richText
|
||||
|
||||
@@ -67,7 +78,7 @@ export function RichText({
|
||||
<Text
|
||||
selectable={selectable}
|
||||
testID={testID}
|
||||
style={styles}
|
||||
style={plainStyles}
|
||||
numberOfLines={numberOfLines}
|
||||
// @ts-ignore web only -prf
|
||||
dataSet={WORD_WRAP}>
|
||||
@@ -93,7 +104,7 @@ export function RichText({
|
||||
<InlineLinkText
|
||||
selectable={selectable}
|
||||
to={`/profile/${mention.did}`}
|
||||
style={[...styles, {pointerEvents: 'auto'}]}
|
||||
style={interactiveStyles}
|
||||
// @ts-ignore TODO
|
||||
dataSet={WORD_WRAP}
|
||||
onPress={onLinkPress}>
|
||||
@@ -110,7 +121,7 @@ export function RichText({
|
||||
selectable={selectable}
|
||||
key={key}
|
||||
to={link.uri}
|
||||
style={[...styles, {pointerEvents: 'auto'}]}
|
||||
style={interactiveStyles}
|
||||
// @ts-ignore TODO
|
||||
dataSet={WORD_WRAP}
|
||||
shareOnLongPress
|
||||
@@ -130,7 +141,7 @@ export function RichText({
|
||||
key={key}
|
||||
text={segment.text}
|
||||
tag={tag.tag}
|
||||
style={styles}
|
||||
style={interactiveStyles}
|
||||
selectable={selectable}
|
||||
authorHandle={authorHandle}
|
||||
/>,
|
||||
@@ -145,7 +156,7 @@ export function RichText({
|
||||
<Text
|
||||
selectable={selectable}
|
||||
testID={testID}
|
||||
style={styles}
|
||||
style={plainStyles}
|
||||
numberOfLines={numberOfLines}
|
||||
// @ts-ignore web only -prf
|
||||
dataSet={WORD_WRAP}>
|
||||
@@ -219,19 +230,16 @@ function RichTextTag({
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
style={[
|
||||
style,
|
||||
{
|
||||
pointerEvents: 'auto',
|
||||
color: t.palette.primary_500,
|
||||
},
|
||||
web({
|
||||
cursor: 'pointer',
|
||||
}),
|
||||
{color: t.palette.primary_500},
|
||||
(hovered || focused || pressed) && {
|
||||
...web({outline: 0}),
|
||||
textDecorationLine: 'underline',
|
||||
textDecorationColor: t.palette.primary_500,
|
||||
},
|
||||
style,
|
||||
]}>
|
||||
{text}
|
||||
</Text>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, {useCallback, useMemo, useRef, useState} from 'react'
|
||||
import {Keyboard, TextInput, View} from 'react-native'
|
||||
import {TextInput, View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
@@ -216,7 +216,7 @@ function GifList({
|
||||
keyExtractor={(item: Gif) => item.id}
|
||||
// @ts-expect-error web only
|
||||
style={isWeb && {minHeight: '100vh'}}
|
||||
onScrollBeginDrag={() => Keyboard.dismiss()}
|
||||
keyboardDismissMode="on-drag"
|
||||
ListFooterComponent={
|
||||
hasData ? (
|
||||
<ListFooter
|
||||
|
||||
@@ -37,12 +37,12 @@ export function MutedWordsDialog() {
|
||||
return (
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Handle />
|
||||
<MutedWordsInner control={control} />
|
||||
<MutedWordsInner />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) {
|
||||
function MutedWordsInner() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
|
||||
@@ -18,7 +18,7 @@ export function SwitchAccountDialog({
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {currentAccount} = useSession()
|
||||
const {onPressSwitchAccount} = useAccountSwitcher()
|
||||
const {onPressSwitchAccount, pendingDid} = useAccountSwitcher()
|
||||
const {setShowLoggedOut} = useLoggedOutViewControls()
|
||||
|
||||
const onSelectAccount = useCallback(
|
||||
@@ -54,6 +54,7 @@ export function SwitchAccountDialog({
|
||||
onSelectAccount={onSelectAccount}
|
||||
onSelectOther={onPressAddAccount}
|
||||
otherLabel={_(msg`Add account`)}
|
||||
pendingDid={pendingDid}
|
||||
/>
|
||||
</View>
|
||||
</Dialog.ScrollableInner>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import React from 'react'
|
||||
import {Keyboard, Pressable, View} from 'react-native'
|
||||
import Animated, {
|
||||
cancelAnimation,
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
import {ChatBskyConvoDefs} from '@atproto-labs/api'
|
||||
|
||||
import {HITSLOP_10} from 'lib/constants'
|
||||
import {useHaptics} from 'lib/haptics'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {MessageMenu} from '#/components/dms/MessageMenu'
|
||||
import {useMenuControl} from '#/components/Menu'
|
||||
|
||||
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
|
||||
|
||||
export function ActionsWrapper({
|
||||
message,
|
||||
isFromSelf,
|
||||
children,
|
||||
}: {
|
||||
message: ChatBskyConvoDefs.MessageView
|
||||
isFromSelf: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const playHaptic = useHaptics()
|
||||
const menuControl = useMenuControl()
|
||||
|
||||
const scale = useSharedValue(1)
|
||||
const animationDidComplete = useSharedValue(false)
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{scale: scale.value}],
|
||||
}))
|
||||
|
||||
// Reanimated's `runOnJS` doesn't like refs, so we can't use `runOnJS(menuControl.open)()`. Instead, we'll use this
|
||||
// function
|
||||
const open = React.useCallback(() => {
|
||||
Keyboard.dismiss()
|
||||
menuControl.open()
|
||||
}, [menuControl])
|
||||
|
||||
const shrink = React.useCallback(() => {
|
||||
'worklet'
|
||||
cancelAnimation(scale)
|
||||
scale.value = withTiming(1, {duration: 200}, () => {
|
||||
animationDidComplete.value = false
|
||||
})
|
||||
}, [animationDidComplete, scale])
|
||||
|
||||
const grow = React.useCallback(() => {
|
||||
'worklet'
|
||||
scale.value = withTiming(1.05, {duration: 450}, finished => {
|
||||
if (!finished) return
|
||||
animationDidComplete.value = true
|
||||
runOnJS(playHaptic)()
|
||||
runOnJS(open)()
|
||||
|
||||
shrink()
|
||||
})
|
||||
}, [scale, animationDidComplete, playHaptic, shrink, open])
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
maxWidth: '65%',
|
||||
},
|
||||
isFromSelf ? a.self_end : a.self_start,
|
||||
]}>
|
||||
<AnimatedPressable
|
||||
style={animatedStyle}
|
||||
unstable_pressDelay={200}
|
||||
onPressIn={grow}
|
||||
onTouchEnd={shrink}
|
||||
hitSlop={HITSLOP_10}>
|
||||
{children}
|
||||
</AnimatedPressable>
|
||||
<MessageMenu message={message} control={menuControl} hideTrigger={true} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import React from 'react'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {ChatBskyConvoDefs} from '@atproto-labs/api'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
import {MessageMenu} from '#/components/dms/MessageMenu'
|
||||
import {useMenuControl} from '#/components/Menu'
|
||||
|
||||
export function ActionsWrapper({
|
||||
message,
|
||||
isFromSelf,
|
||||
children,
|
||||
}: {
|
||||
message: ChatBskyConvoDefs.MessageView
|
||||
isFromSelf: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const menuControl = useMenuControl()
|
||||
const viewRef = React.useRef(null)
|
||||
|
||||
const [showActions, setShowActions] = React.useState(false)
|
||||
|
||||
const onMouseEnter = React.useCallback(() => {
|
||||
setShowActions(true)
|
||||
}, [])
|
||||
|
||||
const onMouseLeave = React.useCallback(() => {
|
||||
setShowActions(false)
|
||||
}, [])
|
||||
|
||||
// We need to handle the `onFocus` separately because we want to know if there is a related target (the element
|
||||
// that is losing focus). If there isn't that means the focus is coming from a dropdown that is now closed.
|
||||
const onFocus = React.useCallback<React.FocusEventHandler>(e => {
|
||||
if (e.nativeEvent.relatedTarget == null) return
|
||||
setShowActions(true)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<View
|
||||
// @ts-expect-error web only
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onFocus={onFocus}
|
||||
onBlur={onMouseLeave}
|
||||
style={StyleSheet.flatten([a.flex_1, a.flex_row])}
|
||||
ref={viewRef}>
|
||||
{isFromSelf && (
|
||||
<View
|
||||
style={[
|
||||
a.mr_xl,
|
||||
a.justify_center,
|
||||
{
|
||||
marginLeft: 'auto',
|
||||
},
|
||||
]}>
|
||||
<MessageMenu
|
||||
message={message}
|
||||
control={menuControl}
|
||||
triggerOpacity={showActions || menuControl.isOpen ? 1 : 0}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<View
|
||||
style={{
|
||||
maxWidth: '65%',
|
||||
}}>
|
||||
{children}
|
||||
</View>
|
||||
{!isFromSelf && (
|
||||
<View style={[a.flex_row, a.align_center, a.ml_xl]}>
|
||||
<MessageMenu
|
||||
message={message}
|
||||
control={menuControl}
|
||||
triggerOpacity={showActions || menuControl.isOpen ? 1 : 0}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import {Keyboard, Pressable, View} from 'react-native'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {ChatBskyConvoDefs} from '@atproto-labs/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {NavigationProp} from '#/lib/routes/types'
|
||||
import {useMarkAsReadMutation} from '#/state/queries/messages/conversation'
|
||||
import {useLeaveConvo} from '#/state/queries/messages/leave-conversation'
|
||||
import {
|
||||
useMuteConvo,
|
||||
useUnmuteConvo,
|
||||
} from '#/state/queries/messages/mute-conversation'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeft} from '#/components/icons/ArrowBoxLeft'
|
||||
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 {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
|
||||
import * as Menu from '#/components/Menu'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '../icons/Bubble'
|
||||
|
||||
let ConvoMenu = ({
|
||||
convo,
|
||||
profile,
|
||||
onUpdateConvo,
|
||||
control,
|
||||
currentScreen,
|
||||
showMarkAsRead,
|
||||
hideTrigger,
|
||||
triggerOpacity,
|
||||
}: {
|
||||
convo: ChatBskyConvoDefs.ConvoView
|
||||
profile: AppBskyActorDefs.ProfileViewBasic
|
||||
onUpdateConvo?: (convo: ChatBskyConvoDefs.ConvoView) => void
|
||||
control?: Menu.MenuControlProps
|
||||
currentScreen: 'list' | 'conversation'
|
||||
showMarkAsRead?: boolean
|
||||
hideTrigger?: boolean
|
||||
triggerOpacity?: number
|
||||
}): React.ReactNode => {
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const leaveConvoControl = Prompt.usePromptControl()
|
||||
const {mutate: markAsRead} = useMarkAsReadMutation()
|
||||
|
||||
const onNavigateToProfile = useCallback(() => {
|
||||
navigation.navigate('Profile', {name: profile.did})
|
||||
}, [navigation, profile.did])
|
||||
|
||||
const {mutate: muteConvo} = useMuteConvo(convo.id, {
|
||||
onSuccess: data => {
|
||||
onUpdateConvo?.(data.convo)
|
||||
Toast.show(_(msg`Chat muted`))
|
||||
},
|
||||
onError: () => {
|
||||
Toast.show(_(msg`Could not mute chat`))
|
||||
},
|
||||
})
|
||||
|
||||
const {mutate: unmuteConvo} = useUnmuteConvo(convo.id, {
|
||||
onSuccess: data => {
|
||||
onUpdateConvo?.(data.convo)
|
||||
Toast.show(_(msg`Chat unmuted`))
|
||||
},
|
||||
onError: () => {
|
||||
Toast.show(_(msg`Could not unmute chat`))
|
||||
},
|
||||
})
|
||||
|
||||
const {mutate: leaveConvo} = useLeaveConvo(convo.id, {
|
||||
onSuccess: () => {
|
||||
if (currentScreen === 'conversation') {
|
||||
navigation.replace('Messages')
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
Toast.show(_(msg`Could not leave chat`))
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu.Root control={control}>
|
||||
{!hideTrigger && (
|
||||
<View style={{opacity: triggerOpacity}}>
|
||||
<Menu.Trigger label={_(msg`Chat settings`)}>
|
||||
{({props, state}) => (
|
||||
<Pressable
|
||||
{...props}
|
||||
onPress={() => {
|
||||
Keyboard.dismiss()
|
||||
// eslint-disable-next-line react/prop-types -- eslint is confused by the name `props`
|
||||
props.onPress()
|
||||
}}
|
||||
style={[
|
||||
a.p_sm,
|
||||
a.rounded_full,
|
||||
(state.hovered || state.pressed) && t.atoms.bg_contrast_25,
|
||||
// make sure pfp is in the middle
|
||||
{marginLeft: -10},
|
||||
]}>
|
||||
<DotsHorizontal size="md" style={t.atoms.text} />
|
||||
</Pressable>
|
||||
)}
|
||||
</Menu.Trigger>
|
||||
</View>
|
||||
)}
|
||||
<Menu.Outer>
|
||||
<Menu.Group>
|
||||
{showMarkAsRead && (
|
||||
<Menu.Item
|
||||
label={_(msg`Mark as read`)}
|
||||
onPress={() =>
|
||||
markAsRead({
|
||||
convoId: convo.id,
|
||||
})
|
||||
}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Mark as read</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Bubble} />
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item
|
||||
label={_(msg`Go to user's profile`)}
|
||||
onPress={onNavigateToProfile}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Go to profile</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Person} />
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
label={_(msg`Mute notifications`)}
|
||||
onPress={() => (convo?.muted ? unmuteConvo() : muteConvo())}>
|
||||
<Menu.ItemText>
|
||||
{convo?.muted ? (
|
||||
<Trans>Unmute notifications</Trans>
|
||||
) : (
|
||||
<Trans>Mute notifications</Trans>
|
||||
)}
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={convo?.muted ? Unmute : Mute} />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
<Menu.Divider />
|
||||
{/* TODO(samuel): implement these */}
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
label={_(msg`Block account`)}
|
||||
onPress={() => {}}
|
||||
disabled>
|
||||
<Menu.ItemText>
|
||||
<Trans>Block account</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon
|
||||
icon={profile.viewer?.blocking ? PersonCheck : PersonX}
|
||||
/>
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
label={_(msg`Report account`)}
|
||||
onPress={() => {}}
|
||||
disabled>
|
||||
<Menu.ItemText>
|
||||
<Trans>Report account</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Flag} />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
<Menu.Divider />
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
label={_(msg`Leave conversation`)}
|
||||
onPress={leaveConvoControl.open}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Leave conversation</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={ArrowBoxLeft} />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
</Menu.Outer>
|
||||
</Menu.Root>
|
||||
|
||||
<Prompt.Basic
|
||||
control={leaveConvoControl}
|
||||
title={_(msg`Leave conversation`)}
|
||||
description={_(
|
||||
msg`Are you sure you want to leave this conversation? Your messages will be deleted for you, but not for other participants.`,
|
||||
)}
|
||||
confirmButtonCta={_(msg`Leave`)}
|
||||
confirmButtonColor="negative"
|
||||
onConfirm={() => leaveConvo()}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
ConvoMenu = React.memo(ConvoMenu)
|
||||
|
||||
export {ConvoMenu}
|
||||
@@ -0,0 +1,206 @@
|
||||
import React, {useCallback, useMemo, useRef} from 'react'
|
||||
import {LayoutAnimation, StyleProp, TextStyle, View} from 'react-native'
|
||||
import {RichText as RichTextAPI} from '@atproto/api'
|
||||
import {ChatBskyConvoDefs} from '@atproto-labs/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useSession} from '#/state/session'
|
||||
import {TimeElapsed} from 'view/com/util/TimeElapsed'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {RichText} from '../RichText'
|
||||
|
||||
let MessageItem = ({
|
||||
item,
|
||||
next,
|
||||
pending,
|
||||
}: {
|
||||
item: ChatBskyConvoDefs.MessageView
|
||||
next:
|
||||
| ChatBskyConvoDefs.MessageView
|
||||
| ChatBskyConvoDefs.DeletedMessageView
|
||||
| null
|
||||
pending?: boolean
|
||||
}): React.ReactNode => {
|
||||
const t = useTheme()
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
const isFromSelf = item.sender?.did === currentAccount?.did
|
||||
|
||||
const isNextFromSelf =
|
||||
ChatBskyConvoDefs.isMessageView(next) &&
|
||||
next.sender?.did === currentAccount?.did
|
||||
|
||||
const isLastInGroup = useMemo(() => {
|
||||
// TODO this means it's a placeholder. Let's figure out the right way to do this though!
|
||||
if (item.id.length > 13) {
|
||||
return false
|
||||
}
|
||||
|
||||
// if the next message is from a different sender, then it's the last in the group
|
||||
if (isFromSelf ? !isNextFromSelf : isNextFromSelf) {
|
||||
return true
|
||||
}
|
||||
|
||||
// or, if there's a 3 minute gap between this message and the next
|
||||
if (ChatBskyConvoDefs.isMessageView(next)) {
|
||||
const thisDate = new Date(item.sentAt)
|
||||
const nextDate = new Date(next.sentAt)
|
||||
|
||||
const diff = nextDate.getTime() - thisDate.getTime()
|
||||
|
||||
// 3 minutes
|
||||
return diff > 3 * 60 * 1000
|
||||
}
|
||||
|
||||
return true
|
||||
}, [item, next, isFromSelf, isNextFromSelf])
|
||||
|
||||
const lastInGroupRef = useRef(isLastInGroup)
|
||||
if (lastInGroupRef.current !== isLastInGroup) {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
lastInGroupRef.current = isLastInGroup
|
||||
}
|
||||
|
||||
const pendingColor =
|
||||
t.name === 'light' ? t.palette.primary_200 : t.palette.primary_800
|
||||
|
||||
const rt = useMemo(() => {
|
||||
return new RichTextAPI({text: item.text, facets: item.facets})
|
||||
}, [item.text, item.facets])
|
||||
|
||||
return (
|
||||
<View>
|
||||
<ActionsWrapper isFromSelf={isFromSelf} message={item}>
|
||||
<View
|
||||
style={[
|
||||
a.py_sm,
|
||||
a.my_2xs,
|
||||
a.rounded_md,
|
||||
{
|
||||
paddingLeft: 14,
|
||||
paddingRight: 14,
|
||||
backgroundColor: isFromSelf
|
||||
? pending
|
||||
? 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},
|
||||
pending && t.name !== 'light' && {color: t.palette.primary_300},
|
||||
]}
|
||||
interactiveStyle={a.underline}
|
||||
enableTags
|
||||
/>
|
||||
</View>
|
||||
</ActionsWrapper>
|
||||
<MessageItemMetadata
|
||||
message={item}
|
||||
isLastInGroup={isLastInGroup}
|
||||
style={isFromSelf ? a.text_right : a.text_left}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
MessageItem = React.memo(MessageItem)
|
||||
export {MessageItem}
|
||||
|
||||
let MessageItemMetadata = ({
|
||||
message,
|
||||
isLastInGroup,
|
||||
style,
|
||||
}: {
|
||||
message: ChatBskyConvoDefs.MessageView
|
||||
isLastInGroup: boolean
|
||||
style: StyleProp<TextStyle>
|
||||
}): React.ReactNode => {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
const relativeTimestamp = useCallback(
|
||||
(timestamp: string) => {
|
||||
const date = new Date(timestamp)
|
||||
const now = new Date()
|
||||
|
||||
const time = new Intl.DateTimeFormat(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
hour12: true,
|
||||
}).format(date)
|
||||
|
||||
const diff = now.getTime() - date.getTime()
|
||||
|
||||
// if under 1 minute
|
||||
if (diff < 1000 * 60) {
|
||||
return _(msg`Now`)
|
||||
}
|
||||
|
||||
// if in the last day
|
||||
if (localDateString(now) === localDateString(date)) {
|
||||
return time
|
||||
}
|
||||
|
||||
// if yesterday
|
||||
const yesterday = new Date(now)
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
|
||||
if (localDateString(yesterday) === localDateString(date)) {
|
||||
return _(msg`Yesterday, ${time}`)
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
hour12: true,
|
||||
day: 'numeric',
|
||||
month: 'numeric',
|
||||
year: 'numeric',
|
||||
}).format(date)
|
||||
},
|
||||
[_],
|
||||
)
|
||||
|
||||
if (!isLastInGroup) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<TimeElapsed timestamp={message.sentAt} timeToString={relativeTimestamp}>
|
||||
{({timeElapsed}) => (
|
||||
<Text
|
||||
style={[
|
||||
t.atoms.text_contrast_medium,
|
||||
a.text_xs,
|
||||
a.mt_2xs,
|
||||
a.mb_lg,
|
||||
style,
|
||||
]}>
|
||||
{timeElapsed}
|
||||
</Text>
|
||||
)}
|
||||
</TimeElapsed>
|
||||
)
|
||||
}
|
||||
|
||||
MessageItemMetadata = React.memo(MessageItemMetadata)
|
||||
|
||||
function localDateString(date: Date) {
|
||||
// can't use toISOString because it should be in local time
|
||||
const mm = date.getMonth()
|
||||
const dd = date.getDate()
|
||||
const yyyy = date.getFullYear()
|
||||
// not padding with 0s because it's not necessary, it's just used for comparison
|
||||
return `${yyyy}-${mm}-${dd}`
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import React from 'react'
|
||||
import {LayoutAnimation, Pressable, View} from 'react-native'
|
||||
import * as Clipboard from 'expo-clipboard'
|
||||
import {ChatBskyConvoDefs} from '@atproto-labs/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {useConvo} from 'state/messages/convo'
|
||||
import {ConvoStatus} from 'state/messages/convo/types'
|
||||
import {useSession} from 'state/session'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
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'
|
||||
import * as Menu from '#/components/Menu'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {usePromptControl} from '#/components/Prompt'
|
||||
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '../icons/Clipboard'
|
||||
|
||||
export let MessageMenu = ({
|
||||
message,
|
||||
control,
|
||||
triggerOpacity,
|
||||
}: {
|
||||
hideTrigger?: boolean
|
||||
triggerOpacity?: number
|
||||
message: ChatBskyConvoDefs.MessageView
|
||||
control: Menu.MenuControlProps
|
||||
}): React.ReactNode => {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {currentAccount} = useSession()
|
||||
const convo = useConvo()
|
||||
const deleteControl = usePromptControl()
|
||||
const retryDeleteControl = usePromptControl()
|
||||
|
||||
const isFromSelf = message.sender?.did === currentAccount?.did
|
||||
|
||||
const onCopyPostText = React.useCallback(() => {
|
||||
// use when we have rich text
|
||||
// const str = richTextToString(richText, true)
|
||||
|
||||
Clipboard.setStringAsync(message.text)
|
||||
Toast.show(_(msg`Copied to clipboard`))
|
||||
}, [_, message.text])
|
||||
|
||||
const onDelete = React.useCallback(() => {
|
||||
if (convo.status !== ConvoStatus.Ready) return
|
||||
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
convo
|
||||
.deleteMessage(message.id)
|
||||
.then(() => Toast.show(_(msg`Message deleted`)))
|
||||
.catch(() => retryDeleteControl.open())
|
||||
}, [_, convo, message.id, retryDeleteControl])
|
||||
|
||||
const onReport = React.useCallback(() => {
|
||||
// TODO report the message
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu.Root control={control}>
|
||||
{isWeb && (
|
||||
<View style={{opacity: triggerOpacity}}>
|
||||
<Menu.Trigger label={_(msg`Chat settings`)}>
|
||||
{({props, state}) => (
|
||||
<Pressable
|
||||
{...props}
|
||||
style={[
|
||||
a.p_sm,
|
||||
a.rounded_full,
|
||||
(state.hovered || state.pressed) && t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<DotsHorizontal size="md" style={t.atoms.text} />
|
||||
</Pressable>
|
||||
)}
|
||||
</Menu.Trigger>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<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 />
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
testID="messageDropdownDeleteBtn"
|
||||
label={_(msg`Delete message for me`)}
|
||||
onPress={deleteControl.open}>
|
||||
<Menu.ItemText>{_(msg`Delete for me`)}</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Trash} position="right" />
|
||||
</Menu.Item>
|
||||
{!isFromSelf && (
|
||||
<Menu.Item
|
||||
testID="messageDropdownReportBtn"
|
||||
label={_(msg`Report message`)}
|
||||
onPress={onReport}>
|
||||
<Menu.ItemText>{_(msg`Report`)}</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Warning} position="right" />
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Group>
|
||||
</Menu.Outer>
|
||||
</Menu.Root>
|
||||
|
||||
<Prompt.Basic
|
||||
control={deleteControl}
|
||||
title={_(msg`Delete message`)}
|
||||
description={_(
|
||||
msg`Are you sure you want to delete this message? The message will be deleted for you, but not for other participants.`,
|
||||
)}
|
||||
confirmButtonCta={_(msg`Delete`)}
|
||||
confirmButtonColor="negative"
|
||||
onConfirm={onDelete}
|
||||
/>
|
||||
|
||||
<Prompt.Basic
|
||||
control={retryDeleteControl}
|
||||
title={_(msg`Failed to delete message`)}
|
||||
description={_(
|
||||
msg`An error occurred while trying to delete the message. Please try again.`,
|
||||
)}
|
||||
confirmButtonCta={_(msg`Retry`)}
|
||||
confirmButtonColor="negative"
|
||||
onConfirm={onDelete}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
MessageMenu = React.memo(MessageMenu)
|
||||
@@ -0,0 +1,255 @@
|
||||
import React, {useCallback, useMemo, useRef, useState} from 'react'
|
||||
import {Keyboard, View} from 'react-native'
|
||||
import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
|
||||
import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
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 {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, useTheme, web} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
import {Button} from '../Button'
|
||||
import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '../icons/Envelope'
|
||||
import {ListMaybePlaceholder} from '../Lists'
|
||||
import {Text} from '../Typography'
|
||||
|
||||
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)
|
||||
},
|
||||
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%']}}}>
|
||||
<Dialog.Handle />
|
||||
<SearchablePeopleList onCreateChat={onCreateChat} />
|
||||
</Dialog.Outer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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 [searchText, setSearchText] = useState('')
|
||||
|
||||
const {
|
||||
data: actorAutocompleteData,
|
||||
isFetching,
|
||||
isError,
|
||||
refetch,
|
||||
} = useActorAutocompleteQuery(searchText, true)
|
||||
|
||||
const renderItem = useCallback(
|
||||
({item: profile}: {item: AppBskyActorDefs.ProfileView}) => {
|
||||
if (!moderationOpts) return null
|
||||
const moderation = moderateProfile(profile, moderationOpts)
|
||||
return (
|
||||
<Button
|
||||
label={profile.displayName || sanitizeHandle(profile.handle)}
|
||||
onPress={() => onCreateChat(profile.did)}>
|
||||
{({hovered, pressed}) => (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.px_md,
|
||||
a.py_sm,
|
||||
a.gap_md,
|
||||
a.align_center,
|
||||
a.flex_row,
|
||||
a.rounded_sm,
|
||||
pressed
|
||||
? t.atoms.bg_contrast_25
|
||||
: hovered
|
||||
? t.atoms.bg_contrast_50
|
||||
: t.atoms.bg,
|
||||
]}>
|
||||
<UserAvatar
|
||||
size={40}
|
||||
avatar={profile.avatar}
|
||||
moderation={moderation.ui('avatar')}
|
||||
type={profile.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
<View style={{flex: 1}}>
|
||||
<Text
|
||||
style={[t.atoms.text, a.font_bold, a.leading_snug]}
|
||||
numberOfLines={1}>
|
||||
{sanitizeDisplayName(
|
||||
profile.displayName || sanitizeHandle(profile.handle),
|
||||
moderation.ui('displayName'),
|
||||
)}
|
||||
</Text>
|
||||
<Text style={t.atoms.text_contrast_high} numberOfLines={1}>
|
||||
{sanitizeHandle(profile.handle, '@')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
[
|
||||
moderationOpts,
|
||||
onCreateChat,
|
||||
t.atoms.bg_contrast_25,
|
||||
t.atoms.bg_contrast_50,
|
||||
t.atoms.bg,
|
||||
t.atoms.text,
|
||||
t.atoms.text_contrast_high,
|
||||
],
|
||||
)
|
||||
|
||||
const listHeader = useMemo(() => {
|
||||
return (
|
||||
<View style={[a.relative, a.mb_lg]}>
|
||||
{/* cover top corners */}
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
{
|
||||
borderBottomLeftRadius: 8,
|
||||
borderBottomRightRadius: 8,
|
||||
},
|
||||
t.atoms.bg,
|
||||
]}
|
||||
/>
|
||||
<Dialog.Close />
|
||||
<Text
|
||||
style={[
|
||||
a.text_2xl,
|
||||
a.font_bold,
|
||||
a.leading_tight,
|
||||
a.pb_lg,
|
||||
web(a.pt_lg),
|
||||
]}>
|
||||
<Trans>Start a new chat</Trans>
|
||||
</Text>
|
||||
<TextField.Root>
|
||||
<TextField.Icon icon={Search} />
|
||||
<Dialog.Input
|
||||
label={_(msg`Search profiles`)}
|
||||
placeholder={_(msg`Search`)}
|
||||
value={searchText}
|
||||
onChangeText={text => {
|
||||
setSearchText(text)
|
||||
listRef.current?.scrollToOffset({offset: 0, animated: false})
|
||||
}}
|
||||
returnKeyType="search"
|
||||
clearButtonMode="while-editing"
|
||||
maxLength={50}
|
||||
onKeyPress={({nativeEvent}) => {
|
||||
if (nativeEvent.key === 'Escape') {
|
||||
control.close()
|
||||
}
|
||||
}}
|
||||
autoCorrect={false}
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
autoFocus
|
||||
/>
|
||||
</TextField.Root>
|
||||
</View>
|
||||
)
|
||||
}, [t.atoms.bg, _, control, searchText])
|
||||
|
||||
return (
|
||||
<Dialog.InnerFlatList
|
||||
ref={listRef}
|
||||
data={actorAutocompleteData}
|
||||
renderItem={renderItem}
|
||||
ListHeaderComponent={
|
||||
<>
|
||||
{listHeader}
|
||||
{searchText.length === 0 ? (
|
||||
<View style={[a.pt_4xl, a.align_center, a.px_lg]}>
|
||||
<Envelope width={64} fill={t.palette.contrast_200} />
|
||||
<Text
|
||||
style={[
|
||||
a.text_lg,
|
||||
a.text_center,
|
||||
a.mt_md,
|
||||
t.atoms.text_contrast_low,
|
||||
]}>
|
||||
<Trans>Search for someone to start a conversation with.</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
!actorAutocompleteData?.length && (
|
||||
<ListMaybePlaceholder
|
||||
isLoading={isFetching}
|
||||
isError={isError}
|
||||
onRetry={refetch}
|
||||
hideBackButton={true}
|
||||
emptyType="results"
|
||||
sideBorders={false}
|
||||
emptyMessage={
|
||||
isError
|
||||
? _(msg`No search results found for "${searchText}".`)
|
||||
: _(msg`Could not load profiles. Please try again later.`)
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
}
|
||||
stickyHeaderIndices={[0]}
|
||||
keyExtractor={(item: AppBskyActorDefs.ProfileView) => item.did}
|
||||
// @ts-expect-error web only
|
||||
style={isWeb && {minHeight: '100vh'}}
|
||||
onScrollBeginDrag={() => Keyboard.dismiss()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import {useCallback, useRef} from 'react'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
|
||||
export function useRefreshOnFocus<T>(refetch: () => Promise<T>) {
|
||||
const firstTimeRef = useRef(true)
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (firstTimeRef.current) {
|
||||
firstTimeRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
refetch()
|
||||
}, [refetch]),
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import React from 'react'
|
||||
import {RichText as RichTextAPI} from '@atproto/api'
|
||||
|
||||
import {getAgent} from '#/state/session'
|
||||
import {useAgent} from '#/state/session'
|
||||
|
||||
export function useRichText(text: string): [RichTextAPI, boolean] {
|
||||
const [prevText, setPrevText] = React.useState(text)
|
||||
const [rawRT, setRawRT] = React.useState(() => new RichTextAPI({text}))
|
||||
const [resolvedRT, setResolvedRT] = React.useState<RichTextAPI | null>(null)
|
||||
const {getAgent} = useAgent()
|
||||
if (text !== prevText) {
|
||||
setPrevText(text)
|
||||
setRawRT(new RichTextAPI({text}))
|
||||
@@ -27,7 +28,7 @@ export function useRichText(text: string): [RichTextAPI, boolean] {
|
||||
return () => {
|
||||
ignore = true
|
||||
}
|
||||
}, [text])
|
||||
}, [text, getAgent])
|
||||
const isResolving = resolvedRT === null
|
||||
return [resolvedRT ?? rawRT, isResolving]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const ArrowBoxLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M3.293 3.293A1 1 0 0 1 4 3h7.25a1 1 0 1 1 0 2H5v14h6.25a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1V4a1 1 0 0 1 .293-.707Zm11.5 3.5a1 1 0 0 1 1.414 0l4.5 4.5a1 1 0 0 1 0 1.414l-4.5 4.5a1 1 0 0 1-1.414-1.414L17.586 13H8.75a1 1 0 1 1 0-2h8.836l-2.793-2.793a1 1 0 0 1 0-1.414Z',
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Bell_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M4.216 8.815a7.853 7.853 0 0 1 15.568 0l1.207 9.053A1 1 0 0 1 20 19h-3.354c-.904 1.748-2.607 3-4.646 3-2.039 0-3.742-1.252-4.646-3H4a1 1 0 0 1-.991-1.132l1.207-9.053ZM9.778 19c.61.637 1.399 1 2.222 1s1.613-.363 2.222-1H9.778ZM12 4a5.853 5.853 0 0 0-5.802 5.08L5.142 17h13.716l-1.056-7.92A5.853 5.853 0 0 0 12 4Z',
|
||||
})
|
||||
|
||||
export const Bell_Filled_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 2a7.853 7.853 0 0 0-7.784 6.815l-1.207 9.053A1 1 0 0 0 4 19h3.354c.904 1.748 2.607 3 4.646 3 2.039 0 3.742-1.252 4.646-3H20a1 1 0 0 0 .991-1.132l-1.207-9.053A7.853 7.853 0 0 0 12 2Zm2.222 17H9.778c.61.637 1.399 1 2.222 1s1.613-.363 2.222-1Z',
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Bell2_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M4.702 8.943a7.307 7.307 0 0 1 14.596 0l.19 3.798 1.321 2.641A1.809 1.809 0 0 1 19.191 18H16.9a5.002 5.002 0 0 1-9.8 0H4.809a1.809 1.809 0 0 1-1.618-2.618l1.32-2.641.19-3.798ZM9.17 18a3.001 3.001 0 0 0 5.658 0H9.171ZM12 4a5.307 5.307 0 0 0-5.3 5.042l-.19 3.798a2 2 0 0 1-.21.795L5.119 16h13.764l-1.183-2.365a2 2 0 0 1-.208-.795l-.19-3.798A5.308 5.308 0 0 0 12 4Z',
|
||||
})
|
||||
|
||||
export const Bell2_Filled_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 2a7.307 7.307 0 0 0-7.298 6.943l-.19 3.798-1.321 2.641A1.809 1.809 0 0 0 4.809 18H7.1a5.002 5.002 0 0 0 9.8 0h2.291a1.809 1.809 0 0 0 1.618-2.618l-1.32-2.641-.19-3.798A7.308 7.308 0 0 0 12 2Zm0 18a3.001 3.001 0 0 1-2.83-2h5.66A3.001 3.001 0 0 1 12 20Z',
|
||||
})
|
||||
@@ -3,3 +3,11 @@ import {createSinglePathSVG} from './TEMPLATE'
|
||||
export const BubbleQuestion_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M5.002 17.036V5h14v12.036h-3.986a1 1 0 0 0-.639.23l-2.375 1.968-2.344-1.965a1 1 0 0 0-.643-.233H5.002ZM20.002 3h-16a1 1 0 0 0-1 1v14.036a1 1 0 0 0 1 1h4.65l2.704 2.266a1 1 0 0 0 1.28.004l2.74-2.27h4.626a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1Zm-7.878 3.663c-1.39 0-2.5 1.135-2.5 2.515a1 1 0 0 0 2 0c0-.294.232-.515.5-.515a.507.507 0 0 1 .489.6.174.174 0 0 1-.027.048 1.1 1.1 0 0 1-.267.226c-.508.345-1.128.923-1.286 1.978a1 1 0 1 0 1.978.297.762.762 0 0 1 .14-.359c.063-.086.155-.169.293-.262.436-.297 1.18-.885 1.18-2.013 0-1.38-1.11-2.515-2.5-2.515ZM12 15.75a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Z',
|
||||
})
|
||||
|
||||
export const Bubble_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||
path: 'M2.002 6a3 3 0 0 1 3-3h14a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H12.28l-4.762 2.858A1 1 0 0 1 6.002 21v-2h-1a3 3 0 0 1-3-3V6Zm3-1a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h2a1 1 0 0 1 1 1v1.234l3.486-2.092a1 1 0 0 1 .514-.142h7a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1h-14Z',
|
||||
})
|
||||
|
||||
export const Bubble_Stroke2_Corner3_Rounded = createSinglePathSVG({
|
||||
path: 'M2.002 7a4 4 0 0 1 4-4h12a4 4 0 0 1 4 4v8a4 4 0 0 1-4 4H12.28l-4.762 2.858A1 1 0 0 1 6.002 21v-2a4 4 0 0 1-4-4V7Zm4-2a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h1a1 1 0 0 1 1 1v1.234l3.486-2.092a1 1 0 0 1 .514-.142h6a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-12Z',
|
||||
})
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const BulletList_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M6 6a1 1 0 1 0 0 2 1 1 0 0 0 0-2ZM3 7a3 3 0 1 1 6 0 3 3 0 0 1-6 0Zm9 0a1 1 0 0 1 1-1h7a1 1 0 1 1 0 2h-7a1 1 0 0 1-1-1Zm-6 9a1 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 0Zm9 0a1 1 0 0 1 1-1h7a1 1 0 1 1 0 2h-7a1 1 0 0 1-1-1Z',
|
||||
})
|
||||
|
||||
export const BulletList_Filled_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M3 7a3 3 0 1 1 6 0 3 3 0 0 1-6 0Zm0 10a3 3 0 1 1 6 0 3 3 0 0 1-6 0Zm10-1a1 1 0 1 0 0 2h7a1 1 0 1 0 0-2h-7Zm-1-9a1 1 0 0 1 1-1h7a1 1 0 1 1 0 2h-7a1 1 0 0 1-1-1Z',
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const EditBig_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M17.293 2.293a1 1 0 0 1 1.414 0l3 3a1 1 0 0 1 0 1.414l-9 9A1 1 0 0 1 12 16H9a1 1 0 0 1-1-1v-3a1 1 0 0 1 .293-.707l9-9ZM10 12.414V14h1.586l8-8L18 4.414l-8 8ZM3 4a1 1 0 0 1 1-1h7a1 1 0 1 1 0 2H5v14h14v-6a1 1 0 1 1 2 0v7a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Z',
|
||||
})
|
||||
@@ -4,6 +4,10 @@ export const EmojiSad_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M6.343 6.343a8 8 0 1 1 11.314 11.314A8 8 0 0 1 6.343 6.343ZM19.071 4.93c-3.905-3.905-10.237-3.905-14.142 0-3.905 3.905-3.905 10.237 0 14.142 3.905 3.905 10.237 3.905 14.142 0 3.905-3.905 3.905-10.237 0-14.142Zm-3.537 9.535a5 5 0 0 0-7.07 0 1 1 0 1 0 1.413 1.415 3 3 0 0 1 4.243 0 1 1 0 0 0 1.414-1.415ZM16 9.5c0 .828-.56 1.5-1.25 1.5s-1.25-.672-1.25-1.5.56-1.5 1.25-1.5S16 8.672 16 9.5ZM9.25 11c.69 0 1.25-.672 1.25-1.5S9.94 8 9.25 8 8 8.672 8 9.5 8.56 11 9.25 11Z',
|
||||
})
|
||||
|
||||
export const EmojiSmile_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M17.657 6.343A8 8 0 1 0 6.343 17.657 8 8 0 0 0 17.657 6.343ZM4.929 4.93c3.905-3.905 10.237-3.905 14.142 0 3.905 3.905 3.905 10.237 0 14.142-3.905 3.905-10.237 3.905-14.142 0-3.905-3.905-3.905-10.237 0-14.142Zm3.536 9.192a1 1 0 0 1 1.414 0 3 3 0 0 0 4.243 0 1 1 0 0 1 1.414 1.415 5 5 0 0 1-7.071 0 1 1 0 0 1 0-1.415ZM10.5 9.5c0 .828-.56 1.5-1.25 1.5S8 10.328 8 9.5 8.56 8 9.25 8s1.25.672 1.25 1.5ZM16 9.5c0 .828-.56 1.5-1.25 1.5s-1.25-.672-1.25-1.5.56-1.5 1.25-1.5S16 8.672 16 9.5Z',
|
||||
})
|
||||
|
||||
export const EmojiArc_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm8-5a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0V8a1 1 0 0 1 1-1Zm4 0a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0V8a1 1 0 0 1 1-1Zm-5.894 7.803a1 1 0 0 1 1.341-.447c1.719.859 3.387.859 5.106 0a1 1 0 1 1 .894 1.788c-2.281 1.141-4.613 1.141-6.894 0a1 1 0 0 1-.447-1.341Z',
|
||||
})
|
||||
|
||||
@@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE'
|
||||
export const Envelope_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M4.568 4h14.864c.252 0 .498 0 .706.017.229.019.499.063.77.201a2 2 0 0 1 .874.874c.138.271.182.541.201.77.017.208.017.454.017.706v10.864c0 .252 0 .498-.017.706a2.022 2.022 0 0 1-.201.77 2 2 0 0 1-.874.874 2.022 2.022 0 0 1-.77.201c-.208.017-.454.017-.706.017H4.568c-.252 0-.498 0-.706-.017a2.022 2.022 0 0 1-.77-.201 2 2 0 0 1-.874-.874 2.022 2.022 0 0 1-.201-.77C2 17.93 2 17.684 2 17.432V6.568c0-.252 0-.498.017-.706.019-.229.063-.499.201-.77a2 2 0 0 1 .874-.874c.271-.138.541-.182.77-.201C4.07 4 4.316 4 4.568 4Zm.456 2L12 11.708 18.976 6H5.024ZM20 7.747l-6.733 5.509a2 2 0 0 1-2.534 0L4 7.746V17.4a8.187 8.187 0 0 0 .011.589h.014c.116.01.278.011.575.011h14.8a8.207 8.207 0 0 0 .589-.012v-.013c.01-.116.011-.279.011-.575V7.747Z',
|
||||
})
|
||||
|
||||
export const Envelope_Filled_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 11.708 2.654 4.06A.998.998 0 0 1 3 4h18c.122 0 .238.022.346.061L12 11.708ZM2 19V6.11l9.367 7.664a1 1 0 0 0 1.266 0L22 6.11V19a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1Z',
|
||||
})
|
||||
|
||||
@@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE'
|
||||
export const Hashtag_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M9.124 3.008a1 1 0 0 1 .868 1.116L9.632 7h5.985l.39-3.124a1 1 0 0 1 1.985.248L17.632 7H20a1 1 0 1 1 0 2h-2.617l-.75 6H20a1 1 0 1 1 0 2h-3.617l-.39 3.124a1 1 0 1 1-1.985-.248l.36-2.876H8.382l-.39 3.124a1 1 0 1 1-1.985-.248L6.368 17H4a1 1 0 1 1 0-2h2.617l.75-6H4a1 1 0 1 1 0-2h3.617l.39-3.124a1 1 0 0 1 1.117-.868ZM9.383 9l-.75 6h5.984l.75-6H9.383Z',
|
||||
})
|
||||
|
||||
export const Hashtag_Filled_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M9.186 2.512a1.5 1.5 0 0 0-1.674 1.302L7.176 6.5H4a1.5 1.5 0 1 0 0 3h2.8l-.624 5H4a1.5 1.5 0 0 0 0 3h1.8l-.288 2.314a1.5 1.5 0 1 0 2.976.372l.336-2.686h4.977l-.29 2.314a1.5 1.5 0 1 0 2.977.372l.336-2.686H20a1.5 1.5 0 0 0 0-3h-2.8l.624-5H20a1.5 1.5 0 0 0 0-3h-1.8l.288-2.314a1.5 1.5 0 1 0-2.976-.372L15.176 6.5h-4.977l.29-2.314a1.5 1.5 0 0 0-1.303-1.674ZM9.2 14.5l.625-5h4.977l-.625 5H9.199Z',
|
||||
})
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const HomeOpen_Stoke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M11.37 1.724a1 1 0 0 1 1.26 0l8 6.5A1 1 0 0 1 21 9v11a1 1 0 0 1-1 1h-6a1 1 0 0 1-1-1v-5h-2v5a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V9a1 1 0 0 1 .37-.776l8-6.5ZM5 9.476V19h4v-5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v5h4V9.476l-7-5.688-7 5.688Z',
|
||||
})
|
||||
|
||||
export const HomeOpen_Filled_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12.63 1.724a1 1 0 0 0-1.26 0l-8 6.5A1 1 0 0 0 3 9v11a1 1 0 0 0 1 1h5a1 1 0 0 0 1-1v-6h4v6a1 1 0 0 0 1 1h5a1 1 0 0 0 1-1V9a1 1 0 0 0-.37-.776l-8-6.5Z',
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const MagnifyingGlass_Filled_Stroke2_Corner0_Rounded =
|
||||
createSinglePathSVG({
|
||||
path: 'M5 11a6 6 0 1 1 12 0 6 6 0 0 1-12 0Zm6-8a8 8 0 1 0 4.906 14.32l3.387 3.387a1 1 0 0 0 1.414-1.414l-3.387-3.387A8 8 0 0 0 11 3Zm4 8a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z',
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Message_Stroke2_Corner0_Rounded_Filled = createSinglePathSVG({
|
||||
path: 'M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10a9.968 9.968 0 0 1-4.136-.893l-4.68.876a1 1 0 0 1-1.164-1.184l.931-4.537A9.965 9.965 0 0 1 2 12Zm4.25 0a1.25 1.25 0 1 0 2.5 0 1.25 1.25 0 0 0-2.5 0Zm4.5 0a1.25 1.25 0 1 0 2.5 0 1.25 1.25 0 0 0-2.5 0Zm5.75 1.25a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Z',
|
||||
})
|
||||
|
||||
export const Message_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M4 12a8 8 0 1 1 4.445 7.169 1 1 0 0 0-.629-.088l-3.537.662.7-3.415a1 1 0 0 0-.09-.66A7.961 7.961 0 0 1 4 12Zm8-10C6.477 2 2 6.477 2 12c0 1.523.341 2.968.951 4.262l-.93 4.537a1 1 0 0 0 1.163 1.184l4.68-.876A9.968 9.968 0 0 0 12 22c5.523 0 10-4.477 10-10S17.523 2 12 2ZM7.5 13.25a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Zm4.5 0a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Zm4.5 0a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Z',
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const PaperPlane_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M3.374 3.22a1 1 0 0 1 1.073-.114l16 8a1 1 0 0 1 0 1.788l-16 8a1 1 0 0 1-1.417-1.136L4.97 12 3.03 4.243a1 1 0 0 1 .344-1.023ZM6.781 13l-1.284 5.133L17.764 12 5.497 5.867 6.781 11H9a1 1 0 1 1 0 2H6.78Z',
|
||||
})
|
||||
@@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE'
|
||||
export const PlusLarge_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 3a1 1 0 0 1 1 1v7h7a1 1 0 1 1 0 2h-7v7a1 1 0 1 1-2 0v-7H4a1 1 0 1 1 0-2h7V4a1 1 0 0 1 1-1Z',
|
||||
})
|
||||
|
||||
export const PlusSmall_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 6a1 1 0 0 1 1 1v4h4a1 1 0 1 1 0 2h-4v4a1 1 0 1 1-2 0v-4H7a1 1 0 1 1 0-2h4V7a1 1 0 0 1 1-1Z',
|
||||
})
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const SettingsGear2_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M11.1 2a1 1 0 0 0-.832.445L8.851 4.57 6.6 4.05a1 1 0 0 0-.932.268l-1.35 1.35a1 1 0 0 0-.267.932l.52 2.251-2.126 1.417A1 1 0 0 0 2 11.1v1.8a1 1 0 0 0 .445.832l2.125 1.417-.52 2.251a1 1 0 0 0 .268.932l1.35 1.35a1 1 0 0 0 .932.267l2.251-.52 1.417 2.126A1 1 0 0 0 11.1 22h1.8a1 1 0 0 0 .832-.445l1.417-2.125 2.251.52a1 1 0 0 0 .932-.268l1.35-1.35a1 1 0 0 0 .267-.932l-.52-2.251 2.126-1.417A1 1 0 0 0 22 12.9v-1.8a1 1 0 0 0-.445-.832L19.43 8.851l.52-2.251a1 1 0 0 0-.268-.932l-1.35-1.35a1 1 0 0 0-.932-.267l-2.251.52-1.417-2.126A1 1 0 0 0 12.9 2h-1.8Zm-.968 4.255L11.635 4h.73l1.503 2.255a1 1 0 0 0 1.057.42l2.385-.551.566.566-.55 2.385a1 1 0 0 0 .42 1.057L20 11.635v.73l-2.255 1.503a1 1 0 0 0-.42 1.057l.551 2.385-.566.566-2.385-.55a1 1 0 0 0-1.057.42L12.365 20h-.73l-1.503-2.255a1 1 0 0 0-1.057-.42l-2.385.551-.566-.566.55-2.385a1 1 0 0 0-.42-1.057L4 12.365v-.73l2.255-1.503a1 1 0 0 0 .42-1.057L6.123 6.69l.566-.566 2.385.55a1 1 0 0 0 1.057-.42ZM8 12a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm4-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z',
|
||||
})
|
||||
|
||||
export const SettingsGear2_Filled_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M9.996 2.869A1.951 1.951 0 0 1 11.62 2h.76c.653 0 1.262.326 1.624.869l1.141 1.712 1.749-.404a1.951 1.951 0 0 1 1.819.522l.588.589c.476.475.673 1.162.522 1.818l-.404 1.749 1.712 1.141c.543.362.869.971.869 1.624v.76c0 .653-.326 1.262-.869 1.624l-1.712 1.141.404 1.749a1.951 1.951 0 0 1-.522 1.819l-.588.588a1.951 1.951 0 0 1-1.819.522l-1.749-.404-1.141 1.712A1.951 1.951 0 0 1 12.38 22h-.76a1.951 1.951 0 0 1-1.624-.869L8.855 19.42l-1.749.404a1.951 1.951 0 0 1-1.818-.522l-.59-.588a1.951 1.951 0 0 1-.52-1.819l.403-1.749-1.712-1.141A1.951 1.951 0 0 1 2 12.38v-.76c0-.653.326-1.262.869-1.624L4.58 8.855l-.404-1.749A1.951 1.951 0 0 1 4.7 5.288l.589-.59a1.951 1.951 0 0 1 1.818-.52l1.749.403 1.141-1.712ZM8.5 12a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0Z',
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const SettingsSliderVertical_Stroke2_Corner0_Rounded =
|
||||
createSinglePathSVG({
|
||||
path: 'M7 3a1 1 0 0 1 1 1v1.126a4 4 0 0 1 0 7.748V20a1 1 0 1 1-2 0v-7.126a4 4 0 0 1 0-7.748V4a1 1 0 0 1 1-1Zm10 0a1 1 0 0 1 1 1v9.126a4 4 0 1 1-2 0V4a1 1 0 0 1 1-1ZM7 7a2 2 0 1 0 0 4 2 2 0 1 0 0-4Zm10 8a2 2 0 1 0 0 4 2 2 0 1 0 0-4Z',
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import Svg, {Path} from 'react-native-svg'
|
||||
|
||||
import {useCommonSVGProps, Props} from '#/components/icons/common'
|
||||
import {Props, useCommonSVGProps} from '#/components/icons/common'
|
||||
|
||||
export const IconTemplate_Stroke2_Corner0_Rounded = React.forwardRef(
|
||||
function LogoImpl(props: Props, ref) {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const UserCircle_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 4a8 8 0 0 0-5.935 13.365C7.56 15.895 9.612 15 12 15c2.388 0 4.44.894 5.935 2.365A8 8 0 0 0 12 4Zm4.412 14.675C15.298 17.636 13.792 17 12 17c-1.791 0-3.298.636-4.412 1.675A7.96 7.96 0 0 0 12 20a7.96 7.96 0 0 0 4.412-1.325ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10a9.98 9.98 0 0 1-3.462 7.567A9.965 9.965 0 0 1 12 22a9.965 9.965 0 0 1-6.538-2.433A9.98 9.98 0 0 1 2 12Zm10-4a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm-4 2a4 4 0 1 1 8 0 4 4 0 0 1-8 0Z',
|
||||
})
|
||||
|
||||
export const UserCircle_Filled_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Zm3-12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm-3 10a7.976 7.976 0 0 1-5.714-2.4C7.618 16.004 9.605 15 12 15c2.396 0 4.383 1.005 5.714 2.6A7.976 7.976 0 0 1 12 20Z',
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, View, ViewStyle} from 'react-native'
|
||||
import {AppBskyFeedDefs, ComAtprotoLabelDefs} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {msg, Plural} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useSession} from '#/state/session'
|
||||
|
||||
@@ -39,7 +39,6 @@ export function LabelsOnMe({
|
||||
return null
|
||||
}
|
||||
|
||||
const labelTarget = isAccount ? _(msg`account`) : _(msg`content`)
|
||||
return (
|
||||
<View style={[a.flex_row, style]}>
|
||||
<LabelsOnMeDialog control={control} subject={details} labels={labels} />
|
||||
@@ -54,11 +53,18 @@ export function LabelsOnMe({
|
||||
}}>
|
||||
<ButtonIcon position="left" icon={CircleInfo} />
|
||||
<ButtonText style={[a.leading_snug]}>
|
||||
{labels.length}{' '}
|
||||
{labels.length === 1 ? (
|
||||
<Trans>label has been placed on this {labelTarget}</Trans>
|
||||
{isAccount ? (
|
||||
<Plural
|
||||
value={labels.length}
|
||||
one="# label has been placed on this account"
|
||||
other="# labels has been placed on this account"
|
||||
/>
|
||||
) : (
|
||||
<Trans>labels have been placed on this {labelTarget}</Trans>
|
||||
<Plural
|
||||
value={labels.length}
|
||||
one="# label has been placed on this content"
|
||||
other="# labels has been placed on this content"
|
||||
/>
|
||||
)}
|
||||
</ButtonText>
|
||||
</Button>
|
||||
|
||||
@@ -7,7 +7,7 @@ import {useLingui} from '@lingui/react'
|
||||
import {useLabelInfo} from '#/lib/moderation/useLabelInfo'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {getAgent} from '#/state/session'
|
||||
import {useAgent} from '#/state/session'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
@@ -173,6 +173,7 @@ function AppealForm({
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const [details, setDetails] = React.useState('')
|
||||
const isAccountReport = 'did' in subject
|
||||
const {getAgent} = useAgent()
|
||||
|
||||
const onSubmit = async () => {
|
||||
try {
|
||||
@@ -189,7 +190,7 @@ function AppealForm({
|
||||
},
|
||||
reason: details,
|
||||
})
|
||||
Toast.show(_(msg`Appeal submitted.`))
|
||||
Toast.show(_(msg`Appeal submitted`))
|
||||
} finally {
|
||||
control.close()
|
||||
}
|
||||
|
||||
@@ -121,20 +121,20 @@ function ModerationDetailsDialogInner({
|
||||
<>
|
||||
<Divider />
|
||||
<Text style={[t.atoms.text, a.text_md, a.leading_snug, a.mt_lg]}>
|
||||
<Trans>
|
||||
This label was applied by{' '}
|
||||
{modcause.source.type === 'user' ? (
|
||||
<Trans>the author</Trans>
|
||||
) : (
|
||||
{modcause.source.type === 'user' ? (
|
||||
<Trans>This label was applied by the author.</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
This label was applied by{' '}
|
||||
<InlineLinkText
|
||||
to={makeProfileLink({did: modcause.label.src, handle: ''})}
|
||||
onPress={() => control.close()}
|
||||
style={a.text_md}>
|
||||
{desc.source}
|
||||
</InlineLinkText>
|
||||
)}
|
||||
.
|
||||
</Trans>
|
||||
.
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
import React, {ComponentProps} from 'react'
|
||||
import {StyleSheet, Pressable, View, ViewStyle, StyleProp} from 'react-native'
|
||||
import {ModerationUI} from '@atproto/api'
|
||||
import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
|
||||
import {AppBskyActorDefs, ModerationUI} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
|
||||
import {addStyle} from 'lib/styles'
|
||||
|
||||
import {useTheme, atoms as a} from '#/alf'
|
||||
import {precacheProfile} from 'state/queries/profile'
|
||||
// import {Link} from '#/components/Link' TODO this imposes some styles that screw things up
|
||||
import {Link} from '#/view/com/util/Link'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {
|
||||
ModerationDetailsDialog,
|
||||
useModerationDetailsDialogControl,
|
||||
} from '#/components/moderation/ModerationDetailsDialog'
|
||||
import {Text} from '#/components/Typography'
|
||||
// import {Link} from '#/components/Link' TODO this imposes some styles that screw things up
|
||||
import {Link} from '#/view/com/util/Link'
|
||||
|
||||
interface Props extends ComponentProps<typeof Link> {
|
||||
iconSize: number
|
||||
iconStyles: StyleProp<ViewStyle>
|
||||
modui: ModerationUI
|
||||
profile: AppBskyActorDefs.ProfileViewBasic
|
||||
}
|
||||
|
||||
export function PostHider({
|
||||
@@ -30,8 +32,10 @@ export function PostHider({
|
||||
children,
|
||||
iconSize,
|
||||
iconStyles,
|
||||
profile,
|
||||
...props
|
||||
}: Props) {
|
||||
const queryClient = useQueryClient()
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const [override, setOverride] = React.useState(false)
|
||||
@@ -39,6 +43,10 @@ export function PostHider({
|
||||
const blur = modui.blurs[0]
|
||||
const desc = useModerationCauseDescription(blur)
|
||||
|
||||
const onBeforePress = React.useCallback(() => {
|
||||
precacheProfile(queryClient, profile)
|
||||
}, [queryClient, profile])
|
||||
|
||||
if (!blur) {
|
||||
return (
|
||||
<Link
|
||||
@@ -46,6 +54,7 @@ export function PostHider({
|
||||
style={style}
|
||||
href={href}
|
||||
accessible={false}
|
||||
onBeforePress={onBeforePress}
|
||||
{...props}>
|
||||
{children}
|
||||
</Link>
|
||||
|
||||
@@ -5,6 +5,7 @@ const ScrollContext = createContext<ScrollHandlers<any>>({
|
||||
onBeginDrag: undefined,
|
||||
onEndDrag: undefined,
|
||||
onScroll: undefined,
|
||||
onMomentumEnd: undefined,
|
||||
})
|
||||
|
||||
export function useScrollHandlers(): ScrollHandlers<any> {
|
||||
@@ -20,14 +21,16 @@ export function ScrollProvider({
|
||||
onBeginDrag,
|
||||
onEndDrag,
|
||||
onScroll,
|
||||
onMomentumEnd,
|
||||
}: ProviderProps) {
|
||||
const handlers = useMemo(
|
||||
() => ({
|
||||
onBeginDrag,
|
||||
onEndDrag,
|
||||
onScroll,
|
||||
onMomentumEnd,
|
||||
}),
|
||||
[onBeginDrag, onEndDrag, onScroll],
|
||||
[onBeginDrag, onEndDrag, onScroll, onMomentumEnd],
|
||||
)
|
||||
return (
|
||||
<ScrollContext.Provider value={handlers}>{children}</ScrollContext.Provider>
|
||||
|
||||