Compare commits
154 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4adeb4befc | |||
| d155b718b5 | |||
| 166c56c4cb | |||
| 131578ca3a | |||
| 0014b8b9cd | |||
| f06a78017a | |||
| 27ae829698 | |||
| ada2ac8b91 | |||
| fd8ac1da7d | |||
| beb0fce56c | |||
| ac43b666cb | |||
| 618c4e4a56 | |||
| 153fc17676 | |||
| 8eb1f8f1fc | |||
| da2b6f4797 | |||
| 1c16c6ef0a | |||
| a7db0bac6f | |||
| 29eef6188a | |||
| ba3cff62f1 | |||
| 3be87fbf3b | |||
| 9fde3957e7 | |||
| dae152da85 | |||
| a42efd15d7 | |||
| d0c2003cc7 | |||
| 1cfdd2a7a0 | |||
| 663f16dfc2 | |||
| 947c876fdb | |||
| a62fe855c3 | |||
| c5d9e282e5 | |||
| f5f02aa4b3 | |||
| 2d854091b9 | |||
| 01a51c3275 | |||
| dda0db9963 | |||
| 67c394bc82 | |||
| c995eb2f2f | |||
| 5c14f69566 | |||
| 66c09d9953 | |||
| c82f768374 | |||
| 7222d6fe52 | |||
| 8b6dde17fd | |||
| 07dd57d1ea | |||
| 1c582c59f0 | |||
| 84fcbe398b | |||
| 3be9dde92d | |||
| 96f4f6359a | |||
| 9e59a2eef2 | |||
| 462708e207 | |||
| 36a7a8d8c6 | |||
| 7c36ea1158 | |||
| 9e9ffd5c6e | |||
| 3f963cfed7 | |||
| a2b71e3a4b | |||
| a3d36393a1 | |||
| b996b304c3 | |||
| ec75cbc01f | |||
| b924c53b23 | |||
| c5b831d5d8 | |||
| 3795d85591 | |||
| 7dc8ba8e4e | |||
| 22aee9704c | |||
| 3409a72548 | |||
| 595a894551 | |||
| 0d1373bb71 | |||
| cc8369e868 | |||
| 0437838649 | |||
| 23f0092440 | |||
| 9c5d92088d | |||
| e40656128d | |||
| d76cd88d91 | |||
| 6254cf020a | |||
| 61a14043e5 | |||
| 3fc4c32ac6 | |||
| a383b9ba28 | |||
| bf4d3e89c2 | |||
| 1fd3af3c46 | |||
| 3f879f0315 | |||
| 7c14b54ab2 | |||
| 9c739912a3 | |||
| c4dec63960 | |||
| a7e1913e22 | |||
| a1699da1a9 | |||
| ae9176c9c2 | |||
| 3d954a00e0 | |||
| f9392d4a96 | |||
| bdcddee7a5 | |||
| 5c3576dee7 | |||
| 7a3a1a2bd0 | |||
| e6193d7036 | |||
| cb85768e9c | |||
| ca7116ec3b | |||
| 80e59d31d0 | |||
| ca70aacb8a | |||
| e6bfeb1bab | |||
| 3b4f1e91e3 | |||
| fff625167f | |||
| 5d30111b78 | |||
| 798bf47827 | |||
| b0f7f51b04 | |||
| ea9f2dfbdd | |||
| 1fda7d0553 | |||
| 7ff65df417 | |||
| f8f88878dc | |||
| 8f63cb86b1 | |||
| ed4add7072 | |||
| 48fd397d46 | |||
| f23a621c7e | |||
| da45c42b6f | |||
| 63ba0a436b | |||
| a51fc8e434 | |||
| e2c0f78719 | |||
| 4146f9fe02 | |||
| a28791680f | |||
| d805dec08f | |||
| 326445d3af | |||
| 40d05ac866 | |||
| cb862bffea | |||
| 1e3b7feccf | |||
| ab4be7a9e1 | |||
| a154459b8f | |||
| d793abf8cd | |||
| 11616846ca | |||
| a14704ddfe | |||
| 22a902e0de | |||
| 99051f0ab1 | |||
| f45f9afab7 | |||
| a3f79cc714 | |||
| ae8ddc2c41 | |||
| d56efe250e | |||
| db25f95c33 | |||
| b37199a5a0 | |||
| 1a3ecdf6ec | |||
| 66f5e3a833 | |||
| 5a865a504c | |||
| ff2a82d358 | |||
| 5d3e2e1467 | |||
| 521a764d4f | |||
| 7d694f605a | |||
| 0d1d0d8fbb | |||
| 1ffdd668dc | |||
| 94e35ee3ea | |||
| 459611ca7a | |||
| 03f75e8ff4 | |||
| 3ab5c6a71f | |||
| accd1834b3 | |||
| b1cc4a5e9f | |||
| eabe3ed231 | |||
| 98b7e95b46 | |||
| 7d6adb416a | |||
| 729b9c9cc0 | |||
| 4b706c9519 | |||
| b12d39b4cf | |||
| be1285ea36 | |||
| 9cd4f92027 | |||
| 1db2668a96 |
+2
-15
@@ -32,6 +32,7 @@ module.exports = {
|
||||
'H6',
|
||||
'P',
|
||||
'Admonition',
|
||||
'Admonition.Admonition',
|
||||
],
|
||||
impliedTextProps: [],
|
||||
suggestedTextWrappers: {
|
||||
@@ -80,20 +81,6 @@ module.exports = {
|
||||
],
|
||||
'simple-import-sort/exports': 'error',
|
||||
'react-compiler/react-compiler': 'warn',
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: '@atproto/api',
|
||||
importNames: ['moderatePost'],
|
||||
message:
|
||||
'Please use `moderatePost_wrapped` from `#/lib/moderatePost_wrapped` instead.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/ban-ts-comment': 'warn',
|
||||
},
|
||||
ignorePatterns: [
|
||||
'**/__mocks__/*.ts',
|
||||
@@ -105,9 +92,9 @@ module.exports = {
|
||||
'*.lock',
|
||||
'.husky',
|
||||
'patches',
|
||||
'bskyweb',
|
||||
'*.html',
|
||||
'bskyweb',
|
||||
'bskyembed',
|
||||
'src/locale/locales/_build/',
|
||||
'src/locale/locales/**/*.js',
|
||||
],
|
||||
|
||||
@@ -14,7 +14,7 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
name: Build and Submit iOS
|
||||
runs-on: macos-14
|
||||
runs-on: macos-15
|
||||
steps:
|
||||
- name: Check for EXPO_TOKEN
|
||||
run: >
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
|
||||
- uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: '15.3'
|
||||
xcode-version: '16.2'
|
||||
|
||||
- name: ☕️ Setup Cocoapods
|
||||
uses: maxim-lobanov/setup-cocoapods@v1
|
||||
|
||||
@@ -134,7 +134,7 @@ jobs:
|
||||
# GitHub actions are horrible so let's just copy paste this in
|
||||
buildIfNecessaryIOS:
|
||||
name: Build and Submit iOS
|
||||
runs-on: macos-14
|
||||
runs-on: macos-15
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-build-ios
|
||||
cancel-in-progress: true
|
||||
@@ -176,7 +176,7 @@ jobs:
|
||||
|
||||
- uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: '15.3'
|
||||
xcode-version: '16.2'
|
||||
|
||||
- name: ☕️ Setup Cocoapods
|
||||
uses: maxim-lobanov/setup-cocoapods@v1
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
name: Nightly Update Source Languages
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 * * *' # run at 2 AM UTC
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
extract-languages:
|
||||
name: Nightly Update Source Languages
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
# Give the default GITHUB_TOKEN write permission to commit and push the
|
||||
# added or changed files to the repository.
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ssh-key: ${{secrets.GH_ACTION_DEPLOY_KEY}}
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Yarn install
|
||||
uses: Wandalen/wretry.action@master
|
||||
with:
|
||||
command: yarn --frozen-lockfile
|
||||
attempt_limit: 3
|
||||
attempt_delay: 2000
|
||||
- name: Extract language strings
|
||||
run: yarn intl:extract
|
||||
- name: Create commit
|
||||
uses: stefanzweifel/git-auto-commit-action@v5
|
||||
with:
|
||||
commit_message: Nightly source-language update
|
||||
file_pattern: ./src/locale/locales/en/messages.po
|
||||
- name: Push source lang to Crowdin
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
upload_sources: true
|
||||
upload_sources_args: "-b main"
|
||||
upload_translations: false
|
||||
push_translations: false
|
||||
push_sources: false
|
||||
create_pull_request: false
|
||||
env:
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
@@ -37,6 +37,6 @@ deps: ## Installs dependent libs using 'yarn install'
|
||||
|
||||
.PHONY: nvm-setup
|
||||
nvm-setup: ## Use NVM to install and activate node+yarn
|
||||
nvm install 18
|
||||
nvm use 18
|
||||
nvm install 20
|
||||
nvm use 20
|
||||
npm install --global yarn
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
flows:
|
||||
- "flows/*"
|
||||
- "flows/**"
|
||||
|
||||
@@ -17,7 +17,7 @@ appId: xyz.blueskyweb.app
|
||||
- inputText: "Post with an image"
|
||||
- tapOn:
|
||||
id: "openGalleryBtn"
|
||||
- tapOn: "Content warnings"
|
||||
- tapOn: "Allow Full Access"
|
||||
- tapOn: "Content warnings"
|
||||
- tapOn: "Porn"
|
||||
- tapOn:
|
||||
|
||||
@@ -24,12 +24,10 @@ appId: xyz.blueskyweb.app
|
||||
- inputText: "example@test.com"
|
||||
- tapOn:
|
||||
id: "passwordInput"
|
||||
- inputText: "hunter2"
|
||||
- pressKey: Enter
|
||||
- inputText: "hunter22"
|
||||
- hideKeyboard
|
||||
- tapOn:
|
||||
id: "nextBtn"
|
||||
- tapOn:
|
||||
id: "handleInput"
|
||||
- inputText: "e2e-test"
|
||||
- tapOn:
|
||||
id: "nextBtn"
|
||||
|
||||
@@ -160,7 +160,7 @@ appId: xyz.blueskyweb.app
|
||||
- tapOn:
|
||||
label: "Adds and removes users on curatelists from the profile"
|
||||
id: "bottomBarSearchBtn"
|
||||
- tapOn: "Search"
|
||||
- tapOn: "Search for posts, users, or feeds"
|
||||
- inputText: "bob"
|
||||
- tapOn:
|
||||
id: "searchAutoCompleteResult-bob.test"
|
||||
|
||||
@@ -16,7 +16,7 @@ appId: xyz.blueskyweb.app
|
||||
- tapOn:
|
||||
id: "profileCardButton"
|
||||
- tapOn:
|
||||
id: "profilePager-selector-4"
|
||||
id: "profilePager-selector-5"
|
||||
- tapOn: "alice-favs"
|
||||
- tapOn: "Pin to Home"
|
||||
- tapOn:
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
appId: xyz.blueskyweb.app
|
||||
---
|
||||
- runScript:
|
||||
file: ../setupServer.js
|
||||
env:
|
||||
SERVER_PATH: "?users&follows&posts"
|
||||
- runFlow:
|
||||
file: ../setupApp.yml
|
||||
- tapOn:
|
||||
id: "e2eSignInAlice"
|
||||
|
||||
- tapOn:
|
||||
id: "postDropdownBtn"
|
||||
index: 0
|
||||
- tapOn:
|
||||
id: "postDropdownReportBtn"
|
||||
- tapOn: "Create report for Misleading Post"
|
||||
- tapOn: "Send report to Dev-env Moderation"
|
||||
- tapOn:
|
||||
point: "50%,90%"
|
||||
@@ -15,7 +15,7 @@ appId: xyz.blueskyweb.app
|
||||
id: "bottomBarSearchBtn"
|
||||
- tapOn:
|
||||
id: "bottomBarSearchBtn"
|
||||
- tapOn: "Search"
|
||||
- tapOn: "Search for posts, users, or feeds"
|
||||
- inputText: "b"
|
||||
- tapOn:
|
||||
id: "searchAutoCompleteResult-bob.test"
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
appId: xyz.blueskyweb.app
|
||||
---
|
||||
- runScript:
|
||||
file: ../../setupServer.js
|
||||
env:
|
||||
SERVER_PATH: ?users&follows&posts&feeds
|
||||
- runFlow:
|
||||
file: ../../setupApp.yml
|
||||
- tapOn:
|
||||
id: "e2eSignInAlice"
|
||||
|
||||
- tapOn: "Carla's avatar"
|
||||
- tapOn:
|
||||
id: "profileHeaderDropdownBtn"
|
||||
- tapOn:
|
||||
id: "profileHeaderDropdownReportBtn"
|
||||
- tapOn:
|
||||
id: "report:option:com.atproto.moderation.defs#reasonSpam"
|
||||
- assertVisible:
|
||||
id: "report:labeler:mod-authority.test"
|
||||
- tapOn:
|
||||
id: "report:submit"
|
||||
- assertNotVisible:
|
||||
id: "report:dialog"
|
||||
@@ -0,0 +1,24 @@
|
||||
appId: xyz.blueskyweb.app
|
||||
---
|
||||
- runScript:
|
||||
file: ../../setupServer.js
|
||||
env:
|
||||
SERVER_PATH: ?users&follows&posts&feeds
|
||||
- runFlow:
|
||||
file: ../../setupApp.yml
|
||||
- tapOn:
|
||||
id: "e2eSignInAlice"
|
||||
|
||||
- tapOn:
|
||||
id: "postDropdownBtn"
|
||||
index: 0
|
||||
- tapOn:
|
||||
id: "postDropdownReportBtn"
|
||||
- tapOn:
|
||||
id: "report:option:com.atproto.moderation.defs#reasonSpam"
|
||||
- assertVisible:
|
||||
id: "report:labeler:mod-authority.test"
|
||||
- tapOn:
|
||||
id: "report:submit"
|
||||
- assertNotVisible:
|
||||
id: "report:dialog"
|
||||
@@ -0,0 +1,35 @@
|
||||
appId: xyz.blueskyweb.app
|
||||
---
|
||||
- runScript:
|
||||
file: ../../setupServer.js
|
||||
env:
|
||||
SERVER_PATH: ?users&follows&posts&feeds
|
||||
- runFlow:
|
||||
file: ../../setupApp.yml
|
||||
- tapOn:
|
||||
id: "e2eSignInAlice"
|
||||
|
||||
- tapOn:
|
||||
id: "postDropdownBtn"
|
||||
index: 0
|
||||
- tapOn:
|
||||
id: "postDropdownReportBtn"
|
||||
- tapOn:
|
||||
id: "report:option:com.atproto.moderation.defs#reasonOther"
|
||||
- assertVisible:
|
||||
id: "report:labeler:mod-authority.test"
|
||||
# reason "other" defaults with details open
|
||||
- assertVisible:
|
||||
id: "report:details"
|
||||
- tapOn:
|
||||
id: "report:clearOption"
|
||||
- assertNotVisible:
|
||||
id: "report:details"
|
||||
- tapOn:
|
||||
id: "report:option:com.atproto.moderation.defs#reasonSpam"
|
||||
- assertVisible:
|
||||
id: "report:labeler:mod-authority.test"
|
||||
- tapOn:
|
||||
id: "report:submit"
|
||||
- assertNotVisible:
|
||||
id: "report:dialog"
|
||||
@@ -0,0 +1,30 @@
|
||||
appId: xyz.blueskyweb.app
|
||||
---
|
||||
- runScript:
|
||||
file: ../../setupServer.js
|
||||
env:
|
||||
SERVER_PATH: ?users&follows&posts&feeds
|
||||
- runFlow:
|
||||
file: ../../setupApp.yml
|
||||
- tapOn:
|
||||
id: "e2eSignInAlice"
|
||||
|
||||
- tapOn:
|
||||
id: "postDropdownBtn"
|
||||
index: 0
|
||||
- tapOn:
|
||||
id: "postDropdownReportBtn"
|
||||
- tapOn:
|
||||
id: "report:option:com.atproto.moderation.defs#reasonOther"
|
||||
- assertVisible:
|
||||
id: "report:labeler:mod-authority.test"
|
||||
# reason "other" defaults with details open
|
||||
- assertVisible:
|
||||
id: "report:details"
|
||||
- tapOn:
|
||||
id: "report:details"
|
||||
- inputText: "This is a test report"
|
||||
- tapOn:
|
||||
id: "report:submit"
|
||||
- assertNotVisible:
|
||||
id: "report:dialog"
|
||||
@@ -12,8 +12,8 @@ appId: xyz.blueskyweb.app
|
||||
# Navigate to another user profile via autocomplete
|
||||
- tapOn:
|
||||
id: "bottomBarSearchBtn"
|
||||
- assertVisible: "Search"
|
||||
- tapOn: "Search"
|
||||
- assertVisible: "Search for posts, users, or feeds"
|
||||
- tapOn: "Search for posts, users, or feeds"
|
||||
- inputText: "b"
|
||||
- tapOn:
|
||||
id: "searchAutoCompleteResult-bob.test"
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import {IsValidHandle, validateServiceHandle} from '#/lib/strings/handles'
|
||||
|
||||
describe('handle validation', () => {
|
||||
const valid = [
|
||||
['ali', 'bsky.social'],
|
||||
['alice', 'bsky.social'],
|
||||
['a-lice', 'bsky.social'],
|
||||
['a-----lice', 'bsky.social'],
|
||||
['123', 'bsky.social'],
|
||||
['123456789012345678', 'bsky.social'],
|
||||
['alice', 'custom-pds.com'],
|
||||
['alice', 'my-custom-pds-with-long-name.social'],
|
||||
['123456789012345678', 'my-custom-pds-with-long-name.social'],
|
||||
]
|
||||
it.each(valid)(`should be valid: %s.%s`, (handle, service) => {
|
||||
const result = validateServiceHandle(handle, service)
|
||||
expect(result.overall).toEqual(true)
|
||||
})
|
||||
|
||||
const invalid = [
|
||||
['al', 'bsky.social', 'frontLength'],
|
||||
['-alice', 'bsky.social', 'hyphenStartOrEnd'],
|
||||
['alice-', 'bsky.social', 'hyphenStartOrEnd'],
|
||||
['%%%', 'bsky.social', 'handleChars'],
|
||||
['1234567890123456789', 'bsky.social', 'frontLength'],
|
||||
[
|
||||
'1234567890123456789',
|
||||
'my-custom-pds-with-long-name.social',
|
||||
'frontLength',
|
||||
],
|
||||
['al', 'my-custom-pds-with-long-name.social', 'frontLength'],
|
||||
['a'.repeat(300), 'toolong.com', 'totalLength'],
|
||||
] satisfies [string, string, keyof IsValidHandle][]
|
||||
it.each(invalid)(
|
||||
`should be invalid: %s.%s due to %s`,
|
||||
(handle, service, expectedError) => {
|
||||
const result = validateServiceHandle(handle, service)
|
||||
expect(result.overall).toEqual(false)
|
||||
expect(result[expectedError]).toEqual(false)
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -75,17 +75,21 @@ module.exports = function (config) {
|
||||
'an',
|
||||
'ast',
|
||||
'ca',
|
||||
'cy',
|
||||
'da',
|
||||
'de',
|
||||
'el',
|
||||
'eo',
|
||||
'es',
|
||||
'eu',
|
||||
'fi',
|
||||
'fr',
|
||||
'ga',
|
||||
'gd',
|
||||
'gl',
|
||||
'hi',
|
||||
'hu',
|
||||
'ia',
|
||||
'id',
|
||||
'it',
|
||||
'ja',
|
||||
@@ -236,11 +240,13 @@ module.exports = function (config) {
|
||||
},
|
||||
],
|
||||
'./plugins/starterPackAppClipExtension/withStarterPackAppClip.js',
|
||||
'./plugins/withGradleJVMHeapSizeIncrease.js',
|
||||
'./plugins/withAndroidManifestPlugin.js',
|
||||
'./plugins/withAndroidManifestFCMIconPlugin.js',
|
||||
'./plugins/withAndroidStylesAccentColorPlugin.js',
|
||||
'./plugins/withAndroidSplashScreenStatusBarTranslucentPlugin.js',
|
||||
'./plugins/withAndroidNoJitpackPlugin.js',
|
||||
'./plugins/withNoBundleCompression.js',
|
||||
'./plugins/shareExtension/withShareExtensions.js',
|
||||
'./plugins/notificationsExtension/withNotificationsExtension.js',
|
||||
'./plugins/withAppDelegateReferrer.js',
|
||||
|
||||
@@ -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.293 4.293a1 1 0 0 1 1.414 0l4 4a1 1 0 0 1-1.414 1.414L12 6.414 8.707 9.707a1 1 0 0 1-1.414-1.414l4-4Zm-4 10a1 1 0 0 1 1.414 0L12 17.586l3.293-3.293a1 1 0 0 1 1.414 1.414l-4 4a1 1 0 0 1-1.414 0l-4-4a1 1 0 0 1 0-1.414Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 363 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 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 12Zm6.293-3.707a1 1 0 0 1 1.414 0L12 10.586l2.293-2.293a1 1 0 1 1 1.414 1.414L13.414 12l2.293 2.293a1 1 0 0 1-1.414 1.414L12 13.414l-2.293 2.293a1 1 0 0 1-1.414-1.414L10.586 12 8.293 9.707a1 1 0 0 1 0-1.414Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 454 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M7 2a1 1 0 0 1 1 1v2a1 1 0 0 1-2 0V3a1 1 0 0 1 1-1Zm4 0a1 1 0 0 1 1 1v2a1 1 0 1 1-2 0V3a1 1 0 0 1 1-1Zm4 0a1 1 0 0 1 1 1v2a1 1 0 1 1-2 0V3a1 1 0 0 1 1-1ZM4 9a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2h.5a3.5 3.5 0 1 1 0 7H18v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V9Zm12 0H6v11h10V9Zm2 5h.5a1.5 1.5 0 0 0 0-3H18v3Z" clip-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 437 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M6.5 4.999Zm0 0a.1.1 0 0 1-.016.024.2.2 0 0 1-.152.043q.086.014.168.032V7.5a1 1 0 0 1-.33.742c-.543.49-.917 1.176-1.23 2.036a1 1 0 0 1-.94.657H3V14h1a1 1 0 0 1 .866.5c.436.754.879 1.195 1.637 1.636A1 1 0 0 1 7 17v2h2a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2h2v-2.537a1 1 0 0 1 .332-.744A4.98 4.98 0 0 0 19 11.999 5 5 0 0 0 14 7H9.474a1 1 0 0 1-.893-.55v-.001l-.001-.002-.005-.008L9.474 6l-.9.438h.001v.002l.002.001.001.003.002.003v.003a2.6 2.6 0 0 0-.443-.538c-.319-.298-.839-.648-1.637-.814V5Zm2.082 1.452ZM9.5 4.447c.211.196.379.387.508.553H14a7 7 0 0 1 6.72 8.96 1.003 1.003 0 0 0 1.146-1.46 1 1 0 1 1 1.731-1 3 3 0 0 1-3.714 4.286A7 7 0 0 1 19 16.888V19a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2h-2a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-1.45A5.8 5.8 0 0 1 3.448 16H3a2 2 0 0 1-2-2v-3.065a2 2 0 0 1 2-2h.324c.286-.649.657-1.291 1.176-1.852V5c0-1.047.89-2.12 2.161-1.907 1.33.223 2.248.805 2.839 1.354ZM8.25 12a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Z" clip-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 4c0-1.012.894-2.187 2.237-1.846a5.002 5.002 0 0 1 3.218 7.119 4 4 0 0 1 2.345 4.976A3.501 3.501 0 0 1 18.5 21h-13a3.5 3.5 0 0 1-1.3-6.75 4 4 0 0 1 2.052-4.848A4 4 0 0 1 10 4h2.001Zm-4.187 7.009A2 2 0 0 0 6 13c0 .366.098.706.271 1H12a1 1 0 1 1 0 2H5.5a1.5 1.5 0 0 0 0 3h13a1.5 1.5 0 1 0 0-3H17a1 1 0 1 1 0-2h.729A1.96 1.96 0 0 0 18 13a2 2 0 0 0-2-2h-2a1 1 0 1 1 0-2h1.236a3.002 3.002 0 0 0-1.243-4.832A2 2 0 0 1 12 6h-2a2 2 0 0 0-1.728 3.009H9a1 1 0 0 1 0 2H7.813Z" clip-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 610 B |
@@ -68,6 +68,7 @@ function scan(node = document) {
|
||||
if (ref_url.startsWith('http')) {
|
||||
searchParams.set('ref_url', encodeURIComponent(ref_url))
|
||||
}
|
||||
searchParams.set('colorMode', embed.dataset.blueskyColorMode || 'system')
|
||||
|
||||
const iframe = document.createElement('iframe')
|
||||
iframe.setAttribute('data-bluesky-id', id)
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
export type ColorModeValues = 'system' | 'light' | 'dark'
|
||||
|
||||
export function assertColorModeValues(value: string): value is ColorModeValues {
|
||||
return ['system', 'light', 'dark'].includes(value)
|
||||
}
|
||||
|
||||
export function applyTheme(theme: 'light' | 'dark') {
|
||||
document.documentElement.classList.remove('light', 'dark')
|
||||
document.documentElement.classList.add(theme)
|
||||
}
|
||||
|
||||
export function initColorMode() {
|
||||
export function initSystemColorMode() {
|
||||
applyTheme(
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
|
||||
@@ -9,3 +9,14 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
select {
|
||||
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' height='14px' width='14px' fill='none' viewBox='0 0 24 24'><path fill='black' fill-rule='evenodd' d='M3.293 8.293a1 1 0 0 1 1.414 0L12 15.586l7.293-7.293a1 1 0 1 1 1.414 1.414l-8 8a1 1 0 0 1-1.414 0l-8-8a1 1 0 0 1 0-1.414Z' clip-rule='evenodd'/></svg>");
|
||||
background-repeat: no-repeat;
|
||||
background-position: calc(100% - 0.75rem) center;
|
||||
padding-right: 2rem;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' height='14px' width='14px' fill='none' viewBox='0 0 24 24'><path fill='white' fill-rule='evenodd' d='M3.293 8.293a1 1 0 0 1 1.414 0L12 15.586l7.293-7.293a1 1 0 1 1 1.414 1.414l-8 8a1 1 0 0 1-1.414 0l-8-8a1 1 0 0 1 0-1.414Z' clip-rule='evenodd'/></svg>");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export function labelsToInfo(
|
||||
return 'Adult Content'
|
||||
case 'nudity':
|
||||
return 'Non-sexual Nudity'
|
||||
case 'gore':
|
||||
case 'graphic-media':
|
||||
return 'Graphic Media'
|
||||
default:
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import '../index.css'
|
||||
|
||||
import {AppBskyFeedDefs, AppBskyFeedPost, AtUri, BskyAgent} from '@atproto/api'
|
||||
import {AppBskyFeedDefs, AppBskyFeedPost, AtpAgent, AtUri} from '@atproto/api'
|
||||
import {h, render} from 'preact'
|
||||
import {useEffect, useMemo, useRef, useState} from 'preact/hooks'
|
||||
|
||||
import arrowBottom from '../../assets/arrowBottom_stroke2_corner0_rounded.svg'
|
||||
import logo from '../../assets/logo.svg'
|
||||
import {initColorMode} from '../color-mode'
|
||||
import {
|
||||
assertColorModeValues,
|
||||
ColorModeValues,
|
||||
initSystemColorMode,
|
||||
} from '../color-mode'
|
||||
import {Container} from '../components/container'
|
||||
import {Link} from '../components/link'
|
||||
import {Post} from '../components/post'
|
||||
@@ -22,9 +26,9 @@ export const EMBED_SCRIPT = `${EMBED_SERVICE}/static/embed.js`
|
||||
const root = document.getElementById('app')
|
||||
if (!root) throw new Error('No root element')
|
||||
|
||||
initColorMode()
|
||||
initSystemColorMode()
|
||||
|
||||
const agent = new BskyAgent({
|
||||
const agent = new AtpAgent({
|
||||
service: 'https://public.api.bsky.app',
|
||||
})
|
||||
|
||||
@@ -32,6 +36,7 @@ render(<LandingPage />, root)
|
||||
|
||||
function LandingPage() {
|
||||
const [uri, setUri] = useState('')
|
||||
const [colorMode, setColorMode] = useState<ColorModeValues>('system')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [thread, setThread] = useState<AppBskyFeedDefs.ThreadViewPost | null>(
|
||||
@@ -120,24 +125,50 @@ function LandingPage() {
|
||||
|
||||
<h1 className="text-4xl font-bold text-center">Embed a Bluesky Post</h1>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={uri}
|
||||
onInput={e => setUri(e.currentTarget.value)}
|
||||
className="border rounded-lg py-3 w-full max-w-[600px] px-4 dark:bg-dimmedBg dark:border-slate-500"
|
||||
placeholder={DEFAULT_POST}
|
||||
/>
|
||||
<div className="flex flex-col w-full max-w-[600px] gap-6">
|
||||
<input
|
||||
type="text"
|
||||
value={uri}
|
||||
onInput={e => setUri(e.currentTarget.value)}
|
||||
className="border rounded-lg py-3 px-4 dark:bg-dimmedBg dark:border-slate-500"
|
||||
placeholder={DEFAULT_POST}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium" for="colorModeSelect">
|
||||
Theme
|
||||
</label>
|
||||
<select
|
||||
value={colorMode}
|
||||
onChange={e => {
|
||||
const value = e.currentTarget.value
|
||||
if (assertColorModeValues(value)) {
|
||||
setColorMode(value)
|
||||
}
|
||||
}}
|
||||
id="colorModeSelect"
|
||||
className="appearance-none bg-white border w-full rounded-lg text-sm px-3 py-2 dark:bg-dimmedBg dark:border-slate-500">
|
||||
<option value="system">System</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<img src={arrowBottom} className="w-6 dark:invert" />
|
||||
|
||||
{loading ? (
|
||||
<div className="w-full max-w-[600px]">
|
||||
<div className={`${colorMode} w-full max-w-[600px]`}>
|
||||
<Skeleton />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full max-w-[600px] gap-8 flex flex-col">
|
||||
{!error && thread && uri && <Snippet thread={thread} />}
|
||||
{!error && thread && <Post thread={thread} key={thread.post.uri} />}
|
||||
{!error && thread && uri && (
|
||||
<Snippet thread={thread} colorMode={colorMode} />
|
||||
)}
|
||||
<div className={colorMode}>
|
||||
{!error && thread && <Post thread={thread} key={thread.post.uri} />}
|
||||
</div>
|
||||
{error && (
|
||||
<div className="w-full border border-red-500 bg-red-500/10 px-4 py-3 rounded-lg">
|
||||
<p className="text-red-500 text-center">{error}</p>
|
||||
@@ -168,7 +199,13 @@ function Skeleton() {
|
||||
)
|
||||
}
|
||||
|
||||
function Snippet({thread}: {thread: AppBskyFeedDefs.ThreadViewPost}) {
|
||||
function Snippet({
|
||||
thread,
|
||||
colorMode,
|
||||
}: {
|
||||
thread: AppBskyFeedDefs.ThreadViewPost
|
||||
colorMode: ColorModeValues
|
||||
}) {
|
||||
const ref = useRef<HTMLInputElement>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
@@ -204,9 +241,11 @@ function Snippet({thread}: {thread: AppBskyFeedDefs.ThreadViewPost}) {
|
||||
// x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x
|
||||
return `<blockquote class="bluesky-embed" data-bluesky-uri="${escapeHtml(
|
||||
thread.post.uri,
|
||||
)}" data-bluesky-cid="${escapeHtml(thread.post.cid)}"><p lang="${escapeHtml(
|
||||
lang,
|
||||
)}">${escapeHtml(record.text)}${
|
||||
)}" data-bluesky-cid="${escapeHtml(
|
||||
thread.post.cid,
|
||||
)}" data-bluesky-embed-color-mode="${escapeHtml(
|
||||
colorMode,
|
||||
)}"><p lang="${escapeHtml(lang)}">${escapeHtml(record.text)}${
|
||||
record.embed
|
||||
? `<br><br><a href="${escapeHtml(href)}">[image or embed]</a>`
|
||||
: ''
|
||||
@@ -217,7 +256,7 @@ function Snippet({thread}: {thread: AppBskyFeedDefs.ThreadViewPost}) {
|
||||
)}</a>) <a href="${escapeHtml(href)}">${escapeHtml(
|
||||
niceDate(thread.post.indexedAt),
|
||||
)}</a></blockquote><script async src="${EMBED_SCRIPT}" charset="utf-8"></script>`
|
||||
}, [thread])
|
||||
}, [thread, colorMode])
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 w-full">
|
||||
|
||||
@@ -4,7 +4,7 @@ import {AppBskyFeedDefs, AtpAgent} from '@atproto/api'
|
||||
import {h, render} from 'preact'
|
||||
|
||||
import logo from '../../assets/logo.svg'
|
||||
import {initColorMode} from '../color-mode'
|
||||
import {applyTheme, initSystemColorMode} from '../color-mode'
|
||||
import {Container} from '../components/container'
|
||||
import {Link} from '../components/link'
|
||||
import {Post} from '../components/post'
|
||||
@@ -22,7 +22,23 @@ if (!uri) {
|
||||
throw new Error('No uri in path')
|
||||
}
|
||||
|
||||
initColorMode()
|
||||
const query = new URLSearchParams(window.location.search)
|
||||
|
||||
// theme - default to light mode
|
||||
const colorMode = query.get('colorMode')
|
||||
|
||||
switch (colorMode) {
|
||||
case 'dark':
|
||||
applyTheme('dark')
|
||||
break
|
||||
case 'system':
|
||||
initSystemColorMode()
|
||||
break
|
||||
case 'light':
|
||||
default:
|
||||
applyTheme('light')
|
||||
break
|
||||
}
|
||||
|
||||
agent
|
||||
.getPostThread({
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import {Express} from 'express'
|
||||
|
||||
import {AppContext} from '../context.js'
|
||||
import {default as create} from './create.js'
|
||||
import {default as createShortLink} from './createShortLink.js'
|
||||
import {default as health} from './health.js'
|
||||
import {default as redirect} from './redirect.js'
|
||||
import {default as shortLink} from './shortLink.js'
|
||||
import {default as siteAssociation} from './siteAssociation.js'
|
||||
|
||||
export * from './util.js'
|
||||
@@ -11,7 +12,8 @@ export * from './util.js'
|
||||
export default function (ctx: AppContext, app: Express) {
|
||||
app = health(ctx, app) // GET /_health
|
||||
app = siteAssociation(ctx, app) // GET /.well-known/apple-app-site-association
|
||||
app = create(ctx, app) // POST /link
|
||||
app = redirect(ctx, app) // GET /:linkId (should go last due to permissive matching)
|
||||
app = redirect(ctx, app) // GET /redirect
|
||||
app = createShortLink(ctx, app) // POST /link
|
||||
app = shortLink(ctx, app) // GET /:linkId (should go last due to permissive matching)
|
||||
return app
|
||||
}
|
||||
|
||||
@@ -6,47 +6,40 @@ import {Express} from 'express'
|
||||
import {AppContext} from '../context.js'
|
||||
import {handler} from './util.js'
|
||||
|
||||
const INTERNAL_IP_REGEX = new RegExp(
|
||||
'(^127.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}$)|(^10.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}$)|(^172.1[6-9]{1}[0-9]{0,1}.[0-9]{1,3}.[0-9]{1,3}$)|(^172.2[0-9]{1}[0-9]{0,1}.[0-9]{1,3}.[0-9]{1,3}$)|(^172.3[0-1]{1}[0-9]{0,1}.[0-9]{1,3}.[0-9]{1,3}$)|(^192.168.[0-9]{1,3}.[0-9]{1,3}$)|^localhost',
|
||||
'i',
|
||||
)
|
||||
|
||||
export default function (ctx: AppContext, app: Express) {
|
||||
return app.get(
|
||||
'/:linkId',
|
||||
'/redirect',
|
||||
handler(async (req, res) => {
|
||||
const linkId = req.params.linkId
|
||||
const contentType = req.accepts(['html', 'json'])
|
||||
let link = req.query.u
|
||||
assert(
|
||||
typeof linkId === 'string',
|
||||
'express guarantees id parameter is a string',
|
||||
typeof link === 'string',
|
||||
'express guarantees link query parameter is a string',
|
||||
)
|
||||
const found = await ctx.db.db
|
||||
.selectFrom('link')
|
||||
.selectAll()
|
||||
.where('id', '=', linkId)
|
||||
.executeTakeFirst()
|
||||
if (!found) {
|
||||
// potentially broken or mistyped link
|
||||
link = decodeURIComponent(link)
|
||||
|
||||
let url: URL | undefined
|
||||
try {
|
||||
url = new URL(link)
|
||||
} catch {}
|
||||
|
||||
if (
|
||||
!url ||
|
||||
(url.protocol !== 'http:' && url.protocol !== 'https:') || // is a http(s) url
|
||||
(ctx.cfg.service.hostnames.includes(url.hostname.toLowerCase()) &&
|
||||
url.pathname === '/redirect') || // is a redirect loop
|
||||
INTERNAL_IP_REGEX.test(url.hostname) // isn't directing to an internal location
|
||||
) {
|
||||
res.setHeader('Cache-Control', 'no-store')
|
||||
if (contentType === 'json') {
|
||||
return res
|
||||
.status(404)
|
||||
.json({
|
||||
error: 'NotFound',
|
||||
message: 'Link not found',
|
||||
})
|
||||
.end()
|
||||
}
|
||||
// send the user to the app
|
||||
res.setHeader('Location', `https://${ctx.cfg.service.appHostname}`)
|
||||
return res.status(302).end()
|
||||
}
|
||||
// build url from original url in order to preserve query params
|
||||
const url = new URL(
|
||||
req.originalUrl,
|
||||
`https://${ctx.cfg.service.appHostname}`,
|
||||
)
|
||||
url.pathname = found.path
|
||||
|
||||
res.setHeader('Cache-Control', `max-age=${(7 * DAY) / SECOND}`)
|
||||
if (contentType === 'json') {
|
||||
return res.json({url: url.href}).end()
|
||||
}
|
||||
res.setHeader('Location', url.href)
|
||||
return res.status(301).end()
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import assert from 'node:assert'
|
||||
|
||||
import {DAY, SECOND} from '@atproto/common'
|
||||
import {Express} from 'express'
|
||||
|
||||
import {AppContext} from '../context.js'
|
||||
import {handler} from './util.js'
|
||||
|
||||
export default function (ctx: AppContext, app: Express) {
|
||||
return app.get(
|
||||
'/:linkId',
|
||||
handler(async (req, res) => {
|
||||
const linkId = req.params.linkId
|
||||
const contentType = req.accepts(['html', 'json'])
|
||||
assert(
|
||||
typeof linkId === 'string',
|
||||
'express guarantees id parameter is a string',
|
||||
)
|
||||
const found = await ctx.db.db
|
||||
.selectFrom('link')
|
||||
.selectAll()
|
||||
.where('id', '=', linkId)
|
||||
.executeTakeFirst()
|
||||
if (!found) {
|
||||
// potentially broken or mistyped link
|
||||
res.setHeader('Cache-Control', 'no-store')
|
||||
if (contentType === 'json') {
|
||||
return res
|
||||
.status(404)
|
||||
.json({
|
||||
error: 'NotFound',
|
||||
message: 'Link not found',
|
||||
})
|
||||
.end()
|
||||
}
|
||||
// send the user to the app
|
||||
res.setHeader('Location', `https://${ctx.cfg.service.appHostname}`)
|
||||
return res.status(302).end()
|
||||
}
|
||||
// build url from original url in order to preserve query params
|
||||
const url = new URL(
|
||||
req.originalUrl,
|
||||
`https://${ctx.cfg.service.appHostname}`,
|
||||
)
|
||||
url.pathname = found.path
|
||||
res.setHeader('Cache-Control', `max-age=${(7 * DAY) / SECOND}`)
|
||||
if (contentType === 'json') {
|
||||
return res.json({url: url.href}).end()
|
||||
}
|
||||
res.setHeader('Location', url.href)
|
||||
return res.status(301).end()
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -97,6 +97,7 @@ const hideAvatarLabels = new Set([
|
||||
'nudity',
|
||||
'sexual-figurative',
|
||||
'graphic-media',
|
||||
'gore',
|
||||
'self-harm',
|
||||
'sensitive',
|
||||
'security',
|
||||
|
||||
@@ -94,6 +94,20 @@ func run(args []string) {
|
||||
Value: "",
|
||||
EnvVars: []string{"STATIC_CDN_HOST"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "bsky-canonical-instance",
|
||||
Usage: "Enable if this is the canonical deployment (bsky.app)",
|
||||
Value: false,
|
||||
Required: false,
|
||||
EnvVars: []string{"BSKY_CANONICAL_INSTANCE"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "robots-disallow-all",
|
||||
Usage: "Crawling is allowed by default. Enable this flag to Disallow all",
|
||||
Value: false,
|
||||
Required: false,
|
||||
EnvVars: []string{"ROBOTS_DISALLOW_ALL"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -63,6 +63,8 @@ func serve(cctx *cli.Context) error {
|
||||
corsOrigins := cctx.StringSlice("cors-allowed-origins")
|
||||
staticCDNHost := cctx.String("static-cdn-host")
|
||||
staticCDNHost = strings.TrimSuffix(staticCDNHost, "/")
|
||||
canonicalInstance := cctx.Bool("bsky-canonical-instance")
|
||||
robotsDisallowAll := cctx.Bool("robots-disallow-all")
|
||||
|
||||
// Echo
|
||||
e := echo.New()
|
||||
@@ -204,13 +206,23 @@ func serve(cctx *cli.Context) error {
|
||||
return http.FS(fsys)
|
||||
}())
|
||||
|
||||
e.GET("/robots.txt", echo.WrapHandler(staticHandler))
|
||||
e.GET("/ips-v4", echo.WrapHandler(staticHandler))
|
||||
e.GET("/ips-v6", echo.WrapHandler(staticHandler))
|
||||
e.GET("/.well-known/*", echo.WrapHandler(staticHandler))
|
||||
e.GET("/security.txt", func(c echo.Context) error {
|
||||
return c.Redirect(http.StatusMovedPermanently, "/.well-known/security.txt")
|
||||
})
|
||||
// enable some special endpoints for the "canonical" deployment (bsky.app). not having these enabled should *not* impact regular operation
|
||||
if canonicalInstance {
|
||||
e.GET("/ips-v4", echo.WrapHandler(staticHandler))
|
||||
e.GET("/ips-v6", echo.WrapHandler(staticHandler))
|
||||
e.GET("/security.txt", func(c echo.Context) error {
|
||||
return c.Redirect(http.StatusMovedPermanently, "/.well-known/security.txt")
|
||||
})
|
||||
e.GET("/.well-known/*", echo.WrapHandler(staticHandler))
|
||||
}
|
||||
|
||||
// default to permissive, but Disallow all if flag set
|
||||
if robotsDisallowAll {
|
||||
e.File("/robots.txt", "static/robots-disallow-all.txt")
|
||||
} else {
|
||||
e.GET("/robots.txt", echo.WrapHandler(staticHandler))
|
||||
}
|
||||
|
||||
e.GET("/iframe/youtube.html", echo.WrapHandler(staticHandler))
|
||||
e.GET("/static/*", echo.WrapHandler(http.StripPrefix("/static/", staticHandler)), func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
@@ -283,6 +295,7 @@ func serve(cctx *cli.Context) error {
|
||||
e.GET("/profile/:handleOrDID/follows", server.WebGeneric)
|
||||
e.GET("/profile/:handleOrDID/followers", server.WebGeneric)
|
||||
e.GET("/profile/:handleOrDID/known-followers", server.WebGeneric)
|
||||
e.GET("/profile/:handleOrDID/search", server.WebGeneric)
|
||||
e.GET("/profile/:handleOrDID/lists/:rkey", server.WebGeneric)
|
||||
e.GET("/profile/:handleOrDID/feed/:rkey", server.WebGeneric)
|
||||
e.GET("/profile/:handleOrDID/feed/:rkey/liked-by", server.WebGeneric)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# This is an development or self-hosted instance of the bsky web app, and crawling has been disallowed by the operator team.
|
||||
User-Agent: *
|
||||
Disallow: /
|
||||
+21
-3
@@ -1,3 +1,21 @@
|
||||
files:
|
||||
- source: /src/locale/locales/en/messages.po
|
||||
translation: /src/locale/locales/%two_letters_code%/messages.po
|
||||
{
|
||||
"project_id": "1",
|
||||
"base_url": "https://bluesky.api.crowdin.com",
|
||||
"base_path": ".",
|
||||
"preserve_hierarchy": true,
|
||||
"files": [
|
||||
{
|
||||
"source": "/src/locale/locales/en/messages.po",
|
||||
"translation": "/src/locale/locales/%two_letters_code%/messages.po",
|
||||
"languages_mapping": {
|
||||
"two_letters_code": {
|
||||
"pt-BR": "pt-BR",
|
||||
"en-GB": "en-GB",
|
||||
"zh-CN": "zh-CN",
|
||||
"zh-TW": "zh-TW",
|
||||
"zh-HK": "zh-HK"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+59
-8
@@ -1,11 +1,67 @@
|
||||
# Internationalization
|
||||
|
||||
We want the official Bluesky app to be supported in as many languages as possible. If you want to help us translate the app, please open a PR or issue on the [Bluesky app repo on GitHub](https://github.com/bluesky-social/social-app)
|
||||
We want the official Bluesky app to be supported in as many languages as possible. If you want to help us translate the app, please get involved on [Crowdin](https://bluesky.crowdin.com/u/projects/1) or open an issue on the [Bluesky app repo on GitHub](https://github.com/bluesky-social/social-app).
|
||||
|
||||
## Tools
|
||||
We are using Lingui to manage translations. You can find the documentation [here](https://lingui.dev/).
|
||||
|
||||
- We use Crowdin to manage translations.
|
||||
- Bluesky Crowdin: https://bluesky.crowdin.com/u/projects/1
|
||||
- Introduction to Crowdin: https://support.crowdin.com/for-translators/
|
||||
- We use Lingui to implement translations. You can find the documentation [here](https://lingui.dev/).
|
||||
|
||||
## Translators
|
||||
|
||||
Much of the app is translated by community contributions. (We <3 our translators!) If you want to participate in the translation of the app, read this section.
|
||||
|
||||
### Using Crowdin
|
||||
|
||||
[Crowdin](https://bluesky.crowdin.com/u/projects/1) is our primary tool for managing translations. There are two roles:
|
||||
|
||||
- **Proof-readers**. Can create new translations and approve submitted translations.
|
||||
- **Translators**. Can create new translations.
|
||||
|
||||
All translations must be approved by proof-readers before they are accepted into the app.
|
||||
|
||||
### Using other platforms
|
||||
|
||||
You may contribute PRs separately from Crowdin, however we strongly recommend using Crowdin to avoid conflicts.
|
||||
|
||||
### Code of conduct on Crowdin
|
||||
|
||||
Please treat everyone with respect. Proof-readers are given final say on translations. Translators who frequently come into conflict with other translators, or who contribute noticably incorrect translations, will have their membership to the Crowdin project revoked.
|
||||
|
||||
### Adding a new language
|
||||
|
||||
You can request a new language be added to the project by clicking **Request New Language** on Crowdin or you can create a [GitHub issue](https://github.com/bluesky-social/social-app/issues).
|
||||
|
||||
Please only request a new language when you are certain you will be able to contribute a substantive portion of translations for the language.
|
||||
|
||||
## Maintainers
|
||||
|
||||
Install the [Crowdin CLI](https://crowdin.github.io/crowdin-cli/). You will need to [configure your API token](https://crowdin.github.io/crowdin-cli/configuration) to access the project.
|
||||
|
||||
### English source-file sync with Crowdin
|
||||
|
||||
Every night, a GitHub action will run `yarn intl:extract` to update the english `messages.po` file. This will be automatically synced with Crowdin. Crowdin should notify all subscribed users of new translations.
|
||||
|
||||
### Release process
|
||||
|
||||
1. Pull main and create a branch.
|
||||
1. Run `yarn intl:pull` to fetch all translation updates from Crowdin. Commit.
|
||||
1. Run `yarn intl:extract:all` to ensure all `.po` files are synced with the current state of the code. Commit.
|
||||
1. Create a PR, ensure the translations all look correct, and merge.
|
||||
1. If needed:
|
||||
1. Merge all approved translation PRs (contributions from outside crowdin).
|
||||
1. Run `yarn intl:push` to sync Crowdin with the state of the repo.
|
||||
|
||||
### Testing the translations in Crowdin
|
||||
|
||||
You can run `yarn intl:pull` to pull the currently-approved translations from Crowdin.
|
||||
|
||||
## Developers
|
||||
|
||||
### Adding new strings
|
||||
|
||||
When adding a new string, do it as follows:
|
||||
```jsx
|
||||
// Before
|
||||
@@ -61,6 +117,7 @@ So the workflow is as follows:
|
||||
6. Enjoy translated app!
|
||||
|
||||
### Common pitfalls
|
||||
|
||||
These pitfalls are memoization pitfalls that will cause the components to not re-render when the locale is changed -- causing stale translations to be shown.
|
||||
|
||||
```jsx
|
||||
@@ -111,9 +168,3 @@ export function Welcome() {
|
||||
return <div>{welcome}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### Credits
|
||||
Please check each individual `messages.po` file for the credits of the translators. We are very grateful for their help!
|
||||
|
||||
If you would like to translate the Bluesky app into your language, please open a PR or issue on this repo.
|
||||
|
||||
+1
-1
@@ -119,5 +119,5 @@ jest.mock('statsig-react-native-expo', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('../src/lib/bitdrift', () => ({}))
|
||||
jest.mock('../src/logger/bitdrift/lib', () => ({}))
|
||||
jest.mock('../src/lib/statsig/statsig', () => ({}))
|
||||
|
||||
@@ -5,18 +5,22 @@ module.exports = {
|
||||
'an',
|
||||
'ast',
|
||||
'ca',
|
||||
'cy',
|
||||
'da',
|
||||
'de',
|
||||
'el',
|
||||
'en-GB',
|
||||
'eo',
|
||||
'es',
|
||||
'eu',
|
||||
'fi',
|
||||
'fr',
|
||||
'ga',
|
||||
'gd',
|
||||
'gl',
|
||||
'hi',
|
||||
'hu',
|
||||
'ia',
|
||||
'id',
|
||||
'it',
|
||||
'ja',
|
||||
|
||||
+28
-11
@@ -31,9 +31,13 @@ class BottomSheetView(
|
||||
private lateinit var dialogRootViewGroup: DialogRootViewGroup
|
||||
private var eventDispatcher: EventDispatcher? = null
|
||||
|
||||
private val screenHeight =
|
||||
context.resources.displayMetrics.heightPixels
|
||||
.toFloat()
|
||||
private val rawScreenHeight = context.resources.displayMetrics.heightPixels.toFloat()
|
||||
private val safeScreenHeight = (rawScreenHeight - getNavigationBarHeight()).toFloat()
|
||||
|
||||
private fun getNavigationBarHeight(): Int {
|
||||
val resourceId = resources.getIdentifier("navigation_bar_height", "dimen", "android")
|
||||
return if (resourceId > 0) resources.getDimensionPixelSize(resourceId) else 0
|
||||
}
|
||||
|
||||
private val onAttemptDismiss by EventDispatcher()
|
||||
private val onSnapPointChange by EventDispatcher()
|
||||
@@ -63,12 +67,12 @@ class BottomSheetView(
|
||||
}
|
||||
}
|
||||
|
||||
var maxHeight = this.screenHeight
|
||||
var maxHeight = this.safeScreenHeight
|
||||
set(value) {
|
||||
val px = dpToPx(value)
|
||||
field =
|
||||
if (px > this.screenHeight) {
|
||||
this.screenHeight
|
||||
if (px > this.safeScreenHeight) {
|
||||
this.safeScreenHeight
|
||||
} else {
|
||||
px
|
||||
}
|
||||
@@ -153,6 +157,19 @@ class BottomSheetView(
|
||||
|
||||
// Presentation
|
||||
|
||||
private fun getHalfExpandedRatio(contentHeight: Float): Float {
|
||||
return when {
|
||||
// Full height sheets
|
||||
contentHeight >= safeScreenHeight -> 0.99f
|
||||
// Medium height sheets (>50% but <100%)
|
||||
contentHeight >= safeScreenHeight / 2 ->
|
||||
this.clampRatio(this.getTargetHeight() / safeScreenHeight)
|
||||
// Small height sheets (<50%)
|
||||
else ->
|
||||
this.clampRatio(this.getTargetHeight() / rawScreenHeight)
|
||||
}
|
||||
}
|
||||
|
||||
private fun present() {
|
||||
if (this.isOpen || this.isOpening || this.isClosing) return
|
||||
|
||||
@@ -172,12 +189,12 @@ class BottomSheetView(
|
||||
val behavior = BottomSheetBehavior.from(it)
|
||||
behavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
behavior.isFitToContents = true
|
||||
behavior.halfExpandedRatio = this.clampRatio(this.getTargetHeight() / this.screenHeight)
|
||||
behavior.halfExpandedRatio = getHalfExpandedRatio(contentHeight)
|
||||
behavior.skipCollapsed = true
|
||||
behavior.isDraggable = true
|
||||
behavior.isHideable = true
|
||||
|
||||
if (contentHeight >= this.screenHeight || this.minHeight >= this.screenHeight) {
|
||||
if (contentHeight >= this.safeScreenHeight || this.minHeight >= this.safeScreenHeight) {
|
||||
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
this.selectedSnapPoint = 2
|
||||
} else {
|
||||
@@ -227,11 +244,11 @@ class BottomSheetView(
|
||||
bottomSheet?.let {
|
||||
val behavior = BottomSheetBehavior.from(it)
|
||||
|
||||
behavior.halfExpandedRatio = this.clampRatio(this.getTargetHeight() / this.screenHeight)
|
||||
behavior.halfExpandedRatio = getHalfExpandedRatio(contentHeight)
|
||||
|
||||
if (contentHeight > this.screenHeight && behavior.state != BottomSheetBehavior.STATE_EXPANDED) {
|
||||
if (contentHeight > this.safeScreenHeight && behavior.state != BottomSheetBehavior.STATE_EXPANDED) {
|
||||
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
} else if (contentHeight < this.screenHeight && behavior.state != BottomSheetBehavior.STATE_HALF_EXPANDED) {
|
||||
} else if (contentHeight < this.safeScreenHeight && behavior.state != BottomSheetBehavior.STATE_HALF_EXPANDED) {
|
||||
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import * as React from 'react'
|
||||
import {
|
||||
Dimensions,
|
||||
LayoutChangeEvent,
|
||||
NativeSyntheticEvent,
|
||||
Platform,
|
||||
StyleProp,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from 'react-native'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core'
|
||||
|
||||
import {isIOS} from '#/platform/detection'
|
||||
import {BottomSheetState, BottomSheetViewProps} from './BottomSheet.types'
|
||||
import {BottomSheetPortalProvider} from './BottomSheetPortal'
|
||||
import {Context as PortalContext} from './BottomSheetPortal'
|
||||
@@ -81,12 +84,10 @@ export class BottomSheetNativeComponent extends React.Component<
|
||||
return null
|
||||
}
|
||||
|
||||
const {children, backgroundColor, ...rest} = this.props
|
||||
const cornerRadius = rest.cornerRadius ?? 0
|
||||
|
||||
let extraStyles
|
||||
if (isIOS15 && this.state.viewHeight) {
|
||||
const {viewHeight} = this.state
|
||||
const cornerRadius = this.props.cornerRadius ?? 0
|
||||
if (viewHeight < screenHeight / 2) {
|
||||
extraStyles = {
|
||||
height: viewHeight,
|
||||
@@ -99,39 +100,70 @@ export class BottomSheetNativeComponent extends React.Component<
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<NativeView
|
||||
{...rest}
|
||||
<BottomSheetNativeComponentInner
|
||||
{...this.props}
|
||||
nativeViewRef={this.ref}
|
||||
onStateChange={this.onStateChange}
|
||||
ref={this.ref}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
height: screenHeight,
|
||||
width: '100%',
|
||||
extraStyles={extraStyles}
|
||||
onLayout={e => {
|
||||
const {height} = e.nativeEvent.layout
|
||||
this.setState({viewHeight: height})
|
||||
this.updateLayout()
|
||||
}}
|
||||
containerBackgroundColor={backgroundColor}>
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
flex: 1,
|
||||
backgroundColor,
|
||||
},
|
||||
Platform.OS === 'android' && {
|
||||
borderTopLeftRadius: cornerRadius,
|
||||
borderTopRightRadius: cornerRadius,
|
||||
},
|
||||
extraStyles,
|
||||
]}>
|
||||
<View
|
||||
onLayout={e => {
|
||||
const {height} = e.nativeEvent.layout
|
||||
this.setState({viewHeight: height})
|
||||
this.updateLayout()
|
||||
}}>
|
||||
<BottomSheetPortalProvider>{children}</BottomSheetPortalProvider>
|
||||
</View>
|
||||
</View>
|
||||
</NativeView>
|
||||
/>
|
||||
</Portal>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function BottomSheetNativeComponentInner({
|
||||
children,
|
||||
backgroundColor,
|
||||
onLayout,
|
||||
onStateChange,
|
||||
nativeViewRef,
|
||||
extraStyles,
|
||||
...rest
|
||||
}: BottomSheetViewProps & {
|
||||
extraStyles?: StyleProp<ViewStyle>
|
||||
onStateChange: (
|
||||
event: NativeSyntheticEvent<{state: BottomSheetState}>,
|
||||
) => void
|
||||
nativeViewRef: React.RefObject<View>
|
||||
onLayout: (event: LayoutChangeEvent) => void
|
||||
}) {
|
||||
const insets = useSafeAreaInsets()
|
||||
const cornerRadius = rest.cornerRadius ?? 0
|
||||
|
||||
const sheetHeight = isIOS ? screenHeight - insets.top : screenHeight
|
||||
|
||||
return (
|
||||
<NativeView
|
||||
{...rest}
|
||||
onStateChange={onStateChange}
|
||||
ref={nativeViewRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
height: sheetHeight,
|
||||
width: '100%',
|
||||
}}
|
||||
containerBackgroundColor={backgroundColor}>
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
flex: 1,
|
||||
backgroundColor,
|
||||
},
|
||||
Platform.OS === 'android' && {
|
||||
borderTopLeftRadius: cornerRadius,
|
||||
borderTopRightRadius: cornerRadius,
|
||||
},
|
||||
extraStyles,
|
||||
]}>
|
||||
<View onLayout={onLayout}>
|
||||
<BottomSheetPortalProvider>{children}</BottomSheetPortalProvider>
|
||||
</View>
|
||||
</View>
|
||||
</NativeView>
|
||||
)
|
||||
}
|
||||
|
||||
+12
-10
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.98.0",
|
||||
"version": "1.99.1",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -42,9 +42,12 @@
|
||||
"perf:test:measure": "NODE_ENV=test flashlight test --bundleId xyz.blueskyweb.app --testCommand \"yarn perf:test\" --duration 150000 --resultsFilePath .perf/results.json",
|
||||
"perf:test:results": "NODE_ENV=test flashlight report .perf/results.json",
|
||||
"perf:measure": "NODE_ENV=test flashlight measure",
|
||||
"intl:build": "yarn intl:extract && yarn intl:compile",
|
||||
"intl:extract": "lingui extract",
|
||||
"intl:build": "yarn intl:extract:all && yarn intl:compile",
|
||||
"intl:extract": "lingui extract --clean --locale en",
|
||||
"intl:extract:all": "lingui extract --clean",
|
||||
"intl:compile": "lingui compile",
|
||||
"intl:pull": "crowdin download translations --verbose -b main",
|
||||
"intl:push": "crowdin push translations --verbose -b main",
|
||||
"nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android",
|
||||
"update-extensions": "bash scripts/updateExtensions.sh",
|
||||
"export": "npx expo export",
|
||||
@@ -54,8 +57,8 @@
|
||||
"icons:optimize": "svgo -f ./assets/icons"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.13.31",
|
||||
"@bitdrift/react-native": "^0.6.2",
|
||||
"@atproto/api": "^0.14.7",
|
||||
"@bitdrift/react-native": "^0.6.8",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
@@ -76,10 +79,10 @@
|
||||
"@mattermost/react-native-paste-input": "^0.7.1",
|
||||
"@miblanchard/react-native-slider": "^2.3.1",
|
||||
"@mozzius/expo-dynamic-app-icon": "^1.5.0",
|
||||
"@radix-ui/react-dismissable-layer": "^1.1.1",
|
||||
"@radix-ui/react-dropdown-menu": "2.0.1",
|
||||
"@radix-ui/react-dismissable-layer": "^1.1.5",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.6",
|
||||
"@radix-ui/react-focus-guards": "^1.1.1",
|
||||
"@radix-ui/react-focus-scope": "^1.1.0",
|
||||
"@radix-ui/react-focus-scope": "^1.1.2",
|
||||
"@react-native-async-storage/async-storage": "1.23.1",
|
||||
"@react-native-menu/menu": "^1.1.7",
|
||||
"@react-native-picker/picker": "2.10.2",
|
||||
@@ -209,7 +212,7 @@
|
||||
"zod": "^3.20.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@atproto/dev-env": "^0.3.67",
|
||||
"@atproto/dev-env": "^0.3.87",
|
||||
"@babel/core": "^7.26.0",
|
||||
"@babel/preset-env": "^7.26.0",
|
||||
"@babel/runtime": "^7.26.0",
|
||||
@@ -268,7 +271,6 @@
|
||||
"@expo/image-utils": "0.6.3",
|
||||
"@react-native/babel-preset": "0.76.1",
|
||||
"@react-native/normalize-colors": "0.76.1",
|
||||
"@radix-ui/react-focus-scope": "1.1.0",
|
||||
"@types/react": "^18",
|
||||
"**/expo-constants": "17.0.3",
|
||||
"**/expo-device": "7.0.1",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
const {withGradleProperties} = require('expo/config-plugins')
|
||||
|
||||
function setGradlePropertiesValue(config, key, value) {
|
||||
return withGradleProperties(config, exportedConfig => {
|
||||
const keyIdx = exportedConfig.modResults.findIndex(
|
||||
item => item.type === 'property' && item.key === key,
|
||||
)
|
||||
if (keyIdx >= 0) {
|
||||
exportedConfig.modResults.splice(keyIdx, 1, {
|
||||
type: 'property',
|
||||
key,
|
||||
value,
|
||||
})
|
||||
} else {
|
||||
exportedConfig.modResults.push({
|
||||
type: 'property',
|
||||
key,
|
||||
value,
|
||||
})
|
||||
}
|
||||
|
||||
return exportedConfig
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = function withGradleJVMHeapSizeIncrease(config) {
|
||||
config = setGradlePropertiesValue(
|
||||
config,
|
||||
'org.gradle.jvmargs',
|
||||
'-Xmx4096m -XX:MaxMetaspaceSize=1024m', //Set data of your choice
|
||||
)
|
||||
|
||||
return config
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
const {withAppBuildGradle} = require('@expo/config-plugins')
|
||||
|
||||
/**
|
||||
* A Config Plugin to disable bundle compression in Android build.gradle.
|
||||
* @param {import('@expo/config-plugins').ConfigPlugin} config
|
||||
* @returns {import('@expo/config-plugins').ConfigPlugin}
|
||||
*/
|
||||
module.exports = function withNoBundleCompression(config) {
|
||||
return withAppBuildGradle(config, androidConfig => {
|
||||
let buildGradle = androidConfig.modResults.contents
|
||||
|
||||
const hasAndroidResources = buildGradle.includes('androidResources {')
|
||||
const hasNoCompress = buildGradle.includes('noCompress')
|
||||
|
||||
if (hasAndroidResources) {
|
||||
if (hasNoCompress) {
|
||||
if (
|
||||
buildGradle.includes('noCompress += ["bundle"]') ||
|
||||
buildGradle.includes("noCompress += 'bundle'") ||
|
||||
buildGradle.includes('noCompress += "bundle"')
|
||||
) {
|
||||
return androidConfig
|
||||
}
|
||||
|
||||
const lines = buildGradle.split('\n')
|
||||
const modifiedLines = lines.map(line => {
|
||||
if (line.trim().startsWith('noCompress')) {
|
||||
if (line.includes('+=')) {
|
||||
return line.replace(/\]/, ', "bundle"]')
|
||||
} else if (line.includes('=')) {
|
||||
return line.replace('=', '+= ["bundle",') + ']'
|
||||
}
|
||||
}
|
||||
return line
|
||||
})
|
||||
androidConfig.modResults.contents = modifiedLines.join('\n')
|
||||
} else {
|
||||
const androidResources = buildGradle.indexOf('androidResources {')
|
||||
if (androidResources === -1) {
|
||||
throw new Error(
|
||||
`Cannot find androidResources { block in build.gradle!`,
|
||||
)
|
||||
}
|
||||
const insertPosition = buildGradle.indexOf('\n', androidResources) + 1
|
||||
const newContent =
|
||||
buildGradle.slice(0, insertPosition) +
|
||||
' noCompress += ["bundle"]\n' +
|
||||
buildGradle.slice(insertPosition)
|
||||
|
||||
androidConfig.modResults.contents = newContent
|
||||
}
|
||||
} else {
|
||||
const androidBlock = buildGradle.indexOf('android {')
|
||||
if (androidBlock === -1) {
|
||||
throw new Error(`Cannot find android { block in build.gradle!`)
|
||||
}
|
||||
const insertPosition = buildGradle.indexOf('\n', androidBlock) + 1
|
||||
const newContent =
|
||||
buildGradle.slice(0, insertPosition) +
|
||||
' androidResources {\n' +
|
||||
' noCompress += ["bundle"]\n' +
|
||||
' }\n' +
|
||||
buildGradle.slice(insertPosition)
|
||||
|
||||
androidConfig.modResults.contents = newContent
|
||||
}
|
||||
|
||||
return androidConfig
|
||||
})
|
||||
}
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
import 'react-native-url-polyfill/auto'
|
||||
import '#/lib/sentry' // must be near top
|
||||
import '#/lib/bitdrift' // must be near top
|
||||
import '#/logger/sentry/setup'
|
||||
import '#/logger/bitdrift/setup'
|
||||
import '#/view/icons'
|
||||
|
||||
import React, {useEffect, useState} from 'react'
|
||||
@@ -118,7 +118,7 @@ function InnerApp() {
|
||||
useEffect(() => {
|
||||
return listenSessionDropped(() => {
|
||||
Toast.show(
|
||||
_(msg`Sorry! Your session expired. Please log in again.`),
|
||||
_(msg`Sorry! Your session expired. Please sign in again.`),
|
||||
'info',
|
||||
)
|
||||
})
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
import '#/lib/sentry' // must be near top
|
||||
import '#/logger/sentry/setup' // must be near top
|
||||
import '#/view/icons'
|
||||
import './style.css'
|
||||
|
||||
@@ -94,7 +94,7 @@ function InnerApp() {
|
||||
useEffect(() => {
|
||||
return listenSessionDropped(() => {
|
||||
Toast.show(
|
||||
_(msg`Sorry! Your session expired. Please log in again.`),
|
||||
_(msg`Sorry! Your session expired. Please sign in again.`),
|
||||
'info',
|
||||
)
|
||||
})
|
||||
|
||||
+29
-4
@@ -32,6 +32,7 @@ import {
|
||||
import {RouteParams, State} from '#/lib/routes/types'
|
||||
import {attachRouteToLogEvents, logEvent} from '#/lib/statsig/statsig'
|
||||
import {bskyTitle} from '#/lib/strings/headings'
|
||||
import {logger} from '#/logger'
|
||||
import {isNative, isWeb} from '#/platform/detection'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
|
||||
@@ -68,8 +69,10 @@ import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTest
|
||||
import HashtagScreen from '#/screens/Hashtag'
|
||||
import {MessagesScreen} from '#/screens/Messages/ChatList'
|
||||
import {MessagesConversationScreen} from '#/screens/Messages/Conversation'
|
||||
import {MessagesInboxScreen} from '#/screens/Messages/Inbox'
|
||||
import {MessagesSettingsScreen} from '#/screens/Messages/Settings'
|
||||
import {ModerationScreen} from '#/screens/Moderation'
|
||||
import {Screen as ModerationInteractionSettings} from '#/screens/ModerationInteractionSettings'
|
||||
import {PostLikedByScreen} from '#/screens/Post/PostLikedBy'
|
||||
import {PostQuotesScreen} from '#/screens/Post/PostQuotes'
|
||||
import {PostRepostedByScreen} from '#/screens/Post/PostRepostedBy'
|
||||
@@ -90,6 +93,7 @@ import {VideoFeed} from '#/screens/VideoFeed'
|
||||
import {useTheme} from '#/alf'
|
||||
import {router} from '#/routes'
|
||||
import {Referrer} from '../modules/expo-bluesky-swiss-army'
|
||||
import {ProfileSearchScreen} from './screens/Profile/ProfileSearch'
|
||||
import {AboutSettingsScreen} from './screens/Settings/AboutSettings'
|
||||
import {AccessibilitySettingsScreen} from './screens/Settings/AccessibilitySettings'
|
||||
import {AccountSettingsScreen} from './screens/Settings/AccountSettings'
|
||||
@@ -155,6 +159,14 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
||||
getComponent={() => ModerationBlockedAccounts}
|
||||
options={{title: title(msg`Blocked Accounts`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ModerationInteractionSettings"
|
||||
getComponent={() => ModerationInteractionSettings}
|
||||
options={{
|
||||
title: title(msg`Post Interaction Settings`),
|
||||
requireAuth: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Settings"
|
||||
getComponent={() => SettingsScreen}
|
||||
@@ -198,6 +210,13 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
||||
getComponent={() => ProfileListScreen}
|
||||
options={{title: title(msg`List`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ProfileSearch"
|
||||
getComponent={() => ProfileSearchScreen}
|
||||
options={({route}) => ({
|
||||
title: title(msg`Search @${route.params.name}'s posts`),
|
||||
})}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="PostThread"
|
||||
getComponent={() => PostThreadScreen}
|
||||
@@ -393,6 +412,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
||||
getComponent={() => MessagesSettingsScreen}
|
||||
options={{title: title(msg`Chat settings`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="MessagesInbox"
|
||||
getComponent={() => MessagesInboxScreen}
|
||||
options={{title: title(msg`Chat request inbox`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="NotificationSettings"
|
||||
getComponent={() => NotificationSettingsScreen}
|
||||
@@ -712,15 +736,16 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
linking={LINKING}
|
||||
theme={theme}
|
||||
onStateChange={() => {
|
||||
const routeName = getCurrentRouteName()
|
||||
if (routeName === 'Notifications') {
|
||||
logEvent('router:navigate:notifications', {})
|
||||
}
|
||||
logger.metric('router:navigate', {
|
||||
from: prevLoggedRouteName.current,
|
||||
})
|
||||
prevLoggedRouteName.current = getCurrentRouteName()
|
||||
}}
|
||||
onReady={() => {
|
||||
attachRouteToLogEvents(getCurrentRouteName)
|
||||
logModuleInitTime()
|
||||
onReady()
|
||||
logger.metric('router:navigate', {})
|
||||
}}>
|
||||
{children}
|
||||
</NavigationContainer>
|
||||
|
||||
+57
-3
@@ -31,6 +31,18 @@ export const atoms = {
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
top_0: {
|
||||
top: 0,
|
||||
},
|
||||
right_0: {
|
||||
right: 0,
|
||||
},
|
||||
bottom_0: {
|
||||
bottom: 0,
|
||||
},
|
||||
left_0: {
|
||||
left: 0,
|
||||
},
|
||||
z_10: {
|
||||
zIndex: 10,
|
||||
},
|
||||
@@ -50,9 +62,15 @@ export const atoms = {
|
||||
overflow_hidden: {
|
||||
overflow: 'hidden',
|
||||
},
|
||||
/**
|
||||
* @platform web
|
||||
*/
|
||||
overflow_auto: web({
|
||||
overflow: 'auto',
|
||||
}),
|
||||
|
||||
/*
|
||||
* Width
|
||||
* Width & Height
|
||||
*/
|
||||
w_full: {
|
||||
width: '100%',
|
||||
@@ -63,12 +81,17 @@ export const atoms = {
|
||||
h_full_vh: web({
|
||||
height: '100vh',
|
||||
}),
|
||||
max_w_full: {
|
||||
maxWidth: '100%',
|
||||
},
|
||||
max_h_full: {
|
||||
maxHeight: '100%',
|
||||
},
|
||||
|
||||
/**
|
||||
* Used for the outermost components on screens, to ensure that they can fill
|
||||
* the screen and extend beyond.
|
||||
*/
|
||||
// @ts-ignore - web only minHeight string
|
||||
util_screen_outer: [
|
||||
web({
|
||||
minHeight: '100vh',
|
||||
@@ -76,7 +99,7 @@ export const atoms = {
|
||||
native({
|
||||
height: '100%',
|
||||
}),
|
||||
] as ViewStyle,
|
||||
] as StyleProp<ViewStyle>,
|
||||
|
||||
/*
|
||||
* Theme-independent bg colors
|
||||
@@ -88,6 +111,9 @@ export const atoms = {
|
||||
/*
|
||||
* Border radius
|
||||
*/
|
||||
rounded_0: {
|
||||
borderRadius: 0,
|
||||
},
|
||||
rounded_2xs: {
|
||||
borderRadius: tokens.borderRadius._2xs,
|
||||
},
|
||||
@@ -965,6 +991,34 @@ export const atoms = {
|
||||
transitionDelay: '50ms',
|
||||
}),
|
||||
|
||||
/*
|
||||
* Animaations
|
||||
*/
|
||||
fade_in: web({
|
||||
animation: 'fadeIn ease-out 0.15s',
|
||||
}),
|
||||
fade_out: web({
|
||||
animation: 'fadeOut ease-out 0.15s',
|
||||
}),
|
||||
zoom_in: web({
|
||||
animation: 'zoomIn ease-out 0.1s',
|
||||
}),
|
||||
zoom_out: web({
|
||||
animation: 'zoomOut ease-out 0.1s',
|
||||
}),
|
||||
slide_in_left: web({
|
||||
// exponential easing function
|
||||
animation: 'slideInLeft cubic-bezier(0.16, 1, 0.3, 1) 0.5s',
|
||||
}),
|
||||
slide_out_left: web({
|
||||
animation: 'slideOutLeft ease-in 0.15s',
|
||||
animationFillMode: 'forwards',
|
||||
}),
|
||||
// special composite animation for dialogs
|
||||
zoom_fade_in: web({
|
||||
animation: 'zoomIn ease-out 0.1s, fadeIn ease-out 0.1s',
|
||||
}),
|
||||
|
||||
/**
|
||||
* {@link Layout.SCROLLBAR_OFFSET}
|
||||
*/
|
||||
|
||||
@@ -26,3 +26,18 @@ export function useBreakpoints(): Record<Breakpoint, boolean> & {
|
||||
}
|
||||
}, [gtPhone, gtMobile, gtTablet])
|
||||
}
|
||||
|
||||
/**
|
||||
* Fine-tuned breakpoints for the shell layout
|
||||
*/
|
||||
export function useLayoutBreakpoints() {
|
||||
const rightNavVisible = useMediaQuery({minWidth: 1075})
|
||||
const centerColumnOffset = useMediaQuery({minWidth: 1075, maxWidth: 1300})
|
||||
const leftNavMinimal = useMediaQuery({maxWidth: 1300})
|
||||
|
||||
return {
|
||||
rightNavVisible,
|
||||
centerColumnOffset,
|
||||
leftNavMinimal,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ export function AccountList({
|
||||
testID="chooseAddAccountBtn"
|
||||
style={[a.flex_1]}
|
||||
onPress={pendingDid ? undefined : onPressAddAccount}
|
||||
label={_(msg`Login to account that is not listed`)}>
|
||||
label={_(msg`Sign in to account that is not listed`)}>
|
||||
{({hovered, pressed}) => (
|
||||
<View
|
||||
style={[
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react'
|
||||
import {StyleProp, View, ViewStyle} from 'react-native'
|
||||
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button as BaseButton, ButtonProps} from '#/components/Button'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo'
|
||||
import {Eye_Stroke2_Corner0_Rounded as InfoIcon} from '#/components/icons/Eye'
|
||||
import {Leaf_Stroke2_Corner0_Rounded as TipIcon} from '#/components/icons/Leaf'
|
||||
@@ -49,22 +50,29 @@ export function Text({
|
||||
return (
|
||||
<BaseText
|
||||
{...rest}
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.text_sm,
|
||||
a.leading_snug,
|
||||
{
|
||||
paddingTop: 1,
|
||||
},
|
||||
style,
|
||||
]}>
|
||||
style={[a.flex_1, a.text_sm, a.leading_snug, a.pr_md, style]}>
|
||||
{children}
|
||||
</BaseText>
|
||||
)
|
||||
}
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
...props
|
||||
}: Omit<ButtonProps, 'size' | 'variant' | 'color'>) {
|
||||
return (
|
||||
<BaseButton size="tiny" variant="outline" color="secondary" {...props}>
|
||||
{children}
|
||||
</BaseButton>
|
||||
)
|
||||
}
|
||||
|
||||
export function Row({children}: {children: React.ReactNode}) {
|
||||
return <View style={[a.flex_row, a.gap_sm]}>{children}</View>
|
||||
return (
|
||||
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function Outer({
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
import {View} from 'react-native'
|
||||
import {moderateProfile} from '@atproto/api'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useProfilesQuery} from '#/state/queries/profile'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
export function AvatarStack({
|
||||
profiles,
|
||||
size = 26,
|
||||
numPending,
|
||||
backgroundColor,
|
||||
}: {
|
||||
profiles: string[]
|
||||
profiles: bsky.profile.AnyProfileView[]
|
||||
size?: number
|
||||
numPending?: number
|
||||
backgroundColor?: string
|
||||
}) {
|
||||
const halfSize = size / 2
|
||||
const {data, error} = useProfilesQuery({handles: profiles})
|
||||
const t = useTheme()
|
||||
const moderationOpts = useModerationOpts()
|
||||
|
||||
if (error) {
|
||||
console.error(error)
|
||||
return null
|
||||
}
|
||||
|
||||
const isPending = !data || !moderationOpts
|
||||
const isPending = (numPending && profiles.length === 0) || !moderationOpts
|
||||
|
||||
const items = isPending
|
||||
? Array.from({length: profiles.length}).map((_, i) => ({
|
||||
? Array.from({length: numPending ?? profiles.length}).map((_, i) => ({
|
||||
key: i,
|
||||
profile: null,
|
||||
moderation: null,
|
||||
}))
|
||||
: data.profiles.map(item => ({
|
||||
: profiles.map(item => ({
|
||||
key: item.did,
|
||||
profile: item,
|
||||
moderation: moderateProfile(item, moderationOpts),
|
||||
@@ -56,7 +56,7 @@ export function AvatarStack({
|
||||
height: size,
|
||||
left: i * -halfSize,
|
||||
borderWidth: 1,
|
||||
borderColor: t.atoms.bg.backgroundColor,
|
||||
borderColor: backgroundColor ?? t.atoms.bg.backgroundColor,
|
||||
borderRadius: 999,
|
||||
zIndex: 3 - i,
|
||||
},
|
||||
@@ -74,3 +74,33 @@ export function AvatarStack({
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function AvatarStackWithFetch({
|
||||
profiles,
|
||||
size,
|
||||
backgroundColor,
|
||||
}: {
|
||||
profiles: string[]
|
||||
size?: number
|
||||
backgroundColor?: string
|
||||
}) {
|
||||
const {data, error} = useProfilesQuery({handles: profiles})
|
||||
|
||||
if (error) {
|
||||
if (error.name !== 'AbortError') {
|
||||
logger.error('Error fetching profiles for AvatarStack', {
|
||||
safeMessage: error,
|
||||
})
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<AvatarStack
|
||||
numPending={profiles.length}
|
||||
profiles={data?.profiles || []}
|
||||
size={size}
|
||||
backgroundColor={backgroundColor}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export const Context = React.createContext<DialogContextProps>({
|
||||
nativeSnapPoint: BottomSheetSnapPoint.Hidden,
|
||||
disableDrag: false,
|
||||
setDisableDrag: () => {},
|
||||
isWithinDialog: false,
|
||||
})
|
||||
|
||||
export function useDialogContext() {
|
||||
|
||||
@@ -26,7 +26,7 @@ import {isAndroid, isIOS} from '#/platform/detection'
|
||||
import {useA11y} from '#/state/a11y'
|
||||
import {useDialogStateControlContext} from '#/state/dialogs'
|
||||
import {List, ListMethods, ListProps} from '#/view/com/util/List'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {atoms as a, tokens, useTheme} from '#/alf'
|
||||
import {useThemeName} from '#/alf/util/useColorModeTheme'
|
||||
import {Context, useDialogContext} from '#/components/Dialog/context'
|
||||
import {
|
||||
@@ -46,7 +46,7 @@ export {useDialogContext, useDialogControl} from '#/components/Dialog/context'
|
||||
export * from '#/components/Dialog/shared'
|
||||
export * from '#/components/Dialog/types'
|
||||
export * from '#/components/Dialog/utils'
|
||||
// @ts-ignore
|
||||
|
||||
export const Input = createInput(TextInput)
|
||||
|
||||
export function Outer({
|
||||
@@ -154,6 +154,7 @@ export function Outer({
|
||||
nativeSnapPoint: snapPoint,
|
||||
disableDrag,
|
||||
setDisableDrag,
|
||||
isWithinDialog: true,
|
||||
}),
|
||||
[close, snapPoint, disableDrag, setDisableDrag],
|
||||
)
|
||||
@@ -168,7 +169,9 @@ export function Outer({
|
||||
onStateChange={onStateChange}
|
||||
disableDrag={disableDrag}>
|
||||
<Context.Provider value={context}>
|
||||
<View testID={testID}>{children}</View>
|
||||
<View testID={testID} style={[a.relative]}>
|
||||
{children}
|
||||
</View>
|
||||
</Context.Provider>
|
||||
</BottomSheet>
|
||||
)
|
||||
@@ -196,7 +199,7 @@ export function Inner({children, style, header}: DialogInnerProps) {
|
||||
|
||||
export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
|
||||
function ScrollableInner(
|
||||
{children, style, contentContainerStyle, header, ...props},
|
||||
{children, contentContainerStyle, header, ...props},
|
||||
ref,
|
||||
) {
|
||||
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
|
||||
@@ -216,13 +219,21 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
|
||||
[],
|
||||
)
|
||||
|
||||
const basePading =
|
||||
(isIOS ? 30 : 50) + (isIOS ? keyboardHeight / 4 : keyboardHeight)
|
||||
const fullPaddingBase = insets.bottom + insets.top + basePading
|
||||
const fullPadding = isIOS ? fullPaddingBase : fullPaddingBase + 50
|
||||
|
||||
const paddingBottom =
|
||||
nativeSnapPoint === BottomSheetSnapPoint.Full ? fullPadding : basePading
|
||||
let paddingBottom = 0
|
||||
if (isIOS) {
|
||||
paddingBottom += keyboardHeight / 4
|
||||
if (nativeSnapPoint === BottomSheetSnapPoint.Full) {
|
||||
paddingBottom += insets.bottom + tokens.space.md
|
||||
}
|
||||
paddingBottom = Math.max(paddingBottom, tokens.space._2xl)
|
||||
} else {
|
||||
paddingBottom += keyboardHeight
|
||||
if (nativeSnapPoint === BottomSheetSnapPoint.Full) {
|
||||
paddingBottom += insets.top
|
||||
}
|
||||
paddingBottom +=
|
||||
Math.max(insets.bottom, tokens.space._5xl) + tokens.space._2xl
|
||||
}
|
||||
|
||||
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
if (!isAndroid) {
|
||||
@@ -238,7 +249,6 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
|
||||
|
||||
return (
|
||||
<KeyboardAwareScrollView
|
||||
style={[style]}
|
||||
contentContainerStyle={[
|
||||
a.pt_2xl,
|
||||
a.px_xl,
|
||||
@@ -316,7 +326,7 @@ export function Handle() {
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
{
|
||||
top: 10,
|
||||
top: tokens.space._2xl / 2 - 2.5,
|
||||
width: 35,
|
||||
height: 5,
|
||||
alignSelf: 'center',
|
||||
|
||||
@@ -15,6 +15,7 @@ import {FocusScope} from '@radix-ui/react-focus-scope'
|
||||
import {RemoveScrollBar} from 'react-remove-scroll-bar'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {useA11y} from '#/state/a11y'
|
||||
import {useDialogStateControlContext} from '#/state/dialogs'
|
||||
import {atoms as a, flatten, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
@@ -40,6 +41,7 @@ export function Outer({
|
||||
children,
|
||||
control,
|
||||
onClose,
|
||||
webOptions,
|
||||
}: React.PropsWithChildren<DialogOuterProps>) {
|
||||
const {_} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
@@ -95,6 +97,7 @@ export function Outer({
|
||||
nativeSnapPoint: 0,
|
||||
disableDrag: false,
|
||||
setDisableDrag: () => {},
|
||||
isWithinDialog: true,
|
||||
}),
|
||||
[close],
|
||||
)
|
||||
@@ -114,20 +117,26 @@ export function Outer({
|
||||
web(a.fixed),
|
||||
a.inset_0,
|
||||
a.z_10,
|
||||
a.px_xl,
|
||||
webOptions?.alignCenter ? a.justify_center : undefined,
|
||||
a.align_center,
|
||||
gtMobile ? a.p_lg : a.p_md,
|
||||
{overflowY: 'auto'},
|
||||
{
|
||||
overflowY: 'auto',
|
||||
paddingVertical: gtMobile ? '10vh' : a.pt_xl.paddingTop,
|
||||
},
|
||||
]}>
|
||||
<Backdrop />
|
||||
{/**
|
||||
* This is needed to prevent centered dialogs from overflowing
|
||||
* above the screen, and provides a "natural" centering so that
|
||||
* stacked dialogs appear relatively aligned.
|
||||
*/}
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.z_20,
|
||||
a.justify_center,
|
||||
a.align_center,
|
||||
{
|
||||
minHeight: web('calc(90vh - 36px)') || undefined,
|
||||
},
|
||||
web({minHeight: '60vh', position: 'static'}),
|
||||
]}>
|
||||
{children}
|
||||
</View>
|
||||
@@ -152,6 +161,7 @@ export function Inner({
|
||||
const t = useTheme()
|
||||
const {close} = React.useContext(Context)
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {reduceMotionEnabled} = useA11y()
|
||||
useFocusGuards()
|
||||
return (
|
||||
<FocusScope loop asChild trapped>
|
||||
@@ -161,7 +171,7 @@ export function Inner({
|
||||
aria-label={label}
|
||||
aria-labelledby={accessibilityLabelledBy}
|
||||
aria-describedby={accessibilityDescribedBy}
|
||||
// @ts-ignore web only -prf
|
||||
// @ts-expect-error web only -prf
|
||||
onClick={stopPropagation}
|
||||
onStartShouldSetResponder={_ => true}
|
||||
onTouchEnd={stopPropagation}
|
||||
@@ -177,10 +187,9 @@ export function Inner({
|
||||
shadowColor: t.palette.black,
|
||||
shadowOpacity: t.name === 'light' ? 0.1 : 0.4,
|
||||
shadowRadius: 30,
|
||||
// @ts-ignore web only
|
||||
animation: 'fadeIn ease-out 0.1s',
|
||||
},
|
||||
flatten(style),
|
||||
!reduceMotionEnabled && a.zoom_fade_in,
|
||||
style,
|
||||
])}>
|
||||
<DismissableLayer
|
||||
onInteractOutside={preventDefault}
|
||||
@@ -216,8 +225,8 @@ export const InnerFlatList = React.forwardRef<
|
||||
style={[
|
||||
a.overflow_hidden,
|
||||
a.px_0,
|
||||
// @ts-ignore web only -sfn
|
||||
{maxHeight: 'calc(-36px + 100vh)'},
|
||||
// 100 minus 10vh of paddingVertical
|
||||
web({maxHeight: '80vh'}),
|
||||
webInnerStyle,
|
||||
]}
|
||||
contentContainerStyle={[a.px_0, webInnerContentContainerStyle]}>
|
||||
@@ -262,20 +271,15 @@ export function Handle() {
|
||||
|
||||
function Backdrop() {
|
||||
const t = useTheme()
|
||||
const {reduceMotionEnabled} = useA11y()
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
opacity: 0.8,
|
||||
}}>
|
||||
<View style={{opacity: 0.8}}>
|
||||
<View
|
||||
style={[
|
||||
a.fixed,
|
||||
a.inset_0,
|
||||
{
|
||||
backgroundColor: t.palette.black,
|
||||
// @ts-ignore web only
|
||||
animation: 'fadeIn ease-out 0.15s',
|
||||
},
|
||||
{backgroundColor: t.palette.black},
|
||||
!reduceMotionEnabled && a.fade_in,
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -44,6 +44,8 @@ export type DialogContextProps = {
|
||||
nativeSnapPoint: BottomSheetSnapPoint
|
||||
disableDrag: boolean
|
||||
setDisableDrag: React.Dispatch<React.SetStateAction<boolean>>
|
||||
// in the event that the hook is used outside of a dialog
|
||||
isWithinDialog: boolean
|
||||
}
|
||||
|
||||
export type DialogControlOpenOptions = {
|
||||
@@ -60,11 +62,16 @@ export type DialogOuterProps = {
|
||||
control: DialogControlProps
|
||||
onClose?: () => void
|
||||
nativeOptions?: Omit<BottomSheetViewProps, 'children'>
|
||||
webOptions?: {}
|
||||
webOptions?: {
|
||||
alignCenter?: boolean
|
||||
}
|
||||
testID?: string
|
||||
}
|
||||
|
||||
type DialogInnerPropsBase<T> = React.PropsWithChildren<ViewStyleProp> & T
|
||||
type DialogInnerPropsBase<T> = React.PropsWithChildren<ViewStyleProp> &
|
||||
T & {
|
||||
testID?: string
|
||||
}
|
||||
export type DialogInnerProps =
|
||||
| DialogInnerPropsBase<{
|
||||
label?: undefined
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react'
|
||||
import {GestureResponderEvent, View} from 'react-native'
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
AppBskyFeedDefs,
|
||||
AppBskyGraphDefs,
|
||||
AtUri,
|
||||
@@ -32,6 +31,7 @@ import {Loader} from '#/components/Loader'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {RichText, RichTextProps} from '#/components/RichText'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
type Props = {
|
||||
view: AppBskyFeedDefs.GeneratorView
|
||||
@@ -115,7 +115,7 @@ export function TitleAndByline({
|
||||
creator,
|
||||
}: {
|
||||
title: string
|
||||
creator?: AppBskyActorDefs.ProfileViewBasic
|
||||
creator?: bsky.profile.AnyProfileView
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
@@ -269,9 +269,9 @@ function SaveButtonInner({
|
||||
},
|
||||
])
|
||||
}
|
||||
Toast.show(_(msg`Feeds updated!`))
|
||||
Toast.show(_(msg({message: 'Feeds updated!', context: 'toast'})))
|
||||
} catch (err: any) {
|
||||
logger.error(err, {context: `FeedCard: failed to update feeds`, pin})
|
||||
logger.error(err, {message: `FeedCard: failed to update feeds`, pin})
|
||||
Toast.show(_(msg`Failed to update feeds`), 'xmark')
|
||||
}
|
||||
},
|
||||
@@ -307,7 +307,7 @@ function SaveButtonInner({
|
||||
|
||||
<Prompt.Basic
|
||||
control={removePromptControl}
|
||||
title={_(msg`Remove from my feeds?`)}
|
||||
title={_(msg`Remove from your feeds?`)}
|
||||
description={_(
|
||||
msg`Are you sure you want to remove this from your feeds?`,
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
import {ScrollView, View} from 'react-native'
|
||||
import {AppBskyActorDefs, AppBskyFeedDefs, AtUri} from '@atproto/api'
|
||||
import {View} from 'react-native'
|
||||
import {ScrollView} from 'react-native-gesture-handler'
|
||||
import {AppBskyFeedDefs, AtUri} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
@@ -26,6 +27,7 @@ import {PersonPlus_Stroke2_Corner0_Rounded as Person} from '#/components/icons/P
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as bsky from '#/types/bsky'
|
||||
import {ProgressGuideList} from './ProgressGuide/List'
|
||||
|
||||
const MOBILE_CARD_WIDTH = 300
|
||||
@@ -227,7 +229,7 @@ export function ProfileGrid({
|
||||
viewContext = 'feed',
|
||||
}: {
|
||||
isSuggestionsLoading: boolean
|
||||
profiles: AppBskyActorDefs.ProfileViewDetailed[]
|
||||
profiles: bsky.profile.AnyProfileView[]
|
||||
recId?: number
|
||||
error: Error | null
|
||||
viewContext: 'profile' | 'feed'
|
||||
|
||||
@@ -10,6 +10,7 @@ import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Link, LinkProps} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
const AVI_SIZE = 30
|
||||
const AVI_SIZE_SMALL = 20
|
||||
@@ -32,11 +33,13 @@ export function KnownFollowers({
|
||||
moderationOpts,
|
||||
onLinkPress,
|
||||
minimal,
|
||||
showIfEmpty,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
profile: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
onLinkPress?: LinkProps['onPress']
|
||||
minimal?: boolean
|
||||
showIfEmpty?: boolean
|
||||
}) {
|
||||
const cache = React.useRef<Map<string, AppBskyActorDefs.KnownFollowers>>(
|
||||
new Map(),
|
||||
@@ -63,11 +66,12 @@ export function KnownFollowers({
|
||||
moderationOpts={moderationOpts}
|
||||
onLinkPress={onLinkPress}
|
||||
minimal={minimal}
|
||||
showIfEmpty={showIfEmpty}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
return <EmptyFallback show={showIfEmpty} />
|
||||
}
|
||||
|
||||
function KnownFollowersInner({
|
||||
@@ -76,22 +80,19 @@ function KnownFollowersInner({
|
||||
cachedKnownFollowers,
|
||||
onLinkPress,
|
||||
minimal,
|
||||
showIfEmpty,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
profile: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
cachedKnownFollowers: AppBskyActorDefs.KnownFollowers
|
||||
onLinkPress?: LinkProps['onPress']
|
||||
minimal?: boolean
|
||||
showIfEmpty?: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
const textStyle = [
|
||||
a.flex_1,
|
||||
a.text_sm,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_medium,
|
||||
]
|
||||
const textStyle = [a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]
|
||||
|
||||
const slice = cachedKnownFollowers.followers.slice(0, 3).map(f => {
|
||||
const moderation = moderateProfile(f, moderationOpts)
|
||||
@@ -114,7 +115,7 @@ function KnownFollowersInner({
|
||||
* We check above too, but here for clarity and a reminder to _check for
|
||||
* valid indices_
|
||||
*/
|
||||
if (slice.length === 0) return null
|
||||
if (slice.length === 0) return <EmptyFallback show={showIfEmpty} />
|
||||
|
||||
const SIZE = minimal ? AVI_SIZE_SMALL : AVI_SIZE
|
||||
|
||||
@@ -126,7 +127,7 @@ function KnownFollowersInner({
|
||||
onPress={onLinkPress}
|
||||
to={makeProfileLink(profile, 'known-followers')}
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.max_w_full,
|
||||
a.flex_row,
|
||||
minimal ? a.gap_sm : a.gap_md,
|
||||
a.align_center,
|
||||
@@ -171,6 +172,7 @@ function KnownFollowersInner({
|
||||
|
||||
<Text
|
||||
style={[
|
||||
a.flex_shrink,
|
||||
textStyle,
|
||||
hovered && {
|
||||
textDecorationLine: 'underline',
|
||||
@@ -242,3 +244,15 @@ function KnownFollowersInner({
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyFallback({show}: {show?: boolean}) {
|
||||
const t = useTheme()
|
||||
|
||||
if (!show) return null
|
||||
|
||||
return (
|
||||
<Text style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||
<Trans>Not followed by anyone you're following</Trans>
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {createContext, useCallback, useContext} from 'react'
|
||||
import {GestureResponderEvent, View} from 'react-native'
|
||||
import {GestureResponderEvent, Keyboard, View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
TextStyleProp,
|
||||
useBreakpoints,
|
||||
useGutters,
|
||||
useLayoutBreakpoints,
|
||||
useTheme,
|
||||
web,
|
||||
} from '#/alf'
|
||||
@@ -23,6 +24,7 @@ import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu'
|
||||
import {
|
||||
BUTTON_VISUAL_ALIGNMENT_OFFSET,
|
||||
HEADER_SLOT_SIZE,
|
||||
SCROLLBAR_OFFSET,
|
||||
} from '#/components/Layout/const'
|
||||
import {ScrollbarOffsetContext} from '#/components/Layout/context'
|
||||
import {Text} from '#/components/Typography'
|
||||
@@ -42,6 +44,7 @@ export function Outer({
|
||||
const gutters = useGutters([0, 'base'])
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {isWithinOffsetView} = useContext(ScrollbarOffsetContext)
|
||||
const {centerColumnOffset} = useLayoutBreakpoints()
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -60,7 +63,12 @@ export function Outer({
|
||||
}),
|
||||
t.atoms.border_contrast_low,
|
||||
gtMobile && [a.mx_auto, {maxWidth: 600}],
|
||||
!isWithinOffsetView && a.scrollbar_offset,
|
||||
!isWithinOffsetView && {
|
||||
transform: [
|
||||
{translateX: centerColumnOffset ? -150 : 0},
|
||||
{translateX: web(SCROLLBAR_OFFSET) ?? 0},
|
||||
],
|
||||
},
|
||||
]}>
|
||||
{children}
|
||||
</View>
|
||||
@@ -140,6 +148,7 @@ export function MenuButton() {
|
||||
const {gtMobile} = useBreakpoints()
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
Keyboard.dismiss()
|
||||
setDrawerOpen(true)
|
||||
}, [setDrawerOpen])
|
||||
|
||||
|
||||
@@ -13,7 +13,15 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {useShellLayout} from '#/state/shell/shell-layout'
|
||||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {
|
||||
atoms as a,
|
||||
useBreakpoints,
|
||||
useLayoutBreakpoints,
|
||||
useTheme,
|
||||
web,
|
||||
} from '#/alf'
|
||||
import {useDialogContext} from '#/components/Dialog'
|
||||
import {SCROLLBAR_OFFSET} from '#/components/Layout/const'
|
||||
import {ScrollbarOffsetContext} from '#/components/Layout/context'
|
||||
|
||||
export * from '#/components/Layout/const'
|
||||
@@ -47,6 +55,7 @@ export const Screen = React.memo(function Screen({
|
||||
export type ContentProps = AnimatedScrollViewProps & {
|
||||
style?: StyleProp<ViewStyle>
|
||||
contentContainerStyle?: StyleProp<ViewStyle>
|
||||
ignoreTabletLayoutOffset?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,6 +65,7 @@ export const Content = React.memo(function Content({
|
||||
children,
|
||||
style,
|
||||
contentContainerStyle,
|
||||
ignoreTabletLayoutOffset,
|
||||
...props
|
||||
}: ContentProps) {
|
||||
const t = useTheme()
|
||||
@@ -84,8 +94,10 @@ export const Content = React.memo(function Content({
|
||||
]}
|
||||
{...props}>
|
||||
{isWeb ? (
|
||||
// @ts-ignore web only -esb
|
||||
<Center>{children}</Center>
|
||||
<Center ignoreTabletLayoutOffset={ignoreTabletLayoutOffset}>
|
||||
{/* @ts-expect-error web only -esb */}
|
||||
{children}
|
||||
</Center>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
@@ -138,10 +150,13 @@ export const KeyboardAwareContent = React.memo(function LayoutScrollView({
|
||||
export const Center = React.memo(function LayoutContent({
|
||||
children,
|
||||
style,
|
||||
ignoreTabletLayoutOffset,
|
||||
...props
|
||||
}: ViewProps) {
|
||||
}: ViewProps & {ignoreTabletLayoutOffset?: boolean}) {
|
||||
const {isWithinOffsetView} = useContext(ScrollbarOffsetContext)
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {centerColumnOffset} = useLayoutBreakpoints()
|
||||
const {isWithinDialog} = useDialogContext()
|
||||
const ctx = useMemo(() => ({isWithinOffsetView: true}), [])
|
||||
return (
|
||||
<View
|
||||
@@ -151,8 +166,20 @@ export const Center = React.memo(function LayoutContent({
|
||||
gtMobile && {
|
||||
maxWidth: 600,
|
||||
},
|
||||
!isWithinOffsetView && {
|
||||
transform: [
|
||||
{
|
||||
translateX:
|
||||
centerColumnOffset &&
|
||||
!ignoreTabletLayoutOffset &&
|
||||
!isWithinDialog
|
||||
? -150
|
||||
: 0,
|
||||
},
|
||||
{translateX: web(SCROLLBAR_OFFSET) ?? 0},
|
||||
],
|
||||
},
|
||||
style,
|
||||
!isWithinOffsetView && a.scrollbar_offset,
|
||||
]}
|
||||
{...props}>
|
||||
<ScrollbarOffsetContext.Provider value={ctx}>
|
||||
@@ -168,6 +195,7 @@ export const Center = React.memo(function LayoutContent({
|
||||
const WebCenterBorders = React.memo(function LayoutContent() {
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {centerColumnOffset} = useLayoutBreakpoints()
|
||||
return gtMobile ? (
|
||||
<View
|
||||
style={[
|
||||
@@ -180,9 +208,8 @@ const WebCenterBorders = React.memo(function LayoutContent() {
|
||||
width: 602,
|
||||
left: '50%',
|
||||
transform: [
|
||||
{
|
||||
translateX: '-50%',
|
||||
},
|
||||
{translateX: '-50%'},
|
||||
{translateX: centerColumnOffset ? -150 : 0},
|
||||
...a.scrollbar_offset.transform,
|
||||
],
|
||||
}),
|
||||
|
||||
+13
-1
@@ -69,6 +69,11 @@ type BaseLinkProps = Pick<
|
||||
* Native-only attribute. If true, will open the share sheet on long press.
|
||||
*/
|
||||
shareOnLongPress?: boolean
|
||||
|
||||
/**
|
||||
* Whether the link should be opened through the redirect proxy.
|
||||
*/
|
||||
shouldProxy?: boolean
|
||||
}
|
||||
|
||||
export function useLink({
|
||||
@@ -80,9 +85,11 @@ export function useLink({
|
||||
onLongPress: outerOnLongPress,
|
||||
shareOnLongPress,
|
||||
overridePresentation,
|
||||
shouldProxy,
|
||||
}: BaseLinkProps & {
|
||||
displayText: string
|
||||
overridePresentation?: boolean
|
||||
shouldProxy?: boolean
|
||||
}) {
|
||||
const navigation = useNavigationDeduped()
|
||||
const {href} = useLinkProps<AllNavigatorParams>({
|
||||
@@ -118,7 +125,7 @@ export function useLink({
|
||||
})
|
||||
} else {
|
||||
if (isExternal) {
|
||||
openLink(href, overridePresentation)
|
||||
openLink(href, overridePresentation, shouldProxy)
|
||||
} else {
|
||||
const shouldOpenInNewTab = shouldClickOpenNewTab(e)
|
||||
|
||||
@@ -161,6 +168,7 @@ export function useLink({
|
||||
action,
|
||||
navigation,
|
||||
overridePresentation,
|
||||
shouldProxy,
|
||||
],
|
||||
)
|
||||
|
||||
@@ -219,6 +227,7 @@ export function Link({
|
||||
onPress: outerOnPress,
|
||||
onLongPress: outerOnLongPress,
|
||||
download,
|
||||
shouldProxy,
|
||||
...rest
|
||||
}: LinkProps) {
|
||||
const {href, isExternal, onPress, onLongPress} = useLink({
|
||||
@@ -227,6 +236,7 @@ export function Link({
|
||||
action,
|
||||
onPress: outerOnPress,
|
||||
onLongPress: outerOnLongPress,
|
||||
shouldProxy: shouldProxy,
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -279,6 +289,7 @@ export function InlineLinkText({
|
||||
shareOnLongPress,
|
||||
disableUnderline,
|
||||
overridePresentation,
|
||||
shouldProxy,
|
||||
...rest
|
||||
}: InlineLinkProps) {
|
||||
const t = useTheme()
|
||||
@@ -292,6 +303,7 @@ export function InlineLinkText({
|
||||
onLongPress: outerOnLongPress,
|
||||
shareOnLongPress,
|
||||
overridePresentation,
|
||||
shouldProxy: shouldProxy,
|
||||
})
|
||||
const {
|
||||
state: hovered,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
AppBskyGraphDefs,
|
||||
AtUri,
|
||||
moderateUserList,
|
||||
@@ -26,6 +25,7 @@ import {
|
||||
import {Link as InternalLink, LinkProps} from '#/components/Link'
|
||||
import * as Hider from '#/components/moderation/Hider'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
/*
|
||||
* This component is based on `FeedCard` and is tightly coupled with that
|
||||
@@ -107,7 +107,7 @@ export function TitleAndByline({
|
||||
modUi,
|
||||
}: {
|
||||
title: string
|
||||
creator?: AppBskyActorDefs.ProfileViewBasic
|
||||
creator?: bsky.profile.AnyProfileView
|
||||
purpose?: AppBskyGraphDefs.ListView['purpose']
|
||||
modUi?: ModerationUI
|
||||
}) {
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {
|
||||
AppBskyEmbedExternal,
|
||||
AppBskyEmbedImages,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyEmbedVideo,
|
||||
} from '@atproto/api'
|
||||
import {AppBskyFeedDefs} from '@atproto/api'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
import {parseTenorGif} from '#/lib/strings/embed-player'
|
||||
import {isTenorGifUri} from '#/lib/strings/embed-player'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
/**
|
||||
* Streamlined MediaPreview component which just handles images, gifs, and videos
|
||||
@@ -22,20 +18,17 @@ export function Embed({
|
||||
embed,
|
||||
style,
|
||||
}: {
|
||||
embed?:
|
||||
| AppBskyEmbedImages.View
|
||||
| AppBskyEmbedRecordWithMedia.View
|
||||
| AppBskyEmbedExternal.View
|
||||
| AppBskyEmbedVideo.View
|
||||
| {[k: string]: unknown}
|
||||
embed: AppBskyFeedDefs.PostView['embed']
|
||||
style?: StyleProp<ViewStyle>
|
||||
}) {
|
||||
let media = AppBskyEmbedRecordWithMedia.isView(embed) ? embed.media : embed
|
||||
const e = bsky.post.parseEmbed(embed)
|
||||
|
||||
if (AppBskyEmbedImages.isView(media)) {
|
||||
if (!e) return null
|
||||
|
||||
if (e.type === 'images') {
|
||||
return (
|
||||
<Outer style={style}>
|
||||
{media.images.map(image => (
|
||||
{e.view.images.map(image => (
|
||||
<ImageItem
|
||||
key={image.thumb}
|
||||
thumbnail={image.thumb}
|
||||
@@ -44,28 +37,21 @@ export function Embed({
|
||||
))}
|
||||
</Outer>
|
||||
)
|
||||
} else if (AppBskyEmbedExternal.isView(media) && media.external.thumb) {
|
||||
let url: URL | undefined
|
||||
try {
|
||||
url = new URL(media.external.uri)
|
||||
} catch {}
|
||||
if (url) {
|
||||
const {success} = parseTenorGif(url)
|
||||
if (success) {
|
||||
return (
|
||||
<Outer style={style}>
|
||||
<GifItem
|
||||
thumbnail={media.external.thumb}
|
||||
alt={media.external.title}
|
||||
/>
|
||||
</Outer>
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (AppBskyEmbedVideo.isView(media)) {
|
||||
} else if (e.type === 'link') {
|
||||
if (!e.view.external.thumb) return null
|
||||
if (!isTenorGifUri(e.view.external.uri)) return null
|
||||
return (
|
||||
<Outer style={style}>
|
||||
<VideoItem thumbnail={media.thumbnail} alt={media.alt} />
|
||||
<GifItem
|
||||
thumbnail={e.view.external.thumb}
|
||||
alt={e.view.external.title}
|
||||
/>
|
||||
</Outer>
|
||||
)
|
||||
} else if (e.type === 'video') {
|
||||
return (
|
||||
<Outer style={style}>
|
||||
<VideoItem thumbnail={e.view.thumbnail} alt={e.view.alt} />
|
||||
</Outer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ export function Outer({
|
||||
<Dialog.Handle />
|
||||
{/* Re-wrap with context since Dialogs are portal-ed to root */}
|
||||
<Context.Provider value={context}>
|
||||
<Dialog.ScrollableInner label={_(msg`Menu`)} style={[a.py_sm]}>
|
||||
<Dialog.ScrollableInner label={_(msg`Menu`)}>
|
||||
<View style={[a.gap_lg]}>
|
||||
{children}
|
||||
{isNative && showCancel && <Cancel />}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||
|
||||
import {useA11y} from '#/state/a11y'
|
||||
import {atoms as a, flatten, useTheme, web} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
@@ -177,10 +178,16 @@ export function Outer({
|
||||
style?: StyleProp<ViewStyle>
|
||||
}>) {
|
||||
const t = useTheme()
|
||||
const {reduceMotionEnabled} = useA11y()
|
||||
|
||||
return (
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content sideOffset={5} loop aria-label="Test">
|
||||
<DropdownMenu.Content
|
||||
sideOffset={5}
|
||||
collisionPadding={{left: 5, right: 5, bottom: 5}}
|
||||
loop
|
||||
aria-label="Test"
|
||||
className="dropdown-menu-transform-origin dropdown-menu-constrain-size">
|
||||
<View
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
@@ -189,6 +196,8 @@ export function Outer({
|
||||
t.name === 'light' ? t.atoms.bg : t.atoms.bg_contrast_25,
|
||||
t.atoms.shadow_md,
|
||||
t.atoms.border_contrast_low,
|
||||
a.overflow_auto,
|
||||
!reduceMotionEnabled && a.zoom_fade_in,
|
||||
style,
|
||||
]}>
|
||||
{children}
|
||||
@@ -373,9 +382,8 @@ export function Divider() {
|
||||
style={flatten([
|
||||
a.my_xs,
|
||||
t.atoms.bg_contrast_100,
|
||||
{
|
||||
height: 1,
|
||||
},
|
||||
a.flex_shrink_0,
|
||||
{height: 1},
|
||||
])}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react'
|
||||
import {GestureResponderEvent, View} from 'react-native'
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
moderateProfile,
|
||||
ModerationOpts,
|
||||
RichText as RichTextApi,
|
||||
@@ -25,13 +24,14 @@ import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus
|
||||
import {Link as InternalLink, LinkProps} from '#/components/Link'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
export function Default({
|
||||
profile,
|
||||
moderationOpts,
|
||||
logContext = 'ProfileCard',
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
profile: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
logContext?: 'ProfileCard' | 'StarterPackProfilesList'
|
||||
}) {
|
||||
@@ -51,7 +51,7 @@ export function Card({
|
||||
moderationOpts,
|
||||
logContext = 'ProfileCard',
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
profile: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
logContext?: 'ProfileCard' | 'StarterPackProfilesList'
|
||||
}) {
|
||||
@@ -101,7 +101,7 @@ export function Link({
|
||||
style,
|
||||
...rest
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
profile: bsky.profile.AnyProfileView
|
||||
} & Omit<LinkProps, 'to' | 'label'>) {
|
||||
const {_} = useLingui()
|
||||
return (
|
||||
@@ -126,7 +126,7 @@ export function Avatar({
|
||||
profile,
|
||||
moderationOpts,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
profile: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
}) {
|
||||
const moderation = moderateProfile(profile, moderationOpts)
|
||||
@@ -161,7 +161,7 @@ export function NameAndHandle({
|
||||
profile,
|
||||
moderationOpts,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
profile: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
}) {
|
||||
const t = useTheme()
|
||||
@@ -224,17 +224,16 @@ export function Description({
|
||||
profile: profileUnshadowed,
|
||||
numberOfLines = 3,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
profile: bsky.profile.AnyProfileView
|
||||
numberOfLines?: number
|
||||
}) {
|
||||
const profile = useProfileShadow(profileUnshadowed)
|
||||
const {description} = profile
|
||||
const rt = React.useMemo(() => {
|
||||
if (!description) return
|
||||
const rt = new RichTextApi({text: description || ''})
|
||||
if (!('description' in profile)) return
|
||||
const rt = new RichTextApi({text: profile.description || ''})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
return rt
|
||||
}, [description])
|
||||
}, [profile])
|
||||
if (!rt) return null
|
||||
if (
|
||||
profile.viewer &&
|
||||
@@ -281,7 +280,7 @@ export function DescriptionPlaceholder({
|
||||
}
|
||||
|
||||
export type FollowButtonProps = {
|
||||
profile: AppBskyActorDefs.ProfileViewBasic
|
||||
profile: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
logContext: LogEvents['profile:follow']['logContext'] &
|
||||
LogEvents['profile:unfollow']['logContext']
|
||||
|
||||
@@ -762,7 +762,7 @@ function SearchInput({
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
accessibilityLabel={_(msg`Search profiles`)}
|
||||
accessibilityHint={_(msg`Search profiles`)}
|
||||
accessibilityHint={_(msg`Searches for profiles`)}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -45,6 +45,7 @@ export function Outer({
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
testID={testID}
|
||||
webOptions={{alignCenter: true}}
|
||||
nativeOptions={{preventExpansion: true, ...nativeOptions}}>
|
||||
<Dialog.Handle />
|
||||
<Context.Provider value={context}>
|
||||
|
||||
@@ -23,6 +23,7 @@ export type RichTextProps = TextStyleProp &
|
||||
onLinkPress?: LinkProps['onPress']
|
||||
interactiveStyle?: TextStyle
|
||||
emojiMultiplier?: number
|
||||
shouldProxyLinks?: boolean
|
||||
}
|
||||
|
||||
export function RichText({
|
||||
@@ -39,6 +40,7 @@ export function RichText({
|
||||
emojiMultiplier = 1.85,
|
||||
onLayout,
|
||||
onTextLayout,
|
||||
shouldProxyLinks,
|
||||
}: RichTextProps) {
|
||||
const richText = React.useMemo(
|
||||
() =>
|
||||
@@ -110,6 +112,7 @@ export function RichText({
|
||||
style={interactiveStyles}
|
||||
// @ts-ignore TODO
|
||||
dataSet={WORD_WRAP}
|
||||
shouldProxy={shouldProxyLinks}
|
||||
onPress={onLinkPress}>
|
||||
{segment.text}
|
||||
</InlineLinkText>
|
||||
@@ -128,6 +131,7 @@ export function RichText({
|
||||
// @ts-ignore TODO
|
||||
dataSet={WORD_WRAP}
|
||||
shareOnLongPress
|
||||
shouldProxy={shouldProxyLinks}
|
||||
onPress={onLinkPress}
|
||||
emoji>
|
||||
{toShortUrl(segment.text)}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import React from 'react'
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
findNodeHandle,
|
||||
ListRenderItemInfo,
|
||||
@@ -6,11 +11,10 @@ import {
|
||||
View,
|
||||
ViewStyle,
|
||||
} from 'react-native'
|
||||
import {AppBskyGraphDefs, AppBskyGraphGetActorStarterPacks} from '@atproto/api'
|
||||
import {AppBskyGraphDefs} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query'
|
||||
|
||||
import {useGenerateStarterPackMutation} from '#/lib/generate-starterpack'
|
||||
import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset'
|
||||
@@ -19,28 +23,27 @@ import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import {NavigationProp} from '#/lib/routes/types'
|
||||
import {parseStarterPackUri} from '#/lib/strings/starter-pack'
|
||||
import {logger} from '#/logger'
|
||||
import {useActorStarterPacksQuery} from '#/state/queries/actor-starter-packs'
|
||||
import {List, ListRef} from '#/view/com/util/List'
|
||||
import {Text} from '#/view/com/util/text/Text'
|
||||
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
|
||||
import {atoms as a, ios, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog'
|
||||
import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
import {LinearGradientBackground} from '#/components/LinearGradientBackground'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {Default as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
|
||||
import {VerifyEmailDialog} from '../dialogs/VerifyEmailDialog'
|
||||
import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '../icons/Plus'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
interface SectionRef {
|
||||
scrollToTop: () => void
|
||||
}
|
||||
|
||||
interface ProfileFeedgensProps {
|
||||
starterPacksQuery: UseInfiniteQueryResult<
|
||||
InfiniteData<AppBskyGraphGetActorStarterPacks.OutputSchema, unknown>,
|
||||
Error
|
||||
>
|
||||
scrollElRef: ListRef
|
||||
did: string
|
||||
headerOffset: number
|
||||
enabled?: boolean
|
||||
style?: StyleProp<ViewStyle>
|
||||
@@ -58,8 +61,8 @@ export const ProfileStarterPacks = React.forwardRef<
|
||||
ProfileFeedgensProps
|
||||
>(function ProfileFeedgensImpl(
|
||||
{
|
||||
starterPacksQuery: query,
|
||||
scrollElRef,
|
||||
did,
|
||||
headerOffset,
|
||||
enabled,
|
||||
style,
|
||||
@@ -71,17 +74,18 @@ export const ProfileStarterPacks = React.forwardRef<
|
||||
) {
|
||||
const t = useTheme()
|
||||
const bottomBarOffset = useBottomBarOffset(100)
|
||||
const [isPTRing, setIsPTRing] = React.useState(false)
|
||||
const {data, refetch, isFetching, hasNextPage, fetchNextPage} = query
|
||||
const [isPTRing, setIsPTRing] = useState(false)
|
||||
const {data, refetch, isFetching, hasNextPage, fetchNextPage} =
|
||||
useActorStarterPacksQuery({did, enabled})
|
||||
const {isTabletOrDesktop} = useWebMediaQueries()
|
||||
|
||||
const items = data?.pages.flatMap(page => page.starterPacks)
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
useImperativeHandle(ref, () => ({
|
||||
scrollToTop: () => {},
|
||||
}))
|
||||
|
||||
const onRefresh = React.useCallback(async () => {
|
||||
const onRefresh = useCallback(async () => {
|
||||
setIsPTRing(true)
|
||||
try {
|
||||
await refetch()
|
||||
@@ -91,7 +95,7 @@ export const ProfileStarterPacks = React.forwardRef<
|
||||
setIsPTRing(false)
|
||||
}, [refetch, setIsPTRing])
|
||||
|
||||
const onEndReached = React.useCallback(async () => {
|
||||
const onEndReached = useCallback(async () => {
|
||||
if (isFetching || !hasNextPage) return
|
||||
|
||||
try {
|
||||
@@ -101,7 +105,7 @@ export const ProfileStarterPacks = React.forwardRef<
|
||||
}
|
||||
}, [isFetching, hasNextPage, fetchNextPage])
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (enabled && scrollElRef.current) {
|
||||
const nativeTag = findNodeHandle(scrollElRef.current)
|
||||
setScrollViewTag(nativeTag)
|
||||
@@ -140,9 +144,11 @@ export const ProfileStarterPacks = React.forwardRef<
|
||||
desktopFixedHeight
|
||||
onEndReached={onEndReached}
|
||||
onRefresh={onRefresh}
|
||||
ListEmptyComponent={Empty}
|
||||
ListEmptyComponent={
|
||||
data ? (isMe ? Empty : undefined) : FeedLoadingPlaceholder
|
||||
}
|
||||
ListFooterComponent={
|
||||
items?.length !== 0 && isMe ? CreateAnother : undefined
|
||||
!!data && items?.length !== 0 && isMe ? CreateAnother : undefined
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
@@ -181,7 +187,6 @@ function CreateAnother() {
|
||||
|
||||
function Empty() {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const confirmDialogControl = useDialogControl()
|
||||
const followersDialogControl = useDialogControl()
|
||||
@@ -190,7 +195,7 @@ function Empty() {
|
||||
const {needsEmailVerification} = useEmail()
|
||||
const verifyEmailControl = useDialogControl()
|
||||
|
||||
const [isGenerating, setIsGenerating] = React.useState(false)
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
|
||||
const {mutate: generateStarterPack} = useGenerateStarterPackMutation({
|
||||
onSuccess: ({uri}) => {
|
||||
@@ -227,16 +232,10 @@ function Empty() {
|
||||
a.justify_between,
|
||||
a.gap_lg,
|
||||
a.shadow_lg,
|
||||
{marginTop: 1},
|
||||
{marginTop: a.border.borderWidth},
|
||||
]}>
|
||||
<View style={[a.gap_xs]}>
|
||||
<Text
|
||||
style={[
|
||||
a.font_bold,
|
||||
a.text_lg,
|
||||
t.atoms.text_contrast_medium,
|
||||
{color: 'white'},
|
||||
]}>
|
||||
<Text style={[a.font_bold, a.text_lg, {color: 'white'}]}>
|
||||
<Trans>You haven't created a starter pack yet!</Trans>
|
||||
</Text>
|
||||
<Text style={[a.text_md, {color: 'white'}]}>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {useTheme} from '#/alf'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {LinearGradientBackground} from '#/components/LinearGradientBackground'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
const LazyViewShot = React.lazy(
|
||||
// @ts-expect-error dynamic import
|
||||
@@ -30,7 +31,12 @@ export const QrCode = React.forwardRef<ViewShot, Props>(function QrCode(
|
||||
) {
|
||||
const {record} = starterPack
|
||||
|
||||
if (!AppBskyGraphStarterpack.isRecord(record)) {
|
||||
if (
|
||||
!bsky.dangerousIsType<AppBskyGraphStarterpack.Record>(
|
||||
record,
|
||||
AppBskyGraphStarterpack.isRecord,
|
||||
)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import * as Dialog from '#/components/Dialog'
|
||||
import {DialogControlProps} from '#/components/Dialog'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {QrCode} from '#/components/StarterPack/QrCode'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
export function QrCodeDialog({
|
||||
starterPack,
|
||||
@@ -77,7 +78,12 @@ export function QrCodeDialog({
|
||||
} else {
|
||||
setIsProcessing(true)
|
||||
|
||||
if (!AppBskyGraphStarterpack.isRecord(starterPack.record)) {
|
||||
if (
|
||||
!bsky.validate(
|
||||
starterPack.record,
|
||||
AppBskyGraphStarterpack.validateRecord,
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {AppBskyGraphDefs, AppBskyGraphStarterpack, AtUri} from '@atproto/api'
|
||||
import {AppBskyGraphStarterpack, AtUri} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
@@ -12,14 +12,15 @@ import {precacheResolvedUri} from '#/state/queries/resolve-uri'
|
||||
import {precacheStarterPack} from '#/state/queries/starter-packs'
|
||||
import {useSession} from '#/state/session'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {StarterPack} from '#/components/icons/StarterPack'
|
||||
import {StarterPack as StarterPackIcon} from '#/components/icons/StarterPack'
|
||||
import {Link as BaseLink, LinkProps as BaseLinkProps} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
export function Default({
|
||||
starterPack,
|
||||
}: {
|
||||
starterPack?: AppBskyGraphDefs.StarterPackViewBasic
|
||||
starterPack?: bsky.starterPack.AnyStarterPackView
|
||||
}) {
|
||||
if (!starterPack) return null
|
||||
return (
|
||||
@@ -32,7 +33,7 @@ export function Default({
|
||||
export function Notification({
|
||||
starterPack,
|
||||
}: {
|
||||
starterPack?: AppBskyGraphDefs.StarterPackViewBasic
|
||||
starterPack?: bsky.starterPack.AnyStarterPackView
|
||||
}) {
|
||||
if (!starterPack) return null
|
||||
return (
|
||||
@@ -47,7 +48,7 @@ export function Card({
|
||||
noIcon,
|
||||
noDescription,
|
||||
}: {
|
||||
starterPack: AppBskyGraphDefs.StarterPackViewBasic
|
||||
starterPack: bsky.starterPack.AnyStarterPackView
|
||||
noIcon?: boolean
|
||||
noDescription?: boolean
|
||||
}) {
|
||||
@@ -57,14 +58,19 @@ export function Card({
|
||||
const t = useTheme()
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
if (!AppBskyGraphStarterpack.isRecord(record)) {
|
||||
if (
|
||||
!bsky.dangerousIsType<AppBskyGraphStarterpack.Record>(
|
||||
record,
|
||||
AppBskyGraphStarterpack.isRecord,
|
||||
)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[a.w_full, a.gap_md]}>
|
||||
<View style={[a.flex_row, a.gap_sm, a.w_full]}>
|
||||
{!noIcon ? <StarterPack width={40} gradient="sky" /> : null}
|
||||
{!noIcon ? <StarterPackIcon width={40} gradient="sky" /> : null}
|
||||
<View style={[a.flex_1]}>
|
||||
<Text
|
||||
emoji
|
||||
@@ -100,7 +106,7 @@ export function Link({
|
||||
starterPack,
|
||||
children,
|
||||
}: {
|
||||
starterPack: AppBskyGraphDefs.StarterPackViewBasic
|
||||
starterPack: bsky.starterPack.AnyStarterPackView
|
||||
onPress?: () => void
|
||||
children: BaseLinkProps['children']
|
||||
}) {
|
||||
@@ -139,7 +145,7 @@ export function Link({
|
||||
export function Embed({
|
||||
starterPack,
|
||||
}: {
|
||||
starterPack: AppBskyGraphDefs.StarterPackViewBasic
|
||||
starterPack: bsky.starterPack.AnyStarterPackView
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const imageUri = getStarterPackOgCard(starterPack)
|
||||
|
||||
@@ -38,7 +38,7 @@ export function WizardEditListDialog({
|
||||
state: WizardState
|
||||
dispatch: (action: WizardAction) => void
|
||||
moderationOpts: ModerationOpts
|
||||
profile: AppBskyActorDefs.ProfileViewBasic
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
@@ -22,6 +22,7 @@ import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import {Checkbox} from '#/components/forms/Toggle'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
function WizardListCard({
|
||||
type,
|
||||
@@ -123,7 +124,7 @@ export function WizardProfileCard({
|
||||
btnType: 'checkbox' | 'remove'
|
||||
state: WizardState
|
||||
dispatch: (action: WizardAction) => void
|
||||
profile: AppBskyActorDefs.ProfileViewBasic
|
||||
profile: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
}) {
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
@@ -12,6 +12,7 @@ import {PressableScale} from '#/lib/custom-animations/PressableScale'
|
||||
// import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import type {TrendingTopic} from '#/state/queries/trending/useTrendingTopics'
|
||||
import {atoms as a, native, useTheme, ViewStyleProp} from '#/alf'
|
||||
import {StarterPack as StarterPackIcon} from '#/components/icons/StarterPack'
|
||||
import {Link as InternalLink, LinkProps} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
@@ -24,9 +25,8 @@ export function TrendingTopic({
|
||||
const topic = useTopic(raw)
|
||||
|
||||
const isSmall = size === 'small'
|
||||
// const hasAvi = topic.type === 'feed' || topic.type === 'profile'
|
||||
// const aviSize = isSmall ? 16 : 20
|
||||
// const iconSize = isSmall ? 16 : 20
|
||||
const hasIcon = topic.type === 'starter-pack' && !isSmall
|
||||
const iconSize = 20
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -45,45 +45,47 @@ export function TrendingTopic({
|
||||
},
|
||||
]
|
||||
: [a.py_sm, a.px_md],
|
||||
hasIcon && {gap: 6},
|
||||
style,
|
||||
/*
|
||||
{
|
||||
padding: 6,
|
||||
gap: hasAvi ? 4 : 2,
|
||||
},
|
||||
a.pr_md,
|
||||
*/
|
||||
]}>
|
||||
{hasIcon && topic.type === 'starter-pack' && (
|
||||
<StarterPackIcon
|
||||
gradient="sky"
|
||||
width={iconSize}
|
||||
style={{marginLeft: -3, marginVertical: -1}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/*
|
||||
<View
|
||||
style={[
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
a.rounded_full,
|
||||
a.overflow_hidden,
|
||||
{
|
||||
width: aviSize,
|
||||
height: aviSize,
|
||||
},
|
||||
]}>
|
||||
{topic.type === 'tag' ? (
|
||||
<Hashtag width={iconSize} />
|
||||
) : topic.type === 'topic' ? (
|
||||
<Quote width={iconSize - 2} />
|
||||
) : topic.type === 'feed' ? (
|
||||
<UserAvatar
|
||||
type="user"
|
||||
size={aviSize}
|
||||
avatar=""
|
||||
/>
|
||||
) : (
|
||||
<UserAvatar
|
||||
type="user"
|
||||
size={aviSize}
|
||||
avatar=""
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
a.rounded_full,
|
||||
a.overflow_hidden,
|
||||
{
|
||||
width: iconSize,
|
||||
height: iconSize,
|
||||
},
|
||||
]}>
|
||||
{topic.type === 'tag' ? (
|
||||
<Hashtag width={iconSize} />
|
||||
) : topic.type === 'topic' ? (
|
||||
<Quote width={iconSize - 2} />
|
||||
) : topic.type === 'feed' ? (
|
||||
<UserAvatar
|
||||
type="user"
|
||||
size={aviSize}
|
||||
avatar=""
|
||||
/>
|
||||
) : (
|
||||
<UserAvatar
|
||||
type="user"
|
||||
size={aviSize}
|
||||
avatar=""
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
*/}
|
||||
|
||||
<Text
|
||||
@@ -151,7 +153,7 @@ export function TrendingTopicLink({
|
||||
|
||||
type ParsedTrendingTopic =
|
||||
| {
|
||||
type: 'topic' | 'tag' | 'unknown'
|
||||
type: 'topic' | 'tag' | 'starter-pack' | 'unknown'
|
||||
label: string
|
||||
displayName: string
|
||||
url: string
|
||||
@@ -187,6 +189,14 @@ export function useTopic(raw: TrendingTopic): ParsedTrendingTopic {
|
||||
uri: undefined,
|
||||
url: link,
|
||||
}
|
||||
} else if (link.startsWith('/starter-pack')) {
|
||||
return {
|
||||
type: 'starter-pack',
|
||||
label: _(msg`Browse starter pack ${displayName}`),
|
||||
displayName,
|
||||
uri: undefined,
|
||||
url: link,
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -27,6 +27,7 @@ import {Link} from '#/components/Link'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import * as Hider from '#/components/moderation/Hider'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
function getBlackColor(t: ReturnType<typeof useTheme>) {
|
||||
return select(t.name, {
|
||||
@@ -78,7 +79,12 @@ export function VideoPostCard({
|
||||
if (!AppBskyEmbedVideo.isView(embed)) return null
|
||||
|
||||
const author = post.author
|
||||
const text = AppBskyFeedPost.isRecord(post.record) ? post.record?.text : ''
|
||||
const text = bsky.dangerousIsType<AppBskyFeedPost.Record>(
|
||||
post.record,
|
||||
AppBskyFeedPost.isRecord,
|
||||
)
|
||||
? post.record?.text
|
||||
: ''
|
||||
const likeCount = post?.likeCount ?? 0
|
||||
const repostCount = post?.repostCount ?? 0
|
||||
const {thumbnail} = embed
|
||||
@@ -112,7 +118,7 @@ export function VideoPostCard({
|
||||
|
||||
return (
|
||||
<Link
|
||||
accessibilityHint={_(msg`Tap to view video in immersive mode.`)}
|
||||
accessibilityHint={_(msg`Views video in immersive mode`)}
|
||||
label={_(msg`Video from ${author.handle}: ${text}`)}
|
||||
to={{
|
||||
screen: 'VideoFeed',
|
||||
|
||||
@@ -29,6 +29,7 @@ import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe'
|
||||
import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as bsky from '#/types/bsky'
|
||||
import {PencilLine_Stroke2_Corner0_Rounded as PencilLine} from './icons/Pencil'
|
||||
|
||||
interface WhoCanReplyProps {
|
||||
@@ -48,7 +49,10 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
|
||||
* unexpectedly, we should check to make sure it's for sure the root URI.
|
||||
*/
|
||||
const rootUri =
|
||||
AppBskyFeedPost.isRecord(post.record) && post.record.reply?.root
|
||||
bsky.dangerousIsType<AppBskyFeedPost.Record>(
|
||||
post.record,
|
||||
AppBskyFeedPost.isRecord,
|
||||
) && post.record.reply?.root
|
||||
? post.record.reply.root.uri
|
||||
: post.uri
|
||||
const settings = React.useMemo(() => {
|
||||
@@ -209,7 +213,12 @@ function Rules({
|
||||
a.flex_wrap,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
{settings[0].type === 'everybody' ? (
|
||||
{settings.length === 0 ? (
|
||||
<Trans>
|
||||
This post has an unknown type of threadgate on it. Your app may be
|
||||
out of date.
|
||||
</Trans>
|
||||
) : settings[0].type === 'everybody' ? (
|
||||
<Trans>Everybody can reply to this post.</Trans>
|
||||
) : settings[0].type === 'nobody' ? (
|
||||
<Trans>Replies to this post are disabled.</Trans>
|
||||
@@ -253,6 +262,19 @@ function Rule({
|
||||
if (rule.type === 'mention') {
|
||||
return <Trans>mentioned users</Trans>
|
||||
}
|
||||
if (rule.type === 'followers') {
|
||||
return (
|
||||
<Trans>
|
||||
users following{' '}
|
||||
<InlineLinkText
|
||||
label={`@${post.author.handle}`}
|
||||
to={makeProfileLink(post.author)}
|
||||
style={[a.text_sm, a.leading_snug]}>
|
||||
@{post.author.handle}
|
||||
</InlineLinkText>
|
||||
</Trans>
|
||||
)
|
||||
}
|
||||
if (rule.type === 'following') {
|
||||
return (
|
||||
<Trans>
|
||||
|
||||
@@ -4,6 +4,7 @@ import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {getDateAgo} from '#/lib/strings/time'
|
||||
import {logger} from '#/logger'
|
||||
import {isIOS, isWeb} from '#/platform/detection'
|
||||
import {
|
||||
@@ -101,7 +102,7 @@ function BirthdayInner({
|
||||
onChangeDate={newDate => setDate(new Date(newDate))}
|
||||
label={_(msg`Birthday`)}
|
||||
accessibilityHint={_(msg`Enter your birth date`)}
|
||||
maximumDate={new Date()}
|
||||
maximumDate={getDateAgo(13)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, {memo, useRef, useState} from 'react'
|
||||
import {TextInput, View} from 'react-native'
|
||||
import {memo, useEffect, useMemo, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyActorDefs, AppBskyFeedPost, AtUri} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -8,12 +8,19 @@ import {EMBED_SCRIPT} from '#/lib/constants'
|
||||
import {niceDate} from '#/lib/strings/time'
|
||||
import {toShareUrl} from '#/lib/strings/url-helpers'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||
import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBrackets} from '#/components/icons/CodeBrackets'
|
||||
import * as ToggleButton from '#/components/forms/ToggleButton'
|
||||
import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
|
||||
import {
|
||||
ChevronBottom_Stroke2_Corner0_Rounded as ChevronBottomIcon,
|
||||
ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon,
|
||||
} from '#/components/icons/Chevron'
|
||||
import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBracketsIcon} from '#/components/icons/CodeBrackets'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {Button, ButtonIcon, ButtonText} from '../Button'
|
||||
|
||||
export type ColorModeValues = 'system' | 'light' | 'dark'
|
||||
|
||||
type EmbedDialogProps = {
|
||||
control: Dialog.DialogControlProps
|
||||
@@ -44,11 +51,12 @@ function EmbedDialogInner({
|
||||
}: Omit<EmbedDialogProps, 'control'>) {
|
||||
const t = useTheme()
|
||||
const {_, i18n} = useLingui()
|
||||
const ref = useRef<TextInput>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [showCustomisation, setShowCustomisation] = useState(false)
|
||||
const [colorMode, setColorMode] = useState<ColorModeValues>('system')
|
||||
|
||||
// reset copied state after 2 seconds
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (copied) {
|
||||
const timeout = setTimeout(() => {
|
||||
setCopied(false)
|
||||
@@ -57,7 +65,7 @@ function EmbedDialogInner({
|
||||
}
|
||||
}, [copied])
|
||||
|
||||
const snippet = React.useMemo(() => {
|
||||
const snippet = useMemo(() => {
|
||||
function toEmbedUrl(href: string) {
|
||||
return toShareUrl(href) + '?ref_src=embed'
|
||||
}
|
||||
@@ -75,9 +83,11 @@ function EmbedDialogInner({
|
||||
// x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x
|
||||
return `<blockquote class="bluesky-embed" data-bluesky-uri="${escapeHtml(
|
||||
postUri,
|
||||
)}" data-bluesky-cid="${escapeHtml(postCid)}"><p lang="${escapeHtml(
|
||||
lang,
|
||||
)}">${escapeHtml(record.text)}${
|
||||
)}" data-bluesky-cid="${escapeHtml(
|
||||
postCid,
|
||||
)}" data-bluesky-embed-color-mode="${escapeHtml(
|
||||
colorMode,
|
||||
)}"><p lang="${escapeHtml(lang)}">${escapeHtml(record.text)}${
|
||||
record.embed
|
||||
? `<br><br><a href="${escapeHtml(href)}">[image or embed]</a>`
|
||||
: ''
|
||||
@@ -88,60 +98,116 @@ function EmbedDialogInner({
|
||||
)}</a>) <a href="${escapeHtml(href)}">${escapeHtml(
|
||||
niceDate(i18n, timestamp),
|
||||
)}</a></blockquote><script async src="${EMBED_SCRIPT}" charset="utf-8"></script>`
|
||||
}, [i18n, postUri, postCid, record, timestamp, postAuthor])
|
||||
}, [i18n, postUri, postCid, record, timestamp, postAuthor, colorMode])
|
||||
|
||||
return (
|
||||
<Dialog.Inner label="Embed post" style={[a.gap_md, {maxWidth: 500}]}>
|
||||
<View style={[a.gap_sm, a.pb_lg]}>
|
||||
<Text style={[a.text_2xl, a.font_bold]}>
|
||||
<Trans>Embed post</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
style={[a.text_md, t.atoms.text_contrast_medium, a.leading_normal]}>
|
||||
<Trans>
|
||||
Embed this post in your website. Simply copy the following snippet
|
||||
and paste it into the HTML code of your website.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={[a.flex_row, a.gap_sm]}>
|
||||
<View style={[a.flex_1]}>
|
||||
<TextField.Root>
|
||||
<TextField.Icon icon={CodeBrackets} />
|
||||
<TextField.Input
|
||||
label={_(msg`Embed HTML code`)}
|
||||
editable={false}
|
||||
selection={{start: 0, end: snippet.length}}
|
||||
value={snippet}
|
||||
style={{}}
|
||||
/>
|
||||
</TextField.Root>
|
||||
<Dialog.Inner label={_(msg`Embed post`)} style={[{maxWidth: 500}]}>
|
||||
<View style={[a.gap_lg]}>
|
||||
<View style={[a.gap_sm]}>
|
||||
<Text style={[a.text_2xl, a.font_heavy]}>
|
||||
<Trans>Embed post</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
style={[a.text_md, t.atoms.text_contrast_medium, a.leading_normal]}>
|
||||
<Trans>
|
||||
Embed this post in your website. Simply copy the following snippet
|
||||
and paste it into the HTML code of your website.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
label={_(msg`Copy code`)}
|
||||
color="primary"
|
||||
variant="solid"
|
||||
size="large"
|
||||
onPress={() => {
|
||||
ref.current?.focus()
|
||||
ref.current?.setSelection(0, snippet.length)
|
||||
navigator.clipboard.writeText(snippet)
|
||||
setCopied(true)
|
||||
}}>
|
||||
{copied ? (
|
||||
<>
|
||||
<ButtonIcon icon={Check} />
|
||||
<ButtonText>
|
||||
<Trans>Copied!</Trans>
|
||||
</ButtonText>
|
||||
</>
|
||||
) : (
|
||||
<View
|
||||
style={[
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
a.rounded_sm,
|
||||
a.overflow_hidden,
|
||||
]}>
|
||||
<Button
|
||||
label={
|
||||
showCustomisation
|
||||
? _(msg`Hide customization options`)
|
||||
: _(msg`Show customization options`)
|
||||
}
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
shape="default"
|
||||
onPress={() => setShowCustomisation(c => !c)}
|
||||
style={[
|
||||
a.justify_start,
|
||||
showCustomisation && t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<ButtonIcon
|
||||
icon={showCustomisation ? ChevronBottomIcon : ChevronRightIcon}
|
||||
/>
|
||||
<ButtonText>
|
||||
<Trans>Copy code</Trans>
|
||||
<Trans>Customization options</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
|
||||
{showCustomisation && (
|
||||
<View style={[a.gap_sm, a.p_md]}>
|
||||
<Text style={[t.atoms.text_contrast_medium, a.font_bold]}>
|
||||
<Trans>Color theme</Trans>
|
||||
</Text>
|
||||
<ToggleButton.Group
|
||||
label={_(msg`Color mode`)}
|
||||
values={[colorMode]}
|
||||
onChange={([value]) => setColorMode(value as ColorModeValues)}>
|
||||
<ToggleButton.Button name="system" label={_(msg`System`)}>
|
||||
<ToggleButton.ButtonText>
|
||||
<Trans>System</Trans>
|
||||
</ToggleButton.ButtonText>
|
||||
</ToggleButton.Button>
|
||||
<ToggleButton.Button name="light" label={_(msg`Light`)}>
|
||||
<ToggleButton.ButtonText>
|
||||
<Trans>Light</Trans>
|
||||
</ToggleButton.ButtonText>
|
||||
</ToggleButton.Button>
|
||||
<ToggleButton.Button name="dark" label={_(msg`Dark`)}>
|
||||
<ToggleButton.ButtonText>
|
||||
<Trans>Dark</Trans>
|
||||
</ToggleButton.ButtonText>
|
||||
</ToggleButton.Button>
|
||||
</ToggleButton.Group>
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
<View style={[a.flex_row, a.gap_sm]}>
|
||||
<View style={[a.flex_1]}>
|
||||
<TextField.Root>
|
||||
<TextField.Icon icon={CodeBracketsIcon} />
|
||||
<TextField.Input
|
||||
label={_(msg`Embed HTML code`)}
|
||||
editable={false}
|
||||
selection={{start: 0, end: snippet.length}}
|
||||
value={snippet}
|
||||
/>
|
||||
</TextField.Root>
|
||||
</View>
|
||||
<Button
|
||||
label={_(msg`Copy code`)}
|
||||
color="primary"
|
||||
variant="solid"
|
||||
size="large"
|
||||
onPress={() => {
|
||||
navigator.clipboard.writeText(snippet)
|
||||
setCopied(true)
|
||||
}}>
|
||||
{copied ? (
|
||||
<>
|
||||
<ButtonIcon icon={CheckIcon} />
|
||||
<ButtonText>
|
||||
<Trans>Copied!</Trans>
|
||||
</ButtonText>
|
||||
</>
|
||||
) : (
|
||||
<ButtonText>
|
||||
<Trans>Copy code</Trans>
|
||||
</ButtonText>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
<Dialog.Close />
|
||||
</Dialog.Inner>
|
||||
|
||||
@@ -318,7 +318,7 @@ function MutedWordsInner() {
|
||||
<View style={[a.pt_xs]}>
|
||||
<Button
|
||||
disabled={isPending || !field}
|
||||
label={_(msg`Add mute word for configured settings`)}
|
||||
label={_(msg`Add mute word with chosen settings`)}
|
||||
size="large"
|
||||
color="primary"
|
||||
variant="solid"
|
||||
|
||||
@@ -40,6 +40,7 @@ import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export type PostInteractionSettingsFormProps = {
|
||||
canSave?: boolean
|
||||
onSave: () => void
|
||||
isSaving?: boolean
|
||||
|
||||
@@ -58,20 +59,54 @@ export function PostInteractionSettingsControlledDialog({
|
||||
}: PostInteractionSettingsFormProps & {
|
||||
control: Dialog.DialogControlProps
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
return (
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Handle />
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`Edit post interaction settings`)}
|
||||
style={[{maxWidth: 500}, a.w_full]}>
|
||||
<PostInteractionSettingsForm {...rest} />
|
||||
<View style={[a.gap_md]}>
|
||||
<Header />
|
||||
<PostInteractionSettingsForm {...rest} />
|
||||
<Text
|
||||
style={[
|
||||
a.pt_sm,
|
||||
a.text_sm,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>
|
||||
You can set default interaction settings in{' '}
|
||||
<Text style={[a.font_bold, t.atoms.text_contrast_medium]}>
|
||||
Settings → Moderation → Interaction settings
|
||||
</Text>
|
||||
.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
export function Header() {
|
||||
return (
|
||||
<View style={[a.gap_md, a.pb_sm]}>
|
||||
<Text style={[a.text_2xl, a.font_bold]}>
|
||||
<Trans>Post interaction settings</Trans>
|
||||
</Text>
|
||||
<Text style={[a.text_md, a.pb_xs]}>
|
||||
<Trans>Customize who can interact with this post.</Trans>
|
||||
</Text>
|
||||
<Divider />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export type PostInteractionSettingsDialogProps = {
|
||||
control: Dialog.DialogControlProps
|
||||
/**
|
||||
@@ -174,7 +209,7 @@ export function PostInteractionSettingsDialogControlledInner(
|
||||
props.control.close()
|
||||
} catch (e: any) {
|
||||
logger.error(`Failed to save post interaction settings`, {
|
||||
context: 'PostInteractionSettingsDialogControlledInner',
|
||||
source: 'PostInteractionSettingsDialogControlledInner',
|
||||
safeMessage: e.message,
|
||||
})
|
||||
Toast.show(
|
||||
@@ -203,26 +238,31 @@ export function PostInteractionSettingsDialogControlledInner(
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`Edit post interaction settings`)}
|
||||
style={[{maxWidth: 500}, a.w_full]}>
|
||||
{isLoading ? (
|
||||
<View style={[a.flex_1, a.py_4xl, a.align_center, a.justify_center]}>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
) : (
|
||||
<PostInteractionSettingsForm
|
||||
replySettingsDisabled={!isThreadgateOwnedByViewer}
|
||||
isSaving={isSaving}
|
||||
onSave={onSave}
|
||||
postgate={postgateValue}
|
||||
onChangePostgate={setEditedPostgate}
|
||||
threadgateAllowUISettings={allowUIValue}
|
||||
onChangeThreadgateAllowUISettings={setEditedAllowUISettings}
|
||||
/>
|
||||
)}
|
||||
<View style={[a.gap_md]}>
|
||||
<Header />
|
||||
|
||||
{isLoading ? (
|
||||
<View style={[a.flex_1, a.py_4xl, a.align_center, a.justify_center]}>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
) : (
|
||||
<PostInteractionSettingsForm
|
||||
replySettingsDisabled={!isThreadgateOwnedByViewer}
|
||||
isSaving={isSaving}
|
||||
onSave={onSave}
|
||||
postgate={postgateValue}
|
||||
onChangePostgate={setEditedPostgate}
|
||||
threadgateAllowUISettings={allowUIValue}
|
||||
onChangeThreadgateAllowUISettings={setEditedAllowUISettings}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</Dialog.ScrollableInner>
|
||||
)
|
||||
}
|
||||
|
||||
export function PostInteractionSettingsForm({
|
||||
canSave = true,
|
||||
onSave,
|
||||
isSaving,
|
||||
postgate,
|
||||
@@ -283,17 +323,7 @@ export function PostInteractionSettingsForm({
|
||||
return (
|
||||
<View>
|
||||
<View style={[a.flex_1, a.gap_md]}>
|
||||
<Text style={[a.text_2xl, a.font_bold]}>
|
||||
<Trans>Post interaction settings</Trans>
|
||||
</Text>
|
||||
|
||||
<View style={[a.gap_lg]}>
|
||||
<Text style={[a.text_md]}>
|
||||
<Trans>Customize who can interact with this post.</Trans>
|
||||
</Text>
|
||||
|
||||
<Divider />
|
||||
|
||||
<View style={[a.gap_sm]}>
|
||||
<Text style={[a.font_bold, a.text_lg]}>
|
||||
<Trans>Quote settings</Trans>
|
||||
@@ -400,7 +430,7 @@ export function PostInteractionSettingsForm({
|
||||
disabled={replySettingsDisabled}
|
||||
/>
|
||||
<Selectable
|
||||
label={_(msg`Followed users`)}
|
||||
label={_(msg`Users you follow`)}
|
||||
isSelected={
|
||||
!!threadgateAllowUISettings.find(
|
||||
v => v.type === 'following',
|
||||
@@ -409,6 +439,16 @@ export function PostInteractionSettingsForm({
|
||||
onPress={() => onPressAudience({type: 'following'})}
|
||||
disabled={replySettingsDisabled}
|
||||
/>
|
||||
<Selectable
|
||||
label={_(msg`Your followers`)}
|
||||
isSelected={
|
||||
!!threadgateAllowUISettings.find(
|
||||
v => v.type === 'followers',
|
||||
)
|
||||
}
|
||||
onPress={() => onPressAudience({type: 'followers'})}
|
||||
disabled={replySettingsDisabled}
|
||||
/>
|
||||
{lists && lists.length > 0
|
||||
? lists.map(list => (
|
||||
<Selectable
|
||||
@@ -435,6 +475,7 @@ export function PostInteractionSettingsForm({
|
||||
</View>
|
||||
|
||||
<Button
|
||||
disabled={!canSave || isSaving}
|
||||
label={_(msg`Save`)}
|
||||
onPress={onSave}
|
||||
color="primary"
|
||||
|
||||
@@ -43,7 +43,7 @@ function SigninDialogInner({}: {control: Dialog.DialogOuterProps['control']}) {
|
||||
|
||||
return (
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`Sign into Bluesky or create a new account`)}
|
||||
label={_(msg`Sign in to Bluesky or create a new account`)}
|
||||
style={[gtMobile ? {width: 'auto', maxWidth: 420} : a.w_full]}>
|
||||
<View style={[!isNative && a.p_2xl]}>
|
||||
<View
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import {Keyboard, Pressable, View} from 'react-native'
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
ChatBskyConvoDefs,
|
||||
ModerationCause,
|
||||
} from '@atproto/api'
|
||||
import {ChatBskyConvoDefs, ModerationCause} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
@@ -34,6 +30,7 @@ import {
|
||||
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
|
||||
import * as Menu from '#/components/Menu'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import * as bsky from '#/types/bsky'
|
||||
import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '../icons/Bubble'
|
||||
import {ReportDialog} from './ReportDialog'
|
||||
|
||||
@@ -49,7 +46,7 @@ let ConvoMenu = ({
|
||||
style,
|
||||
}: {
|
||||
convo: ChatBskyConvoDefs.ConvoView
|
||||
profile: Shadow<AppBskyActorDefs.ProfileViewBasic>
|
||||
profile: Shadow<bsky.profile.AnyProfileView>
|
||||
control?: Menu.MenuControlProps
|
||||
currentScreen: 'list' | 'conversation'
|
||||
showMarkAsRead?: boolean
|
||||
@@ -148,7 +145,7 @@ function MenuContent({
|
||||
blockedByListControl,
|
||||
}: {
|
||||
convo: ChatBskyConvoDefs.ConvoView
|
||||
profile: Shadow<AppBskyActorDefs.ProfileViewBasic>
|
||||
profile: Shadow<bsky.profile.AnyProfileView>
|
||||
showMarkAsRead?: boolean
|
||||
blockInfo: {
|
||||
listBlocks: ModerationCause[]
|
||||
@@ -176,9 +173,9 @@ function MenuContent({
|
||||
const {mutate: muteConvo} = useMuteConvo(convoId, {
|
||||
onSuccess: data => {
|
||||
if (data.convo.muted) {
|
||||
Toast.show(_(msg`Chat muted`))
|
||||
Toast.show(_(msg({message: 'Chat muted', context: 'toast'})))
|
||||
} else {
|
||||
Toast.show(_(msg`Chat unmuted`))
|
||||
Toast.show(_(msg({message: 'Chat unmuted', context: 'toast'})))
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
|
||||
@@ -13,10 +13,12 @@ export function LeaveConvoPrompt({
|
||||
control,
|
||||
convoId,
|
||||
currentScreen,
|
||||
hasMessages = true,
|
||||
}: {
|
||||
control: DialogOuterProps['control']
|
||||
convoId: string
|
||||
currentScreen: 'list' | 'conversation'
|
||||
hasMessages?: boolean
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
@@ -39,7 +41,9 @@ export function LeaveConvoPrompt({
|
||||
control={control}
|
||||
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 the other participant.`,
|
||||
hasMessages
|
||||
? msg`Are you sure you want to leave this conversation? Your messages will be deleted for you, but not for the other participant.`
|
||||
: msg`Are you sure you want to leave this conversation?`,
|
||||
)}
|
||||
confirmButtonCta={_(msg`Leave`)}
|
||||
confirmButtonColor="negative"
|
||||
|
||||
@@ -69,7 +69,9 @@ export let MessageMenu = ({
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
convo
|
||||
.deleteMessage(message.id)
|
||||
.then(() => Toast.show(_(msg`Message deleted`)))
|
||||
.then(() =>
|
||||
Toast.show(_(msg({message: 'Message deleted', context: 'toast'}))),
|
||||
)
|
||||
.catch(() => Toast.show(_(msg`Failed to delete message`)))
|
||||
}, [_, convo, message.id])
|
||||
|
||||
|
||||
@@ -8,18 +8,20 @@ import {useNavigation} from '@react-navigation/native'
|
||||
import {useEmail} from '#/lib/hooks/useEmail'
|
||||
import {NavigationProp} from '#/lib/routes/types'
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {useMaybeConvoForUser} from '#/state/queries/messages/get-convo-for-members'
|
||||
import {useGetConvoAvailabilityQuery} from '#/state/queries/messages/get-convo-availability'
|
||||
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog'
|
||||
import {canBeMessaged} from '#/components/dms/util'
|
||||
import {Message_Stroke2_Corner0_Rounded as Message} from '#/components/icons/Message'
|
||||
import {useDialogControl} from '../Dialog'
|
||||
import {VerifyEmailDialog} from '../dialogs/VerifyEmailDialog'
|
||||
|
||||
export function MessageProfileButton({
|
||||
profile,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileView
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
@@ -27,10 +29,19 @@ export function MessageProfileButton({
|
||||
const {needsEmailVerification} = useEmail()
|
||||
const verifyEmailControl = useDialogControl()
|
||||
|
||||
const {data: convo, isPending} = useMaybeConvoForUser(profile.did)
|
||||
const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did)
|
||||
const {mutate: initiateConvo} = useGetConvoForMembers({
|
||||
onSuccess: ({convo}) => {
|
||||
logEvent('chat:open', {logContext: 'ProfileHeader'})
|
||||
navigation.navigate('MessagesConversation', {conversation: convo.id})
|
||||
},
|
||||
onError: () => {
|
||||
Toast.show(_(msg`Failed to create conversation`))
|
||||
},
|
||||
})
|
||||
|
||||
const onPress = React.useCallback(() => {
|
||||
if (!convo?.id) {
|
||||
if (!convoAvailability?.canChat) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -39,15 +50,25 @@ export function MessageProfileButton({
|
||||
return
|
||||
}
|
||||
|
||||
if (convo && !convo.lastMessage) {
|
||||
if (convoAvailability.convo) {
|
||||
logEvent('chat:open', {logContext: 'ProfileHeader'})
|
||||
navigation.navigate('MessagesConversation', {
|
||||
conversation: convoAvailability.convo.id,
|
||||
})
|
||||
} else {
|
||||
logEvent('chat:create', {logContext: 'ProfileHeader'})
|
||||
initiateConvo([profile.did])
|
||||
}
|
||||
logEvent('chat:open', {logContext: 'ProfileHeader'})
|
||||
}, [
|
||||
needsEmailVerification,
|
||||
verifyEmailControl,
|
||||
navigation,
|
||||
profile.did,
|
||||
initiateConvo,
|
||||
convoAvailability,
|
||||
])
|
||||
|
||||
navigation.navigate('MessagesConversation', {conversation: convo.id})
|
||||
}, [needsEmailVerification, verifyEmailControl, convo, navigation])
|
||||
|
||||
if (isPending) {
|
||||
if (!convoAvailability) {
|
||||
// show pending state based on declaration
|
||||
if (canBeMessaged(profile)) {
|
||||
return (
|
||||
@@ -69,7 +90,7 @@ export function MessageProfileButton({
|
||||
}
|
||||
}
|
||||
|
||||
if (convo) {
|
||||
if (convoAvailability.canChat) {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyActorDefs, ModerationDecision} from '@atproto/api'
|
||||
import {ModerationDecision} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
@@ -14,6 +14,7 @@ import {BlockedByListDialog} from '#/components/dms/BlockedByListDialog'
|
||||
import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
|
||||
import {ReportConversationPrompt} from '#/components/dms/ReportConversationPrompt'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
export function MessagesListBlockedFooter({
|
||||
recipient: initialRecipient,
|
||||
@@ -21,7 +22,7 @@ export function MessagesListBlockedFooter({
|
||||
hasMessages,
|
||||
moderation,
|
||||
}: {
|
||||
recipient: AppBskyActorDefs.ProfileViewBasic
|
||||
recipient: bsky.profile.AnyProfileView
|
||||
convoId: string
|
||||
hasMessages: boolean
|
||||
moderation: ModerationDecision
|
||||
|
||||
@@ -17,6 +17,7 @@ import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {Shadow} from '#/state/cache/profile-shadow'
|
||||
import {isConvoActive, useConvo} from '#/state/messages/convo'
|
||||
import {ConvoItem} from '#/state/messages/convo/types'
|
||||
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {ConvoMenu} from '#/components/dms/ConvoMenu'
|
||||
@@ -31,7 +32,7 @@ export let MessagesListHeader = ({
|
||||
profile,
|
||||
moderation,
|
||||
}: {
|
||||
profile?: Shadow<AppBskyActorDefs.ProfileViewBasic>
|
||||
profile?: Shadow<AppBskyActorDefs.ProfileViewDetailed>
|
||||
moderation?: ModerationDecision
|
||||
}): React.ReactNode => {
|
||||
const t = useTheme()
|
||||
@@ -52,10 +53,10 @@ export let MessagesListHeader = ({
|
||||
}, [moderation])
|
||||
|
||||
const onPressBack = useCallback(() => {
|
||||
if (isWeb) {
|
||||
navigation.replace('Messages', {})
|
||||
} else {
|
||||
if (navigation.canGoBack()) {
|
||||
navigation.goBack()
|
||||
} else {
|
||||
navigation.navigate('Messages', {})
|
||||
}
|
||||
}, [navigation])
|
||||
|
||||
@@ -138,7 +139,7 @@ function HeaderReady({
|
||||
moderation,
|
||||
blockInfo,
|
||||
}: {
|
||||
profile: Shadow<AppBskyActorDefs.ProfileViewBasic>
|
||||
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
|
||||
moderation: ModerationDecision
|
||||
blockInfo: {
|
||||
listBlocks: ModerationCause[]
|
||||
@@ -157,8 +158,10 @@ function HeaderReady({
|
||||
moderation.ui('displayName'),
|
||||
)
|
||||
|
||||
// @ts-ignore findLast is polyfilled - esb
|
||||
const latestMessageFromOther = convoState.items.findLast(
|
||||
item => item.type === 'message' && item.message.sender.did === profile.did,
|
||||
(item: ConvoItem) =>
|
||||
item.type === 'message' && item.message.sender.did === profile.did,
|
||||
)
|
||||
|
||||
const latestReportableMessage =
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, {memo, useMemo, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {
|
||||
$Typed,
|
||||
AppBskyActorDefs,
|
||||
ChatBskyConvoDefs,
|
||||
ComAtprotoModerationCreateReport,
|
||||
@@ -154,15 +155,16 @@ function SubmitStep({
|
||||
mutationFn: async () => {
|
||||
if (params.type === 'convoMessage') {
|
||||
const {convoId, message} = params
|
||||
const subject: $Typed<ChatBskyConvoDefs.MessageRef> = {
|
||||
$type: 'chat.bsky.convo.defs#messageRef',
|
||||
messageId: message.id,
|
||||
convoId,
|
||||
did: message.sender.did,
|
||||
}
|
||||
|
||||
const report = {
|
||||
reasonType: reportOption.reason,
|
||||
subject: {
|
||||
$type: 'chat.bsky.convo.defs#messageRef',
|
||||
messageId: message.id,
|
||||
convoId,
|
||||
did: message.sender.did,
|
||||
} satisfies ChatBskyConvoDefs.MessageRef,
|
||||
subject,
|
||||
reason: details,
|
||||
} satisfies ComAtprotoModerationCreateReport.InputSchema
|
||||
|
||||
@@ -285,7 +287,7 @@ function DoneStep({
|
||||
}: {
|
||||
convoId: string
|
||||
currentScreen: 'list' | 'conversation'
|
||||
profile: AppBskyActorDefs.ProfileViewBasic
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
@@ -309,6 +311,19 @@ function DoneStep({
|
||||
},
|
||||
})
|
||||
|
||||
let btnText = _(msg`Done`)
|
||||
let toastMsg: string | undefined
|
||||
if (actions.includes('leave') && actions.includes('block')) {
|
||||
btnText = _(msg`Block and Delete`)
|
||||
toastMsg = _(msg({message: 'Conversation deleted', context: 'toast'}))
|
||||
} else if (actions.includes('leave')) {
|
||||
btnText = _(msg`Delete Conversation`)
|
||||
toastMsg = _(msg({message: 'Conversation deleted', context: 'toast'}))
|
||||
} else if (actions.includes('block')) {
|
||||
btnText = _(msg`Block User`)
|
||||
toastMsg = _(msg({message: 'User blocked', context: 'toast'}))
|
||||
}
|
||||
|
||||
const onPressPrimaryAction = () => {
|
||||
control.close(() => {
|
||||
if (actions.includes('block')) {
|
||||
@@ -317,18 +332,12 @@ function DoneStep({
|
||||
if (actions.includes('leave')) {
|
||||
leaveConvo()
|
||||
}
|
||||
if (toastMsg) {
|
||||
Toast.show(toastMsg, 'check')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let btnText = _(msg`Done`)
|
||||
if (actions.includes('leave') && actions.includes('block')) {
|
||||
btnText = _(msg`Block and Delete`)
|
||||
} else if (actions.includes('leave')) {
|
||||
btnText = _(msg`Delete Conversation`)
|
||||
} else if (actions.includes('block')) {
|
||||
btnText = _(msg`Block User`)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={a.gap_2xl}>
|
||||
<View style={[a.justify_center, gtMobile ? a.gap_sm : a.gap_xs]}>
|
||||
|
||||
@@ -6,7 +6,7 @@ import React, {
|
||||
useState,
|
||||
} from 'react'
|
||||
import {TextInput, View} from 'react-native'
|
||||
import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
|
||||
import {moderateProfile, ModerationOpts} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
@@ -28,13 +28,14 @@ import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
type Item =
|
||||
| {
|
||||
type: 'profile'
|
||||
key: string
|
||||
enabled: boolean
|
||||
profile: AppBskyActorDefs.ProfileView
|
||||
profile: bsky.profile.AnyProfileView
|
||||
}
|
||||
| {
|
||||
type: 'empty'
|
||||
@@ -76,7 +77,10 @@ export function SearchablePeopleList({
|
||||
isFetching,
|
||||
} = useActorAutocompleteQuery(searchText, true, 12)
|
||||
const {data: follows} = useProfileFollowsQuery(currentAccount?.did)
|
||||
const {data: convos} = useListConvosQuery({enabled: showRecentConvos})
|
||||
const {data: convos} = useListConvosQuery({
|
||||
enabled: showRecentConvos,
|
||||
status: 'accepted',
|
||||
})
|
||||
|
||||
const items = useMemo(() => {
|
||||
let _items: Item[] = []
|
||||
@@ -330,7 +334,7 @@ function ProfileCard({
|
||||
onPress,
|
||||
}: {
|
||||
enabled: boolean
|
||||
profile: AppBskyActorDefs.ProfileView
|
||||
profile: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
onPress: (did: string) => void
|
||||
}) {
|
||||
@@ -505,7 +509,7 @@ function SearchInput({
|
||||
autoCapitalize="none"
|
||||
autoFocus
|
||||
accessibilityLabel={_(msg`Search profiles`)}
|
||||
accessibilityHint={_(msg`Search profiles`)}
|
||||
accessibilityHint={_(msg`Searches for profiles`)}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
export function canBeMessaged(profile: AppBskyActorDefs.ProfileView) {
|
||||
export function canBeMessaged(profile: bsky.profile.AnyProfileView) {
|
||||
switch (profile.associated?.chat?.allowIncoming) {
|
||||
case 'none':
|
||||
return false
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
import {useCallback, useImperativeHandle, useState} from 'react'
|
||||
import {Keyboard} from 'react-native'
|
||||
import DatePicker from 'react-native-date-picker'
|
||||
|
||||
import {useTheme} from '#/alf'
|
||||
@@ -12,6 +13,7 @@ export const LabelText = TextField.LabelText
|
||||
|
||||
export function DateField({
|
||||
value,
|
||||
inputRef,
|
||||
onChangeDate,
|
||||
label,
|
||||
isInvalid,
|
||||
@@ -20,9 +22,9 @@ export function DateField({
|
||||
maximumDate,
|
||||
}: DateFieldProps) {
|
||||
const t = useTheme()
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const onChangeInternal = React.useCallback(
|
||||
const onChangeInternal = useCallback(
|
||||
(date: Date) => {
|
||||
setOpen(false)
|
||||
|
||||
@@ -32,11 +34,25 @@ export function DateField({
|
||||
[onChangeDate, setOpen],
|
||||
)
|
||||
|
||||
const onPress = React.useCallback(() => {
|
||||
useImperativeHandle(
|
||||
inputRef,
|
||||
() => ({
|
||||
focus: () => {
|
||||
Keyboard.dismiss()
|
||||
setOpen(true)
|
||||
},
|
||||
blur: () => {
|
||||
setOpen(false)
|
||||
},
|
||||
}),
|
||||
[],
|
||||
)
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
setOpen(true)
|
||||
}, [])
|
||||
|
||||
const onCancel = React.useCallback(() => {
|
||||
const onCancel = useCallback(() => {
|
||||
setOpen(false)
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {Pressable, View} from 'react-native'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {android, atoms as a, useTheme, web} from '#/alf'
|
||||
import {atoms as a, native, useTheme, web} from '#/alf'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays'
|
||||
@@ -60,16 +60,16 @@ export function DateFieldButton({
|
||||
onBlur={onBlur}
|
||||
style={[
|
||||
{
|
||||
paddingTop: 12,
|
||||
paddingBottom: 12,
|
||||
paddingLeft: 14,
|
||||
paddingRight: 14,
|
||||
borderColor: 'transparent',
|
||||
borderWidth: 2,
|
||||
},
|
||||
android({
|
||||
minHeight: 57.5,
|
||||
native({
|
||||
paddingTop: 10,
|
||||
paddingBottom: 10,
|
||||
}),
|
||||
web(a.py_md),
|
||||
a.flex_row,
|
||||
a.flex_1,
|
||||
a.w_full,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import {useCallback, useImperativeHandle} from 'react'
|
||||
import {Keyboard, View} from 'react-native'
|
||||
import DatePicker from 'react-native-date-picker'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
@@ -25,6 +25,7 @@ export const LabelText = TextField.LabelText
|
||||
*/
|
||||
export function DateField({
|
||||
value,
|
||||
inputRef,
|
||||
onChangeDate,
|
||||
testID,
|
||||
label,
|
||||
@@ -36,7 +37,7 @@ export function DateField({
|
||||
const t = useTheme()
|
||||
const control = Dialog.useDialogControl()
|
||||
|
||||
const onChangeInternal = React.useCallback(
|
||||
const onChangeInternal = useCallback(
|
||||
(date: Date | undefined) => {
|
||||
if (date) {
|
||||
const formatted = toSimpleDateString(date)
|
||||
@@ -46,6 +47,20 @@ export function DateField({
|
||||
[onChangeDate],
|
||||
)
|
||||
|
||||
useImperativeHandle(
|
||||
inputRef,
|
||||
() => ({
|
||||
focus: () => {
|
||||
Keyboard.dismiss()
|
||||
control.open()
|
||||
},
|
||||
blur: () => {
|
||||
control.close()
|
||||
},
|
||||
}),
|
||||
[control],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<DateFieldButton
|
||||
|
||||
@@ -34,6 +34,7 @@ const Input = TextField.createInput(InputBase as unknown as typeof TextInput)
|
||||
|
||||
export function DateField({
|
||||
value,
|
||||
inputRef,
|
||||
onChangeDate,
|
||||
label,
|
||||
isInvalid,
|
||||
@@ -58,9 +59,9 @@ export function DateField({
|
||||
<TextField.Icon icon={CalendarDays} />
|
||||
<Input
|
||||
value={toSimpleDateString(value)}
|
||||
inputRef={inputRef as React.Ref<TextInput>}
|
||||
label={label}
|
||||
onChange={handleOnChange}
|
||||
onChangeText={() => {}}
|
||||
testID={testID}
|
||||
accessibilityHint={accessibilityHint}
|
||||
// @ts-expect-error not typed as <input type="date"> even though it is one
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user