Compare commits
389 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9eda1c77bd | |||
| 512e550c2e | |||
| 500fc1c934 | |||
| b196ddef73 | |||
| 5a2135733f | |||
| 69164c640f | |||
| f30acadc73 | |||
| 9b20023c86 | |||
| e8ee30398a | |||
| 6897116625 | |||
| 2262040797 | |||
| 498d321bb1 | |||
| a697841a21 | |||
| 8ac63d780d | |||
| 0419066f45 | |||
| 1463323289 | |||
| f4e14626aa | |||
| ce000ada50 | |||
| 4d0774b75e | |||
| bc7b6f1e13 | |||
| d44060a34e | |||
| 2c828b5755 | |||
| c06312f09a | |||
| cc5580848c | |||
| fa84c451d0 | |||
| 894e2b89d4 | |||
| 3e37696d88 | |||
| 6a15ca88b6 | |||
| 5a6942025c | |||
| 3866142ccd | |||
| 7fd6f8f04b | |||
| 21d8b07bfe | |||
| f08bf5fef9 | |||
| 3e7e859c9c | |||
| 854ae60e7b | |||
| cc4a436e45 | |||
| 0532e120b8 | |||
| 288fad67f4 | |||
| 0e1e790c34 | |||
| 74d8ca8fa7 | |||
| 34d8c6fe58 | |||
| 5bcb909081 | |||
| e28f6d2f37 | |||
| 3e7d7ce5f4 | |||
| 876e20166a | |||
| 2a87f6f6b5 | |||
| bfd2d20192 | |||
| dc8570c524 | |||
| 530afe87c7 | |||
| 317ce2b3da | |||
| 45df50ec19 | |||
| b9a3256e51 | |||
| 1386a559b7 | |||
| be0d00de17 | |||
| dbba97f28d | |||
| 5e8ef6aa9b | |||
| 177bdcd2b7 | |||
| 2027589c55 | |||
| b8d60eb0e2 | |||
| 97fdd7c59b | |||
| 6afe48db6a | |||
| 0b6ff8000d | |||
| 35cb2bcf94 | |||
| 9c9970f680 | |||
| 7b5d5a4f76 | |||
| 4737bfb7ed | |||
| 374ce2c39e | |||
| 1db01a09a8 | |||
| 80429ec902 | |||
| 0c7b2d5353 | |||
| 850e6e6f52 | |||
| 0937f522af | |||
| f5994af620 | |||
| 8b67a3ec2e | |||
| 6187e79b0e | |||
| 2bd9811652 | |||
| 8bdb1c30c8 | |||
| aa897f55a0 | |||
| 18d7e775f6 | |||
| b8aae166d9 | |||
| f8886fbfe6 | |||
| 041e348581 | |||
| e2c54a858c | |||
| 60a0edbbe2 | |||
| 562bf3be22 | |||
| 290e0f2b54 | |||
| ece6dc251c | |||
| 8c705864a2 | |||
| 7a08b82810 | |||
| 20bf2cd117 | |||
| fe8e8ce7de | |||
| 8b8acb7bd1 | |||
| 1a487d0943 | |||
| 1ae77aa997 | |||
| 7cec490cef | |||
| 1281eb01e6 | |||
| 4339585b27 | |||
| 30c9ea5789 | |||
| afbade6f6f | |||
| 1782a65174 | |||
| 7fd218c97d | |||
| 8446efb739 | |||
| b84f28f884 | |||
| 7dc3b4fa8e | |||
| 37a82761f5 | |||
| 612a778361 | |||
| 162a78e1ae | |||
| c42502fdcb | |||
| 8b1c47a499 | |||
| c2fd87bd8b | |||
| d247a65683 | |||
| a95b877ea3 | |||
| f614bf650d | |||
| 5ee667f307 | |||
| 9c29b2867a | |||
| 9746dd8e4c | |||
| 8e2a5abfff | |||
| 6cdc1fe2b5 | |||
| 9bbcb472ef | |||
| 7c9f05a2af | |||
| d4357b2cb8 | |||
| 576761a645 | |||
| 8a1f8997fe | |||
| 6cb88ce1f3 | |||
| 65c4b7833d | |||
| 9b24d75c9c | |||
| 398df1a4b5 | |||
| db9c2a1596 | |||
| 00816b70dc | |||
| 49272be36b | |||
| 85ffc77983 | |||
| 95bede2dcb | |||
| e656fe688d | |||
| db1f45de92 | |||
| bc61314c41 | |||
| 1850042c37 | |||
| 782edbf281 | |||
| 73b096e443 | |||
| 9b0f0a8d24 | |||
| 9ec06971af | |||
| 75242b9b1f | |||
| 21e53b226d | |||
| 02c2a9ea7c | |||
| cb7e2ab976 | |||
| 067c6dacc9 | |||
| 9b8c46cdb7 | |||
| f3cc850b62 | |||
| 35f9ed82b9 | |||
| 8c4fc087f8 | |||
| f3ddc074a6 | |||
| 5e0ffc0917 | |||
| 3fae42c077 | |||
| 80c0aa35e2 | |||
| 4e3c3e9905 | |||
| 1565e071c1 | |||
| c906fea77a | |||
| 775a041b71 | |||
| 98d17fd950 | |||
| 41b3742365 | |||
| 512ce3d5d0 | |||
| bdf143ce79 | |||
| adc079e095 | |||
| 74330500de | |||
| ac939dc84e | |||
| b12d703353 | |||
| c188fd4f04 | |||
| a1cda126e6 | |||
| ae115ca17e | |||
| 197c069416 | |||
| e67d81f269 | |||
| 7c08bba6cf | |||
| 29bc0f1372 | |||
| 82f42e734c | |||
| 3b9f0b0a68 | |||
| a6ba0810b7 | |||
| 49253dbd88 | |||
| cc2255cc49 | |||
| 8ee9709fbb | |||
| 90d650def2 | |||
| 79e27e303b | |||
| 6be7751769 | |||
| 9b830fd425 | |||
| 4ebeb94b41 | |||
| b6b364700e | |||
| 95f9cc1ed5 | |||
| 06fcea988e | |||
| b166d395d9 | |||
| 1228306309 | |||
| bc001168f3 | |||
| b4cb4c4d13 | |||
| 2e34f965fc | |||
| 9cdfb40ce7 | |||
| c83dddce57 | |||
| 795428971d | |||
| 82fea6da7d | |||
| 2f156bb906 | |||
| c46219bc4d | |||
| 6d68a5bd21 | |||
| cbac380c96 | |||
| 8c4820b1f1 | |||
| 93d2f3a8db | |||
| 3a9526d55e | |||
| 830f305d2d | |||
| 8a088e8710 | |||
| 8cbe3985fa | |||
| 1ec70420e6 | |||
| 63fbf577d9 | |||
| 355868e01e | |||
| 37e5a0f7a4 | |||
| 4356ff0040 | |||
| 4eca13941a | |||
| 8580e4a00e | |||
| 8581285d34 | |||
| 57997d6d16 | |||
| a25bc8f249 | |||
| 177a6e50bd | |||
| 5459857112 | |||
| 338016ed5c | |||
| e2058c8ecc | |||
| 3cbded0269 | |||
| 5ea96736de | |||
| efed955003 | |||
| b8db36afb0 | |||
| 5642bc40bb | |||
| 3be1f5caa9 | |||
| 18eea81e76 | |||
| 917f099e26 | |||
| 6bfe758d2a | |||
| 18ab5d6f34 | |||
| dc3f01591f | |||
| 3a28a07081 | |||
| a57faedbae | |||
| 423eddc474 | |||
| 5c5b365ee9 | |||
| 1584c2de48 | |||
| bb6ce83198 | |||
| 3abc361800 | |||
| e6c4a539ca | |||
| 965b17ff86 | |||
| a148b2477b | |||
| 546201c93c | |||
| b3cbb3440a | |||
| da5c356fc7 | |||
| 6f01503eac | |||
| 24dbe799dc | |||
| 3197cbcf36 | |||
| 017120b3e8 | |||
| 27d9462639 | |||
| b2c95bc35a | |||
| cbd48c855a | |||
| 421c18ea7c | |||
| 26c84399dd | |||
| 4f1d4821c5 | |||
| bfd5ed480f | |||
| d9ef02737b | |||
| 809b318fe4 | |||
| 864af57fd9 | |||
| ab98130295 | |||
| a168e97d07 | |||
| 748706daab | |||
| 45d6e50eb4 | |||
| e2a56b0191 | |||
| 1eb09b7210 | |||
| ee99320e1f | |||
| 9689a6daaa | |||
| 45735144ee | |||
| 4b86444bd8 | |||
| 9ff5b2da50 | |||
| 58f532a495 | |||
| 0babd0f475 | |||
| 2cee96da8e | |||
| 31553f0d40 | |||
| 0ac5b07bfd | |||
| d13df6c7e6 | |||
| a18f1b68e8 | |||
| b9c92d39eb | |||
| d45a2c832d | |||
| fd49349b71 | |||
| 277e985e1f | |||
| 3b2573bc56 | |||
| ae77320d81 | |||
| 72b7516d9b | |||
| 220e989f3d | |||
| 39e7132fd8 | |||
| 26605ee730 | |||
| f1c50baa4e | |||
| e49ce42a12 | |||
| fd6b8fcbf1 | |||
| 2953b3a094 | |||
| 69a765cc0d | |||
| dd7bb1bc14 | |||
| d31bc7f335 | |||
| f26ac583e6 | |||
| 2ad84d7dfc | |||
| 282b5e8460 | |||
| f82bc98d8d | |||
| 3b7806963a | |||
| 49e7a96cd9 | |||
| 3523e7ce58 | |||
| 967b3b49d9 | |||
| 97083bb55b | |||
| df89b79c2a | |||
| 6534630d16 | |||
| 25a1ed1209 | |||
| 8614e5e105 | |||
| 7da65efc08 | |||
| 4f01d4cc81 | |||
| b4f57b94aa | |||
| bd9e8244f1 | |||
| 5989bf7fad | |||
| a4f173d008 | |||
| f244afcd9c | |||
| 62c0c06bbf | |||
| 0589bd7d9e | |||
| bd510d8468 | |||
| acc26c965d | |||
| fbd1138d97 | |||
| 9c563f2a48 | |||
| 1d4263078d | |||
| 367eebe600 | |||
| 06a8a7efc2 | |||
| 29f8bc998d | |||
| 4760e59ea4 | |||
| d9e37a222a | |||
| 3b98f2f8ab | |||
| ab85b509c0 | |||
| 65faee4f08 | |||
| 94be661794 | |||
| 10aa46aa9e | |||
| 8f82ad66df | |||
| 1cb362b596 | |||
| f960d2fbc6 | |||
| 58ac9a3e7b | |||
| bbab7695f9 | |||
| 26aa7c0d9a | |||
| 498a09e797 | |||
| 2223babc2b | |||
| 5922c6622f | |||
| e684c21572 | |||
| 226b19ae43 | |||
| cb6e65e313 | |||
| af92610864 | |||
| ca905eb3e1 | |||
| 61c919110e | |||
| 9aa360d328 | |||
| 2d8de1ddc3 | |||
| c540dae4e7 | |||
| 2818c7f508 | |||
| c4fd9980cc | |||
| 966ae68dd2 | |||
| d2aea9161b | |||
| edb19dd6cf | |||
| 85a616eee0 | |||
| 7285157ab5 | |||
| b3f775d1d8 | |||
| a1857d62bd | |||
| d82592f072 | |||
| dae03312c9 | |||
| c2e5a18e42 | |||
| 2efc047f64 | |||
| 80049bbe82 | |||
| e627d37f42 | |||
| 24fe45aaa1 | |||
| 0deb9511af | |||
| 43ebb80a5b | |||
| d91e206e21 | |||
| 493bd6dcf6 | |||
| 4f6ff6fc73 | |||
| 7ba2997af5 | |||
| 4b6fbcc050 | |||
| 55806f1087 | |||
| 0aaf11aa7b | |||
| 9cfa7d0ba8 | |||
| 9743149c26 | |||
| 82877a096d | |||
| 12dc38c5fc | |||
| 7d5de60401 | |||
| 8136258cd8 | |||
| afe5db8b57 | |||
| 893dd78a00 | |||
| b38013a12f | |||
| aa1395542f | |||
| caa02db87b | |||
| f390167f66 | |||
| 6275d2e0fd | |||
| 3e1e1ba00e | |||
| 699fdbac01 | |||
| a14a9be127 | |||
| 998ee78e3d |
@@ -28,11 +28,24 @@ EXPO_PUBLIC_CHAT_PROXY_DID=
|
||||
#
|
||||
#
|
||||
|
||||
# Bluesky's metrics API
|
||||
EXPO_PUBLIC_METRICS_API_HOST=
|
||||
|
||||
# Growthbook config
|
||||
EXPO_PUBLIC_GROWTHBOOK_API_HOST=
|
||||
EXPO_PUBLIC_GROWTHBOOK_CLIENT_KEY=
|
||||
|
||||
# Sentry DSN for telemetry
|
||||
EXPO_PUBLIC_SENTRY_DSN=
|
||||
|
||||
# Bitdrift API key. If undefined, Bitdrift will be disabled.
|
||||
EXPO_PUBLIC_BITDRIFT_API_KEY=
|
||||
|
||||
# bapp-config web worker URL
|
||||
BAPP_CONFIG_DEV_URL=
|
||||
# geolocation web worker URL
|
||||
GEOLOCATION_DEV_URL=
|
||||
|
||||
# live-events web worker URL
|
||||
LIVE_EVENTS_DEV_URL=
|
||||
|
||||
# app-config web worker URL
|
||||
APP_CONFIG_DEV_URL=
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: [
|
||||
'@react-native',
|
||||
'plugin:react/recommended',
|
||||
'plugin:react/jsx-runtime',
|
||||
'plugin:react-native-a11y/ios',
|
||||
'prettier',
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: [
|
||||
'@typescript-eslint',
|
||||
'react',
|
||||
'lingui',
|
||||
'simple-import-sort',
|
||||
'bsky-internal',
|
||||
'eslint-plugin-react-compiler',
|
||||
'import',
|
||||
],
|
||||
rules: {
|
||||
'react/no-unescaped-entities': 0,
|
||||
'react/prop-types': 0,
|
||||
'react-native/no-inline-styles': 0,
|
||||
'bsky-internal/avoid-unwrapped-text': [
|
||||
'error',
|
||||
{
|
||||
impliedTextComponents: [
|
||||
'H1',
|
||||
'H2',
|
||||
'H3',
|
||||
'H4',
|
||||
'H5',
|
||||
'H6',
|
||||
'P',
|
||||
'Admonition',
|
||||
'Admonition.Admonition',
|
||||
'Toast.Action',
|
||||
'AgeAssuranceAdmonition',
|
||||
'Span',
|
||||
'StackedButton',
|
||||
],
|
||||
impliedTextProps: [],
|
||||
suggestedTextWrappers: {
|
||||
Button: 'ButtonText',
|
||||
'ToggleButton.Button': 'ToggleButton.ButtonText',
|
||||
'SegmentedControl.Item': 'SegmentedControl.ItemText',
|
||||
},
|
||||
},
|
||||
],
|
||||
'bsky-internal/use-exact-imports': 'error',
|
||||
'bsky-internal/use-typed-gates': 'error',
|
||||
'bsky-internal/use-prefixed-imports': 'error',
|
||||
'simple-import-sort/imports': [
|
||||
'error',
|
||||
{
|
||||
groups: [
|
||||
// Side effect imports.
|
||||
['^\\u0000'],
|
||||
// Node.js builtins prefixed with `node:`.
|
||||
['^node:'],
|
||||
// Packages.
|
||||
// Things that start with a letter (or digit or underscore), or `@` followed by a letter.
|
||||
// React/React Native priortized, followed by expo
|
||||
// Followed by all packages excluding unprefixed relative ones
|
||||
[
|
||||
'^(react\\/(.*)$)|^(react$)|^(react-native(.*)$)',
|
||||
'^(expo(.*)$)|^(expo$)',
|
||||
'^(?!(?:alf|components|lib|locale|logger|platform|screens|state|view)(?:$|\\/))@?\\w',
|
||||
],
|
||||
// Relative imports.
|
||||
// Ideally, anything that starts with a dot or #
|
||||
// due to unprefixed relative imports being used, we whitelist the relative paths we use
|
||||
// (?:$|\\/) matches end of string or /
|
||||
[
|
||||
'^(?:#\\/)?(?:lib|state|logger|platform|locale)(?:$|\\/)',
|
||||
'^(?:#\\/)?view(?:$|\\/)',
|
||||
'^(?:#\\/)?screens(?:$|\\/)',
|
||||
'^(?:#\\/)?alf(?:$|\\/)',
|
||||
'^(?:#\\/)?components(?:$|\\/)',
|
||||
'^#\\/',
|
||||
'^\\.',
|
||||
],
|
||||
// anything else - hopefully we don't have any of these
|
||||
['^'],
|
||||
],
|
||||
},
|
||||
],
|
||||
'simple-import-sort/exports': 'error',
|
||||
'react-compiler/react-compiler': 'warn',
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{argsIgnorePattern: '^_', varsIgnorePattern: '^_.+'},
|
||||
],
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'warn',
|
||||
{prefer: 'type-imports', fixStyle: 'inline-type-imports'},
|
||||
],
|
||||
'import/consistent-type-specifier-style': ['warn', 'prefer-inline'],
|
||||
},
|
||||
ignorePatterns: [
|
||||
'**/__mocks__/*.ts',
|
||||
'src/platform/polyfills.ts',
|
||||
'src/third-party',
|
||||
'ios',
|
||||
'android',
|
||||
'coverage',
|
||||
'*.lock',
|
||||
'.husky',
|
||||
'patches',
|
||||
'*.html',
|
||||
'bskyweb',
|
||||
'bskyembed',
|
||||
'src/locale/locales/_build/',
|
||||
'src/locale/locales/**/*.js',
|
||||
'*.e2e.ts',
|
||||
'*.e2e.tsx',
|
||||
],
|
||||
settings: {
|
||||
componentWrapperFunctions: ['observer'],
|
||||
},
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
ecmaVersion: 'latest',
|
||||
},
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
name: "Bug Report"
|
||||
description: "Create a report for an issue you have experience in the app."
|
||||
description: "Create a report for an issue you have experienced in the app."
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
@@ -19,13 +19,14 @@ body:
|
||||
4. See error
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
- type: upload
|
||||
attributes:
|
||||
label: Attachments
|
||||
description: |
|
||||
If possible, please provide any images or videos that may help us understand the issue you are experiencing.
|
||||
validations:
|
||||
required: false
|
||||
accept: ".png,.jpg,.jpeg,.gif,.webp,.mp4,.mov,.webm"
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: What platform(s) does this occur on?
|
||||
|
||||
@@ -26,13 +26,14 @@ body:
|
||||
4. See error
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
- type: upload
|
||||
attributes:
|
||||
label: Attachments
|
||||
description: |
|
||||
If possible, please provide any images or videos that may help us understand the issue you are experiencing.
|
||||
validations:
|
||||
required: false
|
||||
accept: ".png,.jpg,.jpeg,.gif,.webp,.mp4,.mov,.webm"
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: What platform(s) does this occur on?
|
||||
|
||||
@@ -15,7 +15,7 @@ body:
|
||||
implement it in a timely manner.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
- type: upload
|
||||
attributes:
|
||||
label: Attachments
|
||||
description: |
|
||||
@@ -24,6 +24,7 @@ body:
|
||||
in or is missing from.
|
||||
validations:
|
||||
required: false
|
||||
accept: ".png,.jpg,.jpeg,.gif,.webp,.mp4,.mov,.webm"
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe Alternatives
|
||||
|
||||
@@ -16,6 +16,9 @@ jobs:
|
||||
if: github.repository == 'bluesky-social/social-app'
|
||||
name: Build and Submit Android
|
||||
runs-on: Linux-x64-32core
|
||||
concurrency:
|
||||
group: android-build
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Check for EXPO_TOKEN
|
||||
run: >
|
||||
@@ -79,13 +82,15 @@ jobs:
|
||||
echo "$json" > google-services.json
|
||||
|
||||
- name: 🏗️ EAS Build
|
||||
env:
|
||||
PROFILE: ${{ inputs.profile || 'testflight-android' }}
|
||||
run: >
|
||||
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_RELEASE=${{ steps.env.outputs.EXPO_PUBLIC_RELEASE_VERSION }}
|
||||
SENTRY_DIST=${{ steps.env.outputs.EXPO_PUBLIC_BUNDLE_IDENTIFIER }}
|
||||
yarn use-build-number-with-bump
|
||||
eas build -p android
|
||||
--profile ${{ inputs.profile || 'testflight-android' }}
|
||||
--profile $PROFILE
|
||||
--local --output build.aab --non-interactive
|
||||
|
||||
- name: ✍️ Rename Testflight bundle
|
||||
@@ -194,4 +199,6 @@ jobs:
|
||||
|
||||
- name: ✏️ Write commit hash to cache
|
||||
if: ${{ inputs.profile == 'testflight-android' }}
|
||||
run: echo ${{ github.sha }} > most-recent-testflight-commit.txt
|
||||
env:
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
|
||||
|
||||
@@ -16,6 +16,9 @@ jobs:
|
||||
if: github.repository == 'bluesky-social/social-app'
|
||||
name: Build and Submit iOS
|
||||
runs-on: macos-26-xlarge
|
||||
concurrency:
|
||||
group: ios-build
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Check for EXPO_TOKEN
|
||||
run: >
|
||||
@@ -91,13 +94,15 @@ jobs:
|
||||
echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json
|
||||
|
||||
- name: 🏗️ EAS Build
|
||||
env:
|
||||
PROFILE: ${{ inputs.profile || 'testflight' }}
|
||||
run: >
|
||||
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_RELEASE=${{ steps.env.outputs.EXPO_PUBLIC_RELEASE_VERSION }}
|
||||
SENTRY_DIST=${{ steps.env.outputs.EXPO_PUBLIC_BUNDLE_IDENTIFIER }}
|
||||
yarn use-build-number-with-bump
|
||||
eas build -p ios
|
||||
--profile ${{ inputs.profile || 'testflight' }}
|
||||
--profile $PROFILE
|
||||
--local --output build.tar.gz --non-interactive
|
||||
|
||||
- name: 📂 Extract build artifact
|
||||
@@ -147,5 +152,7 @@ jobs:
|
||||
key: most-recent-testflight-commit
|
||||
|
||||
- name: ✏️ Write commit hash to cache
|
||||
env:
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
if: ${{ inputs.profile == 'testflight' }}
|
||||
run: echo ${{ github.sha }} > most-recent-testflight-commit.txt
|
||||
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
|
||||
|
||||
@@ -39,10 +39,12 @@ jobs:
|
||||
|
||||
# Validate the version if one is supplied. This should generally happen if the update is for a production client
|
||||
- name: 🧐 Validate version
|
||||
env:
|
||||
RUNTIME_VERSION: ${{ inputs.runtimeVersion }}
|
||||
if: ${{ inputs.runtimeVersion }}
|
||||
run: |
|
||||
if [ -z "${{ inputs.runtimeVersion }}" ]; then
|
||||
[[ "${{ inputs.runtimeVersion }}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] && echo "Version is valid" || exit 1
|
||||
if [ -z "$RUNTIME_VERSION" ]; then
|
||||
[[ "$RUNTIME_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] && echo "Version is valid" || exit 1
|
||||
fi
|
||||
|
||||
- name: ⬇️ Checkout
|
||||
@@ -103,12 +105,15 @@ jobs:
|
||||
|
||||
# eas.json not used here, set EXPO_PUBLIC_ENV
|
||||
- name: Env
|
||||
env:
|
||||
CHANNEL: ${{ inputs.channel || 'testflight' }}
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
id: env
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
||||
run: |
|
||||
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
|
||||
echo "${{ secrets.ENV_TOKEN }}" > .env
|
||||
echo "EXPO_PUBLIC_ENV=${{ inputs.channel || 'testflight' }}" >> .env
|
||||
echo "EXPO_PUBLIC_ENV=$CHANNEL" >> .env
|
||||
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> .env
|
||||
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT
|
||||
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> .env
|
||||
@@ -145,15 +150,15 @@ jobs:
|
||||
|
||||
- name: ✏️ Write commit hash to cache
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
||||
run: echo ${{ github.sha }} > most-recent-testflight-commit.txt
|
||||
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
|
||||
|
||||
# GitHub actions are horrible so let's just copy paste this in
|
||||
buildIfNecessaryIOS:
|
||||
name: Build and Submit iOS
|
||||
runs-on: macos-26
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-build-ios
|
||||
cancel-in-progress: true
|
||||
group: ios-build
|
||||
cancel-in-progress: false
|
||||
needs: [bundleDeploy]
|
||||
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
|
||||
# available here
|
||||
@@ -249,13 +254,15 @@ jobs:
|
||||
|
||||
- name: ✏️ Write commit hash to cache
|
||||
if: ${{ inputs.channel == 'testflight' }}
|
||||
run: echo ${{ github.sha }} > most-recent-testflight-commit.txt
|
||||
env:
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
|
||||
|
||||
buildIfNecessaryAndroid:
|
||||
name: Build and Submit Android
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-build-android
|
||||
group: android-build
|
||||
cancel-in-progress: false
|
||||
needs: [bundleDeploy]
|
||||
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
|
||||
@@ -363,5 +370,7 @@ jobs:
|
||||
key: most-recent-testflight-commit
|
||||
|
||||
- name: ✏️ Write commit hash to cache
|
||||
env:
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
if: ${{ inputs.channel != 'testflight' && inputs.channel != 'production' }}
|
||||
run: echo ${{ github.sha }} > most-recent-testflight-commit.txt
|
||||
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||
|
||||
# Optional: Add claude_args to customize behavior and configuration
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
||||
|
||||
# NOTE(sfn): we can add a custom system prompt here
|
||||
|
||||
claude_args: |
|
||||
--model claude-opus-4-5-20251101
|
||||
@@ -15,11 +15,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Git Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
- name: Set up Go tooling
|
||||
uses: actions/setup-go@v3
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.25"
|
||||
go-version-file: bskyweb/go.mod
|
||||
- name: Dummy Static Files
|
||||
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/css/blah.txt && touch bskyweb/static/media/blah.txt
|
||||
- name: Check
|
||||
@@ -32,11 +32,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Git Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
- name: Set up Go tooling
|
||||
uses: actions/setup-go@v3
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.25"
|
||||
go-version-file: bskyweb/go.mod
|
||||
- name: Dummy Static Files
|
||||
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/css/blah.txt && touch bskyweb/static/media/blah.txt
|
||||
- name: Lint
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
fi
|
||||
|
||||
|
||||
if [[ "${{ github.event.comment.body }}" == *"ota"* ]]; then
|
||||
if [[ "$COMMENT" == *"ota"* ]]; then
|
||||
has_ota=true
|
||||
else
|
||||
has_ota=false
|
||||
@@ -75,6 +75,8 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Get PR HEAD SHA
|
||||
env:
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
id: pr-info
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
@@ -82,7 +84,7 @@ jobs:
|
||||
const pr = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: ${{ github.event.issue.number }}
|
||||
pull_number: process.env.ISSUE_NUMBER,
|
||||
});
|
||||
|
||||
console.log(`PR HEAD SHA: ${pr.data.head.sha}`);
|
||||
@@ -184,13 +186,15 @@ jobs:
|
||||
|
||||
- name: 💬 Drop a comment
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
env:
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
with:
|
||||
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
|
||||
number: ${{ github.event.issue.number }}
|
||||
message: |
|
||||
Your requested OTA deployment was successful! You may now apply it by either scanning the QR code or opening the deep link below in your browser:
|
||||
|
||||
<img src="https://bsky-qr.vercel.app?channel=pull-request-${{ github.event.issue.number }}" width=300 height=300>
|
||||
<img src="https://bsky-qr.vercel.app?channel=pull-request-$ISSUE_NUMBER" width=300 height=300>
|
||||
|
||||
`bluesky://intent/apply-ota?channel=pull-request-${{ github.event.issue.number }}`
|
||||
---
|
||||
|
||||
@@ -35,20 +35,27 @@ jobs:
|
||||
cache: yarn
|
||||
|
||||
- name: Ensure tracking relevant branches and checkout base
|
||||
env:
|
||||
HEAD_REF: ${{ github.head_ref }}
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
run: |
|
||||
git checkout ${{ github.head_ref }}
|
||||
git checkout ${{ github.base_ref }}
|
||||
git checkout $HEAD_REF
|
||||
git checkout $BASE_REF
|
||||
|
||||
- name: Get the base commit
|
||||
id: base-commit
|
||||
run: echo base-commit=$(git log -n 1 ${{ github.base_ref }} --pretty=format:'%H') >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
run: echo base-commit=$(git log -n 1 $BASE_REF --pretty=format:'%H') >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Merge PR commit
|
||||
env:
|
||||
HEAD_REF: ${{ github.head_ref }}
|
||||
run: |
|
||||
# Have to set a git config for the merge to work
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git merge --no-edit ${{ github.head_ref }}
|
||||
git merge --no-edit $HEAD_REF
|
||||
yarn install
|
||||
yarn intl:build
|
||||
|
||||
@@ -84,7 +91,7 @@ jobs:
|
||||
with:
|
||||
base_path: "stats-base.json"
|
||||
pr_path: "../stats-new.json"
|
||||
excluded_assets: "(.+).chunk.js|(.+).js.map|(.+).json|(.+).png"
|
||||
excluded_assets: "(.+).chunk.js|(.+).js.map|(.+).json|(.+).png|(.+).svg|(.+).webp|(.+).jpg|(.+).ico"
|
||||
|
||||
- name: 💬 Drop a comment
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
|
||||
@@ -17,7 +17,9 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Fetch base branch
|
||||
run: git fetch origin ${{ github.base_ref }} --depth=1
|
||||
env:
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
run: git fetch origin $BASE_REF --depth=1
|
||||
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v6
|
||||
@@ -25,7 +27,9 @@ jobs:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Reset yarn.lock to base
|
||||
run: git show "origin/${{ github.base_ref }}:yarn.lock" > yarn.lock
|
||||
env:
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
run: git show "origin/$BASE_REF:yarn.lock" > yarn.lock
|
||||
|
||||
- name: Yarn install
|
||||
uses: Wandalen/wretry.action@master
|
||||
|
||||
@@ -110,7 +110,9 @@ google-services.json
|
||||
|
||||
# i18n
|
||||
src/locale/locales/_build/
|
||||
src/locale/locales/**/*.js
|
||||
src/locale/locales/**/messages.js
|
||||
src/locale/locales/**/messages.mjs
|
||||
src/locale/locales/**/messages.ts
|
||||
|
||||
# local builds
|
||||
*.apk
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Codemod to replace namespaced React calls with named imports
|
||||
*
|
||||
* Before:
|
||||
* import React from 'react'
|
||||
* React.useEffect(() => {}, [])
|
||||
*
|
||||
* After:
|
||||
* import { useEffect } from 'react'
|
||||
* useEffect(() => {}, [])
|
||||
*
|
||||
* Usage: jscodeshift -t .jscodeshift/react-import.js <file-path>
|
||||
* Example: jscodeshift -t .jscodeshift/react-import.js src/App.native.tsx
|
||||
*/
|
||||
|
||||
/* eslint-disable */
|
||||
|
||||
export const parser = 'tsx'
|
||||
|
||||
export default function transformer(file, api) {
|
||||
const j = api.jscodeshift
|
||||
const root = j(file.source)
|
||||
|
||||
// Find the React import
|
||||
let reactImportPath = null
|
||||
const reactMembers = new Set()
|
||||
|
||||
root.find(j.ImportDeclaration).forEach(path => {
|
||||
const node = path.value
|
||||
if (node.source.value === 'react') {
|
||||
node.specifiers.forEach(spec => {
|
||||
// Check if this is a default import of React
|
||||
if (
|
||||
spec.type === 'ImportDefaultSpecifier' &&
|
||||
spec.local.name === 'React'
|
||||
) {
|
||||
reactImportPath = path
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if (!reactImportPath) {
|
||||
// No React import found, nothing to do
|
||||
return file.source
|
||||
}
|
||||
|
||||
// Find all React.* member expressions
|
||||
root
|
||||
.find(j.MemberExpression)
|
||||
.filter(path => {
|
||||
const node = path.value
|
||||
return (
|
||||
node.object.type === 'Identifier' &&
|
||||
node.object.name === 'React' &&
|
||||
node.property.type === 'Identifier'
|
||||
)
|
||||
})
|
||||
.forEach(path => {
|
||||
const propertyName = path.value.property.name
|
||||
reactMembers.add(propertyName)
|
||||
})
|
||||
|
||||
// Find all React.* JSX member expressions (e.g., <React.Fragment>)
|
||||
root
|
||||
.find(j.JSXMemberExpression)
|
||||
.filter(path => {
|
||||
const node = path.value
|
||||
return node.object.name === 'React' && node.property.name
|
||||
})
|
||||
.forEach(path => {
|
||||
const propertyName = path.value.property.name
|
||||
reactMembers.add(propertyName)
|
||||
})
|
||||
|
||||
// If no React members are used, remove the import
|
||||
if (reactMembers.size === 0) {
|
||||
reactImportPath.prune()
|
||||
return root.toSource()
|
||||
}
|
||||
|
||||
// Sort the members for consistent output
|
||||
const sortedMembers = Array.from(reactMembers).sort()
|
||||
|
||||
// Create new import specifiers
|
||||
const newSpecifiers = sortedMembers.map(name =>
|
||||
j.importSpecifier(j.identifier(name), j.identifier(name)),
|
||||
)
|
||||
|
||||
// Get the existing import specifiers
|
||||
const sortedImports = Array.from(reactImportPath.value.specifiers).sort()
|
||||
const existingSpecifiers = sortedImports.filter(
|
||||
specifier => specifier.type !== 'ImportDefaultSpecifier',
|
||||
)
|
||||
|
||||
const allSpecifiers = [
|
||||
...new Map(
|
||||
[...existingSpecifiers, ...newSpecifiers].map(item => [
|
||||
item.imported.name,
|
||||
item,
|
||||
]),
|
||||
).values(),
|
||||
]
|
||||
|
||||
// Update the import declaration
|
||||
reactImportPath.value.specifiers = allSpecifiers
|
||||
|
||||
// Replace all React.* member expressions with just the identifier
|
||||
root
|
||||
.find(j.MemberExpression)
|
||||
.filter(path => {
|
||||
const node = path.value
|
||||
return (
|
||||
node.object.type === 'Identifier' &&
|
||||
node.object.name === 'React' &&
|
||||
node.property.type === 'Identifier'
|
||||
)
|
||||
})
|
||||
.replaceWith(path => {
|
||||
return j.identifier(path.value.property.name)
|
||||
})
|
||||
|
||||
// Replace all React.* JSX member expressions with just the identifier
|
||||
root
|
||||
.find(j.JSXMemberExpression)
|
||||
.filter(path => {
|
||||
const node = path.value
|
||||
return node.object.name === 'React' && node.property.name
|
||||
})
|
||||
.replaceWith(path => {
|
||||
return j.jsxIdentifier(path.value.property.name)
|
||||
})
|
||||
|
||||
return root.toSource()
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Codemod to replace namespaced React calls with named imports
|
||||
*
|
||||
* Before:
|
||||
* import * as Toast from '#/view/com/util/Toast'
|
||||
* Toast.show(message, 'xmark')
|
||||
*
|
||||
* After:
|
||||
* import * as Toast from '#/components/Toast'
|
||||
* Toast.show(message, {type: 'error'})
|
||||
*
|
||||
* Usage: jscodeshift -t .jscodeshift/toast-v2.js <file-path>
|
||||
* Example: jscodeshift -t .jscodeshift/toast-v2.js src/App.native.tsx
|
||||
*/
|
||||
|
||||
/* eslint-disable */
|
||||
|
||||
export const parser = 'tsx'
|
||||
|
||||
const OLD_IMPORT = '#/view/com/util/Toast'
|
||||
const NEW_IMPORT = '#/components/Toast'
|
||||
|
||||
const convertLegacyToastType = type => {
|
||||
switch (type) {
|
||||
// these ones are fine
|
||||
case 'default':
|
||||
case 'success':
|
||||
case 'error':
|
||||
case 'warning':
|
||||
case 'info':
|
||||
return type
|
||||
// legacy ones need conversion
|
||||
case 'xmark':
|
||||
return 'error'
|
||||
case 'exclamation-circle':
|
||||
return 'warning'
|
||||
case 'check':
|
||||
return 'success'
|
||||
case 'clipboard-check':
|
||||
return 'success'
|
||||
case 'circle-exclamation':
|
||||
case 'exclamation-circle':
|
||||
return 'warning'
|
||||
default:
|
||||
return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
export default function transformer(file, api) {
|
||||
const j = api.jscodeshift
|
||||
const root = j(file.source)
|
||||
|
||||
// Find Toast import declarations using the old path
|
||||
const toastImports = root
|
||||
.find(j.ImportDeclaration)
|
||||
.filter(path => path.value.source.value === OLD_IMPORT)
|
||||
|
||||
if (toastImports.length === 0) {
|
||||
return file.source
|
||||
}
|
||||
|
||||
// Update import path
|
||||
toastImports.forEach(path => {
|
||||
path.value.source.value = NEW_IMPORT
|
||||
})
|
||||
|
||||
// Collect all local names the Toast namespace is bound to
|
||||
const toastLocalNames = new Set()
|
||||
toastImports.forEach(path => {
|
||||
path.value.specifiers.forEach(spec => {
|
||||
if (spec.type === 'ImportNamespaceSpecifier') {
|
||||
toastLocalNames.add(spec.local.name)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Transform Toast.show(message, type) calls
|
||||
root.find(j.CallExpression).forEach(path => {
|
||||
const {callee, arguments: args} = path.value
|
||||
|
||||
// Match <ToastName>.show(...)
|
||||
if (
|
||||
callee.type !== 'MemberExpression' ||
|
||||
callee.object.type !== 'Identifier' ||
|
||||
!toastLocalNames.has(callee.object.name) ||
|
||||
callee.property.name !== 'show'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Only transform 2-arg calls where the second arg is a string literal
|
||||
if (args.length !== 2) return
|
||||
const typeArg = args[1]
|
||||
if (typeArg.type !== 'StringLiteral' && typeArg.type !== 'Literal') return
|
||||
|
||||
const legacyType = typeArg.value
|
||||
const newType = convertLegacyToastType(legacyType)
|
||||
|
||||
// Replace the second argument with an options object: {type: 'newType'}
|
||||
args[1] = j.objectExpression([
|
||||
j.property('init', j.identifier('type'), j.stringLiteral(newType)),
|
||||
])
|
||||
})
|
||||
|
||||
return root.toSource()
|
||||
}
|
||||
@@ -0,0 +1,755 @@
|
||||
# CLAUDE.md - Bluesky Social App Development Guide
|
||||
|
||||
This document provides guidance for working effectively in the Bluesky Social app codebase.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Bluesky Social is a cross-platform social media application built with React Native and Expo. It runs on iOS, Android, and Web, connecting to the AT Protocol (atproto) decentralized social network.
|
||||
|
||||
**Tech Stack:**
|
||||
- React Native 0.81 with Expo 54
|
||||
- TypeScript
|
||||
- React Navigation for routing
|
||||
- TanStack Query (React Query) for data fetching
|
||||
- Lingui for internationalization
|
||||
- Custom design system called ALF (Application Layout Framework)
|
||||
|
||||
## Essential Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
yarn start # Start Expo dev server
|
||||
yarn web # Start web version
|
||||
yarn android # Run on Android
|
||||
yarn ios # Run on iOS
|
||||
|
||||
# Testing & Quality
|
||||
# IMPORTANT: Always use these yarn scripts, never call the underlying tools directly
|
||||
yarn test # Run Jest tests
|
||||
yarn lint # Run ESLint
|
||||
yarn typecheck # Run TypeScript type checking
|
||||
|
||||
# Internationalization
|
||||
# DO NOT run these commands - extraction and compilation are handled by CI
|
||||
yarn intl:extract # Extract translation strings (nightly CI job)
|
||||
yarn intl:compile # Compile translations for runtime (nightly CI job)
|
||||
|
||||
# Build
|
||||
yarn build-web # Build web version
|
||||
yarn prebuild # Generate native projects
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── alf/ # Design system (ALF) - themes, atoms, tokens
|
||||
├── components/ # Shared UI components (Button, Dialog, Menu, etc.)
|
||||
├── screens/ # Full-page screen components (newer pattern)
|
||||
├── features/ # Macro-features that bridge components/screens
|
||||
├── view/
|
||||
│ ├── screens/ # Full-page screens (legacy location)
|
||||
│ ├── com/ # Reusable view components
|
||||
│ └── shell/ # App shell (navigation bars, tabs)
|
||||
├── state/
|
||||
│ ├── queries/ # TanStack Query hooks
|
||||
│ ├── preferences/ # User preferences (React Context)
|
||||
│ ├── session/ # Authentication state
|
||||
│ └── persisted/ # Persistent storage layer
|
||||
├── lib/ # Utilities, constants, helpers
|
||||
├── locale/ # i18n configuration and language files
|
||||
└── Navigation.tsx # Main navigation configuration
|
||||
```
|
||||
|
||||
### Project Structure in Depth
|
||||
|
||||
When building new things, follow these guidelines for where to put code.
|
||||
|
||||
#### Components vs Screens vs Features
|
||||
|
||||
**Components** are reusable UI elements that are not full screens. Should be
|
||||
platform-agnostic when possible. Examples: Button, Dialog, Menu, TextField. Put
|
||||
these in `/components` if they are shared across screens.
|
||||
|
||||
**Screens** are full-page components that represent a route in the app. They
|
||||
often contain multiple components and handle layout for a page. New screens
|
||||
should go in `/screens` (not `/view/screens`) to encourage better organization
|
||||
and separation from legacy code.
|
||||
|
||||
For complex screens that have specific components or data needs that _are not
|
||||
shared by other screens_, we encourage subdirectoreis within `/screens/<name>`
|
||||
e.g. `/screens/ProfileScreen/ProfileScreen.tsx` and
|
||||
`/screens/ProfileScreen/components/`.
|
||||
|
||||
**Features** are higher-level modules that may include context, data fetching,
|
||||
components, and utilities related to a specific feature e.g.
|
||||
`/features/liveNow`. They don't neatly fit into components or screens and often
|
||||
span multiple screens. This is an optional pattern for organizing complex
|
||||
features.
|
||||
|
||||
#### Legacy Directories
|
||||
|
||||
For the most part, avoid writing new files into the `/view` directory and
|
||||
subdirectories. This is the older pattern for organizing screens and components,
|
||||
and it has become a bit disorganized over time. New development should go into
|
||||
`/screens`, `/components`, and `/features`.
|
||||
|
||||
#### State
|
||||
|
||||
The `/state` directory is where we've historically put all our data fetching and
|
||||
state management logic. This is perfectly fine, but for new features, consider
|
||||
organizing state logic closer to the components that use it, either within a
|
||||
feature directory or co-located with a screen. The key is to keep related code
|
||||
together and avoid having "god files" with too much unrelated logic.
|
||||
|
||||
#### Lib
|
||||
|
||||
The `/lib` directory is for utilities and helpers that don't fit into other
|
||||
categories. This can include things like API clients, formatting functions,
|
||||
constants, and other shared logic.
|
||||
|
||||
#### Top Level Directories
|
||||
|
||||
Avoid writing new top-level subdirectories within `/src`. We've done this for a
|
||||
few things in the past that, but we have stronger patterns now. Examples:
|
||||
`/logger` should probably have been written into `/lib`. And `ageAssurance` is
|
||||
better classified within `/features`. We will probably migrate these things
|
||||
eventually.
|
||||
|
||||
### File and Directory Naming Conventions
|
||||
|
||||
Typically JS style for variables, functions, etc. We use ProudCamelCase for
|
||||
components, and camelCase directories and files.
|
||||
|
||||
When organizing new code, consider if it fits into a single file, or if it
|
||||
should be broken down into multiple files. For "macro" component cases, or
|
||||
things that live in `/features` or `/screens`, we often follow a pattern of
|
||||
having an `index.tsx` for the main component, and then co-locating related
|
||||
components, hooks, and utilities in the same directory. For example:
|
||||
|
||||
```
|
||||
src
|
||||
├── screens/
|
||||
│ ├── ProfileScreen/
|
||||
│ │ ├── index.tsx # Main screen component
|
||||
│ │ ├── components/ # Sub-components used only by this screen
|
||||
```
|
||||
|
||||
Similar patterns can be found in `/features` and `/components`. The idea here is
|
||||
to keep related code together and make it easier to navigate.
|
||||
|
||||
You should ask yourself: if someone new was looking for the code related to this
|
||||
feature or screen, where would they expect to find it? Organizing code in a way
|
||||
that matches developer expectations can make the codebase much more
|
||||
approachable. Being able to say "Live Now stuff lives in `/features/liveNow`" is
|
||||
easier to understand than having it scattered across multiple directories.
|
||||
|
||||
No need to go overboard with this. If a component or feature fits into a single
|
||||
file, there's no reason to have a `/Component/index.tsx` file when it could just
|
||||
be `/Component.tsx`. Use your judgment based on the complexity and amount of
|
||||
related code.
|
||||
|
||||
#### Platform Specific Files
|
||||
|
||||
We have conflicting patterns in the app for this. The preferred approach is to
|
||||
group platform-specific files into a directory as much as possible. For example,
|
||||
rather than having `Component.tsx`, `Component.web.tsx`, and
|
||||
`Component.native.tsx` in the same directory, we prefer to have a `Component/`
|
||||
directory with `index.tsx`, `index.web.tsx`, and `index.native.tsx`. This keeps
|
||||
related code together and gives us a better visual cue that there are probably
|
||||
other files contained within this "macro" feature, whereas `Component.tsx` on
|
||||
its own looks more like a single component file.
|
||||
|
||||
### Documentation and Tests Within Features
|
||||
|
||||
For larger features or components, it's helpful to include a README.md file
|
||||
within the directory that explains the purpose of the feature, how it works, and
|
||||
any important implementation details. The `/Component/index.tsx` pattern lends
|
||||
itself well to this, since the `index.tsx` can be the main component file, and
|
||||
the `README.md` can provide documentation for the whole feature. This is
|
||||
optional, but can be a nice way to keep documentation close to the code it
|
||||
describes.
|
||||
|
||||
Similarly, if there are tests that are specific to a component or feature, it
|
||||
can be helpful to include them in the same directory, either as
|
||||
`Component.test.tsx` or in a `__tests__/` subdirectory. This keeps everything
|
||||
related to the component or feature in one place and makes it easier to find and
|
||||
maintain tests.
|
||||
|
||||
## Styling System (ALF)
|
||||
|
||||
ALF is the custom design system. It uses Tailwind-inspired naming with underscores instead of hyphens.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```tsx
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
|
||||
function MyComponent() {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View style={[a.flex_row, a.gap_md, a.p_lg, t.atoms.bg]}>
|
||||
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
|
||||
Hello
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Key Concepts
|
||||
|
||||
**Static Atoms** - Theme-independent styles imported from `atoms`:
|
||||
```tsx
|
||||
import {atoms as a} from '#/alf'
|
||||
// a.flex_row, a.p_md, a.gap_sm, a.rounded_md, a.text_lg, etc.
|
||||
```
|
||||
|
||||
**Theme Atoms** - Theme-dependent colors from `useTheme()`:
|
||||
```tsx
|
||||
const t = useTheme()
|
||||
// t.atoms.bg, t.atoms.text, t.atoms.border_contrast_low, etc.
|
||||
// t.palette.primary_500, t.palette.negative_400, etc.
|
||||
```
|
||||
|
||||
**Platform Utilities** - For platform-specific styles:
|
||||
```tsx
|
||||
import {web, native, ios, android, platform} from '#/alf'
|
||||
|
||||
const styles = [
|
||||
a.p_md,
|
||||
web({cursor: 'pointer'}),
|
||||
native({paddingBottom: 20}),
|
||||
platform({ios: {...}, android: {...}, web: {...}}),
|
||||
]
|
||||
```
|
||||
|
||||
**Breakpoints** - Responsive design:
|
||||
```tsx
|
||||
import {useBreakpoints} from '#/alf'
|
||||
|
||||
const {gtPhone, gtMobile, gtTablet} = useBreakpoints()
|
||||
if (gtMobile) {
|
||||
// Tablet or desktop layout
|
||||
}
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
- Spacing: `2xs`, `xs`, `sm`, `md`, `lg`, `xl`, `2xl` (t-shirt sizes)
|
||||
- Text: `text_xs`, `text_sm`, `text_md`, `text_lg`, `text_xl`
|
||||
- Gaps/Padding: `gap_sm`, `p_md`, `px_lg`, `py_xl`
|
||||
- Flex: `flex_row`, `flex_1`, `align_center`, `justify_between`
|
||||
- Borders: `border`, `border_t`, `rounded_md`, `rounded_full`
|
||||
|
||||
## Component Patterns
|
||||
|
||||
### Dialog Component
|
||||
|
||||
Dialogs use a bottom sheet on native and a modal on web. Use `useDialogControl()` hook to manage state.
|
||||
|
||||
```tsx
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
|
||||
function MyFeature() {
|
||||
const control = Dialog.useDialogControl()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button label="Open" onPress={control.open}>
|
||||
<ButtonText>Open Dialog</ButtonText>
|
||||
</Button>
|
||||
|
||||
<Dialog.Outer control={control}>
|
||||
{/* Typically the inner part is in its own component */}
|
||||
<Dialog.Handle /> {/* Native-only drag handle */}
|
||||
<Dialog.ScrollableInner label={_(msg`My Dialog`)}>
|
||||
<Dialog.Header>
|
||||
<Dialog.HeaderText>Title</Dialog.HeaderText>
|
||||
</Dialog.Header>
|
||||
|
||||
<Text>Dialog content here</Text>
|
||||
|
||||
<Button label="Done" onPress={() => control.close()}>
|
||||
<ButtonText>Done</ButtonText>
|
||||
</Button>
|
||||
<Dialog.Close /> {/* Web-only X button in top left */}
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Menu Component
|
||||
|
||||
Menus render as a dropdown on web and a bottom sheet dialog on native.
|
||||
|
||||
```tsx
|
||||
import * as Menu from '#/components/Menu'
|
||||
|
||||
function MyMenu() {
|
||||
return (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger label="Open menu">
|
||||
{({props}) => (
|
||||
<Button {...props} label="Menu">
|
||||
<ButtonIcon icon={DotsHorizontal} />
|
||||
</Button>
|
||||
)}
|
||||
</Menu.Trigger>
|
||||
|
||||
<Menu.Outer>
|
||||
<Menu.Group>
|
||||
<Menu.Item label="Edit" onPress={handleEdit}>
|
||||
<Menu.ItemIcon icon={Pencil} />
|
||||
<Menu.ItemText>Edit</Menu.ItemText>
|
||||
</Menu.Item>
|
||||
<Menu.Item label="Delete" onPress={handleDelete}>
|
||||
<Menu.ItemIcon icon={Trash} />
|
||||
<Menu.ItemText>Delete</Menu.ItemText>
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
</Menu.Outer>
|
||||
</Menu.Root>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Button Component
|
||||
|
||||
```tsx
|
||||
import {Button, ButtonText, ButtonIcon} from '#/components/Button'
|
||||
|
||||
// Solid primary button (most common)
|
||||
<Button label="Save" onPress={handleSave} color="primary" size="large">
|
||||
<ButtonText>Save</ButtonText>
|
||||
</Button>
|
||||
|
||||
// With icon
|
||||
<Button label="Share" onPress={handleShare} color="secondary" size="small">
|
||||
<ButtonIcon icon={Share} />
|
||||
<ButtonText>Share</ButtonText>
|
||||
</Button>
|
||||
|
||||
// Icon-only button
|
||||
<Button label="Close" onPress={handleClose} color="secondary" size="small" shape="round">
|
||||
<ButtonIcon icon={XIcon} />
|
||||
</Button>
|
||||
|
||||
// Ghost variant (deprecated - use color prop)
|
||||
<Button label="Cancel" variant="ghost" color="secondary" size="small">
|
||||
<ButtonText>Cancel</ButtonText>
|
||||
</Button>
|
||||
```
|
||||
|
||||
**Button Props:**
|
||||
- `color`: `'primary'` | `'secondary'` | `'negative'` | `'primary_subtle'` | `'negative_subtle'` | `'secondary_inverted'`
|
||||
- `size`: `'tiny'` | `'small'` | `'large'`
|
||||
- `shape`: `'default'` (pill) | `'round'` | `'square'` | `'rectangular'`
|
||||
- `variant`: `'solid'` | `'outline'` | `'ghost'` (deprecated, use `color`)
|
||||
|
||||
### Typography
|
||||
|
||||
```tsx
|
||||
import {Text, H1, H2, P} from '#/components/Typography'
|
||||
|
||||
<H1 style={[a.text_xl, a.font_bold]}>Heading</H1>
|
||||
<P>Paragraph text with default styling.</P>
|
||||
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>Custom text</Text>
|
||||
|
||||
// For text with emoji, add the emoji prop
|
||||
<Text emoji>Hello! 👋</Text>
|
||||
```
|
||||
|
||||
### TextField
|
||||
|
||||
```tsx
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
|
||||
<TextField.LabelText>Email</TextField.LabelText>
|
||||
<TextField.Root>
|
||||
<TextField.Icon icon={AtSign} />
|
||||
<TextField.Input
|
||||
label="Email address"
|
||||
placeholder="you@example.com"
|
||||
defaultValue={email}
|
||||
onChangeText={setEmail}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
</TextField.Root>
|
||||
```
|
||||
|
||||
## Internationalization (i18n)
|
||||
|
||||
All user-facing strings must be wrapped for translation using Lingui.
|
||||
|
||||
```tsx
|
||||
import {msg, plural} from '@lingui/core/macro'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
function MyComponent() {
|
||||
const {_} = useLingui()
|
||||
|
||||
// Simple strings - use msg() with _() function
|
||||
const title = _(msg`Settings`)
|
||||
const errorMessage = _(msg`Something went wrong`)
|
||||
|
||||
// Strings with variables
|
||||
const greeting = _(msg`Hello, ${name}!`)
|
||||
|
||||
// Pluralization
|
||||
const countLabel = _(plural(count, {
|
||||
one: '# item',
|
||||
other: '# items',
|
||||
}))
|
||||
|
||||
// JSX content - use Trans component
|
||||
return (
|
||||
<Text>
|
||||
<Trans>Welcome to <Text style={a.font_bold}>Bluesky</Text></Trans>
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Commands:**
|
||||
```bash
|
||||
# DO NOT run these commands - extraction and compilation are handled by a nightly CI job
|
||||
yarn intl:extract # Extract new strings to locale files
|
||||
yarn intl:compile # Compile translations for runtime
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
### TanStack Query (Data Fetching)
|
||||
|
||||
```tsx
|
||||
// src/state/queries/profile.ts
|
||||
import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
// Query key pattern
|
||||
const RQKEY_ROOT = 'profile'
|
||||
export const RQKEY = (did: string) => [RQKEY_ROOT, did]
|
||||
|
||||
// Query hook
|
||||
export function useProfileQuery({did}: {did: string}) {
|
||||
const agent = useAgent()
|
||||
|
||||
return useQuery({
|
||||
queryKey: RQKEY(did),
|
||||
queryFn: async () => {
|
||||
const res = await agent.getProfile({actor: did})
|
||||
return res.data
|
||||
},
|
||||
staleTime: STALE.MINUTES.FIVE,
|
||||
enabled: !!did,
|
||||
})
|
||||
}
|
||||
|
||||
// Mutation hook
|
||||
export function useUpdateProfile() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data) => {
|
||||
// Update logic
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({queryKey: RQKEY(variables.did)})
|
||||
},
|
||||
onError: (error) => {
|
||||
if (isNetworkError(error)) {
|
||||
// don't log, but inform user
|
||||
} else if (error instanceof AppBskyExampleProcedure.ExampleError) {
|
||||
// XRPC APIs often have typed errors, allows nicer handling
|
||||
} else {
|
||||
// Log unexpected errors to Sentry
|
||||
logger.error('Error updating profile', {safeMessage: error})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Stale Time Constants** (from `src/state/queries/index.ts`):
|
||||
```tsx
|
||||
STALE.SECONDS.FIFTEEN // 15 seconds
|
||||
STALE.MINUTES.ONE // 1 minute
|
||||
STALE.MINUTES.FIVE // 5 minutes
|
||||
STALE.HOURS.ONE // 1 hour
|
||||
STALE.INFINITY // Never stale
|
||||
```
|
||||
|
||||
**Paginated APIs:** Many atproto APIs return paginated results with a `cursor`. Use `useInfiniteQuery` for these:
|
||||
|
||||
```tsx
|
||||
export function useDraftsQuery() {
|
||||
const agent = useAgent()
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey: ['drafts'],
|
||||
queryFn: async ({pageParam}) => {
|
||||
const res = await agent.app.bsky.draft.getDrafts({cursor: pageParam})
|
||||
return res.data
|
||||
},
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: page => page.cursor,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
To get all items from pages: `data?.pages.flatMap(page => page.items) ?? []`
|
||||
|
||||
### Preferences (React Context)
|
||||
|
||||
```tsx
|
||||
// Simple boolean preference pattern
|
||||
import {useAutoplayDisabled, useSetAutoplayDisabled} from '#/state/preferences'
|
||||
|
||||
function SettingsScreen() {
|
||||
const autoplayDisabled = useAutoplayDisabled()
|
||||
const setAutoplayDisabled = useSetAutoplayDisabled()
|
||||
|
||||
return (
|
||||
<Toggle
|
||||
value={autoplayDisabled}
|
||||
onValueChange={setAutoplayDisabled}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Session State
|
||||
|
||||
```tsx
|
||||
import {useSession, useAgent} from '#/state/session'
|
||||
|
||||
function MyComponent() {
|
||||
const {hasSession, currentAccount} = useSession()
|
||||
const agent = useAgent()
|
||||
|
||||
if (!hasSession) {
|
||||
return <LoginPrompt />
|
||||
}
|
||||
|
||||
// Use agent for API calls
|
||||
const response = await agent.getProfile({actor: currentAccount.did})
|
||||
}
|
||||
```
|
||||
|
||||
## Navigation
|
||||
|
||||
Navigation uses React Navigation with type-safe route parameters.
|
||||
|
||||
```tsx
|
||||
// Screen component
|
||||
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||
import {type CommonNavigatorParams} from '#/lib/routes/types'
|
||||
|
||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'>
|
||||
|
||||
export function ProfileScreen({route, navigation}: Props) {
|
||||
const {name} = route.params // Type-safe params
|
||||
|
||||
return (
|
||||
<Layout.Screen>
|
||||
{/* Screen content */}
|
||||
</Layout.Screen>
|
||||
)
|
||||
}
|
||||
|
||||
// Programmatic navigation
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
const navigation = useNavigation()
|
||||
navigation.navigate('Profile', {name: 'alice.bsky.social'})
|
||||
|
||||
// Or use the navigate helper
|
||||
import {navigate} from '#/Navigation'
|
||||
navigate('Profile', {name: 'alice.bsky.social'})
|
||||
```
|
||||
|
||||
## Platform-Specific Code
|
||||
|
||||
Use file extensions for platform-specific implementations:
|
||||
|
||||
```
|
||||
Component.tsx # Shared/default
|
||||
Component.web.tsx # Web-only
|
||||
Component.native.tsx # iOS + Android
|
||||
Component.ios.tsx # iOS-only
|
||||
Component.android.tsx # Android-only
|
||||
```
|
||||
|
||||
Example from Dialog:
|
||||
- `src/components/Dialog/index.tsx` - Native (uses BottomSheet)
|
||||
- `src/components/Dialog/index.web.tsx` - Web (uses modal with Radix primitives)
|
||||
|
||||
**Important:** The bundler automatically resolves platform-specific files. Just import normally:
|
||||
|
||||
```tsx
|
||||
// CORRECT - bundler picks storage.ts or storage.web.ts automatically
|
||||
import * as storage from '#/state/drafts/storage'
|
||||
|
||||
// WRONG - don't use require() or conditional imports for platform files
|
||||
const storage = IS_NATIVE
|
||||
? require('#/state/drafts/storage')
|
||||
: require('#/state/drafts/storage.web')
|
||||
```
|
||||
|
||||
Platform detection (for runtime logic, not imports):
|
||||
```tsx
|
||||
import {IS_WEB, IS_NATIVE, IS_IOS, IS_ANDROID} from '#/env'
|
||||
|
||||
if (IS_NATIVE) {
|
||||
// Native-specific logic
|
||||
}
|
||||
```
|
||||
|
||||
## Import Aliases
|
||||
|
||||
Always use the `#/` alias for absolute imports:
|
||||
|
||||
```tsx
|
||||
// Good
|
||||
import {useSession} from '#/state/session'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
|
||||
// Avoid
|
||||
import {useSession} from '../../../state/session'
|
||||
```
|
||||
|
||||
## Footguns
|
||||
|
||||
Common pitfalls to avoid in this codebase:
|
||||
|
||||
### Dialog Close Callback (Critical)
|
||||
|
||||
**Always use `control.close(() => ...)` when performing actions after closing a dialog.** The callback ensures the action runs after the dialog's close animation completes. Failing to do this causes race conditions with React state updates.
|
||||
|
||||
```tsx
|
||||
// WRONG - causes bugs with state updates, navigation, opening other dialogs
|
||||
const onConfirm = () => {
|
||||
control.close()
|
||||
navigation.navigate('Home') // May race with dialog animation
|
||||
}
|
||||
|
||||
// WRONG - same problem
|
||||
const onConfirm = () => {
|
||||
control.close()
|
||||
otherDialogControl.open() // Will likely fail or cause visual glitches
|
||||
}
|
||||
|
||||
// CORRECT - action runs after dialog fully closes
|
||||
const onConfirm = () => {
|
||||
control.close(() => {
|
||||
navigation.navigate('Home')
|
||||
})
|
||||
}
|
||||
|
||||
// CORRECT - opening another dialog after close
|
||||
const onConfirm = () => {
|
||||
control.close(() => {
|
||||
otherDialogControl.open()
|
||||
})
|
||||
}
|
||||
|
||||
// CORRECT - state updates after close
|
||||
const onConfirm = () => {
|
||||
control.close(() => {
|
||||
setSomeState(newValue)
|
||||
onCallback?.()
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
This applies to:
|
||||
- Navigation (`navigation.navigate()`, `navigation.push()`)
|
||||
- Opening other dialogs or menus
|
||||
- State updates that affect UI (`setState`, `queryClient.invalidateQueries`)
|
||||
- Callbacks passed from parent components
|
||||
|
||||
The Menu component on iOS specifically uses this pattern - see `src/components/Menu/index.tsx:151`.
|
||||
|
||||
### Controlled vs Uncontrolled Inputs
|
||||
|
||||
Prefer `defaultValue` over `value` for TextInput on the old architecture:
|
||||
|
||||
```tsx
|
||||
// Preferred - uncontrolled
|
||||
<TextField.Input
|
||||
defaultValue={initialEmail}
|
||||
onChangeText={setEmail}
|
||||
/>
|
||||
|
||||
// Avoid when possible - controlled (can cause performance issues)
|
||||
<TextField.Input
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
/>
|
||||
```
|
||||
|
||||
### Platform-Specific Behavior
|
||||
|
||||
Some components behave differently across platforms:
|
||||
- `Dialog.Handle` - Only renders on native (drag handle for bottom sheet)
|
||||
- `Dialog.Close` - Only renders on web (X button)
|
||||
- `Menu.Divider` - Only renders on web
|
||||
- `Menu.ContainerItem` - Only works on native
|
||||
|
||||
Always test on multiple platforms when using these components.
|
||||
|
||||
### React Compiler is Enabled
|
||||
|
||||
This codebase uses React Compiler, so **don't proactively add `useMemo` or `useCallback`**. The compiler handles memoization automatically.
|
||||
|
||||
```tsx
|
||||
// UNNECESSARY - React Compiler handles this
|
||||
const handlePress = useCallback(() => {
|
||||
doSomething()
|
||||
}, [doSomething])
|
||||
|
||||
// JUST WRITE THIS
|
||||
const handlePress = () => {
|
||||
doSomething()
|
||||
}
|
||||
```
|
||||
|
||||
Only use `useMemo`/`useCallback` when you have a specific reason, such as:
|
||||
- The value is immediately used in an effect's dependency array
|
||||
- You're passing a callback to a non-React library that needs referential stability
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Accessibility**: Always provide `label` prop for interactive elements, use `accessibilityHint` where helpful
|
||||
|
||||
2. **Translations**: Wrap ALL user-facing strings with `msg()` or `<Trans>`
|
||||
|
||||
3. **Styling**: Combine static atoms with theme atoms, use platform utilities for platform-specific styles
|
||||
|
||||
4. **State**: Use TanStack Query for server state, React Context for UI preferences
|
||||
|
||||
5. **Components**: Check if a component exists in `#/components/` before creating new ones
|
||||
|
||||
6. **Types**: Define explicit types for props, use `NativeStackScreenProps` for screens
|
||||
|
||||
7. **Testing**: Components should have `testID` props for E2E testing
|
||||
|
||||
## Key Files Reference
|
||||
|
||||
| Purpose | Location |
|
||||
|---------|----------|
|
||||
| Theme definitions | `src/alf/themes.ts` |
|
||||
| Design tokens | `src/alf/tokens.ts` |
|
||||
| Static atoms | `src/alf/atoms.ts` (extends `@bsky.app/alf`) |
|
||||
| Navigation config | `src/Navigation.tsx` |
|
||||
| Route definitions | `src/routes.ts` |
|
||||
| Route types | `src/lib/routes/types.ts` |
|
||||
| Query hooks | `src/state/queries/*.ts` |
|
||||
| Session state | `src/state/session/index.tsx` |
|
||||
| i18n setup | `src/locale/i18n.ts` |
|
||||
@@ -1,4 +1,4 @@
|
||||
Copyright 2023–2025 Bluesky Social PBC
|
||||
Copyright 2023–2026 Bluesky Social PBC
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@ appId: xyz.blueskyweb.app
|
||||
id: "editListNameInput"
|
||||
- eraseText
|
||||
- inputText: "Bad Ppl"
|
||||
- hideKeyboard
|
||||
- tapOn:
|
||||
id: "editListDescriptionInput"
|
||||
- eraseText
|
||||
@@ -92,7 +91,8 @@ appId: xyz.blueskyweb.app
|
||||
- tapOn: "Add user to list"
|
||||
- swipe:
|
||||
direction: DOWN
|
||||
- assertVisible: "View Bob's profile"
|
||||
- assertVisible:
|
||||
id: "profileCard-bob.test-link"
|
||||
|
||||
- tapOn: "Posts"
|
||||
- assertVisible:
|
||||
@@ -124,7 +124,8 @@ appId: xyz.blueskyweb.app
|
||||
- tapOn: "Good Ppl"
|
||||
|
||||
- tapOn: "People"
|
||||
- assertVisible: "View Bob's profile"
|
||||
- assertVisible:
|
||||
id: "profileCard-bob.test-link"
|
||||
- tapOn:
|
||||
point: "90%,43%"
|
||||
- tapOn:
|
||||
|
||||
@@ -35,9 +35,12 @@ appId: xyz.blueskyweb.app
|
||||
id: "menuItemButton-Feeds"
|
||||
- tapOn:
|
||||
id: "editFeedsBtn"
|
||||
- tapOn:
|
||||
label: "Tap on down arrow"
|
||||
id: "feed-timeline-moveDown"
|
||||
- swipe:
|
||||
label: "Drag feed down"
|
||||
from:
|
||||
id: "feed-drag-handle"
|
||||
direction: "DOWN"
|
||||
duration: 1000
|
||||
- tapOn:
|
||||
label: "Save button"
|
||||
id: "saveChangesBtn"
|
||||
@@ -55,9 +58,12 @@ appId: xyz.blueskyweb.app
|
||||
id: "menuItemButton-Feeds"
|
||||
- tapOn:
|
||||
id: "editFeedsBtn"
|
||||
- tapOn:
|
||||
label: "Tap on down arrow"
|
||||
id: "feed-feed-moveDown"
|
||||
- swipe:
|
||||
label: "Drag feed down"
|
||||
from:
|
||||
id: "feed-drag-handle"
|
||||
direction: "DOWN"
|
||||
duration: 1000
|
||||
- tapOn:
|
||||
label: "Save button"
|
||||
id: "saveChangesBtn"
|
||||
|
||||
@@ -15,6 +15,11 @@ appId: xyz.blueskyweb.app
|
||||
- tapOn:
|
||||
id: "customServerTextInput"
|
||||
- inputText: "http://localhost:3000"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- hideKeyboard
|
||||
- tapOn: "Done"
|
||||
- tapOn:
|
||||
id: "loginUsernameInput"
|
||||
|
||||
@@ -26,6 +26,7 @@ appId: xyz.blueskyweb.app
|
||||
- tapOn:
|
||||
id: "report:details"
|
||||
- inputText: "This is a test report"
|
||||
- hideKeyboard
|
||||
- tapOn:
|
||||
id: "report:submit"
|
||||
- assertNotVisible:
|
||||
|
||||
@@ -1,529 +0,0 @@
|
||||
import {createServer as createHTTPServer} from 'node:http'
|
||||
import {parse} from 'node:url'
|
||||
|
||||
import {createServer, type TestPDS} from '../jest/test-pds'
|
||||
|
||||
async function main() {
|
||||
let server: TestPDS
|
||||
createHTTPServer(async (req, res) => {
|
||||
const url = parse(req.url || '/', true)
|
||||
if (req.method !== 'POST') {
|
||||
return res.writeHead(200).end()
|
||||
}
|
||||
try {
|
||||
console.log('Closing old server')
|
||||
await server?.close()
|
||||
console.log('Starting new server')
|
||||
const inviteRequired = url?.query && 'invite' in url.query
|
||||
server = await createServer({inviteRequired})
|
||||
console.log('Listening at', server.pdsUrl)
|
||||
if (url?.query) {
|
||||
if ('users' in url.query) {
|
||||
console.log('Generating mock users')
|
||||
await server.mocker.createUser('alice')
|
||||
await server.mocker.createUser('bob')
|
||||
await server.mocker.createUser('carla')
|
||||
await server.mocker.users.alice.agent.upsertProfile(() => ({
|
||||
displayName: 'Alice',
|
||||
description: 'Test user 1',
|
||||
}))
|
||||
await server.mocker.users.bob.agent.upsertProfile(() => ({
|
||||
displayName: 'Bob',
|
||||
description: 'Test user 2',
|
||||
}))
|
||||
await server.mocker.users.carla.agent.upsertProfile(() => ({
|
||||
displayName: 'Carla',
|
||||
description: 'Test user 3',
|
||||
}))
|
||||
if (inviteRequired) {
|
||||
await server.mocker.createInvite(server.mocker.users.alice.did)
|
||||
}
|
||||
}
|
||||
if ('follows' in url.query) {
|
||||
console.log('Generating mock follows')
|
||||
await server.mocker.follow('alice', 'bob')
|
||||
await server.mocker.follow('alice', 'carla')
|
||||
await server.mocker.follow('bob', 'alice')
|
||||
await server.mocker.follow('bob', 'carla')
|
||||
await server.mocker.follow('carla', 'alice')
|
||||
await server.mocker.follow('carla', 'bob')
|
||||
}
|
||||
if ('posts' in url.query) {
|
||||
console.log('Generating mock posts')
|
||||
for (let user in server.mocker.users) {
|
||||
await server.mocker.users[user].agent.post({text: 'Post'})
|
||||
}
|
||||
}
|
||||
if ('feeds' in url.query) {
|
||||
console.log('Generating mock feed')
|
||||
await server.mocker.createFeed('alice', 'alice-favs', [])
|
||||
}
|
||||
if ('thread' in url.query) {
|
||||
console.log('Generating mock posts')
|
||||
const res = await server.mocker.users.bob.agent.post({
|
||||
text: 'Thread root',
|
||||
})
|
||||
await server.mocker.users.carla.agent.post({
|
||||
text: 'Thread reply',
|
||||
reply: {
|
||||
parent: {cid: res.cid, uri: res.uri},
|
||||
root: {cid: res.cid, uri: res.uri},
|
||||
},
|
||||
})
|
||||
}
|
||||
if ('mergefeed' in url.query) {
|
||||
console.log('Generating mock users')
|
||||
await server.mocker.createUser('alice')
|
||||
await server.mocker.createUser('bob')
|
||||
await server.mocker.createUser('carla')
|
||||
await server.mocker.createUser('dan')
|
||||
await server.mocker.users.alice.agent.upsertProfile(() => ({
|
||||
displayName: 'Alice',
|
||||
description: 'Test user 1',
|
||||
}))
|
||||
await server.mocker.users.bob.agent.upsertProfile(() => ({
|
||||
displayName: 'Bob',
|
||||
description: 'Test user 2',
|
||||
}))
|
||||
await server.mocker.users.carla.agent.upsertProfile(() => ({
|
||||
displayName: 'Carla',
|
||||
description: 'Test user 3',
|
||||
}))
|
||||
await server.mocker.users.dan.agent.upsertProfile(() => ({
|
||||
displayName: 'Dan',
|
||||
description: 'Test user 4',
|
||||
}))
|
||||
console.log('Generating mock follows')
|
||||
await server.mocker.follow('alice', 'bob')
|
||||
await server.mocker.follow('alice', 'carla')
|
||||
console.log('Generating mock posts')
|
||||
let posts: Record<string, any[]> = {
|
||||
alice: [],
|
||||
bob: [],
|
||||
carla: [],
|
||||
dan: [],
|
||||
}
|
||||
for (let i = 0; i < 10; i++) {
|
||||
for (let user in server.mocker.users) {
|
||||
if (user === 'alice') continue
|
||||
posts[user].push(
|
||||
await server.mocker.createPost(user, `Post ${i}`),
|
||||
)
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < 10; i++) {
|
||||
for (let user in server.mocker.users) {
|
||||
if (user === 'alice') continue
|
||||
if (i % 5 === 0) {
|
||||
await server.mocker.createReply(user, 'Self reply', {
|
||||
cid: posts[user][i].cid,
|
||||
uri: posts[user][i].uri,
|
||||
})
|
||||
}
|
||||
if (i % 5 === 1) {
|
||||
await server.mocker.createReply(user, 'Reply to bob', {
|
||||
cid: posts.bob[i].cid,
|
||||
uri: posts.bob[i].uri,
|
||||
})
|
||||
}
|
||||
if (i % 5 === 2) {
|
||||
await server.mocker.createReply(user, 'Reply to dan', {
|
||||
cid: posts.dan[i].cid,
|
||||
uri: posts.dan[i].uri,
|
||||
})
|
||||
}
|
||||
await server.mocker.users[user].agent.post({text: `Post ${i}`})
|
||||
}
|
||||
}
|
||||
console.log('Generating mock feeds')
|
||||
await server.mocker.createFeed(
|
||||
'alice',
|
||||
'alice-favs',
|
||||
posts.dan.map(p => p.uri),
|
||||
)
|
||||
await server.mocker.createFeed(
|
||||
'alice',
|
||||
'alice-favs2',
|
||||
posts.dan.map(p => p.uri),
|
||||
)
|
||||
}
|
||||
if ('labels' in url.query) {
|
||||
console.log('Generating naughty users with labels')
|
||||
|
||||
const anchorPost = await server.mocker.createPost(
|
||||
'alice',
|
||||
'Anchor post',
|
||||
)
|
||||
|
||||
for (const user of [
|
||||
'dmca-account',
|
||||
'dmca-profile',
|
||||
'dmca-posts',
|
||||
'porn-account',
|
||||
'porn-profile',
|
||||
'porn-posts',
|
||||
'nudity-account',
|
||||
'nudity-profile',
|
||||
'nudity-posts',
|
||||
'scam-account',
|
||||
'scam-profile',
|
||||
'scam-posts',
|
||||
'unknown-account',
|
||||
'unknown-profile',
|
||||
'unknown-posts',
|
||||
'hide-account',
|
||||
'hide-profile',
|
||||
'hide-posts',
|
||||
'no-promote-account',
|
||||
'no-promote-profile',
|
||||
'no-promote-posts',
|
||||
'warn-account',
|
||||
'warn-profile',
|
||||
'warn-posts',
|
||||
'muted-account',
|
||||
'muted-by-list-acc',
|
||||
'blocking-account',
|
||||
'blockedby-account',
|
||||
'mutual-block-acc',
|
||||
]) {
|
||||
await server.mocker.createUser(user)
|
||||
await server.mocker.follow('alice', user)
|
||||
await server.mocker.follow(user, 'alice')
|
||||
await server.mocker.createPost(user, `Unlabeled post from ${user}`)
|
||||
await server.mocker.createReply(
|
||||
user,
|
||||
`Unlabeled reply from ${user}`,
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.like(user, anchorPost)
|
||||
}
|
||||
|
||||
await server.mocker.labelAccount('dmca-violation', 'dmca-account')
|
||||
await server.mocker.labelProfile('dmca-violation', 'dmca-profile')
|
||||
await server.mocker.labelPost(
|
||||
'dmca-violation',
|
||||
await server.mocker.createPost('dmca-posts', 'dmca post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'dmca-violation',
|
||||
await server.mocker.createQuotePost(
|
||||
'dmca-posts',
|
||||
'dmca quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'dmca-violation',
|
||||
await server.mocker.createReply(
|
||||
'dmca-posts',
|
||||
'dmca reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount('porn', 'porn-account')
|
||||
await server.mocker.labelProfile('porn', 'porn-profile')
|
||||
await server.mocker.labelPost(
|
||||
'porn',
|
||||
await server.mocker.createImagePost('porn-posts', 'porn post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'porn',
|
||||
await server.mocker.createQuotePost(
|
||||
'porn-posts',
|
||||
'porn quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'porn',
|
||||
await server.mocker.createReply(
|
||||
'porn-posts',
|
||||
'porn reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount('nudity', 'nudity-account')
|
||||
await server.mocker.labelProfile('nudity', 'nudity-profile')
|
||||
await server.mocker.labelPost(
|
||||
'nudity',
|
||||
await server.mocker.createImagePost('nudity-posts', 'nudity post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'nudity',
|
||||
await server.mocker.createQuotePost(
|
||||
'nudity-posts',
|
||||
'nudity quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'nudity',
|
||||
await server.mocker.createReply(
|
||||
'nudity-posts',
|
||||
'nudity reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount('scam', 'scam-account')
|
||||
await server.mocker.labelProfile('scam', 'scam-profile')
|
||||
await server.mocker.labelPost(
|
||||
'scam',
|
||||
await server.mocker.createPost('scam-posts', 'scam post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'scam',
|
||||
await server.mocker.createQuotePost(
|
||||
'scam-posts',
|
||||
'scam quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'scam',
|
||||
await server.mocker.createReply(
|
||||
'scam-posts',
|
||||
'scam reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount(
|
||||
'not-a-real-label',
|
||||
'unknown-account',
|
||||
)
|
||||
await server.mocker.labelProfile(
|
||||
'not-a-real-label',
|
||||
'unknown-profile',
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'not-a-real-label',
|
||||
await server.mocker.createPost('unknown-posts', 'unknown post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'not-a-real-label',
|
||||
await server.mocker.createQuotePost(
|
||||
'unknown-posts',
|
||||
'unknown quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'not-a-real-label',
|
||||
await server.mocker.createReply(
|
||||
'unknown-posts',
|
||||
'unknown reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount('!hide', 'hide-account')
|
||||
await server.mocker.labelProfile('!hide', 'hide-profile')
|
||||
await server.mocker.labelPost(
|
||||
'!hide',
|
||||
await server.mocker.createPost('hide-posts', 'hide post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'!hide',
|
||||
await server.mocker.createQuotePost(
|
||||
'hide-posts',
|
||||
'hide quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'!hide',
|
||||
await server.mocker.createReply(
|
||||
'hide-posts',
|
||||
'hide reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount('!no-promote', 'no-promote-account')
|
||||
await server.mocker.labelProfile('!no-promote', 'no-promote-profile')
|
||||
await server.mocker.labelPost(
|
||||
'!no-promote',
|
||||
await server.mocker.createPost(
|
||||
'no-promote-posts',
|
||||
'no-promote post',
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'!no-promote',
|
||||
await server.mocker.createQuotePost(
|
||||
'no-promote-posts',
|
||||
'no-promote quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'!no-promote',
|
||||
await server.mocker.createReply(
|
||||
'no-promote-posts',
|
||||
'no-promote reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount('!warn', 'warn-account')
|
||||
await server.mocker.labelProfile('!warn', 'warn-profile')
|
||||
await server.mocker.labelPost(
|
||||
'!warn',
|
||||
await server.mocker.createPost('warn-posts', 'warn post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'!warn',
|
||||
await server.mocker.createQuotePost(
|
||||
'warn-posts',
|
||||
'warn quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'!warn',
|
||||
await server.mocker.createReply(
|
||||
'warn-posts',
|
||||
'warn reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.users.alice.agent.mute('muted-account.test')
|
||||
await server.mocker.createPost('muted-account', 'muted post')
|
||||
await server.mocker.createQuotePost(
|
||||
'muted-account',
|
||||
'muted quote post',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.createReply(
|
||||
'muted-account',
|
||||
'muted reply',
|
||||
anchorPost,
|
||||
)
|
||||
|
||||
const list = await server.mocker.createMuteList(
|
||||
'alice',
|
||||
'Muted Users',
|
||||
)
|
||||
await server.mocker.addToMuteList(
|
||||
'alice',
|
||||
list,
|
||||
server.mocker.users['muted-by-list-acc'].did,
|
||||
)
|
||||
await server.mocker.createPost('muted-by-list-acc', 'muted post')
|
||||
await server.mocker.createQuotePost(
|
||||
'muted-by-list-acc',
|
||||
'account quote post',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.createReply(
|
||||
'muted-by-list-acc',
|
||||
'account reply',
|
||||
anchorPost,
|
||||
)
|
||||
|
||||
await server.mocker.createPost('blocking-account', 'blocking post')
|
||||
await server.mocker.createQuotePost(
|
||||
'blocking-account',
|
||||
'blocking quote post',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.createReply(
|
||||
'blocking-account',
|
||||
'blocking reply',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.users.alice.agent.app.bsky.graph.block.create(
|
||||
{
|
||||
repo: server.mocker.users.alice.did,
|
||||
},
|
||||
{
|
||||
subject: server.mocker.users['blocking-account'].did,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
)
|
||||
|
||||
await server.mocker.createPost('blockedby-account', 'blockedby post')
|
||||
await server.mocker.createQuotePost(
|
||||
'blockedby-account',
|
||||
'blockedby quote post',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.createReply(
|
||||
'blockedby-account',
|
||||
'blockedby reply',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.users[
|
||||
'blockedby-account'
|
||||
].agent.app.bsky.graph.block.create(
|
||||
{
|
||||
repo: server.mocker.users['blockedby-account'].did,
|
||||
},
|
||||
{
|
||||
subject: server.mocker.users.alice.did,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
)
|
||||
|
||||
await server.mocker.createPost(
|
||||
'mutual-block-acc',
|
||||
'mutual-block post',
|
||||
)
|
||||
await server.mocker.createQuotePost(
|
||||
'mutual-block-acc',
|
||||
'mutual-block quote post',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.createReply(
|
||||
'mutual-block-acc',
|
||||
'mutual-block reply',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.users.alice.agent.app.bsky.graph.block.create(
|
||||
{
|
||||
repo: server.mocker.users.alice.did,
|
||||
},
|
||||
{
|
||||
subject: server.mocker.users['mutual-block-acc'].did,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
)
|
||||
await server.mocker.users[
|
||||
'mutual-block-acc'
|
||||
].agent.app.bsky.graph.block.create(
|
||||
{
|
||||
repo: server.mocker.users['mutual-block-acc'].did,
|
||||
},
|
||||
{
|
||||
subject: server.mocker.users.alice.did,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
)
|
||||
|
||||
// flush caches
|
||||
await server.mocker.testNet.processAll()
|
||||
}
|
||||
}
|
||||
console.log('Ready')
|
||||
return res
|
||||
.writeHead(200, {
|
||||
'content-type': 'application/json',
|
||||
})
|
||||
.end(
|
||||
JSON.stringify({
|
||||
pdsUrl: server.pdsUrl,
|
||||
appviewDid: server.appviewDid,
|
||||
}),
|
||||
)
|
||||
} catch (e) {
|
||||
console.error('Error!', e)
|
||||
return res.writeHead(500).end()
|
||||
}
|
||||
}).listen(1986)
|
||||
console.log('Mock server manager listening on 1986')
|
||||
}
|
||||
main()
|
||||
@@ -3,15 +3,29 @@ appId: xyz.blueskyweb.app
|
||||
- launchApp:
|
||||
appId: "xyz.blueskyweb.app"
|
||||
clearState: true
|
||||
- waitForAnimationToEnd
|
||||
- tapOn: "http://localhost:8081"
|
||||
- waitForAnimationToEnd
|
||||
- extendedWaitUntil:
|
||||
visible: "Continue"
|
||||
- swipe:
|
||||
from: "Bluesky"
|
||||
direction: DOWN
|
||||
duration: 100
|
||||
arguments:
|
||||
"-EXDevMenuIsOnboardingFinished": true
|
||||
- runFlow:
|
||||
when:
|
||||
platform: iOS
|
||||
commands:
|
||||
- openLink: "exp+bluesky://expo-development-client/?url=http%3A%2F%2Flocalhost%3A8081"
|
||||
- runFlow:
|
||||
when:
|
||||
visible: 'Open in "Bluesky"'
|
||||
commands:
|
||||
- tapOn: Open
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- tapOn: 'http://localhost:8081'
|
||||
- runFlow:
|
||||
label: "Dismiss Expo dev menu"
|
||||
when:
|
||||
visible: "Continue"
|
||||
commands:
|
||||
- back
|
||||
- tapOn:
|
||||
id: e2eProxyHeaderInput
|
||||
- inputText: ${output.result}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
requestPermission: jest.fn(),
|
||||
onForegroundEvent: jest.fn(),
|
||||
setBadgeCount: jest.fn(),
|
||||
displayNotification: jest.fn(),
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export const CameraRoll = {
|
||||
getPhotos: jest.fn().mockResolvedValue({
|
||||
edges: [
|
||||
{node: {image: {uri: 'path/to/image1.jpg'}}},
|
||||
{node: {image: {uri: 'path/to/image2.jpg'}}},
|
||||
{node: {image: {uri: 'path/to/image3.jpg'}}},
|
||||
],
|
||||
}),
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export default {
|
||||
configure: jest.fn().mockResolvedValue(0),
|
||||
finish: jest.fn(),
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export default {}
|
||||
@@ -1,10 +0,0 @@
|
||||
jest.mock('rn-fetch-blob', () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: {
|
||||
fs: {
|
||||
unlink: jest.fn(),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -1,2 +0,0 @@
|
||||
export const DropdownMenu = jest.fn().mockImplementation(() => {})
|
||||
export const create = jest.fn().mockImplementation(() => {})
|
||||
@@ -1,4 +1,5 @@
|
||||
import {RichText} from '@atproto/api'
|
||||
import {i18n} from '@lingui/core'
|
||||
|
||||
import {parseEmbedPlayerFromUrl} from '#/lib/strings/embed-player'
|
||||
import {
|
||||
@@ -6,6 +7,7 @@ import {
|
||||
createStarterPackLinkFromAndroidReferrer,
|
||||
parseStarterPackUri,
|
||||
} from '#/lib/strings/starter-pack'
|
||||
import {messages} from '#/locale/locales/en/messages'
|
||||
import {tenorUrlToBskyGifUrl} from '#/state/queries/tenor'
|
||||
import {cleanError} from '../../src/lib/strings/errors'
|
||||
import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles'
|
||||
@@ -202,6 +204,9 @@ describe('enforceLen', () => {
|
||||
})
|
||||
|
||||
describe('cleanError', () => {
|
||||
// cleanError uses lingui
|
||||
i18n.loadAndActivate({locale: 'en', messages})
|
||||
|
||||
const inputs = [
|
||||
'TypeError: Network request failed',
|
||||
'Error: Aborted',
|
||||
@@ -327,6 +332,7 @@ describe('shortenLinks', () => {
|
||||
expect(outputRT.text).toEqual(outputs[i][0])
|
||||
expect(outputRT.facets?.length).toEqual(outputs[i][1].length)
|
||||
for (let j = 0; j < outputs[i][1].length; j++) {
|
||||
// @ts-expect-error whatever
|
||||
expect(outputRT.facets![j].features[0].uri).toEqual(outputs[i][1][j])
|
||||
}
|
||||
}
|
||||
@@ -437,6 +443,13 @@ describe('parseEmbedPlayerFromUrl', () => {
|
||||
|
||||
'https://www.flickr.com/groups/898944@N23/',
|
||||
'https://www.flickr.com/groups',
|
||||
|
||||
'https://maxblansjaar.bandcamp.com/album/false-comforts',
|
||||
'https://grmnygrmny.bandcamp.com/track/fluid',
|
||||
'https://sufjanstevens.bandcamp.com/',
|
||||
'https://sufjanstevens.bandcamp.com',
|
||||
'https://bandcamp.com/',
|
||||
'https://bandcamp.com',
|
||||
]
|
||||
|
||||
const outputs = [
|
||||
@@ -815,6 +828,23 @@ describe('parseEmbedPlayerFromUrl', () => {
|
||||
|
||||
undefined,
|
||||
undefined,
|
||||
|
||||
{
|
||||
type: 'bandcamp_album',
|
||||
source: 'bandcamp',
|
||||
playerUri:
|
||||
'https://bandcamp.com/EmbeddedPlayer/url=https%3A%2F%2Fmaxblansjaar.bandcamp.com%2Falbum%2Ffalse-comforts/size=large/bgcol=ffffff/linkcol=0687f5/minimal=true/transparent=true/',
|
||||
},
|
||||
{
|
||||
type: 'bandcamp_track',
|
||||
source: 'bandcamp',
|
||||
playerUri:
|
||||
'https://bandcamp.com/EmbeddedPlayer/url=https%3A%2F%2Fgrmnygrmny.bandcamp.com%2Ftrack%2Ffluid/size=large/bgcol=ffffff/linkcol=0687f5/minimal=true/transparent=true/',
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
]
|
||||
|
||||
it('correctly grabs the correct id from uri', () => {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
// @ts-check
|
||||
const pkg = require('./package.json')
|
||||
|
||||
/**
|
||||
* @param {import('@expo/config-types').ExpoConfig} _config
|
||||
* @returns {{ expo: import('@expo/config-types').ExpoConfig }}
|
||||
*/
|
||||
module.exports = function (_config) {
|
||||
/**
|
||||
* App version number. Should be incremented as part of a release cycle.
|
||||
@@ -15,7 +20,7 @@ module.exports = function (_config) {
|
||||
|
||||
const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight'
|
||||
const IS_PRODUCTION = process.env.EXPO_PUBLIC_ENV === 'production'
|
||||
const IS_DEV = !IS_TESTFLIGHT || !IS_PRODUCTION
|
||||
const IS_DEV = !IS_TESTFLIGHT && !IS_PRODUCTION
|
||||
|
||||
const ASSOCIATED_DOMAINS = [
|
||||
'applinks:bsky.app',
|
||||
@@ -30,6 +35,13 @@ module.exports = function (_config) {
|
||||
|
||||
const USE_SENTRY = Boolean(process.env.SENTRY_AUTH_TOKEN)
|
||||
|
||||
const IOS_ICON_FILE =
|
||||
PLATFORM === 'web' // web build doesn't like .icon files
|
||||
? './assets/app-icons/ios_icon_default_next.png'
|
||||
: IS_TESTFLIGHT
|
||||
? './assets/app-icons/ios_icon_testflight.icon'
|
||||
: './assets/app-icons/ios_icon_default.icon'
|
||||
|
||||
return {
|
||||
expo: {
|
||||
version: VERSION,
|
||||
@@ -50,10 +62,7 @@ module.exports = function (_config) {
|
||||
config: {
|
||||
usesNonExemptEncryption: false,
|
||||
},
|
||||
icon:
|
||||
PLATFORM === 'web' // web build doesn't like .icon files
|
||||
? './assets/app-icons/ios_icon_default_next.png'
|
||||
: './assets/app-icons/ios_icon_default.icon',
|
||||
icon: IOS_ICON_FILE,
|
||||
infoPlist: {
|
||||
UIBackgroundModes: ['remote-notification'],
|
||||
NSCameraUsageDescription:
|
||||
@@ -107,13 +116,13 @@ module.exports = function (_config) {
|
||||
'zh-Hans',
|
||||
'zh-Hant',
|
||||
],
|
||||
UIDesignRequiresCompatibility: true,
|
||||
},
|
||||
associatedDomains: ASSOCIATED_DOMAINS,
|
||||
entitlements: {
|
||||
'com.apple.developer.kernel.increased-memory-limit': true,
|
||||
'com.apple.developer.kernel.extended-virtual-addressing': true,
|
||||
'com.apple.security.application-groups': 'group.app.bsky',
|
||||
// 'com.apple.developer.device-information.user-assigned-device-name': true,
|
||||
},
|
||||
privacyManifests: {
|
||||
NSPrivacyCollectedDataTypes: [
|
||||
@@ -192,10 +201,14 @@ module.exports = function (_config) {
|
||||
scheme: 'https',
|
||||
host: 'bsky.app',
|
||||
},
|
||||
IS_DEV && {
|
||||
scheme: 'http',
|
||||
host: 'localhost:19006',
|
||||
},
|
||||
...(IS_DEV
|
||||
? [
|
||||
{
|
||||
scheme: 'http',
|
||||
host: 'localhost:19006',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
category: ['BROWSABLE', 'DEFAULT'],
|
||||
},
|
||||
@@ -227,23 +240,35 @@ module.exports = function (_config) {
|
||||
'react-native-edge-to-edge',
|
||||
{android: {enforceNavigationBarContrast: false}},
|
||||
],
|
||||
USE_SENTRY && [
|
||||
'@sentry/react-native/expo',
|
||||
{
|
||||
organization: 'blueskyweb',
|
||||
project: 'app',
|
||||
url: 'https://sentry.io',
|
||||
},
|
||||
],
|
||||
...(USE_SENTRY
|
||||
? [
|
||||
/** @type {[string, any]} */ ([
|
||||
'@sentry/react-native/expo',
|
||||
{
|
||||
organization: 'blueskyweb',
|
||||
project: 'app',
|
||||
url: 'https://sentry.io',
|
||||
},
|
||||
]),
|
||||
]
|
||||
: []),
|
||||
[
|
||||
'expo-build-properties',
|
||||
{
|
||||
ios: {
|
||||
deploymentTarget: '15.1',
|
||||
buildReactNativeFromSource: true,
|
||||
ccacheEnabled: IS_DEV,
|
||||
extraPods: [
|
||||
{
|
||||
name: 'MCEmojiPicker',
|
||||
git: 'https://github.com/bluesky-social/MCEmojiPicker.git',
|
||||
branch: 'main',
|
||||
},
|
||||
],
|
||||
},
|
||||
android: {
|
||||
compileSdkVersion: 35,
|
||||
compileSdkVersion: 36,
|
||||
targetSdkVersion: 35,
|
||||
buildToolsVersion: '35.0.0',
|
||||
buildReactNativeFromSource: IS_PRODUCTION,
|
||||
@@ -297,25 +322,25 @@ module.exports = function (_config) {
|
||||
'expo-splash-screen',
|
||||
{
|
||||
ios: {
|
||||
enableFullScreenImage_legacy: true,
|
||||
backgroundColor: '#ffffff',
|
||||
image: './assets/splash.png',
|
||||
enableFullScreenImage_legacy: true, // iOS only
|
||||
backgroundColor: '#006AFF', // primary_500
|
||||
image: './assets/splash/splash.png',
|
||||
resizeMode: 'cover',
|
||||
dark: {
|
||||
enableFullScreenImage_legacy: true,
|
||||
backgroundColor: '#001429',
|
||||
image: './assets/splash-dark.png',
|
||||
enableFullScreenImage_legacy: true, // iOS only
|
||||
backgroundColor: '#002861', // primary_900
|
||||
image: './assets/splash/splash-dark.png',
|
||||
resizeMode: 'cover',
|
||||
},
|
||||
},
|
||||
android: {
|
||||
backgroundColor: '#0c7cff',
|
||||
image: './assets/splash-android-icon.png',
|
||||
imageWidth: 150,
|
||||
backgroundColor: '#006AFF', // primary_500
|
||||
image: './assets/splash/android-splash-logo-white.png',
|
||||
imageWidth: 102, // even division of 306px
|
||||
dark: {
|
||||
backgroundColor: '#0c2a49',
|
||||
image: './assets/splash-android-icon-dark.png',
|
||||
imageWidth: 150,
|
||||
backgroundColor: '#002861', // primary_900
|
||||
image: './assets/splash/android-splash-logo-white.png',
|
||||
imageWidth: 102,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -396,7 +421,7 @@ module.exports = function (_config) {
|
||||
'I agree to allow Bluesky to use my contacts for friend discovery until I opt out.',
|
||||
},
|
||||
],
|
||||
].filter(Boolean),
|
||||
],
|
||||
extra: {
|
||||
eas: {
|
||||
build: {
|
||||
|
||||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 771 KiB |
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"fill" : {
|
||||
"automatic-gradient" : "srgb:1.00000,1.00000,1.00000,1.00000"
|
||||
},
|
||||
"groups" : [
|
||||
{
|
||||
"blend-mode-specializations" : [
|
||||
{
|
||||
"value" : "overlay"
|
||||
},
|
||||
{
|
||||
"appearance" : "dark",
|
||||
"value" : "screen"
|
||||
},
|
||||
{
|
||||
"appearance" : "tinted",
|
||||
"value" : "screen"
|
||||
}
|
||||
],
|
||||
"blur-material-specializations" : [
|
||||
{
|
||||
"value" : 0.5
|
||||
},
|
||||
{
|
||||
"appearance" : "dark",
|
||||
"value" : 0.5
|
||||
},
|
||||
{
|
||||
"appearance" : "tinted",
|
||||
"value" : null
|
||||
}
|
||||
],
|
||||
"hidden" : false,
|
||||
"layers" : [
|
||||
{
|
||||
"image-name" : "TestFlight notice.png",
|
||||
"name" : "TestFlight notice"
|
||||
}
|
||||
],
|
||||
"lighting" : "individual",
|
||||
"position" : {
|
||||
"scale" : 0.4,
|
||||
"translation-in-points" : [
|
||||
0,
|
||||
350
|
||||
]
|
||||
},
|
||||
"shadow" : {
|
||||
"kind" : "neutral",
|
||||
"opacity" : 0.5
|
||||
},
|
||||
"specular-specializations" : [
|
||||
{
|
||||
"value" : false
|
||||
},
|
||||
{
|
||||
"appearance" : "dark",
|
||||
"value" : false
|
||||
},
|
||||
{
|
||||
"appearance" : "tinted",
|
||||
"value" : false
|
||||
}
|
||||
],
|
||||
"translucency-specializations" : [
|
||||
{
|
||||
"value" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"appearance" : "dark",
|
||||
"value" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"appearance" : "tinted",
|
||||
"value" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.5
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"layers" : [
|
||||
{
|
||||
"fill" : "none",
|
||||
"glass" : false,
|
||||
"image-name" : "iOS transparent.png",
|
||||
"name" : "iOS transparent"
|
||||
}
|
||||
],
|
||||
"shadow" : {
|
||||
"kind" : "neutral",
|
||||
"opacity" : 0.5
|
||||
},
|
||||
"translucency" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.5
|
||||
}
|
||||
}
|
||||
],
|
||||
"supported-platforms" : {
|
||||
"circles" : [
|
||||
"watchOS"
|
||||
],
|
||||
"squares" : "shared"
|
||||
}
|
||||
}
|
||||
@@ -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 0a2 2 0 0 1 1 3.73V5h4.2c1.68 0 2.52 0 3.162.327a3 3 0 0 1 1.31 1.31C22 7.28 22 8.12 22 9.8v.25a2.501 2.501 0 0 1 0 4.9V15c0 2.8 0 4.2-.545 5.27a5 5 0 0 1-2.185 2.185C18.2 23 16.8 23 14 23h-4c-2.8 0-4.2 0-5.27-.545a5 5 0 0 1-2.185-2.185C2 19.2 2 17.8 2 15v-.05a2.5 2.5 0 0 1 0-4.9V9.8c0-1.68 0-2.52.327-3.162a3 3 0 0 1 1.31-1.31C4.28 5 5.12 5 6.8 5H11V3.73A2 2 0 0 1 12 0M8 10a2 2 0 0 0-2 2v2a2 2 0 1 0 4 0v-2a2 2 0 0 0-2-2m8 0a2 2 0 0 0-2 2v2a2 2 0 1 0 4 0v-2a2 2 0 0 0-2-2" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 621 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12 2a1 1 0 0 1 1 1v2h3.2l1.113.005c.975.015 1.568.077 2.05.322a3 3 0 0 1 1.31 1.31C21 7.28 21 8.12 21 9.8v.287a1.498 1.498 0 0 1-.005 2.827c-.006 2.204-.058 3.41-.54 4.356l-.093.174a5 5 0 0 1-2.092 2.011l-.205.096c-.766.33-1.72.417-3.21.44L13 20h-2l-1.854-.009c-1.49-.023-2.445-.11-3.211-.44l-.205-.096a5 5 0 0 1-2.185-2.185c-.409-.803-.51-1.79-.536-3.415l-.005-.94A1.498 1.498 0 0 1 3 10.086V9.8c0-1.575 0-2.412.27-3.04l.057-.122a3 3 0 0 1 1.105-1.196l.206-.115C5.279 5 6.12 5 7.8 5H11V3a1 1 0 0 1 1-1M7.8 7c-.873 0-1.408.002-1.808.034a3 3 0 0 0-.367.051l-.063.018-.016.006a1 1 0 0 0-.437.437l-.006.016-.018.063a3 3 0 0 0-.05.367C5.001 8.392 5 8.927 5 9.8V12c0 1.433.002 2.388.062 3.121.058.71.16 1.036.265 1.241a3 3 0 0 0 1.31 1.31c.207.106.532.209 1.242.267.733.06 1.688.061 3.121.061h2c1.433 0 2.388-.002 3.121-.061.71-.058 1.036-.161 1.241-.266a3 3 0 0 0 1.31-1.31c.106-.206.209-.532.267-1.242.06-.733.061-1.688.061-3.121V9.8c0-.873-.002-1.408-.034-1.808a2.5 2.5 0 0 0-.051-.367l-.017-.063-.007-.016a1 1 0 0 0-.437-.437l-.015-.006-.064-.018a3 3 0 0 0-.367-.05C17.608 7.001 17.073 7 16.2 7zM9 10a1 1 0 0 1 1 1v2a1 1 0 1 1-2 0v-2a1 1 0 0 1 1-1m6 0a1 1 0 0 1 1 1v2a1 1 0 1 1-2 0v-2a1 1 0 0 1 1-1"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M9 17a2 2 0 1 1 0 4 2 2 0 0 1 0-4Zm6 0a2 2 0 1 1 0 4 2 2 0 0 1 0-4Zm-6-7a2 2 0 1 1 0 4 2 2 0 0 1 0-4Zm6 0a2 2 0 1 1 0 4 2 2 0 0 1 0-4ZM9 3a2 2 0 1 1 0 4 2 2 0 0 1 0-4Zm6 0a2 2 0 1 1 0 4 2 2 0 0 1 0-4Z"/></svg>
|
||||
|
After Width: | Height: | Size: 303 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 64 64"><path fill="#000" d="M32.457 7c1.68 0 3.29.668 4.478 1.855L49.813 21.73a6.33 6.33 0 0 1 1.854 4.479v24.458A6.333 6.333 0 0 1 45.333 57H18.666a6.334 6.334 0 0 1-6.333-6.333V13.333A6.334 6.334 0 0 1 18.666 7h13.791ZM18.666 9a4.334 4.334 0 0 0-4.333 4.333v37.334A4.334 4.334 0 0 0 18.666 55h26.667a4.333 4.333 0 0 0 4.333-4.333V26.209c0-.418-.061-.829-.177-1.223a1 1 0 0 1-.155.014H40a6.334 6.334 0 0 1-6.325-6.008l-.008-.326V9.333q0-.08.013-.156A4.3 4.3 0 0 0 32.457 9H18.666Zm18.627 22.293a1 1 0 1 1 1.414 1.414L33.414 38l5.293 5.293a1 1 0 1 1-1.414 1.414L32 39.414l-5.293 5.293a1 1 0 1 1-1.414-1.414L30.586 38l-5.293-5.293a1 1 0 1 1 1.414-1.414L32 36.586l5.293-5.293Zm-1.626-12.627.006.224A4.333 4.333 0 0 0 40 23h8.253L35.667 10.414v8.252Z"/></svg>
|
||||
|
After Width: | Height: | Size: 822 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12.233 2a4.433 4.433 0 1 0 0 8.867 4.433 4.433 0 0 0 0-8.867Zm0 10.133c-3.888 0-6.863 2.263-8.071 5.435-.346.906-.11 1.8.44 2.436.535.619 1.36.996 2.25.996h10.762c.89 0 1.716-.377 2.25-.996.55-.636.786-1.53.441-2.436-1.208-3.173-4.184-5.435-8.072-5.435Z"/></svg>
|
||||
|
After Width: | Height: | Size: 357 B |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 606 KiB After Width: | Height: | Size: 606 KiB |
|
Before Width: | Height: | Size: 563 KiB After Width: | Height: | Size: 563 KiB |
@@ -16,7 +16,7 @@ module.exports = function (api) {
|
||||
],
|
||||
],
|
||||
plugins: [
|
||||
'macros',
|
||||
'@lingui/babel-plugin-lingui-macro',
|
||||
['babel-plugin-react-compiler', {target: '19'}],
|
||||
[
|
||||
'module:react-native-dotenv',
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['@typescript-eslint', 'simple-import-sort'],
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'preact',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:@typescript-eslint/recommended-requiring-type-checking',
|
||||
],
|
||||
rules: {
|
||||
'simple-import-sort/imports': 'warn',
|
||||
'simple-import-sort/exports': 'warn',
|
||||
'no-else-return': 'off',
|
||||
},
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
ecmaVersion: 'latest',
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// @ts-check
|
||||
import js from '@eslint/js'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import simpleImportSort from 'eslint-plugin-simple-import-sort'
|
||||
import globals from 'globals'
|
||||
|
||||
export default tseslint.config(
|
||||
// Global ignores
|
||||
{
|
||||
ignores: ['dist/**', 'node_modules/**'],
|
||||
},
|
||||
|
||||
// Base JS recommended rules
|
||||
js.configs.recommended,
|
||||
|
||||
// TypeScript rules with type checking
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
|
||||
// Main configuration
|
||||
{
|
||||
files: ['**/*.{js,jsx,ts,tsx}'],
|
||||
plugins: {
|
||||
'simple-import-sort': simpleImportSort,
|
||||
},
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
globals: {
|
||||
...globals.browser,
|
||||
},
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'simple-import-sort/imports': 'warn',
|
||||
'simple-import-sort/exports': 'warn',
|
||||
'no-else-return': 'off',
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_.+',
|
||||
caughtErrors: 'none',
|
||||
ignoreRestSiblings: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -7,7 +7,7 @@
|
||||
"dev-snippet": "tsc --project tsconfig.snippet.json && serve -s dist -p 3000 -n",
|
||||
"build": "tsc && vite build",
|
||||
"build-snippet": "tsc --project tsconfig.snippet.json",
|
||||
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src",
|
||||
"lint": "eslint --cache src",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -17,16 +17,21 @@
|
||||
"devDependencies": {
|
||||
"@preact/preset-vite": "^2.10.2",
|
||||
"@vitejs/plugin-legacy": "^7.0.0",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^8.19.0",
|
||||
"eslint-config-preact": "^1.3.0",
|
||||
"eslint-plugin-simple-import-sort": "^12.0.0",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
"globals": "^15.14.0",
|
||||
"postcss": "^8.4.38",
|
||||
"typescript-eslint": "^8.20.0",
|
||||
"serve": "^14.2.5",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"terser": "^5.43.1",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^7.0.4",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/estree": "1.0.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import logo from '../../assets/logo_full_name.svg'
|
||||
import {Like as LikeIcon} from '../icons/Like'
|
||||
import {Reply as ReplyIcon} from '../icons/Reply'
|
||||
import {Repost as RepostIcon} from '../icons/Repost'
|
||||
import {Robot as RobotIcon} from '../icons/Robot'
|
||||
import {CONTENT_LABELS} from '../labels'
|
||||
import * as bsky from '../types/bsky'
|
||||
import {niceDate} from '../util/nice-date'
|
||||
@@ -43,6 +44,9 @@ export function Post({thread}: Props) {
|
||||
}
|
||||
|
||||
const verification = getVerificationState({profile: post.author})
|
||||
const isBot = post.author.labels?.some(
|
||||
l => l.val === 'bot' && l.src === post.author.did,
|
||||
)
|
||||
|
||||
const href = `/profile/${post.author.did}/post/${getRkey(post)}`
|
||||
|
||||
@@ -76,6 +80,12 @@ export function Post({thread}: Props) {
|
||||
size={15}
|
||||
/>
|
||||
)}
|
||||
{isBot && (
|
||||
<RobotIcon
|
||||
className="pl-[3px] mt-px shrink-0 text-slate-500 dark:text-slate-400"
|
||||
size={15}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
href={`/profile/${post.author.did}`}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import {h} from 'preact'
|
||||
|
||||
export const Robot = ({
|
||||
size = 14,
|
||||
className,
|
||||
}: {
|
||||
size?: number
|
||||
className?: string
|
||||
}) => (
|
||||
<svg
|
||||
className={className}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M12 0C13.1046 0 14 0.89543 14 2C14 2.73976 13.5971 3.3835 13 3.72949V5H17.2002C18.8802 5 19.7206 5.00018 20.3623 5.32715C20.9265 5.61472 21.3853 6.07347 21.6729 6.6377C21.9998 7.27941 22 8.11978 22 9.7998V10.0498C23.1411 10.2814 24 11.2905 24 12.5C24 13.7094 23.141 14.7175 22 14.9492V15C22 17.8 21.9999 19.2 21.4551 20.2695C20.9757 21.2103 20.2103 21.9757 19.2695 22.4551C18.2 22.9999 16.8 23 14 23H10C7.20005 23 5.79998 22.9999 4.73047 22.4551C3.78966 21.9757 3.02429 21.2103 2.54492 20.2695C2.00013 19.2 2 17.8 2 15V14.9492C0.858955 14.7175 0 13.7094 0 12.5C0 11.2905 0.85886 10.2814 2 10.0498V9.7998C2 8.11978 2.00018 7.27941 2.32715 6.6377C2.61472 6.07347 3.07347 5.61472 3.6377 5.32715C4.27941 5.00018 5.11978 5 6.7998 5H11V3.72949C10.4029 3.3835 10 2.73976 10 2C10 0.89543 10.8954 0 12 0ZM8 10C6.89543 10 6 10.8954 6 12V14C6 15.1046 6.89543 16 8 16C9.10457 16 10 15.1046 10 14V12C10 10.8954 9.10457 10 8 10ZM16 10C14.8954 10 14 10.8954 14 12V14C14 15.1046 14.8954 16 16 16C17.1046 16 18 15.1046 18 14V12C18 10.8954 17.1046 10 16 10Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
@@ -4,7 +4,9 @@
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"test": "./tests/infra/with-test-db.sh node --loader ts-node/esm --test ./tests/index.ts",
|
||||
"test": "npm run test:unit && npm run test:e2e",
|
||||
"test:e2e": "./tests/infra/with-test-db.sh node --loader ts-node/esm --test ./tests/index.ts",
|
||||
"test:unit": "node --loader ts-node/esm --test ./src/*.test.ts",
|
||||
"build": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -15,6 +15,7 @@ export type ServiceConfig = {
|
||||
safelinkPdsUrl?: string
|
||||
safelinkAgentIdentifier?: string
|
||||
safelinkAgentPass?: string
|
||||
metricsApiHost?: string
|
||||
}
|
||||
|
||||
export type DbConfig = {
|
||||
@@ -45,6 +46,7 @@ export type Environment = {
|
||||
safelinkPdsUrl?: string
|
||||
safelinkAgentIdentifier?: string
|
||||
safelinkAgentPass?: string
|
||||
metricsApiHost?: string
|
||||
}
|
||||
|
||||
export const readEnv = (): Environment => {
|
||||
@@ -65,6 +67,7 @@ export const readEnv = (): Environment => {
|
||||
safelinkPdsUrl: envStr('LINK_SAFELINK_PDS_URL'),
|
||||
safelinkAgentIdentifier: envStr('LINK_SAFELINK_AGENT_IDENTIFIER'),
|
||||
safelinkAgentPass: envStr('LINK_SAFELINK_AGENT_PASS'),
|
||||
metricsApiHost: envStr('LINK_METRICS_API_HOST'),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +82,7 @@ export const envToCfg = (env: Environment): Config => {
|
||||
safelinkPdsUrl: env.safelinkPdsUrl,
|
||||
safelinkAgentIdentifier: env.safelinkAgentIdentifier,
|
||||
safelinkAgentPass: env.safelinkAgentPass,
|
||||
metricsApiHost: env.metricsApiHost,
|
||||
}
|
||||
if (!env.dbPostgresUrl) {
|
||||
throw new Error('Must configure postgres url (LINK_DB_POSTGRES_URL)')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {SafelinkClient} from './cache/safelinkClient.js'
|
||||
import {type Config} from './config.js'
|
||||
import Database from './db/index.js'
|
||||
import {MetricsClient} from './metrics.js'
|
||||
|
||||
export type AppContextOptions = {
|
||||
cfg: Config
|
||||
@@ -12,6 +13,7 @@ export class AppContext {
|
||||
db: Database
|
||||
safelinkClient: SafelinkClient
|
||||
abortController = new AbortController()
|
||||
metrics: MetricsClient
|
||||
|
||||
constructor(private opts: AppContextOptions) {
|
||||
this.cfg = this.opts.cfg
|
||||
@@ -20,6 +22,9 @@ export class AppContext {
|
||||
cfg: this.opts.cfg.service,
|
||||
db: this.opts.db,
|
||||
})
|
||||
this.metrics = new MetricsClient({
|
||||
trackingEndpoint: this.opts.cfg.service.metricsApiHost,
|
||||
})
|
||||
}
|
||||
|
||||
static async fromConfig(cfg: Config, overrides?: Partial<AppContextOptions>) {
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import escapeHTML from 'escape-html'
|
||||
|
||||
export function linkRedirectContents(link: string): string {
|
||||
// Encode characters that could break out of the single-quoted URL in meta refresh.
|
||||
// HTML entity escaping (') is insufficient because the browser decodes entities
|
||||
// before the meta refresh parser processes the URL, allowing apostrophes to
|
||||
// prematurely terminate the URL string.
|
||||
//
|
||||
// Example: "They're" with HTML escaping becomes "They're" in HTML, but after
|
||||
// the browser decodes the content attribute, the meta refresh parser sees "They're"
|
||||
// and interprets the apostrophe as the closing quote, truncating the URL to "They".
|
||||
const safeLink = link.replace(/'/g, '%27')
|
||||
|
||||
return `
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="refresh" content="0; URL='${escapeHTML(link)}'" />
|
||||
<meta http-equiv="refresh" content="0; URL='${escapeHTML(safeLink)}'" />
|
||||
<meta
|
||||
http-equiv="Cache-Control"
|
||||
content="no-store, no-cache, must-revalidate, max-age=0" />
|
||||
|
||||
@@ -36,6 +36,7 @@ export class LinkService {
|
||||
}
|
||||
|
||||
async start() {
|
||||
this.ctx.metrics.start()
|
||||
this.server = this.app.listen(this.ctx.cfg.service.port)
|
||||
this.server.keepAliveTimeout = 90000
|
||||
this.terminator = createHttpTerminator({server: this.server})
|
||||
@@ -46,5 +47,6 @@ export class LinkService {
|
||||
this.ctx.abortController.abort()
|
||||
await this.terminator?.terminate()
|
||||
await this.ctx.db.close()
|
||||
this.ctx.metrics.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import assert from 'node:assert'
|
||||
import {afterEach, beforeEach, describe, it, mock} from 'node:test'
|
||||
|
||||
import {httpLogger} from './logger.js'
|
||||
import {MetricsClient} from './metrics.js'
|
||||
|
||||
type TestEvents = {
|
||||
click: {button: string}
|
||||
view: {screen: string}
|
||||
}
|
||||
|
||||
describe('MetricsClient', () => {
|
||||
let fetchMock: ReturnType<typeof mock.fn>
|
||||
let fetchRequests: {body: any}[]
|
||||
let client: MetricsClient<TestEvents>
|
||||
let loggerErrorMock: ReturnType<typeof mock.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
mock.timers.enable({apis: ['setInterval', 'setTimeout']})
|
||||
fetchRequests = []
|
||||
fetchMock = mock.fn(async (_url: any, options: any) => {
|
||||
const body = JSON.parse(options.body)
|
||||
fetchRequests.push({body})
|
||||
return {ok: true, status: 200, text: async () => ''}
|
||||
})
|
||||
;(globalThis as any).fetch = fetchMock
|
||||
loggerErrorMock = mock.fn()
|
||||
httpLogger.error = loggerErrorMock as any
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
client?.stop()
|
||||
mock.timers.reset()
|
||||
mock.restoreAll()
|
||||
})
|
||||
|
||||
it('flushes events on interval', async () => {
|
||||
client = new MetricsClient<TestEvents>({
|
||||
trackingEndpoint: 'https://test.metrics.api',
|
||||
})
|
||||
client.track('click', {button: 'submit'})
|
||||
client.track('view', {screen: 'home'})
|
||||
|
||||
assert.strictEqual(fetchRequests.length, 0)
|
||||
|
||||
mock.timers.tick(10_000)
|
||||
await flush()
|
||||
|
||||
assert.strictEqual(fetchRequests.length, 1)
|
||||
assert.strictEqual(fetchRequests[0].body.events.length, 2)
|
||||
assert.strictEqual(fetchRequests[0].body.events[0].event, 'click')
|
||||
assert.strictEqual(fetchRequests[0].body.events[1].event, 'view')
|
||||
})
|
||||
|
||||
it('flushes when maxBatchSize is exceeded', async () => {
|
||||
client = new MetricsClient<TestEvents>({
|
||||
trackingEndpoint: 'https://test.metrics.api',
|
||||
})
|
||||
client.maxBatchSize = 5
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
client.track('click', {button: `btn-${i}`})
|
||||
}
|
||||
|
||||
assert.strictEqual(fetchRequests.length, 0)
|
||||
|
||||
client.track('click', {button: 'btn-trigger'})
|
||||
await flush()
|
||||
|
||||
assert.strictEqual(fetchRequests.length, 1)
|
||||
assert.strictEqual(fetchRequests[0].body.events.length, 6)
|
||||
})
|
||||
|
||||
it('logs error on failed request', async () => {
|
||||
fetchMock.mock.mockImplementation(async () => {
|
||||
return {
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => 'Internal Server Error',
|
||||
}
|
||||
})
|
||||
|
||||
client = new MetricsClient<TestEvents>({
|
||||
trackingEndpoint: 'https://test.metrics.api',
|
||||
})
|
||||
client.track('click', {button: 'submit'})
|
||||
|
||||
mock.timers.tick(10_000)
|
||||
await flush()
|
||||
|
||||
assert.strictEqual(fetchMock.mock.callCount(), 1)
|
||||
assert.strictEqual(loggerErrorMock.mock.callCount(), 1)
|
||||
const call = loggerErrorMock.mock.calls[0]
|
||||
const arg = call.arguments[0] as {err: Error}
|
||||
assert.ok(arg.err instanceof Error)
|
||||
assert.strictEqual(call.arguments[1], 'Failed to send metrics')
|
||||
})
|
||||
|
||||
it('handles fetch text() error gracefully', async () => {
|
||||
fetchMock.mock.mockImplementation(async () => {
|
||||
return {
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => {
|
||||
throw new Error('Failed to read response')
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
client = new MetricsClient<TestEvents>({
|
||||
trackingEndpoint: 'https://test.metrics.api',
|
||||
})
|
||||
client.track('click', {button: 'submit'})
|
||||
|
||||
mock.timers.tick(10_000)
|
||||
await flush()
|
||||
|
||||
assert.strictEqual(fetchMock.mock.callCount(), 1)
|
||||
assert.strictEqual(loggerErrorMock.mock.callCount(), 1)
|
||||
const call = loggerErrorMock.mock.calls[0]
|
||||
const arg = call.arguments[0] as {err: Error}
|
||||
assert.ok(arg.err instanceof Error)
|
||||
assert.match(arg.err.message, /Unknown error/)
|
||||
assert.strictEqual(call.arguments[1], 'Failed to send metrics')
|
||||
})
|
||||
|
||||
it('flushes when stop() is called', async () => {
|
||||
client = new MetricsClient<TestEvents>({
|
||||
trackingEndpoint: 'https://test.metrics.api',
|
||||
})
|
||||
client.track('click', {button: 'submit'})
|
||||
|
||||
assert.strictEqual(fetchRequests.length, 0)
|
||||
|
||||
client.stop()
|
||||
await flush()
|
||||
|
||||
assert.strictEqual(fetchRequests.length, 1)
|
||||
assert.strictEqual(fetchRequests[0].body.events.length, 1)
|
||||
assert.strictEqual(fetchRequests[0].body.events[0].event, 'click')
|
||||
})
|
||||
|
||||
it('does not send if trackingEndpoint is not configured', async () => {
|
||||
client = new MetricsClient<TestEvents>({})
|
||||
client.track('click', {button: 'submit'})
|
||||
|
||||
mock.timers.tick(10_000)
|
||||
await flush()
|
||||
|
||||
assert.strictEqual(fetchMock.mock.callCount(), 0)
|
||||
})
|
||||
|
||||
it('start() is idempotent', async () => {
|
||||
client = new MetricsClient<TestEvents>({
|
||||
trackingEndpoint: 'https://test.metrics.api',
|
||||
})
|
||||
|
||||
client.track('click', {button: 'submit'})
|
||||
client.start()
|
||||
client.start()
|
||||
|
||||
mock.timers.tick(10_000)
|
||||
await flush()
|
||||
|
||||
assert.strictEqual(fetchRequests.length, 1)
|
||||
})
|
||||
|
||||
it('does not flush if queue is empty', async () => {
|
||||
client = new MetricsClient<TestEvents>({
|
||||
trackingEndpoint: 'https://test.metrics.api',
|
||||
})
|
||||
client.start()
|
||||
|
||||
mock.timers.tick(10_000)
|
||||
await flush()
|
||||
|
||||
assert.strictEqual(fetchMock.mock.callCount(), 0)
|
||||
})
|
||||
})
|
||||
|
||||
function flush() {
|
||||
return new Promise(r => setImmediate(r))
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
import {httpLogger} from './logger.js'
|
||||
|
||||
/**
|
||||
* New metrics events should be added here
|
||||
*/
|
||||
type Events = {
|
||||
redirect: {
|
||||
link: string
|
||||
whitelisted: 'unknown' | 'yes'
|
||||
blocked: boolean
|
||||
warned: boolean
|
||||
utm_source?: string
|
||||
utm_medium?: string
|
||||
utm_campaign?: string
|
||||
utm_content?: string
|
||||
utm_term?: string
|
||||
}
|
||||
invalid_redirect: {
|
||||
link: string
|
||||
}
|
||||
}
|
||||
|
||||
type Event<M extends Record<string, any>> = {
|
||||
time: number
|
||||
event: keyof M
|
||||
payload: M[keyof M]
|
||||
metadata: Record<string, any>
|
||||
}
|
||||
|
||||
export type Config = {
|
||||
trackingEndpoint?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* This MetricsClient is duplicated from both `social-app` and `atproto`
|
||||
* codebases.
|
||||
*/
|
||||
export class MetricsClient<M extends Record<string, any> = Events> {
|
||||
maxBatchSize = 100
|
||||
|
||||
private disabled: boolean = false
|
||||
private started: boolean = false
|
||||
private queue: Event<M>[] = []
|
||||
private flushInterval: NodeJS.Timeout | null = null
|
||||
constructor(private config: Config) {
|
||||
this.disabled = !config.trackingEndpoint
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.disabled) return
|
||||
if (this.started) return
|
||||
this.started = true
|
||||
this.flushInterval = setInterval(() => {
|
||||
this.flush()
|
||||
}, 10_000)
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.flushInterval) {
|
||||
clearInterval(this.flushInterval)
|
||||
this.flushInterval = null
|
||||
}
|
||||
this.flush()
|
||||
}
|
||||
|
||||
track<E extends keyof M>(event: E, payload: M[E]) {
|
||||
if (this.disabled) return
|
||||
|
||||
this.start()
|
||||
|
||||
/**
|
||||
* deviceId is required for sharding events in Middleman. To avoid a hot
|
||||
* shard, we generate a random anonymous IDs for this client.
|
||||
*
|
||||
* @see https://github.com/bluesky-social/tango/blob/d5819cde419d13e0d2cf837f4b30d48529d64060/middleman/handlers_tracking.go#L195
|
||||
*/
|
||||
const anonId = `anon-${crypto.randomUUID()}`
|
||||
|
||||
/**
|
||||
* Event structure is like this to ensure compat with Middleman, which
|
||||
* receives events like this from other codebases, including `social-app`.
|
||||
*/
|
||||
const e = {
|
||||
source: 'blink',
|
||||
time: Date.now(),
|
||||
event,
|
||||
payload,
|
||||
metadata: {
|
||||
base: {
|
||||
deviceId: anonId,
|
||||
sessionId: anonId,
|
||||
},
|
||||
session: {
|
||||
did: undefined,
|
||||
},
|
||||
},
|
||||
}
|
||||
this.queue.push(e)
|
||||
|
||||
if (this.queue.length > this.maxBatchSize) {
|
||||
this.flush()
|
||||
}
|
||||
}
|
||||
|
||||
flush() {
|
||||
if (this.disabled) return
|
||||
if (!this.queue.length) return
|
||||
const events = this.queue.splice(0, this.queue.length)
|
||||
this.sendBatch(events)
|
||||
}
|
||||
|
||||
private async sendBatch(events: Event<M>[]) {
|
||||
if (this.disabled || !this.config.trackingEndpoint) return
|
||||
|
||||
try {
|
||||
const res = await fetch(this.config.trackingEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({events}),
|
||||
keepalive: true,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text().catch(() => 'Unknown error')
|
||||
httpLogger.error(
|
||||
{err: new Error(`${res.status} Failed to fetch - ${errorText}`)},
|
||||
'Failed to send metrics',
|
||||
)
|
||||
} else {
|
||||
// Drain response body to allow connection reuse.
|
||||
await res.text().catch(() => {})
|
||||
}
|
||||
} catch (err) {
|
||||
httpLogger.error({err}, 'Failed to send metrics')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ export default function (ctx: AppContext, app: Express) {
|
||||
url.pathname === '/redirect') || // is a redirect loop
|
||||
INTERNAL_IP_REGEX.test(url.hostname) // isn't directing to an internal location
|
||||
) {
|
||||
ctx.metrics.track('invalid_redirect', {link})
|
||||
res.setHeader('Cache-Control', 'no-store')
|
||||
res.setHeader('Location', `https://${ctx.cfg.service.appHostname}`)
|
||||
return res.status(302).end()
|
||||
@@ -48,6 +49,9 @@ export default function (ctx: AppContext, app: Express) {
|
||||
res.type('html')
|
||||
|
||||
let html: string | undefined
|
||||
let whitelisted: 'unknown' | 'yes' = 'unknown'
|
||||
let blocked: boolean = false
|
||||
let warned: boolean = false
|
||||
|
||||
if (ctx.cfg.service.safelinkEnabled) {
|
||||
const rule = await ctx.safelinkClient.tryFindRule(link)
|
||||
@@ -55,6 +59,7 @@ export default function (ctx: AppContext, app: Express) {
|
||||
switch (rule.action) {
|
||||
case 'whitelist':
|
||||
redirectLogger.info({rule}, 'Whitelist rule matched')
|
||||
whitelisted = 'yes'
|
||||
break
|
||||
case 'block':
|
||||
html = linkWarningLayout(
|
||||
@@ -66,6 +71,7 @@ export default function (ctx: AppContext, app: Express) {
|
||||
)
|
||||
res.setHeader('Cache-Control', 'no-store')
|
||||
redirectLogger.info({rule}, 'Block rule matched')
|
||||
blocked = true
|
||||
break
|
||||
case 'warn':
|
||||
html = linkWarningLayout(
|
||||
@@ -77,6 +83,7 @@ export default function (ctx: AppContext, app: Express) {
|
||||
)
|
||||
res.setHeader('Cache-Control', 'no-store')
|
||||
redirectLogger.info({rule}, 'Warn rule matched')
|
||||
warned = true
|
||||
break
|
||||
default:
|
||||
redirectLogger.warn({rule}, 'Unknown rule matched')
|
||||
@@ -89,6 +96,18 @@ export default function (ctx: AppContext, app: Express) {
|
||||
html = linkRedirectContents(url.href)
|
||||
}
|
||||
|
||||
ctx.metrics.track('redirect', {
|
||||
link,
|
||||
whitelisted,
|
||||
blocked,
|
||||
warned,
|
||||
utm_source: req.query.utm_source?.toString(),
|
||||
utm_medium: req.query.utm_medium?.toString(),
|
||||
utm_campaign: req.query.utm_campaign?.toString(),
|
||||
utm_content: req.query.utm_content?.toString(),
|
||||
utm_term: req.query.utm_term?.toString(),
|
||||
})
|
||||
|
||||
return res.end(html)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2,11 +2,9 @@ import assert from 'node:assert'
|
||||
import {type AddressInfo} from 'node:net'
|
||||
import {after, before, describe, it} from 'node:test'
|
||||
|
||||
import {ToolsOzoneSafelinkDefs} from '@atproto/api'
|
||||
|
||||
import {Database, envToCfg, LinkService, readEnv} from '../src/index.js'
|
||||
|
||||
describe('link service', async () => {
|
||||
describe.skip('link service', async () => {
|
||||
let linkService: LinkService
|
||||
let baseUrl: string
|
||||
before(async () => {
|
||||
@@ -18,9 +16,9 @@ describe('link service', async () => {
|
||||
dbPostgresSchema: 'link_test',
|
||||
dbPostgresUrl: process.env.DB_POSTGRES_URL,
|
||||
safelinkEnabled: true,
|
||||
ozoneUrl: 'http://localhost:2583',
|
||||
ozoneAgentHandle: 'mod-authority.test',
|
||||
ozoneAgentPass: 'hunter2',
|
||||
safelinkPdsUrl: 'http://localhost:2583',
|
||||
safelinkAgentIdentifier: 'mod-authority.test',
|
||||
safelinkAgentPass: 'hunter2',
|
||||
})
|
||||
const migrateDb = Database.postgres({
|
||||
url: cfg.db.url,
|
||||
@@ -33,6 +31,7 @@ describe('link service', async () => {
|
||||
const {port} = linkService.server?.address() as AddressInfo
|
||||
baseUrl = `http://localhost:${port}`
|
||||
|
||||
/*
|
||||
// Ensure blocklist, whitelist, and safelink rules are set up
|
||||
const now = new Date().toISOString()
|
||||
linkService.ctx.cfg.eventCache.smartUpdate({
|
||||
@@ -110,6 +109,7 @@ describe('link service', async () => {
|
||||
comment:
|
||||
'Could be quite the mistake to get into this addicting game, but we will warn instead of block',
|
||||
})
|
||||
*/
|
||||
})
|
||||
after(async () => {
|
||||
await linkService?.destroy()
|
||||
@@ -213,6 +213,7 @@ describe('link service', async () => {
|
||||
)
|
||||
})
|
||||
|
||||
/*
|
||||
it('Rule adjustment, safe redirect, 200 response for Instagram Account of teamsesh Bones', async () => {
|
||||
// Retrieve the latest event after all updates
|
||||
const result = linkService.ctx.cfg.eventCache.smartGet(
|
||||
@@ -232,6 +233,7 @@ describe('link service', async () => {
|
||||
new RegExp(urlToRedirect.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
|
||||
)
|
||||
})
|
||||
*/
|
||||
|
||||
async function getRedirect(link: string): Promise<[number, string]> {
|
||||
const url = new URL(link)
|
||||
@@ -291,9 +293,10 @@ describe('link service no safelink', async () => {
|
||||
dbPostgresSchema: 'link_test',
|
||||
dbPostgresUrl: process.env.DB_POSTGRES_URL,
|
||||
safelinkEnabled: false,
|
||||
ozoneUrl: 'http://localhost:2583',
|
||||
ozoneAgentHandle: 'mod-authority.test',
|
||||
ozoneAgentPass: 'hunter2',
|
||||
safelinkPdsUrl: 'http://localhost:2583',
|
||||
safelinkAgentIdentifier: 'mod-authority.test',
|
||||
safelinkAgentPass: 'hunter2',
|
||||
metricsApiHost: 'http://localhost:2584',
|
||||
})
|
||||
const migrateDb = Database.postgres({
|
||||
url: cfg.db.url,
|
||||
@@ -357,4 +360,21 @@ describe('link service no safelink', async () => {
|
||||
// No blocked-site div, always safe
|
||||
assert.doesNotMatch(html, /"blocked-site"/)
|
||||
})
|
||||
|
||||
it('normal redirect with query params', async () => {
|
||||
const urlToRedirect = 'https://bsky.app/settings'
|
||||
const url = new URL(`${baseUrl}/redirect`)
|
||||
url.searchParams.set('u', urlToRedirect)
|
||||
url.searchParams.set('utm_source', 'test')
|
||||
const res = await fetch(url, {redirect: 'manual'})
|
||||
assert.strictEqual(res.status, 200)
|
||||
const html = await res.text()
|
||||
assert.match(html, /meta http-equiv="refresh"/)
|
||||
assert.match(
|
||||
html,
|
||||
new RegExp(urlToRedirect.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
|
||||
)
|
||||
// No blocked-site div, always safe
|
||||
assert.doesNotMatch(html, /"blocked-site"/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"exclude": ["node_modules", "dist"],
|
||||
"ts-node": {
|
||||
"logError": true,
|
||||
"pretty": true /* <= technically not required */
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import React from 'react'
|
||||
|
||||
// @NOTE satori does not currently support webp, see vercel/satori#273
|
||||
function detectMime(buf: Buffer): string {
|
||||
if (buf[0] === 0xff && buf[1] === 0xd8) return 'image/jpeg'
|
||||
if (buf[0] === 0x89 && buf[1] === 0x50) return 'image/png'
|
||||
if (buf[0] === 0x52 && buf[1] === 0x49) return 'image/webp'
|
||||
if (buf[0] === 0x47 && buf[1] === 0x49) return 'image/gif'
|
||||
return 'image/jpeg'
|
||||
}
|
||||
|
||||
export function Img(
|
||||
props: Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'src'> & {src: Buffer},
|
||||
) {
|
||||
const {src, ...others} = props
|
||||
return (
|
||||
<img {...others} src={`data:image/jpeg;base64,${src.toString('base64')}`} />
|
||||
<img
|
||||
{...others}
|
||||
src={`data:${detectMime(src)};base64,${src.toString('base64')}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import assert from 'node:assert'
|
||||
|
||||
import React from 'react'
|
||||
import {AppBskyGraphDefs, AtUri} from '@atproto/api'
|
||||
import {type AppBskyGraphDefs, AtUri} from '@atproto/api'
|
||||
import resvg from '@resvg/resvg-js'
|
||||
import {Express} from 'express'
|
||||
import {type Express} from 'express'
|
||||
import satori from 'satori'
|
||||
|
||||
import {
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
STARTERPACK_HEIGHT,
|
||||
STARTERPACK_WIDTH,
|
||||
} from '../components/StarterPack.js'
|
||||
import {AppContext} from '../context.js'
|
||||
import {type AppContext} from '../context.js'
|
||||
import {httpLogger} from '../logger.js'
|
||||
import {loadEmojiAsSvg} from '../util.js'
|
||||
import {handler, originVerifyMiddleware} from './util.js'
|
||||
@@ -83,12 +83,18 @@ export default function (ctx: AppContext, app: Express) {
|
||||
}
|
||||
|
||||
async function getImage(url: string) {
|
||||
const response = await fetch(url)
|
||||
const response = await fetch(ensureJpeg(url))
|
||||
const arrayBuf = await response.arrayBuffer() // must drain body even if it will be discarded
|
||||
if (response.status !== 200) return null
|
||||
return Buffer.from(arrayBuf)
|
||||
}
|
||||
|
||||
// CDN URLs end with @jpeg, @webp, or no extension (which may default to webp).
|
||||
// We want to ensure the image URLs we use are for jpegs, required for compat with satori.
|
||||
function ensureJpeg(url: string) {
|
||||
return url.replace(/(@[a-z]{3,5})?$/, '@jpeg')
|
||||
}
|
||||
|
||||
const hideAvatarLabels = new Set([
|
||||
'!hide',
|
||||
'!warn',
|
||||
|
||||
@@ -6,21 +6,26 @@ To build the SPA bundle (`bundle.web.js`), first get a JavaScript development
|
||||
environment set up. Either follow the top-level README, or something quick
|
||||
like:
|
||||
|
||||
# install nodejs
|
||||
nvm install
|
||||
nvm use
|
||||
npm install --global yarn
|
||||
```bash
|
||||
# install nodejs
|
||||
nvm install
|
||||
nvm use
|
||||
npm install --global yarn
|
||||
|
||||
# setup tools and deps (in top level of this repo)
|
||||
yarn install --frozen-lockfile
|
||||
# setup tools and deps (in top level of this repo)
|
||||
yarn install --frozen-lockfile
|
||||
|
||||
# run yarn web dev server, if you wanted
|
||||
yarn web
|
||||
# run yarn web dev server, if you wanted
|
||||
yarn web
|
||||
```
|
||||
|
||||
Then build and copy over the big 'ol `bundle.web.js` file:
|
||||
|
||||
# in the top level of this repo
|
||||
yarn build-web
|
||||
|
||||
```bash
|
||||
# in the top level of this repo
|
||||
yarn build-web
|
||||
```
|
||||
|
||||
### Golang Daemon
|
||||
|
||||
@@ -28,11 +33,13 @@ Install golang. We generally develop against the current stable release of the l
|
||||
|
||||
In this directory (`bskyweb/`):
|
||||
|
||||
# re-build and run daemon
|
||||
go run ./cmd/bskyweb serve
|
||||
```bash
|
||||
# re-build and run daemon
|
||||
go run ./cmd/bskyweb serve
|
||||
|
||||
# build and output a binary
|
||||
go build -o bskyweb ./cmd/bskyweb/
|
||||
# build and output a binary
|
||||
go build -o bskyweb ./cmd/bskyweb/
|
||||
```
|
||||
|
||||
The easiest way to configure the daemon is to copy `example.env` to `.env` and
|
||||
fill in auth values there.
|
||||
|
||||
@@ -2,12 +2,14 @@ package main
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/flosch/pongo2/v6"
|
||||
)
|
||||
|
||||
func init() {
|
||||
pongo2.RegisterFilter("canonicalize_url", filterCanonicalizeURL)
|
||||
pongo2.RegisterFilter("avatar_thumbnail", filterAvatarThumbnail)
|
||||
}
|
||||
|
||||
func filterCanonicalizeURL(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, *pongo2.Error) {
|
||||
@@ -26,3 +28,8 @@ func filterCanonicalizeURL(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value
|
||||
// Return the cleaned URL
|
||||
return pongo2.AsValue(parsedURL.String()), nil
|
||||
}
|
||||
|
||||
func filterAvatarThumbnail(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, *pongo2.Error) {
|
||||
urlStr := in.String()
|
||||
return pongo2.AsValue(strings.Replace(urlStr, "/img/avatar/plain/", "/img/avatar_thumbnail/plain/", 1)), nil
|
||||
}
|
||||
|
||||
@@ -292,6 +292,7 @@ func serve(cctx *cli.Context) error {
|
||||
e.GET("/settings/accessibility", server.WebGeneric)
|
||||
e.GET("/settings/appearance", server.WebGeneric)
|
||||
e.GET("/settings/account", server.WebGeneric)
|
||||
e.GET("/settings/automation-label", server.WebGeneric)
|
||||
e.GET("/settings/privacy-and-security", server.WebGeneric)
|
||||
e.GET("/settings/privacy-and-security/activity", server.WebGeneric)
|
||||
e.GET("/settings/content-and-media", server.WebGeneric)
|
||||
@@ -574,7 +575,10 @@ func (srv *Server) WebPost(c echo.Context) error {
|
||||
|
||||
if postView.Embed != nil && !isEmbedHidden {
|
||||
hasImages := postView.Embed.EmbedImages_View != nil
|
||||
hasMedia := postView.Embed.EmbedRecordWithMedia_View != nil && postView.Embed.EmbedRecordWithMedia_View.Media != nil && postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View != nil
|
||||
hasVideo := postView.Embed.EmbedVideo_View != nil
|
||||
hasMedia := postView.Embed.EmbedRecordWithMedia_View != nil && postView.Embed.EmbedRecordWithMedia_View.Media != nil
|
||||
hasMediaImages := hasMedia && postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View != nil
|
||||
hasMediaVideo := hasMedia && postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View != nil
|
||||
|
||||
if hasImages {
|
||||
var thumbUrls []string
|
||||
@@ -582,12 +586,36 @@ func (srv *Server) WebPost(c echo.Context) error {
|
||||
thumbUrls = append(thumbUrls, postView.Embed.EmbedImages_View.Images[i].Thumb)
|
||||
}
|
||||
data["imgThumbUrls"] = thumbUrls
|
||||
} else if hasMedia {
|
||||
} else if hasVideo {
|
||||
if postView.Embed.EmbedVideo_View.Thumbnail != nil {
|
||||
data["imgThumbUrls"] = []string{*postView.Embed.EmbedVideo_View.Thumbnail}
|
||||
}
|
||||
if postView.Embed.EmbedVideo_View.Playlist != "" {
|
||||
data["videoUrl"] = postView.Embed.EmbedVideo_View.Playlist
|
||||
data["videoType"] = "application/vnd.apple.mpegurl"
|
||||
if postView.Embed.EmbedVideo_View.AspectRatio != nil {
|
||||
data["videoWidth"] = postView.Embed.EmbedVideo_View.AspectRatio.Width
|
||||
data["videoHeight"] = postView.Embed.EmbedVideo_View.AspectRatio.Height
|
||||
}
|
||||
}
|
||||
} else if hasMediaImages {
|
||||
var thumbUrls []string
|
||||
for i := range postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View.Images {
|
||||
thumbUrls = append(thumbUrls, postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View.Images[i].Thumb)
|
||||
}
|
||||
data["imgThumbUrls"] = thumbUrls
|
||||
} else if hasMediaVideo {
|
||||
if postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.Thumbnail != nil {
|
||||
data["imgThumbUrls"] = []string{*postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.Thumbnail}
|
||||
}
|
||||
if postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.Playlist != "" {
|
||||
data["videoUrl"] = postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.Playlist
|
||||
data["videoType"] = "application/vnd.apple.mpegurl"
|
||||
if postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.AspectRatio != nil {
|
||||
data["videoWidth"] = postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.AspectRatio.Width
|
||||
data["videoHeight"] = postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.AspectRatio.Height
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -178,6 +178,13 @@ func serve(cctx *cli.Context) error {
|
||||
return http.FS(fsys)
|
||||
}())
|
||||
|
||||
// Create CORS middleware for oembed
|
||||
oembedCORS := middleware.CORSWithConfig(middleware.CORSConfig{
|
||||
AllowOrigins: []string{"*"},
|
||||
AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodOptions},
|
||||
AllowHeaders: []string{"Origin", "Content-Type", "Accept"},
|
||||
})
|
||||
|
||||
e.GET("/robots.txt", echo.WrapHandler(staticHandler))
|
||||
e.GET("/ips-v4", echo.WrapHandler(staticHandler))
|
||||
e.GET("/ips-v6", echo.WrapHandler(staticHandler))
|
||||
@@ -205,7 +212,7 @@ func serve(cctx *cli.Context) error {
|
||||
e.GET("/", server.WebHome)
|
||||
e.GET("/iframe-resize.js", echo.WrapHandler(staticHandler))
|
||||
e.GET("/embed.js", echo.WrapHandler(staticHandler))
|
||||
e.GET("/oembed", server.WebOEmbed)
|
||||
e.GET("/oembed", server.WebOEmbed, oembedCORS)
|
||||
e.GET("/embed/:did/app.bsky.feed.post/:rkey", server.WebPostEmbed)
|
||||
|
||||
// Start the server.
|
||||
|
||||
@@ -37,14 +37,6 @@
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
html {
|
||||
background-color: white;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html {
|
||||
background-color: black;
|
||||
}
|
||||
}
|
||||
html,
|
||||
body {
|
||||
margin: 0px;
|
||||
@@ -59,6 +51,19 @@
|
||||
-ms-overflow-style: scrollbar;
|
||||
font-synthesis-weight: none;
|
||||
}
|
||||
:root {
|
||||
--text: black;
|
||||
--background: white;
|
||||
--backgroundLight: #e2e7ee;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--text: white;
|
||||
--background: black;
|
||||
--backgroundLight: #232e3e;
|
||||
}
|
||||
}
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
@@ -67,6 +72,32 @@
|
||||
min-height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
html.theme--light,
|
||||
html.theme--light body,
|
||||
html.theme--light #root {
|
||||
background-color: white;
|
||||
--text: black;
|
||||
--background: white;
|
||||
--backgroundLight: #DCE2EA;
|
||||
}
|
||||
html.theme--dark,
|
||||
html.theme--dark body,
|
||||
html.theme--dark #root {
|
||||
color-scheme: dark;
|
||||
background-color: black;
|
||||
--text: white;
|
||||
--background: black;
|
||||
--backgroundLight: #232E3E;
|
||||
}
|
||||
html.theme--dim,
|
||||
html.theme--dim body,
|
||||
html.theme--dim #root {
|
||||
color-scheme: dark;
|
||||
background-color: #151D28;
|
||||
--text: white;
|
||||
--background: #151D28;
|
||||
--backgroundLight: #2C3A4E;
|
||||
}
|
||||
#splash {
|
||||
display: flex;
|
||||
position: fixed;
|
||||
@@ -93,6 +124,12 @@
|
||||
overflow-y: scroll;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
const theme = localStorage.getItem('ALF_THEME')
|
||||
if (theme) {
|
||||
document.documentElement.classList.add(`theme--${theme}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
{% include "scripts.html" %}
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{{ staticCDNHost }}/static/apple-touch-icon.png">
|
||||
@@ -111,11 +148,11 @@
|
||||
</head>
|
||||
<body>
|
||||
{%- block body_all %}
|
||||
<div id="splash">
|
||||
<!-- Bluesky SVG -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 57"><path fill="#006AFF" d="M13.873 3.805C21.21 9.332 29.103 20.537 32 26.55v15.882c0-.338-.13.044-.41.867-1.512 4.456-7.418 21.847-20.923 7.944-7.111-7.32-3.819-14.64 9.125-16.85-7.405 1.264-15.73-.825-18.014-9.015C1.12 23.022 0 8.51 0 6.55 0-3.268 8.579-.182 13.873 3.805ZM50.127 3.805C42.79 9.332 34.897 20.537 32 26.55v15.882c0-.338.13.044.41.867 1.512 4.456 7.418 21.847 20.923 7.944 7.111-7.32 3.819-14.64-9.125-16.85 7.405 1.264 15.73-.825 18.014-9.015C62.88 23.022 64 8.51 64 6.55c0-9.818-8.578-6.732-13.873-2.745Z"/></svg>
|
||||
</div>
|
||||
<div id="root">
|
||||
<div id="splash">
|
||||
<!-- Bluesky SVG -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 57"><path fill="#006AFF" d="M13.873 3.805C21.21 9.332 29.103 20.537 32 26.55v15.882c0-.338-.13.044-.41.867-1.512 4.456-7.418 21.847-20.923 7.944-7.111-7.32-3.819-14.64 9.125-16.85-7.405 1.264-15.73-.825-18.014-9.015C1.12 23.022 0 8.51 0 6.55 0-3.268 8.579-.182 13.873 3.805ZM50.127 3.805C42.79 9.332 34.897 20.537 32 26.55v15.882c0-.338.13.044.41.867 1.512 4.456 7.418 21.847 20.923 7.944 7.111-7.32 3.819-14.64-9.125-16.85 7.405 1.264 15.73-.825 18.014-9.015C62.88 23.022 64 8.51 64 6.55c0-9.818-8.578-6.732-13.873-2.745Z"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<noscript>
|
||||
|
||||
@@ -34,9 +34,17 @@
|
||||
<meta property="twitter:image" content="{{ imgThumbUrl }}">
|
||||
{% endfor %}
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
{%- if videoUrl %}
|
||||
<meta property="og:video" content="{{ videoUrl }}">
|
||||
<meta property="og:video:type" content="{{ videoType }}">
|
||||
{%- if videoWidth %}
|
||||
<meta property="og:video:width" content="{{ videoWidth }}">
|
||||
<meta property="og:video:height" content="{{ videoHeight }}">
|
||||
{% endif -%}
|
||||
{% endif -%}
|
||||
{% else %}
|
||||
<meta property="og:image" content="{{ postView.Author.Avatar }}">
|
||||
<meta property="twitter:image" content="{{ postView.Author.Avatar }}">
|
||||
<meta property="og:image" content="{{ postView.Author.Avatar|avatar_thumbnail }}">
|
||||
<meta property="twitter:image" content="{{ postView.Author.Avatar|avatar_thumbnail }}">
|
||||
<meta name="twitter:card" content="summary">
|
||||
{% endif %}
|
||||
<meta name="twitter:label1" content="Posted At">
|
||||
@@ -108,9 +116,9 @@
|
||||
{% else %}
|
||||
<meta property="og:title" content="@{{ profileHandle }}">
|
||||
{% endif -%}
|
||||
<meta name="description" content="This post requires authentication to view.">
|
||||
<meta property="og:description" content="This post requires authentication to view.">
|
||||
<meta property="twitter:description" content="This post requires authentication to view.">
|
||||
<meta name="description" content="This author has chosen to make their posts visible only to people who are signed in.">
|
||||
<meta property="og:description" content="This author has chosen to make their posts visible only to people who are signed in.">
|
||||
<meta property="twitter:description" content="This author has chosen to make their posts visible only to people who are signed in.">
|
||||
<meta name="twitter:card" content="summary">
|
||||
{% endif -%}
|
||||
{%- endblock %}
|
||||
@@ -129,7 +137,7 @@
|
||||
<div id="bsky_post_summary">
|
||||
<h3>Post</h3>
|
||||
<p id="bsky_handle">{{ profileHandle }}</p>
|
||||
<p id="bsky_post_text">This post requires authentication to view.</p>
|
||||
<p id="bsky_post_text">This author has chosen to make their posts visible only to people who are signed in.</p>
|
||||
</div>
|
||||
{% endif -%}
|
||||
{%- endblock %}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"scripts": {
|
||||
"setup": "yarn install",
|
||||
"run": "yarn web --port $CONDUCTOR_PORT"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
import {createServer as createHTTPServer} from 'node:http'
|
||||
import {parse} from 'node:url'
|
||||
|
||||
import {createServer, type TestPDS} from './test-pds'
|
||||
|
||||
let server: TestPDS
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
createHTTPServer(async (req, res) => {
|
||||
const url = parse(req.url || '/', true)
|
||||
if (req.method !== 'POST') {
|
||||
return res.writeHead(200).end()
|
||||
}
|
||||
try {
|
||||
console.log('Closing old server')
|
||||
await server?.close()
|
||||
console.log('Starting new server')
|
||||
const inviteRequired = url?.query && 'invite' in url.query
|
||||
server = await createServer({inviteRequired})
|
||||
console.log('Listening at', server.pdsUrl)
|
||||
if (url?.query) {
|
||||
if ('users' in url.query) {
|
||||
console.log('Generating mock users')
|
||||
await server.mocker.createUser('alice')
|
||||
await server.mocker.createUser('bob')
|
||||
await server.mocker.createUser('carla')
|
||||
await server.mocker.users.alice.agent.upsertProfile(() => ({
|
||||
displayName: 'Alice',
|
||||
description: 'Test user 1',
|
||||
}))
|
||||
await server.mocker.users.bob.agent.upsertProfile(() => ({
|
||||
displayName: 'Bob',
|
||||
description: 'Test user 2',
|
||||
}))
|
||||
await server.mocker.users.carla.agent.upsertProfile(() => ({
|
||||
displayName: 'Carla',
|
||||
description: 'Test user 3',
|
||||
}))
|
||||
if (inviteRequired) {
|
||||
await server.mocker.createInvite(server.mocker.users.alice.did)
|
||||
}
|
||||
}
|
||||
if ('follows' in url.query) {
|
||||
console.log('Generating mock follows')
|
||||
await server.mocker.follow('alice', 'bob')
|
||||
await server.mocker.follow('alice', 'carla')
|
||||
await server.mocker.follow('bob', 'alice')
|
||||
await server.mocker.follow('bob', 'carla')
|
||||
await server.mocker.follow('carla', 'alice')
|
||||
await server.mocker.follow('carla', 'bob')
|
||||
}
|
||||
if ('posts' in url.query) {
|
||||
console.log('Generating mock posts')
|
||||
for (let user in server.mocker.users) {
|
||||
await server.mocker.users[user].agent.post({text: 'Post'})
|
||||
}
|
||||
}
|
||||
if ('feeds' in url.query) {
|
||||
console.log('Generating mock feed')
|
||||
await server.mocker.createFeed('alice', 'alice-favs', [])
|
||||
}
|
||||
if ('thread' in url.query) {
|
||||
console.log('Generating mock posts')
|
||||
const res = await server.mocker.users.bob.agent.post({
|
||||
text: 'Thread root',
|
||||
})
|
||||
await server.mocker.users.carla.agent.post({
|
||||
text: 'Thread reply',
|
||||
reply: {
|
||||
parent: {cid: res.cid, uri: res.uri},
|
||||
root: {cid: res.cid, uri: res.uri},
|
||||
},
|
||||
})
|
||||
}
|
||||
if ('mergefeed' in url.query) {
|
||||
console.log('Generating mock users')
|
||||
await server.mocker.createUser('alice')
|
||||
await server.mocker.createUser('bob')
|
||||
await server.mocker.createUser('carla')
|
||||
await server.mocker.createUser('dan')
|
||||
await server.mocker.users.alice.agent.upsertProfile(() => ({
|
||||
displayName: 'Alice',
|
||||
description: 'Test user 1',
|
||||
}))
|
||||
await server.mocker.users.bob.agent.upsertProfile(() => ({
|
||||
displayName: 'Bob',
|
||||
description: 'Test user 2',
|
||||
}))
|
||||
await server.mocker.users.carla.agent.upsertProfile(() => ({
|
||||
displayName: 'Carla',
|
||||
description: 'Test user 3',
|
||||
}))
|
||||
await server.mocker.users.dan.agent.upsertProfile(() => ({
|
||||
displayName: 'Dan',
|
||||
description: 'Test user 4',
|
||||
}))
|
||||
console.log('Generating mock follows')
|
||||
await server.mocker.follow('alice', 'bob')
|
||||
await server.mocker.follow('alice', 'carla')
|
||||
console.log('Generating mock posts')
|
||||
let posts: Record<string, any[]> = {
|
||||
alice: [],
|
||||
bob: [],
|
||||
carla: [],
|
||||
dan: [],
|
||||
}
|
||||
for (let i = 0; i < 10; i++) {
|
||||
for (let user in server.mocker.users) {
|
||||
if (user === 'alice') continue
|
||||
posts[user].push(await server.mocker.createPost(user, `Post ${i}`))
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < 10; i++) {
|
||||
for (let user in server.mocker.users) {
|
||||
if (user === 'alice') continue
|
||||
if (i % 5 === 0) {
|
||||
await server.mocker.createReply(user, 'Self reply', {
|
||||
cid: posts[user][i].cid,
|
||||
uri: posts[user][i].uri,
|
||||
})
|
||||
}
|
||||
if (i % 5 === 1) {
|
||||
await server.mocker.createReply(user, 'Reply to bob', {
|
||||
cid: posts.bob[i].cid,
|
||||
uri: posts.bob[i].uri,
|
||||
})
|
||||
}
|
||||
if (i % 5 === 2) {
|
||||
await server.mocker.createReply(user, 'Reply to dan', {
|
||||
cid: posts.dan[i].cid,
|
||||
uri: posts.dan[i].uri,
|
||||
})
|
||||
}
|
||||
await server.mocker.users[user].agent.post({text: `Post ${i}`})
|
||||
}
|
||||
}
|
||||
console.log('Generating mock feeds')
|
||||
await server.mocker.createFeed(
|
||||
'alice',
|
||||
'alice-favs',
|
||||
posts.dan.map(p => p.uri),
|
||||
)
|
||||
await server.mocker.createFeed(
|
||||
'alice',
|
||||
'alice-favs2',
|
||||
posts.dan.map(p => p.uri),
|
||||
)
|
||||
}
|
||||
if ('labels' in url.query) {
|
||||
console.log('Generating naughty users with labels')
|
||||
|
||||
const anchorPost = await server.mocker.createPost(
|
||||
'alice',
|
||||
'Anchor post',
|
||||
)
|
||||
|
||||
for (const user of [
|
||||
'dmca-account',
|
||||
'dmca-profile',
|
||||
'dmca-posts',
|
||||
'porn-account',
|
||||
'porn-profile',
|
||||
'porn-posts',
|
||||
'nudity-account',
|
||||
'nudity-profile',
|
||||
'nudity-posts',
|
||||
'scam-account',
|
||||
'scam-profile',
|
||||
'scam-posts',
|
||||
'unknown-account',
|
||||
'unknown-profile',
|
||||
'unknown-posts',
|
||||
'hide-account',
|
||||
'hide-profile',
|
||||
'hide-posts',
|
||||
'no-promote-account',
|
||||
'no-promote-profile',
|
||||
'no-promote-posts',
|
||||
'warn-account',
|
||||
'warn-profile',
|
||||
'warn-posts',
|
||||
'muted-account',
|
||||
'muted-by-list-acc',
|
||||
'blocking-account',
|
||||
'blockedby-account',
|
||||
'mutual-block-acc',
|
||||
]) {
|
||||
await server.mocker.createUser(user)
|
||||
await server.mocker.follow('alice', user)
|
||||
await server.mocker.follow(user, 'alice')
|
||||
await server.mocker.createPost(user, `Unlabeled post from ${user}`)
|
||||
await server.mocker.createReply(
|
||||
user,
|
||||
`Unlabeled reply from ${user}`,
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.like(user, anchorPost)
|
||||
}
|
||||
|
||||
await server.mocker.labelAccount('dmca-violation', 'dmca-account')
|
||||
await server.mocker.labelProfile('dmca-violation', 'dmca-profile')
|
||||
await server.mocker.labelPost(
|
||||
'dmca-violation',
|
||||
await server.mocker.createPost('dmca-posts', 'dmca post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'dmca-violation',
|
||||
await server.mocker.createQuotePost(
|
||||
'dmca-posts',
|
||||
'dmca quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'dmca-violation',
|
||||
await server.mocker.createReply(
|
||||
'dmca-posts',
|
||||
'dmca reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount('porn', 'porn-account')
|
||||
await server.mocker.labelProfile('porn', 'porn-profile')
|
||||
await server.mocker.labelPost(
|
||||
'porn',
|
||||
await server.mocker.createImagePost('porn-posts', 'porn post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'porn',
|
||||
await server.mocker.createQuotePost(
|
||||
'porn-posts',
|
||||
'porn quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'porn',
|
||||
await server.mocker.createReply(
|
||||
'porn-posts',
|
||||
'porn reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount('nudity', 'nudity-account')
|
||||
await server.mocker.labelProfile('nudity', 'nudity-profile')
|
||||
await server.mocker.labelPost(
|
||||
'nudity',
|
||||
await server.mocker.createImagePost('nudity-posts', 'nudity post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'nudity',
|
||||
await server.mocker.createQuotePost(
|
||||
'nudity-posts',
|
||||
'nudity quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'nudity',
|
||||
await server.mocker.createReply(
|
||||
'nudity-posts',
|
||||
'nudity reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount('scam', 'scam-account')
|
||||
await server.mocker.labelProfile('scam', 'scam-profile')
|
||||
await server.mocker.labelPost(
|
||||
'scam',
|
||||
await server.mocker.createPost('scam-posts', 'scam post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'scam',
|
||||
await server.mocker.createQuotePost(
|
||||
'scam-posts',
|
||||
'scam quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'scam',
|
||||
await server.mocker.createReply(
|
||||
'scam-posts',
|
||||
'scam reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount('not-a-real-label', 'unknown-account')
|
||||
await server.mocker.labelProfile('not-a-real-label', 'unknown-profile')
|
||||
await server.mocker.labelPost(
|
||||
'not-a-real-label',
|
||||
await server.mocker.createPost('unknown-posts', 'unknown post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'not-a-real-label',
|
||||
await server.mocker.createQuotePost(
|
||||
'unknown-posts',
|
||||
'unknown quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'not-a-real-label',
|
||||
await server.mocker.createReply(
|
||||
'unknown-posts',
|
||||
'unknown reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount('!hide', 'hide-account')
|
||||
await server.mocker.labelProfile('!hide', 'hide-profile')
|
||||
await server.mocker.labelPost(
|
||||
'!hide',
|
||||
await server.mocker.createPost('hide-posts', 'hide post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'!hide',
|
||||
await server.mocker.createQuotePost(
|
||||
'hide-posts',
|
||||
'hide quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'!hide',
|
||||
await server.mocker.createReply(
|
||||
'hide-posts',
|
||||
'hide reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount('!no-promote', 'no-promote-account')
|
||||
await server.mocker.labelProfile('!no-promote', 'no-promote-profile')
|
||||
await server.mocker.labelPost(
|
||||
'!no-promote',
|
||||
await server.mocker.createPost('no-promote-posts', 'no-promote post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'!no-promote',
|
||||
await server.mocker.createQuotePost(
|
||||
'no-promote-posts',
|
||||
'no-promote quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'!no-promote',
|
||||
await server.mocker.createReply(
|
||||
'no-promote-posts',
|
||||
'no-promote reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount('!warn', 'warn-account')
|
||||
await server.mocker.labelProfile('!warn', 'warn-profile')
|
||||
await server.mocker.labelPost(
|
||||
'!warn',
|
||||
await server.mocker.createPost('warn-posts', 'warn post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'!warn',
|
||||
await server.mocker.createQuotePost(
|
||||
'warn-posts',
|
||||
'warn quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'!warn',
|
||||
await server.mocker.createReply(
|
||||
'warn-posts',
|
||||
'warn reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.users.alice.agent.mute('muted-account.test')
|
||||
await server.mocker.createPost('muted-account', 'muted post')
|
||||
await server.mocker.createQuotePost(
|
||||
'muted-account',
|
||||
'muted quote post',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.createReply(
|
||||
'muted-account',
|
||||
'muted reply',
|
||||
anchorPost,
|
||||
)
|
||||
|
||||
const list = await server.mocker.createMuteList('alice', 'Muted Users')
|
||||
await server.mocker.addToMuteList(
|
||||
'alice',
|
||||
list,
|
||||
server.mocker.users['muted-by-list-acc'].did,
|
||||
)
|
||||
await server.mocker.createPost('muted-by-list-acc', 'muted post')
|
||||
await server.mocker.createQuotePost(
|
||||
'muted-by-list-acc',
|
||||
'account quote post',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.createReply(
|
||||
'muted-by-list-acc',
|
||||
'account reply',
|
||||
anchorPost,
|
||||
)
|
||||
|
||||
await server.mocker.createPost('blocking-account', 'blocking post')
|
||||
await server.mocker.createQuotePost(
|
||||
'blocking-account',
|
||||
'blocking quote post',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.createReply(
|
||||
'blocking-account',
|
||||
'blocking reply',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.users.alice.agent.app.bsky.graph.block.create(
|
||||
{
|
||||
repo: server.mocker.users.alice.did,
|
||||
},
|
||||
{
|
||||
subject: server.mocker.users['blocking-account'].did,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
)
|
||||
|
||||
await server.mocker.createPost('blockedby-account', 'blockedby post')
|
||||
await server.mocker.createQuotePost(
|
||||
'blockedby-account',
|
||||
'blockedby quote post',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.createReply(
|
||||
'blockedby-account',
|
||||
'blockedby reply',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.users[
|
||||
'blockedby-account'
|
||||
].agent.app.bsky.graph.block.create(
|
||||
{
|
||||
repo: server.mocker.users['blockedby-account'].did,
|
||||
},
|
||||
{
|
||||
subject: server.mocker.users.alice.did,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
)
|
||||
|
||||
await server.mocker.createPost('mutual-block-acc', 'mutual-block post')
|
||||
await server.mocker.createQuotePost(
|
||||
'mutual-block-acc',
|
||||
'mutual-block quote post',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.createReply(
|
||||
'mutual-block-acc',
|
||||
'mutual-block reply',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.users.alice.agent.app.bsky.graph.block.create(
|
||||
{
|
||||
repo: server.mocker.users.alice.did,
|
||||
},
|
||||
{
|
||||
subject: server.mocker.users['mutual-block-acc'].did,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
)
|
||||
await server.mocker.users[
|
||||
'mutual-block-acc'
|
||||
].agent.app.bsky.graph.block.create(
|
||||
{
|
||||
repo: server.mocker.users['mutual-block-acc'].did,
|
||||
},
|
||||
{
|
||||
subject: server.mocker.users.alice.did,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
)
|
||||
|
||||
// flush caches
|
||||
await server.mocker.testNet.processAll()
|
||||
}
|
||||
}
|
||||
console.log('Ready')
|
||||
return res
|
||||
.writeHead(200, {
|
||||
'content-type': 'application/json',
|
||||
})
|
||||
.end(
|
||||
JSON.stringify({
|
||||
pdsUrl: server.pdsUrl,
|
||||
appviewDid: server.appviewDid,
|
||||
}),
|
||||
)
|
||||
} catch (e) {
|
||||
console.error('Error!', e)
|
||||
return res.writeHead(500).end()
|
||||
}
|
||||
}).listen(1986)
|
||||
console.log('Mock server manager listening on 1986')
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "dev-env",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"start": "NODE_ENV=development ./dev-infra/with-test-redis-and-db.sh ts-node ./mock-server.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/dev-env": "^0.3.215",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^6.0.2"
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"paths": {
|
||||
"#/*": ["./src/*"],
|
||||
"lib/*": ["./src/lib/*"],
|
||||
@@ -43,4 +44,4 @@
|
||||
"metro.config.js",
|
||||
"jest.config.js"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -164,8 +164,8 @@ See [testing.md](./testing.md).
|
||||
`./platform/polyfills.*.ts` adds polyfills to the environment. Currently, this includes:
|
||||
|
||||
- TextEncoder / TextDecoder
|
||||
- react-native-url-polyfill
|
||||
- Array#findLast (on web)
|
||||
- setImmediate (on web)
|
||||
|
||||
### Sentry sourcemaps
|
||||
|
||||
|
||||
@@ -47,8 +47,7 @@ Every night, a GitHub action will run `yarn intl:extract` to update the english
|
||||
### 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. Run `yarn intl:release` to fetch all translation updates from Crowdin and extract all `.po` files so that they're synced with the latest code. Commit that.
|
||||
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).
|
||||
@@ -73,7 +72,7 @@ import { Text } from "react-native";
|
||||
```jsx
|
||||
// After
|
||||
import { Text } from "react-native";
|
||||
import { Trans } from "@lingui/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
<Text><Trans>Hello World</Trans></Text>
|
||||
```
|
||||
@@ -90,18 +89,33 @@ const text = "Hello World";
|
||||
```
|
||||
In this case, you can use the `useLingui()` hook:
|
||||
```jsx
|
||||
import { msg } from "@lingui/macro";
|
||||
import { msg } from "@lingui/core/macro";
|
||||
import { useLingui } from "@lingui/react";
|
||||
|
||||
const { _ } = useLingui();
|
||||
return <Text accessibilityLabel={_(msg`Label is here`)}>{text}</Text>
|
||||
```
|
||||
|
||||
If you want to do this outside of a React component, you can use the `t` macro instead (note: this won't react to changes if the locale is switched dynamically within the app):
|
||||
```jsx
|
||||
import { t } from "@lingui/macro";
|
||||
NEW: the latest Lingui version introduced a new macro version of the `useLingui` hook which lets you do this:
|
||||
|
||||
```jsx
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
|
||||
const { t } = useLingui();
|
||||
return <Text accessibilityLabel={t`Label is here`}>{text}</Text>
|
||||
```
|
||||
|
||||
If you want to do this outside of a React component, you can use the global `t` macro instead (note: this won't react to changes if the locale is switched dynamically within the app):
|
||||
```jsx
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
// not ideal - t only gets called once at module evaluation time
|
||||
const text = t`Hello World`;
|
||||
|
||||
// however, this is suitable for strings that are ephemeral:
|
||||
function sayHello() {
|
||||
Toast.show(t`Hello World`); // Each time the toast shows, the current locale at that moment is used
|
||||
}
|
||||
```
|
||||
|
||||
We can then run `yarn intl:extract` to update the catalog in `src/locale/locales/{locale}/messages.po`. This will add the new string to the catalog.
|
||||
@@ -121,7 +135,7 @@ So the workflow is as follows:
|
||||
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
|
||||
import { msg } from "@lingui/macro";
|
||||
import { msg } from "@lingui/core/macro";
|
||||
import { i18n } from "@lingui/core";
|
||||
|
||||
const welcomeMessage = msg`Welcome!`;
|
||||
|
||||
@@ -3,12 +3,26 @@
|
||||
Make sure you've copied `.env.example` to `.env.test` and provided any required
|
||||
values.
|
||||
|
||||
Install dependencies in `/dev-env`
|
||||
|
||||
```
|
||||
cd dev-env && yarn
|
||||
```
|
||||
|
||||
## Using Maestro
|
||||
|
||||
1. Install Maestro by following [these instructions](https://maestro.mobile.dev/getting-started/installing-maestro). This will help us run the E2E tests.
|
||||
2. You can write Maestro tests in `/.maestro/flows/` directory by creating a new `.yml` file or by modifying an existing one.
|
||||
3. You can also use [Maestro Studio](https://maestro.mobile.dev/getting-started/maestro-studio) which automatically generates commands by recording your actions on the app. Therefore, you can create realistic tests without having to manually write any code. Use the `maestro studio` command to start recording your actions.
|
||||
|
||||
### Running on Android
|
||||
|
||||
You will need to allow your device access to the port that the mock server is running on.
|
||||
|
||||
```
|
||||
adb reverse tcp:3000 tcp:3000
|
||||
```
|
||||
|
||||
### Running Maestro tests
|
||||
|
||||
- In one tab, run `yarn e2e:mock-server`
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
// @ts-check
|
||||
import js from '@eslint/js'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import react from 'eslint-plugin-react'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
// @ts-expect-error no types
|
||||
import reactNative from 'eslint-plugin-react-native'
|
||||
// @ts-expect-error no types
|
||||
import reactNativeA11y from 'eslint-plugin-react-native-a11y'
|
||||
import simpleImportSort from 'eslint-plugin-simple-import-sort'
|
||||
import importX from 'eslint-plugin-import-x'
|
||||
import lingui from 'eslint-plugin-lingui'
|
||||
import reactCompiler from 'eslint-plugin-react-compiler'
|
||||
import bskyInternal from 'eslint-plugin-bsky-internal'
|
||||
import globals from 'globals'
|
||||
import tsParser from '@typescript-eslint/parser'
|
||||
|
||||
export default defineConfig(
|
||||
/**
|
||||
* Global ignores
|
||||
*/
|
||||
{
|
||||
ignores: [
|
||||
'**/__mocks__/*.ts',
|
||||
'ios/**',
|
||||
'android/**',
|
||||
'coverage/**',
|
||||
'*.lock',
|
||||
'.husky/**',
|
||||
'patches/**',
|
||||
'*.html',
|
||||
'bskyweb/**',
|
||||
'bskyembed/**',
|
||||
'src/locale/locales/_build/**',
|
||||
'src/locale/locales/**/*.js',
|
||||
'*.e2e.ts',
|
||||
'*.e2e.tsx',
|
||||
'eslint.config.mjs',
|
||||
'.jscodeshift/**',
|
||||
],
|
||||
},
|
||||
|
||||
/**
|
||||
* Base configurations
|
||||
*/
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
reactHooks.configs.flat.recommended,
|
||||
importX.flatConfigs.recommended,
|
||||
importX.flatConfigs.typescript,
|
||||
importX.flatConfigs['react-native'],
|
||||
|
||||
/**
|
||||
* Main configuration for all JS/TS/JSX/TSX files
|
||||
*/
|
||||
{
|
||||
files: ['**/*.{js,jsx,ts,tsx}'],
|
||||
plugins: {
|
||||
react,
|
||||
'react-native': reactNative,
|
||||
'react-native-a11y': reactNativeA11y,
|
||||
'simple-import-sort': simpleImportSort,
|
||||
// @ts-expect-error - not sure why
|
||||
lingui,
|
||||
'react-compiler': reactCompiler,
|
||||
'bsky-internal': bskyInternal,
|
||||
},
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
parserOptions: {
|
||||
parser: tsParser,
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
componentWrapperFunctions: ['observer'],
|
||||
},
|
||||
rules: {
|
||||
/**
|
||||
* Custom rules
|
||||
*/
|
||||
'bsky-internal/avoid-unwrapped-text': [
|
||||
'error',
|
||||
{
|
||||
impliedTextComponents: [
|
||||
'H1',
|
||||
'H2',
|
||||
'H3',
|
||||
'H4',
|
||||
'H5',
|
||||
'H6',
|
||||
'P',
|
||||
'Admonition',
|
||||
'Admonition.Admonition',
|
||||
'Toast.Action',
|
||||
'AgeAssuranceAdmonition',
|
||||
'Span',
|
||||
'StackedButton',
|
||||
],
|
||||
impliedTextProps: [],
|
||||
suggestedTextWrappers: {
|
||||
Button: 'ButtonText',
|
||||
'ToggleButton.Button': 'ToggleButton.ButtonText',
|
||||
'SegmentedControl.Item': 'SegmentedControl.ItemText',
|
||||
},
|
||||
},
|
||||
],
|
||||
'bsky-internal/use-exact-imports': 'error',
|
||||
'bsky-internal/use-prefixed-imports': 'error',
|
||||
'bsky-internal/lingui-msg-rule': 'error',
|
||||
|
||||
/**
|
||||
* React & React Native
|
||||
*/
|
||||
...react.configs.recommended.rules,
|
||||
...react.configs['jsx-runtime'].rules,
|
||||
'react/no-unescaped-entities': 'off',
|
||||
'react/prop-types': 'off',
|
||||
'react-native/no-inline-styles': 'off',
|
||||
...reactNativeA11y.configs.all.rules,
|
||||
'react-compiler/react-compiler': 'warn',
|
||||
// TODO: Fix these and set to error
|
||||
'react-hooks/set-state-in-effect': 'warn',
|
||||
'react-hooks/purity': 'warn',
|
||||
'react-hooks/refs': 'warn',
|
||||
'react-hooks/immutability': 'warn',
|
||||
|
||||
/**
|
||||
* Import sorting
|
||||
*/
|
||||
'simple-import-sort/imports': [
|
||||
'error',
|
||||
{
|
||||
groups: [
|
||||
// Side effect imports.
|
||||
['^\\u0000'],
|
||||
// Node.js builtins prefixed with `node:`.
|
||||
['^node:'],
|
||||
// Packages.
|
||||
// Things that start with a letter (or digit or underscore), or `@` followed by a letter.
|
||||
// React/React Native prioritized, followed by expo
|
||||
// Followed by all packages excluding unprefixed relative ones
|
||||
[
|
||||
'^(react\\/(.*)$)|^(react$)|^(react-native(.*)$)',
|
||||
'^(expo(.*)$)|^(expo$)',
|
||||
'^(?!(?:alf|components|lib|locale|logger|platform|screens|state|view)(?:$|\\/))@?\\w',
|
||||
],
|
||||
// Relative imports.
|
||||
// Ideally, anything that starts with a dot or #
|
||||
// due to unprefixed relative imports being used, we whitelist the relative paths we use
|
||||
// (?:$|\\/) matches end of string or /
|
||||
[
|
||||
'^(?:#\\/)?(?:lib|state|logger|platform|locale)(?:$|\\/)',
|
||||
'^(?:#\\/)?view(?:$|\\/)',
|
||||
'^(?:#\\/)?screens(?:$|\\/)',
|
||||
'^(?:#\\/)?alf(?:$|\\/)',
|
||||
'^(?:#\\/)?components(?:$|\\/)',
|
||||
'^#\\/',
|
||||
'^\\.',
|
||||
],
|
||||
// anything else - hopefully we don't have any of these
|
||||
['^'],
|
||||
],
|
||||
},
|
||||
],
|
||||
'simple-import-sort/exports': 'error',
|
||||
|
||||
/**
|
||||
* Import linting
|
||||
*/
|
||||
'import-x/consistent-type-specifier-style': ['warn', 'prefer-inline'],
|
||||
'import-x/no-unresolved': ['error', {
|
||||
/*
|
||||
* The `postinstall` hook runs `compile-if-needed` locally, but not in
|
||||
* CI. For CI-sake, ignore this.
|
||||
*/
|
||||
ignore: ['^#\/locale\/locales\/.+\/messages'],
|
||||
}],
|
||||
|
||||
/**
|
||||
* TypeScript-specific rules
|
||||
*/
|
||||
'no-unused-vars': 'off', // off, we use TS-specific rule below
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_.+',
|
||||
caughtErrors: 'none',
|
||||
ignoreRestSiblings: true,
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'warn',
|
||||
{prefer: 'type-imports', fixStyle: 'inline-type-imports'},
|
||||
],
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
'@typescript-eslint/no-unused-expressions': ['error', {
|
||||
allowTernary: true,
|
||||
}],
|
||||
/**
|
||||
* Maintain previous behavior - these are stricter in typescript-eslint
|
||||
* v8 `warn` ones are probably worth fixing. `off` ones are a bit too
|
||||
* nit-picky
|
||||
*/
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
'@typescript-eslint/no-empty-object-type': 'off',
|
||||
'@typescript-eslint/no-unsafe-function-type': 'off',
|
||||
'@typescript-eslint/no-unsafe-assignment': 'off',
|
||||
'@typescript-eslint/unbound-method': 'off',
|
||||
'@typescript-eslint/no-unsafe-argument': 'off',
|
||||
'@typescript-eslint/no-unsafe-return': 'off',
|
||||
'@typescript-eslint/no-unsafe-member-access': 'warn',
|
||||
'@typescript-eslint/no-unsafe-call': 'warn',
|
||||
'@typescript-eslint/no-floating-promises': 'warn',
|
||||
'@typescript-eslint/no-misused-promises': 'warn',
|
||||
'@typescript-eslint/require-await': 'warn',
|
||||
'@typescript-eslint/no-unsafe-enum-comparison': 'warn',
|
||||
'@typescript-eslint/no-unnecessary-type-assertion': 'warn',
|
||||
'@typescript-eslint/no-redundant-type-constituents': 'warn',
|
||||
'@typescript-eslint/no-duplicate-type-constituents': 'warn',
|
||||
'@typescript-eslint/no-base-to-string': 'warn',
|
||||
'@typescript-eslint/prefer-promise-reject-errors': 'warn',
|
||||
'@typescript-eslint/await-thenable': 'warn',
|
||||
|
||||
/**
|
||||
* Turn off rules that we haven't enforced thus far
|
||||
*/
|
||||
'no-empty-pattern': 'off',
|
||||
'no-async-promise-executor': 'off',
|
||||
'no-constant-binary-expression': 'warn',
|
||||
'prefer-const': 'off',
|
||||
'no-empty': 'off',
|
||||
'no-unsafe-optional-chaining': 'off',
|
||||
'no-prototype-builtins': 'off',
|
||||
'no-var': 'off',
|
||||
'prefer-rest-params': 'off',
|
||||
'no-case-declarations': 'off',
|
||||
'no-irregular-whitespace': 'off',
|
||||
'no-useless-escape': 'off',
|
||||
'no-sparse-arrays': 'off',
|
||||
'no-fallthrough': 'off',
|
||||
'no-control-regex': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Test files configuration
|
||||
*/
|
||||
{
|
||||
files: ['**/__tests__/**/*.{js,jsx,ts,tsx}', '**/*.test.{js,jsx,ts,tsx}'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.jest,
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -1,14 +1,17 @@
|
||||
const {RuleTester} = require('eslint')
|
||||
const tseslint = require('typescript-eslint')
|
||||
const avoidUnwrappedText = require('../avoid-unwrapped-text')
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
parser: require.resolve('@typescript-eslint/parser'),
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
languageOptions: {
|
||||
parser: tseslint.parser,
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
},
|
||||
ecmaVersion: 6,
|
||||
sourceType: 'module',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -773,15 +776,6 @@ function MyText({ foo }) {
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
<Trans>{'foo'}</Trans>
|
||||
</View>
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View prop={
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
const {RuleTester} = require('eslint')
|
||||
const tseslint = require('typescript-eslint')
|
||||
const linguiMsgRule = require('../lingui-msg-rule')
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
languageOptions: {
|
||||
parser: tseslint.parser,
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
describe('lingui-msg-rule', () => {
|
||||
const tests = {
|
||||
valid: [
|
||||
// msg template literal
|
||||
{
|
||||
code: `
|
||||
const {_} = useLingui()
|
||||
const x = _(msg\`Hello\`)
|
||||
`,
|
||||
},
|
||||
// msg template literal with interpolation
|
||||
{
|
||||
code: `
|
||||
const {_} = useLingui()
|
||||
const name = 'World'
|
||||
const x = _(msg\`Hello \${name}\`)
|
||||
`,
|
||||
},
|
||||
// plural macro
|
||||
{
|
||||
code: `
|
||||
const {_} = useLingui()
|
||||
const count = 5
|
||||
const x = _(plural(count, {one: '# item', other: '# items'}))
|
||||
`,
|
||||
},
|
||||
// select macro
|
||||
{
|
||||
code: `
|
||||
const {_} = useLingui()
|
||||
const gender = 'female'
|
||||
const x = _(select(gender, {male: 'He', female: 'She', other: 'They'}))
|
||||
`,
|
||||
},
|
||||
// selectOrdinal macro
|
||||
{
|
||||
code: `
|
||||
const {_} = useLingui()
|
||||
const position = 1
|
||||
const x = _(selectOrdinal(position, {one: '#st', two: '#nd', few: '#rd', other: '#th'}))
|
||||
`,
|
||||
},
|
||||
// msg function call with object (descriptor form)
|
||||
{
|
||||
code: `
|
||||
const {_} = useLingui()
|
||||
const x = _(msg({message: 'Hello'}))
|
||||
`,
|
||||
},
|
||||
// msg function call with object and context
|
||||
{
|
||||
code: `
|
||||
const {_} = useLingui()
|
||||
const x = _(msg({message: 'Hello', context: 'greeting'}))
|
||||
`,
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
// Plain string literal (single quotes) - with auto-fix
|
||||
{
|
||||
code: `
|
||||
const {_} = useLingui()
|
||||
const x = _('Bad')
|
||||
`,
|
||||
output: `
|
||||
const {_} = useLingui()
|
||||
const x = _(msg\`Bad\`)
|
||||
`,
|
||||
errors: [{messageId: 'missingMsg'}],
|
||||
},
|
||||
// Plain string literal (double quotes) - with auto-fix
|
||||
{
|
||||
code: `
|
||||
const {_} = useLingui()
|
||||
const x = _("Bad")
|
||||
`,
|
||||
output: `
|
||||
const {_} = useLingui()
|
||||
const x = _(msg\`Bad\`)
|
||||
`,
|
||||
errors: [{messageId: 'missingMsg'}],
|
||||
},
|
||||
// Template literal without msg tag - with auto-fix
|
||||
{
|
||||
code: `
|
||||
const {_} = useLingui()
|
||||
const x = _(\`Bad\`)
|
||||
`,
|
||||
output: `
|
||||
const {_} = useLingui()
|
||||
const x = _(msg\`Bad\`)
|
||||
`,
|
||||
errors: [{messageId: 'missingMsg'}],
|
||||
},
|
||||
// Template literal with interpolation - with auto-fix
|
||||
{
|
||||
code: `
|
||||
const {_} = useLingui()
|
||||
const name = 'World'
|
||||
const x = _(\`Hello \${name}\`)
|
||||
`,
|
||||
output: `
|
||||
const {_} = useLingui()
|
||||
const name = 'World'
|
||||
const x = _(msg\`Hello \${name}\`)
|
||||
`,
|
||||
errors: [{messageId: 'missingMsg'}],
|
||||
},
|
||||
// String with backticks that need escaping
|
||||
{
|
||||
code: `
|
||||
const {_} = useLingui()
|
||||
const x = _('Use \\\`code\\\` here')
|
||||
`,
|
||||
output: `
|
||||
const {_} = useLingui()
|
||||
const x = _(msg\`Use \\\`code\\\` here\`)
|
||||
`,
|
||||
errors: [{messageId: 'missingMsg'}],
|
||||
},
|
||||
// Variable/identifier - no auto-fix possible
|
||||
{
|
||||
code: `
|
||||
const {_} = useLingui()
|
||||
const message = 'Hello'
|
||||
const x = _(message)
|
||||
`,
|
||||
output: null,
|
||||
errors: [{messageId: 'missingMsg'}],
|
||||
},
|
||||
// Arbitrary function call - no auto-fix possible
|
||||
{
|
||||
code: `
|
||||
const {_} = useLingui()
|
||||
const x = _(getMessage())
|
||||
`,
|
||||
output: null,
|
||||
errors: [{messageId: 'missingMsg'}],
|
||||
},
|
||||
// Empty call - no auto-fix possible
|
||||
{
|
||||
code: `
|
||||
const {_} = useLingui()
|
||||
const x = _()
|
||||
`,
|
||||
output: null,
|
||||
errors: [{messageId: 'missingMsg'}],
|
||||
},
|
||||
// Tagged template with wrong tag - no auto-fix (would need to replace tag)
|
||||
{
|
||||
code: `
|
||||
const {_} = useLingui()
|
||||
const x = _(html\`Hello\`)
|
||||
`,
|
||||
output: null,
|
||||
errors: [{messageId: 'missingMsg'}],
|
||||
},
|
||||
// Number literal - no auto-fix possible
|
||||
{
|
||||
code: `
|
||||
const {_} = useLingui()
|
||||
const x = _(123)
|
||||
`,
|
||||
output: null,
|
||||
errors: [{messageId: 'missingMsg'}],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
ruleTester.run('lingui-msg-rule', linguiMsgRule, tests)
|
||||
})
|
||||
@@ -29,303 +29,332 @@ function getTagName(node) {
|
||||
return reversedIdentifiers.reverse().join('.')
|
||||
}
|
||||
|
||||
exports.create = function create(context) {
|
||||
const options = context.options[0] || {}
|
||||
const impliedTextProps = options.impliedTextProps ?? []
|
||||
const impliedTextComponents = options.impliedTextComponents ?? []
|
||||
const suggestedTextWrappers = options.suggestedTextWrappers ?? {}
|
||||
const textProps = [...impliedTextProps]
|
||||
const textComponents = ['Text', ...impliedTextComponents]
|
||||
/** @type {import('eslint').Rule.RuleModule} */
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Enforce text strings are wrapped in <Text> components',
|
||||
},
|
||||
schema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
impliedTextComponents: {
|
||||
type: 'array',
|
||||
items: {type: 'string'},
|
||||
},
|
||||
impliedTextProps: {
|
||||
type: 'array',
|
||||
items: {type: 'string'},
|
||||
},
|
||||
suggestedTextWrappers: {
|
||||
type: 'object',
|
||||
additionalProperties: {type: 'string'},
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
create(context) {
|
||||
const options = context.options[0] || {}
|
||||
const impliedTextProps = options.impliedTextProps ?? []
|
||||
const impliedTextComponents = options.impliedTextComponents ?? []
|
||||
const suggestedTextWrappers = options.suggestedTextWrappers ?? {}
|
||||
const textProps = [...impliedTextProps]
|
||||
const textComponents = ['Text', ...impliedTextComponents]
|
||||
|
||||
function isTextComponent(tagName) {
|
||||
return textComponents.includes(tagName) || tagName.endsWith('Text')
|
||||
}
|
||||
function isTextComponent(tagName) {
|
||||
return textComponents.includes(tagName) || tagName.endsWith('Text')
|
||||
}
|
||||
|
||||
return {
|
||||
JSXText(node) {
|
||||
if (typeof node.value !== 'string' || hasOnlyLineBreak(node.value)) {
|
||||
return
|
||||
}
|
||||
let parent = node.parent
|
||||
while (parent) {
|
||||
if (parent.type === 'JSXElement') {
|
||||
const tagName = getTagName(parent)
|
||||
if (isTextComponent(tagName)) {
|
||||
// We're good.
|
||||
return
|
||||
}
|
||||
if (tagName === 'Trans') {
|
||||
// Exit and rely on the traversal for <Trans> JSXElement (code below).
|
||||
// TODO: Maybe validate that it's present.
|
||||
return
|
||||
}
|
||||
const suggestedWrapper = suggestedTextWrappers[tagName]
|
||||
let message = `Wrap this string in <${suggestedWrapper ?? 'Text'}>.`
|
||||
if (tagName !== 'View' && !suggestedWrapper) {
|
||||
message +=
|
||||
' If <' +
|
||||
tagName +
|
||||
'> is guaranteed to render <Text>, ' +
|
||||
'rename it to <' +
|
||||
tagName +
|
||||
'Text> or add it to impliedTextComponents.'
|
||||
}
|
||||
context.report({
|
||||
node,
|
||||
message,
|
||||
})
|
||||
return {
|
||||
JSXText(node) {
|
||||
if (typeof node.value !== 'string' || hasOnlyLineBreak(node.value)) {
|
||||
return
|
||||
}
|
||||
let parent = node.parent
|
||||
while (parent) {
|
||||
if (parent.type === 'JSXElement') {
|
||||
const tagName = getTagName(parent)
|
||||
if (isTextComponent(tagName)) {
|
||||
// We're good.
|
||||
return
|
||||
}
|
||||
if (tagName === 'Trans') {
|
||||
// Exit and rely on the traversal for <Trans> JSXElement (code below).
|
||||
// TODO: Maybe validate that it's present.
|
||||
return
|
||||
}
|
||||
const suggestedWrapper = suggestedTextWrappers[tagName]
|
||||
let message = `Wrap this string in <${suggestedWrapper ?? 'Text'}>.`
|
||||
if (tagName !== 'View' && !suggestedWrapper) {
|
||||
message +=
|
||||
' If <' +
|
||||
tagName +
|
||||
'> is guaranteed to render <Text>, ' +
|
||||
'rename it to <' +
|
||||
tagName +
|
||||
'Text> or add it to impliedTextComponents.'
|
||||
}
|
||||
context.report({
|
||||
node,
|
||||
message,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
parent.type === 'JSXAttribute' &&
|
||||
parent.name.type === 'JSXIdentifier' &&
|
||||
parent.parent.type === 'JSXOpeningElement' &&
|
||||
parent.parent.parent.type === 'JSXElement'
|
||||
) {
|
||||
const tagName = getTagName(parent.parent.parent)
|
||||
const propName = parent.name.name
|
||||
if (
|
||||
textProps.includes(tagName + ' ' + propName) ||
|
||||
propName === 'text' ||
|
||||
propName.endsWith('Text')
|
||||
parent.type === 'JSXAttribute' &&
|
||||
parent.name.type === 'JSXIdentifier' &&
|
||||
parent.parent.type === 'JSXOpeningElement' &&
|
||||
parent.parent.parent.type === 'JSXElement'
|
||||
) {
|
||||
// We're good.
|
||||
const tagName = getTagName(parent.parent.parent)
|
||||
const propName = parent.name.name
|
||||
if (
|
||||
textProps.includes(tagName + ' ' + propName) ||
|
||||
propName === 'text' ||
|
||||
propName.endsWith('Text')
|
||||
) {
|
||||
// We're good.
|
||||
return
|
||||
}
|
||||
const message =
|
||||
'Wrap this string in <Text>.' +
|
||||
' If `' +
|
||||
propName +
|
||||
'` is guaranteed to be wrapped in <Text>, ' +
|
||||
'rename it to `' +
|
||||
propName +
|
||||
'Text' +
|
||||
'` or add it to impliedTextProps.'
|
||||
context.report({
|
||||
node,
|
||||
message,
|
||||
})
|
||||
return
|
||||
}
|
||||
const message =
|
||||
'Wrap this string in <Text>.' +
|
||||
' If `' +
|
||||
propName +
|
||||
'` is guaranteed to be wrapped in <Text>, ' +
|
||||
'rename it to `' +
|
||||
propName +
|
||||
'Text' +
|
||||
'` or add it to impliedTextProps.'
|
||||
context.report({
|
||||
node,
|
||||
message,
|
||||
})
|
||||
|
||||
parent = parent.parent
|
||||
continue
|
||||
}
|
||||
},
|
||||
Literal(node) {
|
||||
if (typeof node.value !== 'string' && typeof node.value !== 'number') {
|
||||
return
|
||||
}
|
||||
|
||||
parent = parent.parent
|
||||
continue
|
||||
}
|
||||
},
|
||||
Literal(node) {
|
||||
if (typeof node.value !== 'string' && typeof node.value !== 'number') {
|
||||
return
|
||||
}
|
||||
let parent = node.parent
|
||||
while (parent) {
|
||||
if (parent.type === 'JSXElement') {
|
||||
const tagName = getTagName(parent)
|
||||
if (isTextComponent(tagName)) {
|
||||
// We're good.
|
||||
let parent = node.parent
|
||||
while (parent) {
|
||||
if (parent.type === 'JSXElement') {
|
||||
const tagName = getTagName(parent)
|
||||
if (isTextComponent(tagName)) {
|
||||
// We're good.
|
||||
return
|
||||
}
|
||||
if (tagName === 'Trans') {
|
||||
// Exit and rely on the traversal for <Trans> JSXElement (code below).
|
||||
// TODO: Maybe validate that it's present.
|
||||
return
|
||||
}
|
||||
const suggestedWrapper = suggestedTextWrappers[tagName]
|
||||
let message = `Wrap this string in <${suggestedWrapper ?? 'Text'}>.`
|
||||
if (tagName !== 'View' && !suggestedWrapper) {
|
||||
message +=
|
||||
' If <' +
|
||||
tagName +
|
||||
'> is guaranteed to render <Text>, ' +
|
||||
'rename it to <' +
|
||||
tagName +
|
||||
'Text> or add it to impliedTextComponents.'
|
||||
}
|
||||
context.report({
|
||||
node,
|
||||
message,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (tagName === 'Trans') {
|
||||
// Exit and rely on the traversal for <Trans> JSXElement (code below).
|
||||
// TODO: Maybe validate that it's present.
|
||||
return
|
||||
}
|
||||
const suggestedWrapper = suggestedTextWrappers[tagName]
|
||||
let message = `Wrap this string in <${suggestedWrapper ?? 'Text'}>.`
|
||||
if (tagName !== 'View' && !suggestedWrapper) {
|
||||
message +=
|
||||
' If <' +
|
||||
tagName +
|
||||
'> is guaranteed to render <Text>, ' +
|
||||
'rename it to <' +
|
||||
tagName +
|
||||
'Text> or add it to impliedTextComponents.'
|
||||
}
|
||||
context.report({
|
||||
node,
|
||||
message,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (parent.type === 'BinaryExpression' && parent.operator === '+') {
|
||||
parent = parent.parent
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
parent.type === 'JSXExpressionContainer' ||
|
||||
parent.type === 'LogicalExpression'
|
||||
) {
|
||||
parent = parent.parent
|
||||
continue
|
||||
}
|
||||
|
||||
// Be conservative for other types.
|
||||
return
|
||||
}
|
||||
},
|
||||
TemplateLiteral(node) {
|
||||
let parent = node.parent
|
||||
while (parent) {
|
||||
if (parent.type === 'JSXElement') {
|
||||
const tagName = getTagName(parent)
|
||||
if (isTextComponent(tagName)) {
|
||||
// We're good.
|
||||
return
|
||||
if (parent.type === 'BinaryExpression' && parent.operator === '+') {
|
||||
parent = parent.parent
|
||||
continue
|
||||
}
|
||||
if (tagName === 'Trans') {
|
||||
// Exit and rely on the traversal for <Trans> JSXElement (code below).
|
||||
// TODO: Maybe validate that it's present.
|
||||
return
|
||||
}
|
||||
const suggestedWrapper = suggestedTextWrappers[tagName]
|
||||
let message = `Wrap this string in <${suggestedWrapper ?? 'Text'}>.`
|
||||
if (tagName !== 'View' && !suggestedWrapper) {
|
||||
message +=
|
||||
' If <' +
|
||||
tagName +
|
||||
'> is guaranteed to render <Text>, ' +
|
||||
'rename it to <' +
|
||||
tagName +
|
||||
'Text> or add it to impliedTextComponents.'
|
||||
}
|
||||
context.report({
|
||||
node,
|
||||
message,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
parent.type === 'CallExpression' &&
|
||||
parent.callee.type === 'Identifier' &&
|
||||
parent.callee.name === '_'
|
||||
) {
|
||||
// This is a user-facing string, keep going up.
|
||||
parent = parent.parent
|
||||
continue
|
||||
}
|
||||
|
||||
if (parent.type === 'BinaryExpression' && parent.operator === '+') {
|
||||
parent = parent.parent
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
parent.type === 'JSXExpressionContainer' ||
|
||||
parent.type === 'LogicalExpression' ||
|
||||
parent.type === 'TaggedTemplateExpression'
|
||||
) {
|
||||
parent = parent.parent
|
||||
continue
|
||||
}
|
||||
|
||||
// Be conservative for other types.
|
||||
return
|
||||
}
|
||||
},
|
||||
JSXElement(node) {
|
||||
if (getTagName(node) !== 'Trans') {
|
||||
return
|
||||
}
|
||||
let parent = node.parent
|
||||
while (parent) {
|
||||
if (parent.type === 'JSXElement') {
|
||||
const tagName = getTagName(parent)
|
||||
if (isTextComponent(tagName)) {
|
||||
// We're good.
|
||||
return
|
||||
}
|
||||
if (tagName === 'Trans') {
|
||||
// Exit and rely on the traversal for this JSXElement.
|
||||
// TODO: Should nested <Trans> even be allowed?
|
||||
return
|
||||
}
|
||||
const suggestedWrapper = suggestedTextWrappers[tagName]
|
||||
let message = `Wrap this <Trans> in <${suggestedWrapper ?? 'Text'}>.`
|
||||
if (tagName !== 'View' && !suggestedWrapper) {
|
||||
message +=
|
||||
' If <' +
|
||||
tagName +
|
||||
'> is guaranteed to render <Text>, ' +
|
||||
'rename it to <' +
|
||||
tagName +
|
||||
'Text> or add it to impliedTextComponents.'
|
||||
}
|
||||
context.report({
|
||||
node,
|
||||
message,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
parent.type === 'JSXAttribute' &&
|
||||
parent.name.type === 'JSXIdentifier' &&
|
||||
parent.parent.type === 'JSXOpeningElement' &&
|
||||
parent.parent.parent.type === 'JSXElement'
|
||||
) {
|
||||
const tagName = getTagName(parent.parent.parent)
|
||||
const propName = parent.name.name
|
||||
if (
|
||||
textProps.includes(tagName + ' ' + propName) ||
|
||||
propName === 'text' ||
|
||||
propName.endsWith('Text')
|
||||
parent.type === 'JSXExpressionContainer' ||
|
||||
parent.type === 'LogicalExpression'
|
||||
) {
|
||||
// We're good.
|
||||
return
|
||||
parent = parent.parent
|
||||
continue
|
||||
}
|
||||
const message =
|
||||
'Wrap this <Trans> in <Text>.' +
|
||||
' If `' +
|
||||
propName +
|
||||
'` is guaranteed to be wrapped in <Text>, ' +
|
||||
'rename it to `' +
|
||||
propName +
|
||||
'Text' +
|
||||
'` or add it to impliedTextProps.'
|
||||
context.report({
|
||||
node,
|
||||
message,
|
||||
})
|
||||
|
||||
// Be conservative for other types.
|
||||
return
|
||||
}
|
||||
},
|
||||
TemplateLiteral(node) {
|
||||
let parent = node.parent
|
||||
while (parent) {
|
||||
if (parent.type === 'JSXElement') {
|
||||
const tagName = getTagName(parent)
|
||||
if (isTextComponent(tagName)) {
|
||||
// We're good.
|
||||
return
|
||||
}
|
||||
if (tagName === 'Trans') {
|
||||
// Exit and rely on the traversal for <Trans> JSXElement (code below).
|
||||
// TODO: Maybe validate that it's present.
|
||||
return
|
||||
}
|
||||
const suggestedWrapper = suggestedTextWrappers[tagName]
|
||||
let message = `Wrap this string in <${suggestedWrapper ?? 'Text'}>.`
|
||||
if (tagName !== 'View' && !suggestedWrapper) {
|
||||
message +=
|
||||
' If <' +
|
||||
tagName +
|
||||
'> is guaranteed to render <Text>, ' +
|
||||
'rename it to <' +
|
||||
tagName +
|
||||
'Text> or add it to impliedTextComponents.'
|
||||
}
|
||||
context.report({
|
||||
node,
|
||||
message,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
parent = parent.parent
|
||||
continue
|
||||
}
|
||||
},
|
||||
ReturnStatement(node) {
|
||||
let fnScope = context.getScope()
|
||||
while (fnScope && fnScope.type !== 'function') {
|
||||
fnScope = fnScope.upper
|
||||
}
|
||||
if (!fnScope) {
|
||||
return
|
||||
}
|
||||
const fn = fnScope.block
|
||||
if (!fn.id || fn.id.type !== 'Identifier' || !fn.id.name) {
|
||||
return
|
||||
}
|
||||
if (!/^[A-Z]\w*Text$/.test(fn.id.name)) {
|
||||
return
|
||||
}
|
||||
if (!node.argument || node.argument.type !== 'JSXElement') {
|
||||
return
|
||||
}
|
||||
const openingEl = node.argument.openingElement
|
||||
if (openingEl.name.type !== 'JSXIdentifier') {
|
||||
return
|
||||
}
|
||||
const returnedComponentName = openingEl.name.name
|
||||
if (!isTextComponent(returnedComponentName)) {
|
||||
context.report({
|
||||
node,
|
||||
message:
|
||||
'Components ending with *Text must return <Text> or <SomeText>.',
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
if (
|
||||
parent.type === 'CallExpression' &&
|
||||
parent.callee.type === 'Identifier' &&
|
||||
parent.callee.name === '_'
|
||||
) {
|
||||
// This is a user-facing string, keep going up.
|
||||
parent = parent.parent
|
||||
continue
|
||||
}
|
||||
|
||||
if (parent.type === 'BinaryExpression' && parent.operator === '+') {
|
||||
parent = parent.parent
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
parent.type === 'JSXExpressionContainer' ||
|
||||
parent.type === 'LogicalExpression' ||
|
||||
parent.type === 'TaggedTemplateExpression'
|
||||
) {
|
||||
parent = parent.parent
|
||||
continue
|
||||
}
|
||||
|
||||
// Be conservative for other types.
|
||||
return
|
||||
}
|
||||
},
|
||||
JSXElement(node) {
|
||||
if (getTagName(node) !== 'Trans') {
|
||||
return
|
||||
}
|
||||
let parent = node.parent
|
||||
while (parent) {
|
||||
if (parent.type === 'JSXElement') {
|
||||
const tagName = getTagName(parent)
|
||||
if (isTextComponent(tagName)) {
|
||||
// We're good.
|
||||
return
|
||||
}
|
||||
if (tagName === 'Trans') {
|
||||
// Exit and rely on the traversal for this JSXElement.
|
||||
// TODO: Should nested <Trans> even be allowed?
|
||||
return
|
||||
}
|
||||
const suggestedWrapper = suggestedTextWrappers[tagName]
|
||||
let message = `Wrap this <Trans> in <${suggestedWrapper ?? 'Text'}>.`
|
||||
if (tagName !== 'View' && !suggestedWrapper) {
|
||||
message +=
|
||||
' If <' +
|
||||
tagName +
|
||||
'> is guaranteed to render <Text>, ' +
|
||||
'rename it to <' +
|
||||
tagName +
|
||||
'Text> or add it to impliedTextComponents.'
|
||||
}
|
||||
context.report({
|
||||
node,
|
||||
message,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
parent.type === 'JSXAttribute' &&
|
||||
parent.name.type === 'JSXIdentifier' &&
|
||||
parent.parent.type === 'JSXOpeningElement' &&
|
||||
parent.parent.parent.type === 'JSXElement'
|
||||
) {
|
||||
const tagName = getTagName(parent.parent.parent)
|
||||
const propName = parent.name.name
|
||||
if (
|
||||
textProps.includes(tagName + ' ' + propName) ||
|
||||
propName === 'text' ||
|
||||
propName.endsWith('Text')
|
||||
) {
|
||||
// We're good.
|
||||
return
|
||||
}
|
||||
const message =
|
||||
'Wrap this <Trans> in <Text>.' +
|
||||
' If `' +
|
||||
propName +
|
||||
'` is guaranteed to be wrapped in <Text>, ' +
|
||||
'rename it to `' +
|
||||
propName +
|
||||
'Text' +
|
||||
'` or add it to impliedTextProps.'
|
||||
context.report({
|
||||
node,
|
||||
message,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
parent = parent.parent
|
||||
continue
|
||||
}
|
||||
},
|
||||
ReturnStatement(node) {
|
||||
let fnScope = context.sourceCode.getScope(node)
|
||||
while (fnScope && fnScope.type !== 'function') {
|
||||
fnScope = fnScope.upper
|
||||
}
|
||||
if (!fnScope) {
|
||||
return
|
||||
}
|
||||
const fn = fnScope.block
|
||||
if (!fn.id || fn.id.type !== 'Identifier' || !fn.id.name) {
|
||||
return
|
||||
}
|
||||
if (!/^[A-Z]\w*Text$/.test(fn.id.name)) {
|
||||
return
|
||||
}
|
||||
if (!node.argument || node.argument.type !== 'JSXElement') {
|
||||
return
|
||||
}
|
||||
const openingEl = node.argument.openingElement
|
||||
if (openingEl.name.type !== 'JSXIdentifier') {
|
||||
return
|
||||
}
|
||||
const returnedComponentName = openingEl.name.name
|
||||
if (!isTextComponent(returnedComponentName)) {
|
||||
context.report({
|
||||
node,
|
||||
message:
|
||||
'Components ending with *Text must return <Text> or <SomeText>.',
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
'use strict'
|
||||
|
||||
module.exports = {
|
||||
const plugin = {
|
||||
meta: {
|
||||
name: 'eslint-plugin-bsky-internal',
|
||||
version: '1.0.0',
|
||||
},
|
||||
rules: {
|
||||
'avoid-unwrapped-text': require('./avoid-unwrapped-text'),
|
||||
'use-exact-imports': require('./use-exact-imports'),
|
||||
'use-typed-gates': require('./use-typed-gates'),
|
||||
'use-prefixed-imports': require('./use-prefixed-imports'),
|
||||
'lingui-msg-rule': require('./lingui-msg-rule'),
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = plugin
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* @type {import('eslint').Rule.RuleModule}
|
||||
*/
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'Enforce that Lingui _() function is called with msg`` template literal or plural/select macros',
|
||||
recommended: true,
|
||||
},
|
||||
fixable: 'code',
|
||||
messages: {
|
||||
missingMsg:
|
||||
'Lingui _() must be called with msg`...` or msg({...}) or plural/select/selectOrdinal. Example: _(msg`Hello`)',
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
|
||||
create(context) {
|
||||
// Valid Lingui macro functions that can be passed to _()
|
||||
const VALID_MACRO_FUNCTIONS = new Set([
|
||||
'msg',
|
||||
'plural',
|
||||
'select',
|
||||
'selectOrdinal',
|
||||
])
|
||||
|
||||
/**
|
||||
* Escape backticks and backslashes for template literal
|
||||
*/
|
||||
function escapeForTemplateLiteral(str) {
|
||||
return str.replace(/\\`/g, '`').replace(/`/g, '\\`')
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to get a fixer for the given argument
|
||||
* Returns null if we can't safely fix it
|
||||
*/
|
||||
function getFixer(firstArg) {
|
||||
const sourceCode = context.sourceCode ?? context.getSourceCode()
|
||||
|
||||
// Fix string literals: _('foo') -> _(msg`foo`)
|
||||
if (firstArg.type === 'Literal' && typeof firstArg.value === 'string') {
|
||||
const escaped = escapeForTemplateLiteral(firstArg.value)
|
||||
return function (fixer) {
|
||||
return fixer.replaceText(firstArg, 'msg`' + escaped + '`')
|
||||
}
|
||||
}
|
||||
|
||||
// Fix untagged template literals: _(`foo`) -> _(msg`foo`)
|
||||
if (firstArg.type === 'TemplateLiteral') {
|
||||
const text = sourceCode.getText(firstArg)
|
||||
return function (fixer) {
|
||||
return fixer.replaceText(firstArg, 'msg' + text)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
CallExpression(node) {
|
||||
// Check if this is a call to _()
|
||||
if (node.callee.type !== 'Identifier' || node.callee.name !== '_') {
|
||||
return
|
||||
}
|
||||
|
||||
// Must have at least one argument
|
||||
if (node.arguments.length === 0) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'missingMsg',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const firstArg = node.arguments[0]
|
||||
|
||||
// Valid: _(msg`...`)
|
||||
if (
|
||||
firstArg.type === 'TaggedTemplateExpression' &&
|
||||
firstArg.tag.type === 'Identifier' &&
|
||||
firstArg.tag.name === 'msg'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Valid: _(msg(...)), _(plural(...)), _(select(...)), _(selectOrdinal(...))
|
||||
if (
|
||||
firstArg.type === 'CallExpression' &&
|
||||
firstArg.callee.type === 'Identifier' &&
|
||||
VALID_MACRO_FUNCTIONS.has(firstArg.callee.name)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Everything else is invalid
|
||||
const fix = getFixer(firstArg)
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'missingMsg',
|
||||
fix,
|
||||
})
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -3,20 +3,30 @@ const BANNED_IMPORTS = [
|
||||
'@fortawesome/free-solid-svg-icons',
|
||||
]
|
||||
|
||||
exports.create = function create(context) {
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
const source = node.source
|
||||
if (typeof source.value !== 'string') {
|
||||
return
|
||||
}
|
||||
if (BANNED_IMPORTS.includes(source.value)) {
|
||||
context.report({
|
||||
node,
|
||||
message:
|
||||
'Import the specific thing you want instead of the entire package',
|
||||
})
|
||||
}
|
||||
/** @type {import('eslint').Rule.RuleModule} */
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'suggestion',
|
||||
docs: {
|
||||
description: 'Prevent importing entire icon packages',
|
||||
},
|
||||
}
|
||||
schema: [],
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
const source = node.source
|
||||
if (typeof source.value !== 'string') {
|
||||
return
|
||||
}
|
||||
if (BANNED_IMPORTS.includes(source.value)) {
|
||||
context.report({
|
||||
node,
|
||||
message:
|
||||
'Import the specific thing you want instead of the entire package',
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -10,10 +10,15 @@ const BANNED_IMPORT_PREFIXES = [
|
||||
'view/',
|
||||
]
|
||||
|
||||
/** @type {import('eslint').Rule.RuleModule} */
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'suggestion',
|
||||
docs: {
|
||||
description: 'Enforce using prefixed imports for internal paths',
|
||||
},
|
||||
fixable: 'code',
|
||||
schema: [],
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
exports.create = function create(context) {
|
||||
return {
|
||||
ImportSpecifier(node) {
|
||||
if (
|
||||
!node.local ||
|
||||
node.local.type !== 'Identifier' ||
|
||||
node.local.name !== 'useGate'
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
node.parent.type !== 'ImportDeclaration' ||
|
||||
!node.parent.source ||
|
||||
node.parent.source.type !== 'Literal'
|
||||
) {
|
||||
return
|
||||
}
|
||||
const source = node.parent.source.value
|
||||
if (source.startsWith('statsig') || source.startsWith('@statsig')) {
|
||||
context.report({
|
||||
node,
|
||||
message:
|
||||
"Use useGate() from '#/lib/statsig/statsig' instead of the one on npm.",
|
||||
})
|
||||
}
|
||||
// TODO: Verify gate() call results aren't stored in variables.
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
/* global jest */
|
||||
import 'react-native-gesture-handler/jestSetup'
|
||||
// IMPORTANT: this is what's used in the native runtime
|
||||
import 'react-native-url-polyfill/auto'
|
||||
|
||||
import {configure} from '@testing-library/react-native'
|
||||
|
||||
@@ -36,6 +34,7 @@ jest.mock('react-native-safe-area-context', () => {
|
||||
jest.mock('expo-file-system/legacy', () => ({
|
||||
getInfoAsync: jest.fn().mockResolvedValue({exists: true, size: 100}),
|
||||
deleteAsync: jest.fn(),
|
||||
moveAsync: jest.fn().mockResolvedValue(undefined),
|
||||
createDownloadResumable: jest.fn(),
|
||||
}))
|
||||
|
||||
@@ -45,6 +44,7 @@ jest.mock('expo-image-manipulator', () => ({
|
||||
}),
|
||||
SaveFormat: {
|
||||
JPEG: 'jpeg',
|
||||
WEBP: 'webp',
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -99,20 +99,9 @@ jest.mock('expo-modules-core', () => ({
|
||||
requireNativeViewManager: jest.fn().mockImplementation(_ => {
|
||||
return () => null
|
||||
}),
|
||||
createPermissionHook: () => () => [true],
|
||||
}))
|
||||
|
||||
jest.mock('expo-localization', () => ({
|
||||
getLocales: () => [],
|
||||
}))
|
||||
|
||||
jest.mock('statsig-react-native-expo', () => ({
|
||||
Statsig: {
|
||||
initialize() {},
|
||||
initializeCalled() {
|
||||
return false
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('../src/logger/bitdrift/lib', () => ({}))
|
||||
jest.mock('../src/lib/statsig/statsig', () => ({}))
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/** @type {import('@lingui/conf').LinguiConfig} */
|
||||
module.exports = {
|
||||
import {defineConfig} from '@lingui/cli'
|
||||
|
||||
export default defineConfig({
|
||||
sourceLocale: 'en',
|
||||
locales: [
|
||||
'en',
|
||||
'an',
|
||||
@@ -49,5 +51,5 @@ module.exports = {
|
||||
include: ['src'],
|
||||
},
|
||||
],
|
||||
format: 'po',
|
||||
}
|
||||
compileNamespace: 'ts',
|
||||
})
|
||||
@@ -44,6 +44,6 @@ android {
|
||||
|
||||
dependencies {
|
||||
implementation project(':expo-modules-core')
|
||||
implementation 'com.google.android.material:material:1.12.0'
|
||||
implementation 'com.google.android.material:material:1.13.0'
|
||||
implementation "com.facebook.react:react-native:+"
|
||||
}
|
||||
|
||||