Compare commits
142 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87d377a868 | |||
| c7f8da8f97 | |||
| 76cd645a40 | |||
| 124cfb57c7 | |||
| ac0c4ef2d9 | |||
| 917256713f | |||
| c26906e15c | |||
| 0c4b908020 | |||
| 457cd3d0fb | |||
| bc827dc97f | |||
| c129108b78 | |||
| 0b480bdaf8 | |||
| 535d4d6cf7 | |||
| 04b869714e | |||
| 9738f4858d | |||
| 0dafc2ccd6 | |||
| f8ae0540a0 | |||
| 625b4e61db | |||
| 0b02d9d9a7 | |||
| 07d4ff4e8c | |||
| 6c8e9fd8c9 | |||
| fd7b1292b8 | |||
| 135f37eae6 | |||
| 125191fc20 | |||
| 566e869802 | |||
| c99500f5d3 | |||
| bec0329774 | |||
| 670995a649 | |||
| b2ecaf9aa7 | |||
| 01c466a213 | |||
| 75f0ee8a1e | |||
| 5ce4aa5139 | |||
| acdc509630 | |||
| b2258fb6cb | |||
| 0bda727e8b | |||
| 3601933ff6 | |||
| 1c78f36a51 | |||
| d6dc52b6ea | |||
| 541502c7ee | |||
| 0114cfcfe1 | |||
| 6172423714 | |||
| 8a4398608a | |||
| 27c1058568 | |||
| 2f2b542e5c | |||
| 2a54781ce0 | |||
| cc4acae758 | |||
| eeeeda3495 | |||
| dcba5c4668 | |||
| 7469e7306c | |||
| 58c2e68a24 | |||
| 01c20958c1 | |||
| 99722436b1 | |||
| 4a1f1efd76 | |||
| bd730db03e | |||
| 8a22584a87 | |||
| 17cbf75747 | |||
| 8adebe6702 | |||
| 39d460db51 | |||
| eac0290143 | |||
| 349c70e413 | |||
| 203ccbb1fb | |||
| 0617fca5ef | |||
| a1ed59e0ee | |||
| 39c6984c6e | |||
| 9f7c920346 | |||
| 6ddc268b91 | |||
| 0517768cd3 | |||
| 0555d3623c | |||
| 8dcf1825ec | |||
| c12fd4482e | |||
| 88e6dff484 | |||
| 98d96bd28b | |||
| dd2830ba6c | |||
| 6a16ef83f6 | |||
| 8ec20026c0 | |||
| acd7211b35 | |||
| b70e5b2f38 | |||
| 5a074fa37a | |||
| df20ae237e | |||
| e91a683810 | |||
| bc8e3ba30b | |||
| e4c5fb7de5 | |||
| d1aab56982 | |||
| bb1eab41c6 | |||
| eb45154823 | |||
| 2b4d32bb09 | |||
| b667e8b985 | |||
| e8cd7ab131 | |||
| 685650b296 | |||
| f9118b2729 | |||
| 5c04c9e3e3 | |||
| 60a1be6de0 | |||
| f3905c574b | |||
| 27c591f031 | |||
| 7771b56a43 | |||
| 3810b29fb6 | |||
| 9d77b361fe | |||
| 54f7af12ca | |||
| ecb77a9b96 | |||
| 45825dd005 | |||
| 5e07b0c77d | |||
| dddea126f4 | |||
| ce2604d0e9 | |||
| c28195067f | |||
| 3b7b93a411 | |||
| 99d7570818 | |||
| 7b9bc52720 | |||
| f84a756691 | |||
| 78e1e26a2c | |||
| d8c904d1d9 | |||
| 560c503aa6 | |||
| 5120c037d1 | |||
| 184df9cbfe | |||
| 912ab1bd9b | |||
| f038ac70da | |||
| e404ddfbcd | |||
| 0baa5198e0 | |||
| 7a1adfe67c | |||
| af55e578b2 | |||
| 994003e603 | |||
| 8ab49da6da | |||
| c097f68a7d | |||
| b1ad229771 | |||
| eabcd9150d | |||
| 962654041c | |||
| d900d0b7a7 | |||
| 0474a3b6f7 | |||
| 16701392b9 | |||
| f7c05c2df4 | |||
| ef7c222e1b | |||
| 9835d0fb7b | |||
| bf6e01551e | |||
| 68e0a78f70 | |||
| e32f280f47 | |||
| f42b5831bb | |||
| ea7f984a40 | |||
| 4923786927 | |||
| 48baf7277d | |||
| 92ad887e38 | |||
| 122a46891a | |||
| 7182cd3d5e | |||
| cced762a7f |
@@ -16,6 +16,9 @@ EXPO_PUBLIC_LOG_LEVEL=debug
|
||||
# Enable debug logs for specific logger instances
|
||||
EXPO_PUBLIC_LOG_DEBUG=session
|
||||
|
||||
# Bluesky appview DID
|
||||
EXPO_PUBLIC_BLUESKY_PROXY_DID=
|
||||
|
||||
# Chat service DID
|
||||
EXPO_PUBLIC_CHAT_PROXY_DID=
|
||||
|
||||
@@ -30,3 +33,9 @@ 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=
|
||||
|
||||
# Dev-only passthrough value for bapp-config web worker
|
||||
BAPP_CONFIG_DEV_BYPASS_SECRET=
|
||||
|
||||
@@ -34,6 +34,7 @@ module.exports = {
|
||||
'P',
|
||||
'Admonition',
|
||||
'Admonition.Admonition',
|
||||
'Toast.Action',
|
||||
'AgeAssuranceAdmonition',
|
||||
'Span',
|
||||
],
|
||||
@@ -110,6 +111,8 @@ module.exports = {
|
||||
'bskyembed',
|
||||
'src/locale/locales/_build/',
|
||||
'src/locale/locales/**/*.js',
|
||||
'*.e2e.ts',
|
||||
'*.e2e.tsx',
|
||||
],
|
||||
settings: {
|
||||
componentWrapperFunctions: ['observer'],
|
||||
|
||||
@@ -84,10 +84,10 @@ jobs:
|
||||
repo: context.repo.repo,
|
||||
pull_number: ${{ github.event.issue.number }}
|
||||
});
|
||||
|
||||
|
||||
console.log(`PR HEAD SHA: ${pr.data.head.sha}`);
|
||||
console.log(`PR HEAD REF: ${pr.data.head.ref}`);
|
||||
|
||||
|
||||
core.setOutput('head-sha', pr.data.head.sha);
|
||||
core.setOutput('head-ref', pr.data.head.ref);
|
||||
|
||||
@@ -188,12 +188,14 @@ jobs:
|
||||
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 opening the deep link below in your browser:
|
||||
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>
|
||||
|
||||
`bluesky://intent/apply-ota?channel=pull-request-${{ github.event.issue.number }}`
|
||||
---
|
||||
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
|
||||
|
||||
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
|
||||
|
||||
- name: 💬 Drop a comment
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
|
||||
@@ -58,6 +58,7 @@ buck-out/
|
||||
# Ruby / CocoaPods
|
||||
/ios/Pods/
|
||||
/vendor/bundle/
|
||||
Gemfile.lock
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
|
||||
@@ -3,20 +3,26 @@ appId: xyz.blueskyweb.app
|
||||
- runScript:
|
||||
file: ../setupServer.js
|
||||
env:
|
||||
SERVER_PATH: ?users
|
||||
SERVER_PATH: ?users
|
||||
- runFlow:
|
||||
file: ../setupApp.yml
|
||||
- tapOn:
|
||||
id: "e2eSignInAlice"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "viewHeaderHomeFeedPrefsBtn"
|
||||
|
||||
# Post an image with the porn label
|
||||
- assertVisible:
|
||||
id: "composeFAB"
|
||||
id: "composeFAB"
|
||||
- tapOn:
|
||||
id: "composeFAB"
|
||||
- inputText: "Post with an image"
|
||||
- tapOn:
|
||||
id: "openGalleryBtn"
|
||||
id: "openMediaBtn"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "selectedPhotosView"
|
||||
- tapOn: "Content warnings"
|
||||
- tapOn: "Porn"
|
||||
- tapOn:
|
||||
|
||||
@@ -3,13 +3,17 @@ appId: xyz.blueskyweb.app
|
||||
- runScript:
|
||||
file: ../setupServer.js
|
||||
env:
|
||||
SERVER_PATH: ?users
|
||||
SERVER_PATH: ?users
|
||||
- runFlow:
|
||||
file: ../setupApp.yml
|
||||
- tapOn:
|
||||
id: "e2eSignInAlice"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "viewHeaderHomeFeedPrefsBtn"
|
||||
|
||||
- assertVisible:
|
||||
id: "composeFAB"
|
||||
id: "composeFAB"
|
||||
- tapOn:
|
||||
id: "composeFAB"
|
||||
- inputText: "Post text only"
|
||||
@@ -21,7 +25,10 @@ appId: xyz.blueskyweb.app
|
||||
id: "composeFAB"
|
||||
- inputText: "Post with an image"
|
||||
- tapOn:
|
||||
id: "openGalleryBtn"
|
||||
id: "openMediaBtn"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "selectedPhotosView"
|
||||
- tapOn:
|
||||
id: "composerPublishBtn"
|
||||
- assertVisible:
|
||||
@@ -46,7 +53,10 @@ appId: xyz.blueskyweb.app
|
||||
id: "replyBtn"
|
||||
- inputText: "Reply with an image"
|
||||
- tapOn:
|
||||
id: "openGalleryBtn"
|
||||
id: "openMediaBtn"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "selectedPhotosView"
|
||||
- tapOn:
|
||||
id: "composerPublishBtn"
|
||||
- assertVisible:
|
||||
@@ -73,7 +83,10 @@ appId: xyz.blueskyweb.app
|
||||
id: "quoteBtn"
|
||||
- inputText: "QP with an image"
|
||||
- tapOn:
|
||||
id: "openGalleryBtn"
|
||||
id: "openMediaBtn"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "selectedPhotosView"
|
||||
- tapOn:
|
||||
id: "composerPublishBtn"
|
||||
- assertVisible:
|
||||
|
||||
@@ -32,6 +32,9 @@ appId: xyz.blueskyweb.app
|
||||
text: "Not Now"
|
||||
optional: true
|
||||
- inputText: "e2e-test"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "handleAvailableCheck"
|
||||
- tapOn:
|
||||
id: "nextBtn"
|
||||
|
||||
- assertVisible: "Give your profile a face"
|
||||
|
||||
@@ -3,11 +3,14 @@ appId: xyz.blueskyweb.app
|
||||
- runScript:
|
||||
file: ../setupServer.js
|
||||
env:
|
||||
SERVER_PATH: "?users&follows&posts"
|
||||
SERVER_PATH: "?users&follows&posts"
|
||||
- runFlow:
|
||||
file: ../setupApp.yml
|
||||
- tapOn:
|
||||
id: "e2eSignInAlice"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "viewHeaderHomeFeedPrefsBtn"
|
||||
|
||||
- tapOn:
|
||||
label: "Create a curate list"
|
||||
@@ -75,9 +78,8 @@ appId: xyz.blueskyweb.app
|
||||
- tapOn:
|
||||
id: "confirmBtn"
|
||||
|
||||
- tapOn:
|
||||
label: "Create a new curatelist"
|
||||
id: "e2eGotoLists"
|
||||
- assertVisible:
|
||||
id: "newUserListBtn"
|
||||
- tapOn:
|
||||
id: "newUserListBtn"
|
||||
- assertVisible:
|
||||
@@ -146,7 +148,7 @@ appId: xyz.blueskyweb.app
|
||||
|
||||
- tapOn:
|
||||
id: "bottomBarSearchBtn"
|
||||
- tapOn: "Search for posts, users, or feeds"
|
||||
- tapOn: "Search for posts, users[,]? or feeds"
|
||||
- inputText: "bob"
|
||||
- tapOn:
|
||||
id: "searchAutoCompleteResult-bob.test"
|
||||
|
||||
@@ -3,11 +3,14 @@ appId: xyz.blueskyweb.app
|
||||
- runScript:
|
||||
file: ../setupServer.js
|
||||
env:
|
||||
SERVER_PATH: "?users&follows&labels"
|
||||
SERVER_PATH: "?users&follows&labels"
|
||||
- runFlow:
|
||||
file: ../setupApp.yml
|
||||
- tapOn:
|
||||
id: "e2eSignInAlice"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "viewHeaderHomeFeedPrefsBtn"
|
||||
|
||||
# create a modlist
|
||||
- tapOn:
|
||||
@@ -28,20 +31,17 @@ appId: xyz.blueskyweb.app
|
||||
- assertVisible: "Muted Users"
|
||||
- assertVisible: "Shhh"
|
||||
|
||||
- tapOn:
|
||||
label: "Dropdown"
|
||||
point: "71%,9%"
|
||||
|
||||
# DOES NOT WORK - THE BUTTON IS NOT ACCESSIBLE
|
||||
# IGNORING FOR NOW, FIX THE COMPONENTS IN THE NEXT RELEASE
|
||||
# BECAUSE THIS IS A LEGIT A11Y PROBLEM -sfn
|
||||
- tapOn: "Subscribe to this list"
|
||||
- tapOn: "Mute accounts"
|
||||
- tapOn: "Mute list"
|
||||
- tapOn: "Unmute"
|
||||
|
||||
- tapOn:
|
||||
label: "Dropdown"
|
||||
point: "71%,9%"
|
||||
|
||||
- tapOn: "Subscribe to this list"
|
||||
- tapOn: "Block accounts"
|
||||
- tapOn: "Block list"
|
||||
- tapOn: "Unblock"
|
||||
|
||||
# the rest of the behaviors are tested in curate-lists.yml
|
||||
# the rest of the behaviors are tested in curate-lists.yml
|
||||
|
||||
@@ -3,11 +3,15 @@ appId: xyz.blueskyweb.app
|
||||
- runScript:
|
||||
file: ../setupServer.js
|
||||
env:
|
||||
SERVER_PATH: "?users"
|
||||
SERVER_PATH: "?users"
|
||||
- runFlow:
|
||||
file: ../setupApp.yml
|
||||
- tapOn:
|
||||
id: "e2eSignInAlice"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "viewHeaderHomeFeedPrefsBtn"
|
||||
|
||||
- tapOn:
|
||||
id: "e2eStartOnboarding"
|
||||
- tapOn: "Open avatar creator"
|
||||
@@ -21,12 +25,17 @@ appId: xyz.blueskyweb.app
|
||||
- tapOn: "Select the atom emoji as your avatar"
|
||||
- tapOn: "Done"
|
||||
- waitForAnimationToEnd
|
||||
- tapOn: "Continue to next step"
|
||||
- tapOn:
|
||||
id: "onboardingContinue"
|
||||
- assertVisible: "What are your interests?"
|
||||
- tapOn:
|
||||
label: "Tap on continue"
|
||||
point: "50%,92%"
|
||||
id: "onboardingContinue"
|
||||
- assertVisible: "You're ready to go!"
|
||||
- tapOn:
|
||||
label: "Tap on Lets go"
|
||||
point: "50%,92%"
|
||||
id: "onboardingFinish"
|
||||
- tapOn:
|
||||
label: "NUX, if applicable"
|
||||
text: "Close"
|
||||
optional: true
|
||||
- waitForAnimationToEnd
|
||||
- assertVisible: "Following"
|
||||
|
||||
@@ -3,11 +3,15 @@ appId: xyz.blueskyweb.app
|
||||
- runScript:
|
||||
file: ../setupServer.js
|
||||
env:
|
||||
SERVER_PATH: "?users"
|
||||
SERVER_PATH: "?users"
|
||||
- runFlow:
|
||||
file: ../setupApp.yml
|
||||
- tapOn:
|
||||
id: "e2eSignInAlice"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "viewHeaderHomeFeedPrefsBtn"
|
||||
|
||||
- tapOn:
|
||||
id: "e2eStartOnboarding"
|
||||
- tapOn: "Select an avatar"
|
||||
@@ -18,12 +22,17 @@ appId: xyz.blueskyweb.app
|
||||
- waitForAnimationToEnd
|
||||
- tapOn: "Done"
|
||||
- waitForAnimationToEnd
|
||||
- tapOn: "Continue to next step"
|
||||
- tapOn:
|
||||
id: "onboardingContinue"
|
||||
- assertVisible: "What are your interests?"
|
||||
- tapOn:
|
||||
label: "Tap on continue"
|
||||
point: "50%,92%"
|
||||
id: "onboardingContinue"
|
||||
- assertVisible: "You're ready to go!"
|
||||
- tapOn:
|
||||
label: "Tap on Lets go"
|
||||
point: "50%,92%"
|
||||
id: "onboardingFinish"
|
||||
- tapOn:
|
||||
label: "NUX, if applicable"
|
||||
text: "Close"
|
||||
optional: true
|
||||
- waitForAnimationToEnd
|
||||
- assertVisible: "Following"
|
||||
|
||||
@@ -3,11 +3,14 @@ appId: xyz.blueskyweb.app
|
||||
- runScript:
|
||||
file: ../setupServer.js
|
||||
env:
|
||||
SERVER_PATH: "?users&follows&posts"
|
||||
SERVER_PATH: "?users&follows&posts"
|
||||
- runFlow:
|
||||
file: ../setupApp.yml
|
||||
- tapOn:
|
||||
id: "e2eSignInAlice"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "viewHeaderHomeFeedPrefsBtn"
|
||||
|
||||
- tapOn:
|
||||
id: "postDropdownBtn"
|
||||
|
||||
@@ -3,7 +3,7 @@ appId: xyz.blueskyweb.app
|
||||
- runScript:
|
||||
file: ../setupServer.js
|
||||
env:
|
||||
SERVER_PATH: "?users&posts&feeds"
|
||||
SERVER_PATH: "?users&posts&feeds"
|
||||
- runFlow:
|
||||
file: ../setupApp.yml
|
||||
- tapOn:
|
||||
@@ -12,10 +12,10 @@ appId: xyz.blueskyweb.app
|
||||
# Navigate to another user profile
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "bottomBarSearchBtn"
|
||||
id: "bottomBarSearchBtn"
|
||||
- tapOn:
|
||||
id: "bottomBarSearchBtn"
|
||||
- tapOn: "Search for posts, users, or feeds"
|
||||
- tapOn: "Search for posts, users[,]? or feeds"
|
||||
- inputText: "b"
|
||||
- tapOn:
|
||||
id: "searchAutoCompleteResult-bob.test"
|
||||
@@ -38,4 +38,4 @@ appId: xyz.blueskyweb.app
|
||||
- tapOn:
|
||||
id: "profileHeaderDropdownBtn"
|
||||
- tapOn: "Unmute Account"
|
||||
- assertNotVisible: "Account Muted"
|
||||
- assertNotVisible: "Account Muted"
|
||||
|
||||
@@ -3,20 +3,22 @@ appId: xyz.blueskyweb.app
|
||||
- runScript:
|
||||
file: ../setupServer.js
|
||||
env:
|
||||
SERVER_PATH: "?users"
|
||||
SERVER_PATH: "?users"
|
||||
- runFlow:
|
||||
file: ../setupApp.yml
|
||||
- tapOn:
|
||||
id: "e2eSignInAlice"
|
||||
|
||||
# Navigate to another user profile via autocomplete
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "bottomBarSearchBtn"
|
||||
- tapOn:
|
||||
id: "bottomBarSearchBtn"
|
||||
- assertVisible: "Search for posts, users, or feeds"
|
||||
- tapOn: "Search for posts, users, or feeds"
|
||||
- assertVisible: "Search for posts, users[,]? or feeds"
|
||||
- tapOn: "Search for posts, users[,]? or feeds"
|
||||
- inputText: "b"
|
||||
- tapOn:
|
||||
id: "searchAutoCompleteResult-bob.test"
|
||||
- assertVisible:
|
||||
id: "profileView"
|
||||
|
||||
|
||||
@@ -8,8 +8,12 @@ appId: xyz.blueskyweb.app
|
||||
file: ../setupApp.yml
|
||||
- tapOn:
|
||||
id: "e2eSignInAlice"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "viewHeaderHomeFeedPrefsBtn"
|
||||
|
||||
- assertVisible:
|
||||
id: "storybookBtn"
|
||||
id: "storybookBtn"
|
||||
- tapOn:
|
||||
id: "storybookBtn"
|
||||
- tapOn:
|
||||
|
||||
@@ -3,13 +3,16 @@ appId: xyz.blueskyweb.app
|
||||
- runScript:
|
||||
file: ../setupServer.js
|
||||
env:
|
||||
SERVER_PATH: "?users&follows"
|
||||
SERVER_PATH: "?users&follows"
|
||||
- runFlow:
|
||||
file: ../setupApp.yml
|
||||
|
||||
# Login, create a thread, and log out
|
||||
- tapOn:
|
||||
id: "e2eSignInAlice"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "viewHeaderHomeFeedPrefsBtn"
|
||||
- assertVisible:
|
||||
id: "composeFAB"
|
||||
- tapOn:
|
||||
@@ -19,8 +22,13 @@ appId: xyz.blueskyweb.app
|
||||
id: "composerPublishBtn"
|
||||
|
||||
# Login, reply to the thread, and log out
|
||||
- tapOn:
|
||||
id: "e2eSignOut"
|
||||
- tapOn:
|
||||
id: "e2eSignInBob"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "viewHeaderHomeFeedPrefsBtn"
|
||||
- tapOn:
|
||||
id: "replyBtn"
|
||||
- inputText: "Reply 1"
|
||||
@@ -28,8 +36,13 @@ appId: xyz.blueskyweb.app
|
||||
id: "composerPublishBtn"
|
||||
|
||||
# Login, confirm notification exists, mute thread, and log out
|
||||
- tapOn:
|
||||
id: "e2eSignOut"
|
||||
- tapOn:
|
||||
id: "e2eSignInAlice"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "viewHeaderHomeFeedPrefsBtn"
|
||||
- tapOn:
|
||||
id: "bottomBarNotificationsBtn"
|
||||
- assertVisible:
|
||||
@@ -39,12 +52,17 @@ appId: xyz.blueskyweb.app
|
||||
- tapOn:
|
||||
id: "postDropdownBtn"
|
||||
childOf:
|
||||
id: "postThreadItem-by-bob.test"
|
||||
id: "postThreadItem-by-bob.test"
|
||||
- tapOn: "Mute thread"
|
||||
|
||||
# Login, reply to the thread twice, and log out
|
||||
- tapOn:
|
||||
id: "e2eSignOut"
|
||||
- tapOn:
|
||||
id: "e2eSignInBob"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "viewHeaderHomeFeedPrefsBtn"
|
||||
- tapOn:
|
||||
id: "bottomBarProfileBtn"
|
||||
- tapOn:
|
||||
@@ -60,24 +78,35 @@ appId: xyz.blueskyweb.app
|
||||
- tapOn:
|
||||
id: "composerPublishBtn"
|
||||
|
||||
|
||||
# Login, confirm notifications dont exist, unmute the thread, confirm notifications exist
|
||||
# Login, confirm notifications dont exist, unmute the thread, ~~confirm notifications exist~~
|
||||
# Mute thread behaviour no longer change old notifications after muting/unmuting a thread -sfn
|
||||
- tapOn:
|
||||
id: "e2eSignOut"
|
||||
- tapOn:
|
||||
id: "e2eSignInAlice"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "viewHeaderHomeFeedPrefsBtn"
|
||||
- tapOn:
|
||||
id: "bottomBarNotificationsBtn"
|
||||
- assertNotVisible:
|
||||
- assertVisible: ".*Reply 1.*"
|
||||
- assertNotVisible: ".*Reply 2.*"
|
||||
- assertNotVisible: ".*Reply 3.*"
|
||||
- assertVisible:
|
||||
id: "feedItem-by-bob.test"
|
||||
- tapOn:
|
||||
id: "bottomBarHomeBtn"
|
||||
id: "feedItem-by-bob.test"
|
||||
- tapOn:
|
||||
id: "postDropdownBtn"
|
||||
childOf:
|
||||
id: "postThreadItem-by-bob.test"
|
||||
- tapOn: "Unmute thread"
|
||||
- tapOn:
|
||||
id: "bottomBarNotificationsBtn"
|
||||
- swipe:
|
||||
from:
|
||||
id: "notifsFeed"
|
||||
id: "notifsFeed"
|
||||
direction: DOWN
|
||||
- assertVisible:
|
||||
id: "feedItem-by-bob.test"
|
||||
- assertVisible: ".*Reply 1.*"
|
||||
- assertNotVisible: ".*Reply 2.*"
|
||||
- assertNotVisible: ".*Reply 3.*"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {createServer as createHTTPServer} from 'node:http'
|
||||
import {parse} from 'node:url'
|
||||
|
||||
import {createServer, TestPDS} from '../jest/test-pds'
|
||||
import {createServer, type TestPDS} from '../jest/test-pds'
|
||||
|
||||
async function main() {
|
||||
let server: TestPDS
|
||||
@@ -181,10 +181,10 @@ async function main() {
|
||||
'warn-profile',
|
||||
'warn-posts',
|
||||
'muted-account',
|
||||
'muted-by-list-account',
|
||||
'muted-by-list-acc',
|
||||
'blocking-account',
|
||||
'blockedby-account',
|
||||
'mutual-block-account',
|
||||
'mutual-block-acc',
|
||||
]) {
|
||||
await server.mocker.createUser(user)
|
||||
await server.mocker.follow('alice', user)
|
||||
@@ -411,16 +411,16 @@ async function main() {
|
||||
await server.mocker.addToMuteList(
|
||||
'alice',
|
||||
list,
|
||||
server.mocker.users['muted-by-list-account'].did,
|
||||
server.mocker.users['muted-by-list-acc'].did,
|
||||
)
|
||||
await server.mocker.createPost('muted-by-list-account', 'muted post')
|
||||
await server.mocker.createPost('muted-by-list-acc', 'muted post')
|
||||
await server.mocker.createQuotePost(
|
||||
'muted-by-list-account',
|
||||
'muted-by-list-acc',
|
||||
'account quote post',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.createReply(
|
||||
'muted-by-list-account',
|
||||
'muted-by-list-acc',
|
||||
'account reply',
|
||||
anchorPost,
|
||||
)
|
||||
@@ -470,16 +470,16 @@ async function main() {
|
||||
)
|
||||
|
||||
await server.mocker.createPost(
|
||||
'mutual-block-account',
|
||||
'mutual-block-acc',
|
||||
'mutual-block post',
|
||||
)
|
||||
await server.mocker.createQuotePost(
|
||||
'mutual-block-account',
|
||||
'mutual-block-acc',
|
||||
'mutual-block quote post',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.createReply(
|
||||
'mutual-block-account',
|
||||
'mutual-block-acc',
|
||||
'mutual-block reply',
|
||||
anchorPost,
|
||||
)
|
||||
@@ -488,15 +488,15 @@ async function main() {
|
||||
repo: server.mocker.users.alice.did,
|
||||
},
|
||||
{
|
||||
subject: server.mocker.users['mutual-block-account'].did,
|
||||
subject: server.mocker.users['mutual-block-acc'].did,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
)
|
||||
await server.mocker.users[
|
||||
'mutual-block-account'
|
||||
'mutual-block-acc'
|
||||
].agent.app.bsky.graph.block.create(
|
||||
{
|
||||
repo: server.mocker.users['mutual-block-account'].did,
|
||||
repo: server.mocker.users['mutual-block-acc'].did,
|
||||
},
|
||||
{
|
||||
subject: server.mocker.users.alice.did,
|
||||
@@ -509,7 +509,16 @@ async function main() {
|
||||
}
|
||||
}
|
||||
console.log('Ready')
|
||||
return res.writeHead(200).end(server.pdsUrl)
|
||||
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()
|
||||
|
||||
@@ -11,3 +11,8 @@ appId: xyz.blueskyweb.app
|
||||
- swipe:
|
||||
from: "Bluesky"
|
||||
direction: DOWN
|
||||
- tapOn:
|
||||
id: e2eProxyHeaderInput
|
||||
- inputText: ${output.result}
|
||||
- pressKey: Enter
|
||||
- hideKeyboard
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// eslint-disable-next-line
|
||||
http.post('http://localhost:1986/' + SERVER_PATH, {
|
||||
// eslint-disable-next-line no-undef
|
||||
var res = http.post('http://localhost:1986/' + SERVER_PATH, {
|
||||
headers: {'Content-Type': 'text/plain'},
|
||||
body: '',
|
||||
})
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
output.result = json(res.body).appviewDid
|
||||
|
||||
@@ -303,6 +303,11 @@ module.exports = function (_config) {
|
||||
android: './assets/app-icons/android_icon_default_dark.png',
|
||||
prerendered: true,
|
||||
},
|
||||
next: {
|
||||
ios: './assets/app-icons/icon_default_next.png',
|
||||
android: './assets/app-icons/icon_default_next.png',
|
||||
prerendered: true,
|
||||
},
|
||||
|
||||
/**
|
||||
* Bluesky+ core set
|
||||
@@ -355,6 +360,7 @@ module.exports = function (_config) {
|
||||
},
|
||||
],
|
||||
['expo-screen-orientation', {initialOrientation: 'PORTRAIT_UP'}],
|
||||
['expo-location'],
|
||||
].filter(Boolean),
|
||||
extra: {
|
||||
eas: {
|
||||
|
||||
|
After Width: | Height: | Size: 486 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M9.7 16.895a4 4 0 0 1 4.6 0l3.7 2.6V6.5a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v12.995l3.7-2.6Zm10.3 2.6c0 1.62-1.825 2.567-3.15 1.636l-3.7-2.6a2 2 0 0 0-2.3 0l-3.7 2.6C5.825 22.062 4 21.115 4 19.495V6.5a4 4 0 0 1 4-4h8a4 4 0 0 1 4 4v12.995Z"/></svg>
|
||||
|
After Width: | Height: | Size: 334 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#405168" d="M14.2 2.625c.834 0 1.482 0 2.001.042.523.043.949.131 1.331.326a3.38 3.38 0 0 1 1.475 1.475c.195.382.283.807.326 1.33.042.52.042 1.168.042 2.002v11.09c0 .495 0 .893-.027 1.199-.028.301-.087.585-.26.809-.249.323-.63.518-1.037.533-.282.01-.547-.107-.808-.26-.265-.154-.588-.385-.991-.673l-3.54-2.528c-.36-.258-.461-.322-.559-.347a.6.6 0 0 0-.306 0c-.098.025-.199.09-.559.347l-3.54 2.528c-.403.288-.726.519-.991.674-.261.152-.526.269-.808.259a1.38 1.38 0 0 1-1.038-.534c-.172-.223-.231-.507-.259-.808a7 7 0 0 1-.024-.528l-.003-.67V7.8c0-.834 0-1.482.042-2.001.043-.523.13-.949.325-1.331a3.38 3.38 0 0 1 1.476-1.475c.382-.195.808-.283 1.33-.326.52-.042 1.168-.042 2.002-.042h4.4Zm-4.4.75c-.846 0-1.458 0-1.94.04-.477.039-.792.114-1.051.246A2.63 2.63 0 0 0 5.66 4.81c-.132.259-.208.574-.247 1.051-.04.482-.039 1.094-.039 1.94v11.09l.003.658c.003.186.01.34.021.473.025.267.07.37.106.418a.63.63 0 0 0 .472.243c.059.002.168-.022.4-.158.23-.133.52-.34.935-.636l3.54-2.529c.308-.22.543-.396.81-.464.222-.056.454-.056.676 0 .267.068.5.244.81.464l3.54 2.529c.414.296.704.503.933.636.233.137.343.16.402.158a.63.63 0 0 0 .472-.243c.036-.048.081-.15.106-.419.024-.263.024-.62.024-1.13V7.8c0-.846 0-1.458-.04-1.94-.039-.477-.114-.792-.246-1.051A2.63 2.63 0 0 0 17.19 3.66c-.259-.132-.575-.207-1.051-.246-.482-.04-1.094-.04-1.94-.04H9.8Zm4.056 4.238a.375.375 0 0 1 .53.53L12.53 10l1.857 1.856a.375.375 0 0 1-.53.53L12 10.53l-1.856 1.857a.375.375 0 0 1-.53-.53L11.47 10 9.613 8.144a.375.375 0 0 1 .53-.53L12 9.47l1.856-1.857Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#006AFF" d="M16 2.5a4 4 0 0 1 4 4v12.995c0 1.62-1.825 2.567-3.15 1.636l-3.7-2.6a2 2 0 0 0-2.3 0l-3.7 2.6C5.825 22.062 4 21.115 4 19.495V6.5a4 4 0 0 1 4-4h8Z"/></svg>
|
||||
|
After Width: | Height: | Size: 250 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12.591 21.806h.002l.001-.002.006-.004.018-.014a10 10 0 0 0 .304-.235 26 26 0 0 0 3.333-3.196C18.048 16.29 20 13.305 20 10a8 8 0 1 0-16 0c0 3.305 1.952 6.29 3.745 8.355a26 26 0 0 0 3.333 3.196 16 16 0 0 0 .304.235l.018.014.006.004.002.002a1 1 0 0 0 1.183 0Zm-.593-9.306a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 449 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12 2a8 8 0 0 1 8 8c0 3.305-1.953 6.29-3.745 8.355a26 26 0 0 1-3.333 3.197q-.152.12-.237.184l-.067.05-.018.014-.005.004-.002.002h-.001c-.003-.004-.042-.055-.592-.806l.592.807a1 1 0 0 1-1.184 0v-.001l-.003-.002-.005-.004-.018-.014-.067-.05a24 24 0 0 1-1.066-.877 26 26 0 0 1-2.504-2.503C5.953 16.29 4 13.305 4 10a8 8 0 0 1 8-8Zm0 2a6 6 0 0 0-6 6c0 2.56 1.547 5.076 3.255 7.044A24 24 0 0 0 12 19.723a24 24 0 0 0 2.745-2.679C16.453 15.076 18 12.56 18 10a6 6 0 0 0-6-6Zm-.002 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 0 1 0-7Zm0 2a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Z"/></svg>
|
||||
|
After Width: | Height: | Size: 656 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#080B12" d="M20.002 7a2 2 0 0 0-2-2h-12a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2a1 1 0 0 1 1 1v1.918l3.375-2.7a1 1 0 0 1 .625-.218h5a2 2 0 0 0 2-2V7Zm2 8a4 4 0 0 1-4 4h-4.648l-4.727 3.781A1.001 1.001 0 0 1 7.002 22v-3h-1a4 4 0 0 1-4-4V7a4 4 0 0 1 4-4h12a4 4 0 0 1 4 4v8Z"/></svg>
|
||||
|
After Width: | Height: | Size: 355 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#080B12" d="M22.002 15a4 4 0 0 1-4 4h-4.648l-4.727 3.781A1.001 1.001 0 0 1 7.002 22v-3h-1a4 4 0 0 1-4-4V7a4 4 0 0 1 4-4h12a4 4 0 0 1 4 4v8Z"/></svg>
|
||||
|
After Width: | Height: | Size: 233 B |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 114 KiB |
@@ -6,24 +6,25 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"build-snippet": "tsc --project tsconfig.snippet.json",
|
||||
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src"
|
||||
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "0.13.6",
|
||||
"@preact/preset-vite": "^2.8.2",
|
||||
"@vitejs/plugin-legacy": "^5.3.2",
|
||||
"preact": "^10.4.8",
|
||||
"terser": "^5.30.3"
|
||||
"@atproto/api": "^0.15.25",
|
||||
"preact": "^10.4.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@preact/preset-vite": "^2.10.2",
|
||||
"@vitejs/plugin-legacy": "^7.0.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^8.19.0",
|
||||
"eslint-config-preact": "^1.3.0",
|
||||
"eslint-plugin-simple-import-sort": "^12.0.0",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.2.8",
|
||||
"vite-tsconfig-paths": "^4.3.2"
|
||||
"terser": "^5.43.1",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^7.0.4",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ export function Container({
|
||||
}
|
||||
}}>
|
||||
{href && <Link href={href} />}
|
||||
<div className="flex-1 px-4 pt-3 pb-2.5">{children}</div>
|
||||
<div className="flex-1 px-4 pt-3 pb-2.5 max-w-full">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,8 +17,11 @@ import infoIcon from '../../assets/circleInfo_stroke2_corner0_rounded.svg'
|
||||
import playIcon from '../../assets/play_filled_corner2_rounded.svg'
|
||||
import starterPackIcon from '../../assets/starterPack.svg'
|
||||
import {CONTENT_LABELS, labelsToInfo} from '../labels'
|
||||
import {getRkey} from '../utils'
|
||||
import * as bsky from '../types/bsky'
|
||||
import {getRkey} from '../util/rkey'
|
||||
import {getVerificationState} from '../util/verification-state'
|
||||
import {Link} from './link'
|
||||
import {VerificationCheck} from './verification-check'
|
||||
|
||||
export function Embed({
|
||||
content,
|
||||
@@ -75,23 +78,35 @@ export function Embed({
|
||||
CONTENT_LABELS.includes(label.val),
|
||||
)
|
||||
|
||||
const verification = getVerificationState({profile: record.author})
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/profile/${record.author.did}/post/${getRkey(record)}`}
|
||||
className="transition-colors hover:bg-neutral-100 dark:hover:bg-slate-700 border dark:border-slate-600 rounded-xl p-2 gap-1.5 w-full flex flex-col">
|
||||
<div className="flex gap-1.5 items-center">
|
||||
<div className="w-4 h-4 overflow-hidden rounded-full bg-neutral-300 dark:bg-slate-700 shrink-0">
|
||||
<div className="w-4 h-4 rounded-full bg-neutral-300 dark:bg-slate-700 shrink-0">
|
||||
<img
|
||||
className="rounded-full"
|
||||
src={record.author.avatar}
|
||||
style={isAuthorLabeled ? {filter: 'blur(1.5px)'} : undefined}
|
||||
/>
|
||||
</div>
|
||||
<p className="line-clamp-1 text-sm">
|
||||
<span className="font-bold">{record.author.displayName}</span>
|
||||
<span className="text-textLight dark:text-textDimmed ml-1">
|
||||
<div className="flex flex-1 items-center shrink min-w-0 min-h-0">
|
||||
<p className="block text-sm shrink-0 font-bold max-w-[70%] line-clamp-1">
|
||||
{record.author.displayName?.trim() || record.author.handle}
|
||||
</p>
|
||||
{verification.isVerified && (
|
||||
<VerificationCheck
|
||||
className="ml-[3px] mt-px shrink-0 self-center"
|
||||
verifier={verification.role === 'verifier'}
|
||||
size={12}
|
||||
/>
|
||||
)}
|
||||
<p className="block line-clamp-1 text-sm text-textLight dark:text-textDimmed shrink-[10] ml-1">
|
||||
@{record.author.handle}
|
||||
</span>
|
||||
</p>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{text && <p className="text-sm">{text}</p>}
|
||||
{record.embeds?.map(embed => (
|
||||
@@ -404,7 +419,12 @@ function StarterPackEmbed({
|
||||
}: {
|
||||
content: AppBskyGraphDefs.StarterPackViewBasic
|
||||
}) {
|
||||
if (!AppBskyGraphStarterpack.isRecord(content.record)) {
|
||||
if (
|
||||
!bsky.dangerousIsType<AppBskyGraphStarterpack.Record>(
|
||||
content.record,
|
||||
AppBskyGraphStarterpack.isRecord,
|
||||
)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -443,7 +463,9 @@ function StarterPackEmbed({
|
||||
}
|
||||
|
||||
// from #/lib/strings/starter-pack.ts
|
||||
function getStarterPackImage(starterPack: AppBskyGraphDefs.StarterPackView) {
|
||||
function getStarterPackImage(
|
||||
starterPack: AppBskyGraphDefs.StarterPackViewBasic,
|
||||
) {
|
||||
const rkey = getRkey({uri: starterPack.uri})
|
||||
return `https://ogcard.cdn.bsky.app/start/${starterPack.creator.did}/${rkey}`
|
||||
}
|
||||
|
||||
@@ -11,10 +11,15 @@ import likeIcon from '../../assets/heart2_filled_stroke2_corner0_rounded.svg'
|
||||
import logo from '../../assets/logo.svg'
|
||||
import repostIcon from '../../assets/repost_stroke2_corner2_rounded.svg'
|
||||
import {CONTENT_LABELS} from '../labels'
|
||||
import {getRkey, niceDate, prettyNumber} from '../utils'
|
||||
import * as bsky from '../types/bsky'
|
||||
import {niceDate} from '../util/nice-date'
|
||||
import {prettyNumber} from '../util/pretty-number'
|
||||
import {getRkey} from '../util/rkey'
|
||||
import {getVerificationState} from '../util/verification-state'
|
||||
import {Container} from './container'
|
||||
import {Embed} from './embed'
|
||||
import {Link} from './link'
|
||||
import {VerificationCheck} from './verification-check'
|
||||
|
||||
interface Props {
|
||||
thread: AppBskyFeedDefs.ThreadViewPost
|
||||
@@ -28,16 +33,25 @@ export function Post({thread}: Props) {
|
||||
)
|
||||
|
||||
let record: AppBskyFeedPost.Record | null = null
|
||||
if (AppBskyFeedPost.isRecord(post.record)) {
|
||||
if (
|
||||
bsky.dangerousIsType<AppBskyFeedPost.Record>(
|
||||
post.record,
|
||||
AppBskyFeedPost.isRecord,
|
||||
)
|
||||
) {
|
||||
record = post.record
|
||||
}
|
||||
|
||||
const verification = getVerificationState({profile: post.author})
|
||||
|
||||
const href = `/profile/${post.author.did}/post/${getRkey(post)}`
|
||||
return (
|
||||
<Container href={href}>
|
||||
<div className="flex-1 flex-col flex gap-2" lang={record?.langs?.[0]}>
|
||||
<div className="flex gap-2.5 items-center cursor-pointer">
|
||||
<Link href={`/profile/${post.author.did}`} className="rounded-full">
|
||||
<div className="flex gap-2.5 items-center cursor-pointer w-full max-w-full">
|
||||
<Link
|
||||
href={`/profile/${post.author.did}`}
|
||||
className="rounded-full shrink-0">
|
||||
<div className="w-10 h-10 overflow-hidden rounded-full bg-neutral-300 dark:bg-slate-700 shrink-0">
|
||||
<img
|
||||
src={post.author.avatar}
|
||||
@@ -45,19 +59,27 @@ export function Post({thread}: Props) {
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex flex-1 flex-col min-w-0">
|
||||
<div className="flex flex-1 items-center">
|
||||
<Link
|
||||
href={`/profile/${post.author.did}`}
|
||||
className="block font-bold text-[17px] leading-5 line-clamp-1 hover:underline underline-offset-2 text-ellipsis decoration-2">
|
||||
{post.author.displayName?.trim() || post.author.handle}
|
||||
</Link>
|
||||
{verification.isVerified && (
|
||||
<VerificationCheck
|
||||
className="pl-[3px] mt-px shrink-0"
|
||||
verifier={verification.role === 'verifier'}
|
||||
size={15}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
href={`/profile/${post.author.did}`}
|
||||
className="font-bold text-[17px] leading-5 line-clamp-1 hover:underline underline-offset-2 decoration-2">
|
||||
<p>{post.author.displayName}</p>
|
||||
</Link>
|
||||
<Link
|
||||
href={`/profile/${post.author.did}`}
|
||||
className="text-[15px] text-textLight dark:text-textDimmed hover:underline line-clamp-1">
|
||||
<p>@{post.author.handle}</p>
|
||||
className="block text-[15px] text-textLight dark:text-textDimmed hover:underline line-clamp-1">
|
||||
@{post.author.handle}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<Link
|
||||
href={href}
|
||||
className="transition-transform hover:scale-110 shrink-0 self-start">
|
||||
@@ -133,7 +155,7 @@ function PostContent({record}: {record: AppBskyFeedPost.Record | null}) {
|
||||
<Link
|
||||
key={counter}
|
||||
href={segment.link.uri}
|
||||
className="text-blue-400 hover:underline"
|
||||
className="text-blue-500 hover:underline"
|
||||
disableTracking={
|
||||
!segment.link.uri.startsWith('https://bsky.app') &&
|
||||
!segment.link.uri.startsWith('https://go.bsky.app')
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import {h} from 'preact'
|
||||
|
||||
type IconProps = {
|
||||
size: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function VerificationCheck({
|
||||
verifier,
|
||||
...rest
|
||||
}: {verifier: boolean} & IconProps) {
|
||||
return verifier ? <VerifierCheck {...rest} /> : <VerifiedCheck {...rest} />
|
||||
}
|
||||
|
||||
export function VerifiedCheck({size, className}: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}>
|
||||
<circle cx="12" cy="12" r="11.5" fill="hsl(211, 99%, 53%)" />
|
||||
<path
|
||||
fill="#fff"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M17.659 8.175a1.361 1.361 0 0 1 0 1.925l-6.224 6.223a1.361 1.361 0 0 1-1.925 0L6.4 13.212a1.361 1.361 0 0 1 1.925-1.925l2.149 2.148 5.26-5.26a1.361 1.361 0 0 1 1.925 0Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function VerifierCheck({size, className}: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}>
|
||||
<path
|
||||
fill="hsl(211, 99%, 53%)"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M8.792 1.615a4.154 4.154 0 0 1 6.416 0 4.154 4.154 0 0 0 3.146 1.515 4.154 4.154 0 0 1 4 5.017 4.154 4.154 0 0 0 .777 3.404 4.154 4.154 0 0 1-1.427 6.255 4.153 4.153 0 0 0-2.177 2.73 4.154 4.154 0 0 1-5.781 2.784 4.154 4.154 0 0 0-3.492 0 4.154 4.154 0 0 1-5.78-2.784 4.154 4.154 0 0 0-2.178-2.73A4.154 4.154 0 0 1 .87 11.551a4.154 4.154 0 0 0 .776-3.404A4.154 4.154 0 0 1 5.646 3.13a4.154 4.154 0 0 0 3.146-1.515Z"
|
||||
/>
|
||||
<path
|
||||
fill="#fff"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M17.861 8.26a1.438 1.438 0 0 1 0 2.033l-6.571 6.571a1.437 1.437 0 0 1-2.033 0L5.97 13.58a1.438 1.438 0 0 1 2.033-2.033l2.27 2.269 5.554-5.555a1.437 1.437 0 0 1 2.033 0Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -14,9 +14,11 @@ import {
|
||||
import {Container} from '../components/container'
|
||||
import {Link} from '../components/link'
|
||||
import {Post} from '../components/post'
|
||||
import {niceDate} from '../utils'
|
||||
import * as bsky from '../types/bsky'
|
||||
import {niceDate} from '../util/nice-date'
|
||||
|
||||
const DEFAULT_POST = 'https://bsky.app/profile/emilyliu.me/post/3jzn6g7ixgq2y'
|
||||
const DEFAULT_POST =
|
||||
'https://bsky.app/profile/did:plc:vjug55kidv6sye7ykr5faxxn/post/3jzn6g7ixgq2y'
|
||||
const DEFAULT_URI =
|
||||
'at://did:plc:vjug55kidv6sye7ykr5faxxn/app.bsky.feed.post/3jzn6g7ixgq2y'
|
||||
|
||||
@@ -222,7 +224,12 @@ function Snippet({
|
||||
const snippet = useMemo(() => {
|
||||
const record = thread.post.record
|
||||
|
||||
if (!AppBskyFeedPost.isRecord(record)) {
|
||||
if (
|
||||
!bsky.dangerousIsType<AppBskyFeedPost.Record>(
|
||||
record,
|
||||
AppBskyFeedPost.isRecord,
|
||||
)
|
||||
) {
|
||||
return ''
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {applyTheme, initSystemColorMode} from '../color-mode'
|
||||
import {Container} from '../components/container'
|
||||
import {Link} from '../components/link'
|
||||
import {Post} from '../components/post'
|
||||
import {getRkey} from '../utils'
|
||||
import {getRkey} from '../util/rkey'
|
||||
|
||||
const root = document.getElementById('app')
|
||||
if (!root) throw new Error('No root element')
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import {ValidationResult} from '@atproto/lexicon'
|
||||
|
||||
export * as profile from './profile'
|
||||
|
||||
/**
|
||||
* Fast type checking without full schema validation, for use with data we
|
||||
* trust, or for non-critical path use cases. Why? Our SDK's `is*` identity
|
||||
* utils do not assert the type of the entire object, only the `$type` string.
|
||||
*
|
||||
* For full validation of the object schema, use the `validate` export from
|
||||
* this file.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* import * as bsky from '#/types/bsky'
|
||||
*
|
||||
* if (bsky.dangerousIsType<AppBskyFeedPost.Record>(item, AppBskyFeedPost.isRecord)) {
|
||||
* // `item` has type `$Typed<AppBskyFeedPost.Record>` here
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function dangerousIsType<R extends {$type?: string}>(
|
||||
record: unknown,
|
||||
identity: <V>(v: V) => v is V & {$type: NonNullable<R['$type']>},
|
||||
): record is R {
|
||||
return identity(record)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fully validates the object schema, which has a performance cost.
|
||||
*
|
||||
* For faster checks with data we trust, like that from our app view, use the
|
||||
* `dangerousIsType` export from this same file.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* import * as bsky from '#/types/bsky'
|
||||
*
|
||||
* if (bsky.validate(item, AppBskyFeedPost.validateRecord)) {
|
||||
* // `item` has type `$Typed<AppBskyFeedPost.Record>` here
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function validate<R extends {$type?: string}>(
|
||||
record: unknown,
|
||||
validator: (v: unknown) => ValidationResult<R>,
|
||||
): record is R {
|
||||
return validator(record).success
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import {type AppBskyActorDefs, type ChatBskyActorDefs} from '@atproto/api'
|
||||
|
||||
/**
|
||||
* Matches any profile view exported by our SDK
|
||||
*/
|
||||
export type AnyProfileView =
|
||||
| AppBskyActorDefs.ProfileViewBasic
|
||||
| AppBskyActorDefs.ProfileView
|
||||
| AppBskyActorDefs.ProfileViewDetailed
|
||||
| ChatBskyActorDefs.ProfileViewBasic
|
||||
@@ -0,0 +1,11 @@
|
||||
export function niceDate(date: number | string | Date) {
|
||||
const d = new Date(date)
|
||||
return `${d.toLocaleDateString('en-us', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})} at ${d.toLocaleTimeString(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})}`
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* This file is a copy of what exists in the social-app
|
||||
*/
|
||||
|
||||
import {
|
||||
AppBskyEmbedExternal,
|
||||
AppBskyEmbedImages,
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyEmbedVideo,
|
||||
AppBskyFeedDefs,
|
||||
AppBskyGraphDefs,
|
||||
AppBskyLabelerDefs,
|
||||
} from '@atproto/api'
|
||||
|
||||
export type Embed =
|
||||
| {
|
||||
type: 'post'
|
||||
view: AppBskyEmbedRecord.ViewRecord
|
||||
}
|
||||
| {
|
||||
type: 'post_not_found'
|
||||
view: AppBskyEmbedRecord.ViewNotFound
|
||||
}
|
||||
| {
|
||||
type: 'post_blocked'
|
||||
view: AppBskyEmbedRecord.ViewBlocked
|
||||
}
|
||||
| {
|
||||
type: 'post_detached'
|
||||
view: AppBskyEmbedRecord.ViewDetached
|
||||
}
|
||||
| {
|
||||
type: 'feed'
|
||||
view: AppBskyFeedDefs.GeneratorView
|
||||
}
|
||||
| {
|
||||
type: 'list'
|
||||
view: AppBskyGraphDefs.ListView
|
||||
}
|
||||
| {
|
||||
type: 'labeler'
|
||||
view: AppBskyLabelerDefs.LabelerView
|
||||
}
|
||||
| {
|
||||
type: 'starter_pack'
|
||||
view: AppBskyGraphDefs.StarterPackViewBasic
|
||||
}
|
||||
| {
|
||||
type: 'images'
|
||||
view: AppBskyEmbedImages.View
|
||||
}
|
||||
| {
|
||||
type: 'link'
|
||||
view: AppBskyEmbedExternal.View
|
||||
}
|
||||
| {
|
||||
type: 'video'
|
||||
view: AppBskyEmbedVideo.View
|
||||
}
|
||||
| {
|
||||
type: 'post_with_media'
|
||||
view: Embed
|
||||
media: Embed
|
||||
}
|
||||
| {
|
||||
type: 'unknown'
|
||||
view: null
|
||||
}
|
||||
|
||||
export type EmbedType<T extends Embed['type']> = Extract<Embed, {type: T}>
|
||||
|
||||
export function parseEmbedRecordView({record}: AppBskyEmbedRecord.View): Embed {
|
||||
if (AppBskyEmbedRecord.isViewRecord(record)) {
|
||||
return {
|
||||
type: 'post',
|
||||
view: record,
|
||||
}
|
||||
} else if (AppBskyEmbedRecord.isViewNotFound(record)) {
|
||||
return {
|
||||
type: 'post_not_found',
|
||||
view: record,
|
||||
}
|
||||
} else if (AppBskyEmbedRecord.isViewBlocked(record)) {
|
||||
return {
|
||||
type: 'post_blocked',
|
||||
view: record,
|
||||
}
|
||||
} else if (AppBskyEmbedRecord.isViewDetached(record)) {
|
||||
return {
|
||||
type: 'post_detached',
|
||||
view: record,
|
||||
}
|
||||
} else if (AppBskyFeedDefs.isGeneratorView(record)) {
|
||||
return {
|
||||
type: 'feed',
|
||||
view: record,
|
||||
}
|
||||
} else if (AppBskyGraphDefs.isListView(record)) {
|
||||
return {
|
||||
type: 'list',
|
||||
view: record,
|
||||
}
|
||||
} else if (AppBskyLabelerDefs.isLabelerView(record)) {
|
||||
return {
|
||||
type: 'labeler',
|
||||
view: record,
|
||||
}
|
||||
} else if (AppBskyGraphDefs.isStarterPackViewBasic(record)) {
|
||||
return {
|
||||
type: 'starter_pack',
|
||||
view: record,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
type: 'unknown',
|
||||
view: null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function parseEmbed(embed: AppBskyFeedDefs.PostView['embed']): Embed {
|
||||
if (AppBskyEmbedImages.isView(embed)) {
|
||||
return {
|
||||
type: 'images',
|
||||
view: embed,
|
||||
}
|
||||
} else if (AppBskyEmbedExternal.isView(embed)) {
|
||||
return {
|
||||
type: 'link',
|
||||
view: embed,
|
||||
}
|
||||
} else if (AppBskyEmbedVideo.isView(embed)) {
|
||||
return {
|
||||
type: 'video',
|
||||
view: embed,
|
||||
}
|
||||
} else if (AppBskyEmbedRecord.isView(embed)) {
|
||||
return parseEmbedRecordView(embed)
|
||||
} else if (AppBskyEmbedRecordWithMedia.isView(embed)) {
|
||||
return {
|
||||
type: 'post_with_media',
|
||||
view: parseEmbedRecordView(embed.record),
|
||||
media: parseEmbed(embed.media),
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
type: 'unknown',
|
||||
view: null,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
const formatter = new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
roundingMode: 'trunc',
|
||||
})
|
||||
|
||||
export function prettyNumber(number: number) {
|
||||
return formatter.format(number)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import {AtUri} from '@atproto/api'
|
||||
|
||||
export function getRkey({uri}: {uri: string}): string {
|
||||
const at = new AtUri(uri)
|
||||
return at.rkey
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import * as bsky from '../types/bsky'
|
||||
|
||||
export type VerificationState = {
|
||||
role: 'default' | 'verifier'
|
||||
isVerified: boolean
|
||||
}
|
||||
|
||||
export function getVerificationState({
|
||||
profile,
|
||||
}: {
|
||||
profile?: bsky.profile.AnyProfileView
|
||||
}): VerificationState {
|
||||
if (!profile || !profile.verification) {
|
||||
return {
|
||||
role: 'default',
|
||||
isVerified: false,
|
||||
}
|
||||
}
|
||||
|
||||
const {verifiedStatus, trustedVerifierStatus} = profile.verification
|
||||
const isVerifiedUser = ['valid', 'invalid'].includes(verifiedStatus)
|
||||
const isVerifierUser = ['valid', 'invalid'].includes(trustedVerifierStatus)
|
||||
const isVerified =
|
||||
(isVerifiedUser && verifiedStatus === 'valid') ||
|
||||
(isVerifierUser && trustedVerifierStatus === 'valid')
|
||||
|
||||
return {
|
||||
role: isVerifierUser ? 'verifier' : 'default',
|
||||
isVerified,
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import {AtUri} from '@atproto/api'
|
||||
|
||||
export function niceDate(date: number | string | Date) {
|
||||
const d = new Date(date)
|
||||
return `${d.toLocaleDateString('en-us', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})} at ${d.toLocaleTimeString(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})}`
|
||||
}
|
||||
|
||||
export function getRkey({uri}: {uri: string}): string {
|
||||
const at = new AtUri(uri)
|
||||
return at.rkey
|
||||
}
|
||||
|
||||
const formatter = new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
roundingMode: 'trunc',
|
||||
})
|
||||
|
||||
export function prettyNumber(number: number) {
|
||||
return formatter.format(number)
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
@@ -19,5 +19,5 @@
|
||||
"jsxFragmentFactory": "Fragment",
|
||||
"downlevelIteration": true
|
||||
},
|
||||
"include": ["src", "snippet", "vite.config.ts"]
|
||||
"include": ["src", "snippet", "vite.config.ts", "*.config.cjs", ".eslintrc.cjs"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Potentially Dangerous Link": "Potentially Dangerous Link",
|
||||
"Blocked Link": "Blocked Link",
|
||||
"This link may be malicious. You should proceed at your own risk.": "This link may be malicious. You should proceed at your own risk.",
|
||||
"This link has been identified as malicious and has blocked for your safety.": "This link has been identified as malicious and has blocked for your safety.",
|
||||
"Continue Anyway": "Continue Anyway",
|
||||
"Return to Bluesky": "Return to Bluesky"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Potentially Dangerous Link": "Enlace Potencialmente Peligroso",
|
||||
"Blocked Link": "Enlace Bloqueado",
|
||||
"This link may be malicious. You should proceed at your own risk.": "Este enlace puede ser malicioso. Debes proceder bajo tu propio riesgo.",
|
||||
"This link has been identified as malicious and has blocked for your safety.": "Este enlace ha sido identificado como malicioso y ha sido bloqueado por tu seguridad.",
|
||||
"Continue Anyway": "Continuar de Todos Modos",
|
||||
"Return to Bluesky": "Regresar a Bluesky"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Potentially Dangerous Link": "Lien potentiellement dangereux",
|
||||
"Blocked Link": "Lien bloqué",
|
||||
"This link may be malicious. You should proceed at your own risk.": "Ce lien est peut-être malveillant. Ne continuez qu’à vos risques et périls.",
|
||||
"This link has been identified as malicious and has blocked for your safety.": "Ce lien a été identifié comme malveillant et a été bloqué pour votre sécurité.",
|
||||
"Continue Anyway": "Continuer quand même",
|
||||
"Return to Bluesky": "Retourner sur Bluesky"
|
||||
}
|
||||
@@ -8,21 +8,28 @@
|
||||
"build": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/common": "^0.4.0",
|
||||
"@atproto/api": "^0.16.7",
|
||||
"@atproto/common": "^0.4.11",
|
||||
"@types/escape-html": "^1.0.4",
|
||||
"body-parser": "^1.20.2",
|
||||
"cors": "^2.8.5",
|
||||
"escape-html": "^1.0.3",
|
||||
"express": "^4.19.2",
|
||||
"http-terminator": "^3.2.0",
|
||||
"i18n": "^0.15.1",
|
||||
"kysely": "^0.27.3",
|
||||
"lru-cache": "^11.1.0",
|
||||
"pg": "^8.12.0",
|
||||
"pino": "^9.2.0",
|
||||
"uint8arrays": "^5.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/body-parser": "^1.19.5",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/i18n": "^0.13.12",
|
||||
"@types/pg": "^8.11.6",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {Database, envToCfg, httpLogger, LinkService, readEnv} from './index.js'
|
||||
|
||||
async function main() {
|
||||
const env = readEnv()
|
||||
const cfg = envToCfg(env)
|
||||
@@ -11,7 +10,13 @@ async function main() {
|
||||
await migrateDb.migrateToLatestOrThrow()
|
||||
await migrateDb.close()
|
||||
}
|
||||
|
||||
const link = await LinkService.create(cfg)
|
||||
|
||||
if (link.ctx.cfg.service.safelinkEnabled) {
|
||||
link.ctx.safelinkClient.runFetchEvents()
|
||||
}
|
||||
|
||||
await link.start()
|
||||
httpLogger.info('link service is running')
|
||||
process.on('SIGTERM', async () => {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import {type SafelinkRule} from '../db/schema'
|
||||
|
||||
export const exampleRule: SafelinkRule = {
|
||||
id: 1,
|
||||
eventType: 'addRule',
|
||||
url: 'https://malicious.example.com/phishing',
|
||||
pattern: 'domain',
|
||||
action: 'block',
|
||||
createdAt: '2024-06-01T12:00:00Z',
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import {
|
||||
AtpAgent,
|
||||
CredentialSession,
|
||||
type ToolsOzoneSafelinkDefs,
|
||||
type ToolsOzoneSafelinkQueryEvents,
|
||||
} from '@atproto/api'
|
||||
import {ExpiredTokenError} from '@atproto/api/dist/client/types/com/atproto/server/confirmEmail.js'
|
||||
import {MINUTE} from '@atproto/common'
|
||||
import {LRUCache} from 'lru-cache'
|
||||
|
||||
import {type ServiceConfig} from '../config.js'
|
||||
import type Database from '../db/index.js'
|
||||
import {type SafelinkRule} from '../db/schema.js'
|
||||
import {redirectLogger} from '../logger.js'
|
||||
|
||||
const SAFELINK_MIN_FETCH_INTERVAL = 1_000
|
||||
const SAFELINK_MAX_FETCH_INTERVAL = 10_000
|
||||
const SCHEME_REGEX = /^[a-zA-Z][a-zA-Z0-9+.-]*:/
|
||||
|
||||
export class SafelinkClient {
|
||||
private domainCache: LRUCache<string, SafelinkRule | 'ok'>
|
||||
private urlCache: LRUCache<string, SafelinkRule | 'ok'>
|
||||
|
||||
private db: Database
|
||||
|
||||
private ozoneAgent: OzoneAgent
|
||||
|
||||
private cursor?: string
|
||||
|
||||
constructor({cfg, db}: {cfg: ServiceConfig; db: Database}) {
|
||||
this.domainCache = new LRUCache<string, SafelinkRule | 'ok'>({
|
||||
max: 10000,
|
||||
})
|
||||
|
||||
this.urlCache = new LRUCache<string, SafelinkRule | 'ok'>({
|
||||
max: 25000,
|
||||
})
|
||||
|
||||
this.db = db
|
||||
|
||||
this.ozoneAgent = new OzoneAgent(
|
||||
cfg.safelinkPdsUrl!,
|
||||
cfg.safelinkAgentIdentifier!,
|
||||
cfg.safelinkAgentPass!,
|
||||
)
|
||||
}
|
||||
|
||||
public async tryFindRule(link: string): Promise<SafelinkRule | 'ok'> {
|
||||
let url: string
|
||||
let domain: string
|
||||
try {
|
||||
url = SafelinkClient.normalizeUrl(link)
|
||||
domain = SafelinkClient.normalizeDomain(link)
|
||||
} catch (e) {
|
||||
redirectLogger.error(
|
||||
{error: e, inputUrl: link},
|
||||
'failed to normalize looked up link',
|
||||
)
|
||||
// fail open
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
// First, check if there is an existing URL rule. Note that even if the rule is 'ok', we still
|
||||
// want to check for a blocking domain rule, so we will only return here if the url rule exists
|
||||
// _and_ it is not 'ok'.
|
||||
const urlRule = this.urlCache.get(url)
|
||||
if (urlRule && urlRule !== 'ok') {
|
||||
return urlRule
|
||||
}
|
||||
|
||||
// If we find a domain rule of _any_ kind, including 'ok', we can now return that rule.
|
||||
const domainRule = this.domainCache.get(domain)
|
||||
if (domainRule) {
|
||||
return domainRule
|
||||
}
|
||||
|
||||
try {
|
||||
const maybeUrlRule = await this.getRule(this.db, url, 'url')
|
||||
this.urlCache.set(url, maybeUrlRule)
|
||||
return maybeUrlRule
|
||||
} catch (e) {
|
||||
this.urlCache.set(url, 'ok')
|
||||
}
|
||||
|
||||
try {
|
||||
const maybeDomainRule = await this.getRule(this.db, domain, 'domain')
|
||||
this.domainCache.set(domain, maybeDomainRule)
|
||||
return maybeDomainRule
|
||||
} catch (e) {
|
||||
this.domainCache.set(domain, 'ok')
|
||||
}
|
||||
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
private async getRule(
|
||||
db: Database,
|
||||
url: string,
|
||||
pattern: ToolsOzoneSafelinkDefs.PatternType,
|
||||
): Promise<SafelinkRule> {
|
||||
return db.db
|
||||
.selectFrom('safelink_rule')
|
||||
.selectAll()
|
||||
.where('url', '=', url)
|
||||
.where('pattern', '=', pattern)
|
||||
.orderBy('createdAt', 'desc')
|
||||
.executeTakeFirstOrThrow()
|
||||
}
|
||||
|
||||
private async addRule(db: Database, rule: SafelinkRule) {
|
||||
try {
|
||||
if (rule.pattern === 'url') {
|
||||
rule.url = SafelinkClient.normalizeUrl(rule.url)
|
||||
} else if (rule.pattern === 'domain') {
|
||||
rule.url = SafelinkClient.normalizeDomain(rule.url)
|
||||
}
|
||||
} catch (e) {
|
||||
redirectLogger.error(
|
||||
{error: e, inputUrl: rule.url},
|
||||
'failed to normalize rule input URL',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
db.db
|
||||
.insertInto('safelink_rule')
|
||||
.values({
|
||||
id: rule.id,
|
||||
eventType: rule.eventType,
|
||||
url: rule.url,
|
||||
pattern: rule.pattern,
|
||||
action: rule.action,
|
||||
createdAt: rule.createdAt,
|
||||
})
|
||||
.execute()
|
||||
.catch(err => {
|
||||
redirectLogger.error(
|
||||
{error: err, rule},
|
||||
'failed to add rule to database',
|
||||
)
|
||||
})
|
||||
|
||||
if (rule.pattern === 'domain') {
|
||||
this.domainCache.delete(rule.url)
|
||||
} else {
|
||||
this.urlCache.delete(rule.url)
|
||||
}
|
||||
}
|
||||
|
||||
private async removeRule(db: Database, rule: SafelinkRule) {
|
||||
try {
|
||||
if (rule.pattern === 'url') {
|
||||
rule.url = SafelinkClient.normalizeUrl(rule.url)
|
||||
} else if (rule.pattern === 'domain') {
|
||||
rule.url = SafelinkClient.normalizeDomain(rule.url)
|
||||
}
|
||||
} catch (e) {
|
||||
redirectLogger.error(
|
||||
{error: e, inputUrl: rule.url},
|
||||
'failed to normalize rule input URL',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
await db.db
|
||||
.deleteFrom('safelink_rule')
|
||||
.where('pattern', '=', 'domain')
|
||||
.where('url', '=', rule.url)
|
||||
.execute()
|
||||
.catch(err => {
|
||||
redirectLogger.error(
|
||||
{error: err, rule},
|
||||
'failed to remove rule from database',
|
||||
)
|
||||
})
|
||||
|
||||
if (rule.pattern === 'domain') {
|
||||
this.domainCache.delete(rule.url)
|
||||
} else {
|
||||
this.urlCache.delete(rule.url)
|
||||
}
|
||||
}
|
||||
|
||||
public async runFetchEvents() {
|
||||
let agent: AtpAgent
|
||||
try {
|
||||
agent = await this.ozoneAgent.getAgent()
|
||||
} catch (err) {
|
||||
redirectLogger.error({error: err}, 'error getting Ozone agent')
|
||||
setTimeout(() => this.runFetchEvents(), SAFELINK_MAX_FETCH_INTERVAL)
|
||||
return
|
||||
}
|
||||
|
||||
let res: ToolsOzoneSafelinkQueryEvents.Response
|
||||
try {
|
||||
const cursor = await this.getCursor()
|
||||
res = await agent.tools.ozone.safelink.queryEvents({
|
||||
cursor,
|
||||
limit: 100,
|
||||
sortDirection: 'asc',
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof ExpiredTokenError) {
|
||||
redirectLogger.info('ozone agent had expired session, refreshing...')
|
||||
await this.ozoneAgent.refreshSession()
|
||||
setTimeout(() => this.runFetchEvents(), SAFELINK_MIN_FETCH_INTERVAL)
|
||||
return
|
||||
}
|
||||
|
||||
redirectLogger.error(
|
||||
{error: err},
|
||||
'error fetching safelink events from Ozone',
|
||||
)
|
||||
setTimeout(() => this.runFetchEvents(), SAFELINK_MAX_FETCH_INTERVAL)
|
||||
return
|
||||
}
|
||||
|
||||
if (res.data.events.length === 0) {
|
||||
redirectLogger.info('received no new safelink events from ozone')
|
||||
setTimeout(() => this.runFetchEvents(), SAFELINK_MAX_FETCH_INTERVAL)
|
||||
} else {
|
||||
await this.db.transaction(async db => {
|
||||
for (const rule of res.data.events) {
|
||||
switch (rule.eventType) {
|
||||
case 'removeRule':
|
||||
await this.removeRule(db, rule)
|
||||
break
|
||||
case 'addRule':
|
||||
case 'updateRule':
|
||||
await this.addRule(db, rule)
|
||||
break
|
||||
default:
|
||||
redirectLogger.warn({rule}, 'received unknown rule event type')
|
||||
}
|
||||
}
|
||||
})
|
||||
if (res.data.cursor) {
|
||||
redirectLogger.info(
|
||||
{cursor: res.data.cursor},
|
||||
'received new safelink events from Ozone',
|
||||
)
|
||||
await this.setCursor(res.data.cursor)
|
||||
}
|
||||
setTimeout(() => this.runFetchEvents(), SAFELINK_MIN_FETCH_INTERVAL)
|
||||
}
|
||||
}
|
||||
|
||||
private async getCursor() {
|
||||
if (this.cursor === '') {
|
||||
const res = await this.db.db
|
||||
.selectFrom('safelink_cursor')
|
||||
.selectAll()
|
||||
.where('id', '=', 1)
|
||||
.executeTakeFirst()
|
||||
if (!res) {
|
||||
return ''
|
||||
}
|
||||
this.cursor = res.cursor
|
||||
}
|
||||
return this.cursor
|
||||
}
|
||||
|
||||
private async setCursor(cursor: string) {
|
||||
const updatedAt = new Date()
|
||||
try {
|
||||
await this.db.db
|
||||
.insertInto('safelink_cursor')
|
||||
.values({
|
||||
id: 1,
|
||||
cursor,
|
||||
updatedAt,
|
||||
})
|
||||
.onConflict(oc => oc.column('id').doUpdateSet({cursor, updatedAt}))
|
||||
.execute()
|
||||
this.cursor = cursor
|
||||
} catch (err) {
|
||||
redirectLogger.error({error: err}, 'failed to update safelink cursor')
|
||||
}
|
||||
}
|
||||
|
||||
private static normalizeUrl(input: string) {
|
||||
if (!SCHEME_REGEX.test(input)) {
|
||||
input = `https://${input}`
|
||||
}
|
||||
const u = new URL(input)
|
||||
u.hash = ''
|
||||
let normalized = u.href.replace(SCHEME_REGEX, '').toLowerCase()
|
||||
if (normalized.endsWith('/')) {
|
||||
normalized = normalized.substring(0, normalized.length - 1)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
private static normalizeDomain(input: string) {
|
||||
if (!SCHEME_REGEX.test(input)) {
|
||||
input = `https://${input}`
|
||||
}
|
||||
const u = new URL(input)
|
||||
return u.host.toLowerCase()
|
||||
}
|
||||
}
|
||||
|
||||
export class OzoneAgent {
|
||||
private identifier: string
|
||||
private password: string
|
||||
|
||||
private session: CredentialSession
|
||||
private agent: AtpAgent
|
||||
|
||||
private refreshAt = 0
|
||||
|
||||
constructor(pdsHost: string, identifier: string, password: string) {
|
||||
this.identifier = identifier
|
||||
this.password = password
|
||||
|
||||
this.session = new CredentialSession(new URL(pdsHost))
|
||||
this.agent = new AtpAgent(this.session)
|
||||
}
|
||||
|
||||
public async getAgent() {
|
||||
if (!this.identifier && !this.password) {
|
||||
throw new Error(
|
||||
'OZONE_AGENT_HANDLE and OZONE_AGENT_PASS environment variables must be set',
|
||||
)
|
||||
}
|
||||
|
||||
if (!this.session.hasSession) {
|
||||
redirectLogger.info('creating Ozone session')
|
||||
await this.session.login({
|
||||
identifier: this.identifier,
|
||||
password: this.password,
|
||||
})
|
||||
redirectLogger.info('ozone session created successfully')
|
||||
this.refreshAt = Date.now() + 50 * MINUTE
|
||||
}
|
||||
|
||||
if (Date.now() <= this.refreshAt) {
|
||||
await this.refreshSession()
|
||||
}
|
||||
|
||||
return this.agent
|
||||
}
|
||||
|
||||
public async refreshSession() {
|
||||
try {
|
||||
await this.session.refreshSession()
|
||||
this.refreshAt = Date.now() + 50 * MINUTE
|
||||
} catch (e) {
|
||||
redirectLogger.error({error: e}, 'error refreshing session')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import {envInt, envList, envStr} from '@atproto/common'
|
||||
import {envBool, envInt, envList, envStr} from '@atproto/common'
|
||||
|
||||
export type Config = {
|
||||
service: ServiceConfig
|
||||
@@ -9,7 +9,12 @@ export type ServiceConfig = {
|
||||
port: number
|
||||
version?: string
|
||||
hostnames: string[]
|
||||
hostnamesSet: Set<string>
|
||||
appHostname: string
|
||||
safelinkEnabled: boolean
|
||||
safelinkPdsUrl?: string
|
||||
safelinkAgentIdentifier?: string
|
||||
safelinkAgentPass?: string
|
||||
}
|
||||
|
||||
export type DbConfig = {
|
||||
@@ -36,6 +41,10 @@ export type Environment = {
|
||||
dbPostgresPoolSize?: number
|
||||
dbPostgresPoolMaxUses?: number
|
||||
dbPostgresPoolIdleTimeoutMs?: number
|
||||
safelinkEnabled?: boolean
|
||||
safelinkPdsUrl?: string
|
||||
safelinkAgentIdentifier?: string
|
||||
safelinkAgentPass?: string
|
||||
}
|
||||
|
||||
export const readEnv = (): Environment => {
|
||||
@@ -52,6 +61,10 @@ export const readEnv = (): Environment => {
|
||||
dbPostgresPoolIdleTimeoutMs: envInt(
|
||||
'LINK_DB_POSTGRES_POOL_IDLE_TIMEOUT_MS',
|
||||
),
|
||||
safelinkEnabled: envBool('LINK_SAFELINK_ENABLED'),
|
||||
safelinkPdsUrl: envStr('LINK_SAFELINK_PDS_URL'),
|
||||
safelinkAgentIdentifier: envStr('LINK_SAFELINK_AGENT_IDENTIFIER'),
|
||||
safelinkAgentPass: envStr('LINK_SAFELINK_AGENT_PASS'),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +73,12 @@ export const envToCfg = (env: Environment): Config => {
|
||||
port: env.port ?? 3000,
|
||||
version: env.version,
|
||||
hostnames: env.hostnames,
|
||||
appHostname: env.appHostname || 'bsky.app',
|
||||
hostnamesSet: new Set(env.hostnames),
|
||||
appHostname: env.appHostname ?? 'bsky.app',
|
||||
safelinkEnabled: env.safelinkEnabled ?? false,
|
||||
safelinkPdsUrl: env.safelinkPdsUrl,
|
||||
safelinkAgentIdentifier: env.safelinkAgentIdentifier,
|
||||
safelinkAgentPass: env.safelinkAgentPass,
|
||||
}
|
||||
if (!env.dbPostgresUrl) {
|
||||
throw new Error('Must configure postgres url (LINK_DB_POSTGRES_URL)')
|
||||
@@ -75,6 +93,7 @@ export const envToCfg = (env: Environment): Config => {
|
||||
size: env.dbPostgresPoolSize ?? 10,
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
service: serviceCfg,
|
||||
db: dbCfg,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {Config} from './config.js'
|
||||
import {SafelinkClient} from './cache/safelinkClient.js'
|
||||
import {type Config} from './config.js'
|
||||
import Database from './db/index.js'
|
||||
|
||||
export type AppContextOptions = {
|
||||
@@ -9,11 +10,16 @@ export type AppContextOptions = {
|
||||
export class AppContext {
|
||||
cfg: Config
|
||||
db: Database
|
||||
safelinkClient: SafelinkClient
|
||||
abortController = new AbortController()
|
||||
|
||||
constructor(private opts: AppContextOptions) {
|
||||
this.cfg = this.opts.cfg
|
||||
this.db = this.opts.db
|
||||
this.safelinkClient = new SafelinkClient({
|
||||
cfg: this.opts.cfg.service,
|
||||
db: this.opts.db,
|
||||
})
|
||||
}
|
||||
|
||||
static async fromConfig(cfg: Config, overrides?: Partial<AppContextOptions>) {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import {type Kysely, sql} from 'kysely'
|
||||
|
||||
export async function up(db: Kysely<unknown>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable('safelink_rule')
|
||||
.addColumn('id', 'bigserial', col => col.primaryKey())
|
||||
.addColumn('eventType', 'varchar', col => col.notNull())
|
||||
.addColumn('url', 'varchar', col => col.notNull())
|
||||
.addColumn('pattern', 'varchar', col => col.notNull())
|
||||
.addColumn('action', 'varchar', col => col.notNull())
|
||||
.addColumn('createdAt', 'timestamptz', col => col.notNull())
|
||||
.execute()
|
||||
|
||||
await db.schema
|
||||
.createTable('safelink_cursor')
|
||||
.addColumn('id', 'bigserial', col => col.notNull())
|
||||
.addColumn('cursor', 'varchar', col => col.notNull())
|
||||
.addColumn('updatedAt', 'timestamptz', col => col.notNull())
|
||||
.execute()
|
||||
|
||||
await db.schema
|
||||
.createIndex('safelink_rule_url_pattern_created_at_idx')
|
||||
.on('safelink_rule')
|
||||
.expression(sql`"url", "pattern", "createdAt" DESC`)
|
||||
.execute()
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<unknown>): Promise<void> {
|
||||
await db.schema
|
||||
.dropIndex('safelink_rule_url_pattern_created_at_idx')
|
||||
.execute()
|
||||
await db.schema.dropTable('safelink_rule').execute()
|
||||
await db.schema.dropTable('safelink_cursor').execute()
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {type Kysely} from 'kysely'
|
||||
|
||||
export async function up(
|
||||
db: Kysely<{safelink_rule: {}; safelink_cursor: {}}>,
|
||||
): Promise<void> {
|
||||
// Remove existing items from safelink_rule that were duplicated due to broken cursor
|
||||
await db.deleteFrom(['safelink_rule']).execute()
|
||||
|
||||
// Delete the old cursor
|
||||
await db.deleteFrom(['safelink_cursor']).execute()
|
||||
|
||||
await db.schema
|
||||
.alterTable('safelink_cursor')
|
||||
.addPrimaryKeyConstraint('pk_id', ['id'])
|
||||
.execute()
|
||||
}
|
||||
|
||||
export async function down(
|
||||
db: Kysely<{safelink_rule: {}; safelink_cursor: {}}>,
|
||||
): Promise<void> {
|
||||
// Remove any rules that were added
|
||||
await db.deleteFrom(['safelink_rule']).execute()
|
||||
|
||||
// Delete the cursor
|
||||
await db.deleteFrom(['safelink_cursor']).execute()
|
||||
|
||||
await db.schema
|
||||
.alterTable('safelink_cursor')
|
||||
.dropConstraint('pk_id')
|
||||
.execute()
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import * as init from './001-init.js'
|
||||
import * as safelink from './002-safelink.js'
|
||||
import * as safelinkCursorConstraint from './003-safelink-cursor-constraint.js'
|
||||
|
||||
export default {
|
||||
'001': init,
|
||||
'002': safelink,
|
||||
'003': safelinkCursorConstraint,
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import {Selectable} from 'kysely'
|
||||
import {type ToolsOzoneSafelinkDefs} from '@atproto/api'
|
||||
import {type Selectable} from 'kysely'
|
||||
|
||||
export type DbSchema = {
|
||||
link: Link
|
||||
safelink_rule: SafelinkRule
|
||||
safelink_cursor: SafelinkCursor
|
||||
}
|
||||
|
||||
export interface Link {
|
||||
@@ -14,4 +17,21 @@ export enum LinkType {
|
||||
StarterPack = 1,
|
||||
}
|
||||
|
||||
export interface SafelinkRule {
|
||||
id: number
|
||||
eventType: ToolsOzoneSafelinkDefs.EventType
|
||||
url: string
|
||||
pattern: ToolsOzoneSafelinkDefs.PatternType
|
||||
action: ToolsOzoneSafelinkDefs.ActionType
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface SafelinkCursor {
|
||||
id: number
|
||||
cursor: string
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
export type LinkEntry = Selectable<Link>
|
||||
export type SafelinkRuleEntry = Selectable<SafelinkRule>
|
||||
export type SafelinkCursorEntry = Selectable<SafelinkCursor>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import escapeHTML from 'escape-html'
|
||||
|
||||
export function linkRedirectContents(link: string): string {
|
||||
return `
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="refresh" content="0; URL='${escapeHTML(link)}'" />
|
||||
<meta
|
||||
http-equiv="Cache-Control"
|
||||
content="no-store, no-cache, must-revalidate, max-age=0" />
|
||||
<meta http-equiv="Pragma" content="no-cache" />
|
||||
<meta http-equiv="Expires" content="0" />
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
</html>
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import escapeHTML from 'escape-html'
|
||||
import {type Request} from 'express'
|
||||
|
||||
export function linkWarningContents(
|
||||
req: Request,
|
||||
opts: {
|
||||
type: 'warn' | 'block'
|
||||
link: string
|
||||
},
|
||||
): string {
|
||||
const continueButton =
|
||||
opts.type === 'warn'
|
||||
? `<a class="button secondary" href="${escapeHTML(opts.link)}">${req.__('Continue Anyway')}</a>`
|
||||
: ''
|
||||
|
||||
return `
|
||||
<div class="warning-icon">⚠️</div>
|
||||
<h1>
|
||||
${
|
||||
opts.type === 'warn'
|
||||
? req.__('Potentially Dangerous Link')
|
||||
: req.__('Blocked Link')
|
||||
}
|
||||
</h1>
|
||||
<p class="warning-text">
|
||||
${
|
||||
opts.type === 'warn'
|
||||
? req.__(
|
||||
'This link may be malicious. You should proceed at your own risk.',
|
||||
)
|
||||
: req.__(
|
||||
'This link has been identified as malicious and has blocked for your safety.',
|
||||
)
|
||||
}
|
||||
</p>
|
||||
<div class="blocked-site">
|
||||
<p class="site-url">${escapeHTML(opts.link)}</p>
|
||||
</div>
|
||||
<div class="button-group">
|
||||
${continueButton}
|
||||
<a class="button primary" href="https://bsky.app">${req.__('Return to Bluesky')}</a>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import escapeHTML from 'escape-html'
|
||||
|
||||
export function linkWarningLayout(
|
||||
title: string,
|
||||
containerContents: string,
|
||||
): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
http-equiv="Cache-Control"
|
||||
content="no-store, no-cache, must-revalidate, max-age=0" />
|
||||
<meta http-equiv="Pragma" content="no-cache" />
|
||||
<meta http-equiv="Expires" content="0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>${escapeHTML(title)}</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial,
|
||||
sans-serif;
|
||||
background-color: #ffffff;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
text-align: center;
|
||||
}
|
||||
.warning-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
color: #000000;
|
||||
}
|
||||
.warning-text {
|
||||
font-size: 15px;
|
||||
color: #536471;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 24px;
|
||||
padding: 0 20px;
|
||||
}
|
||||
.blocked-site {
|
||||
background-color: #f7f9fa;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 24px;
|
||||
text-align: left;
|
||||
word-break: break-all;
|
||||
}
|
||||
.site-name {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #000000;
|
||||
margin-bottom: 4px;
|
||||
word-break: break-word;
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
.site-url {
|
||||
font-size: 14px;
|
||||
color: #536471;
|
||||
word-break: break-all;
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
.button {
|
||||
border: none;
|
||||
border-radius: 24px;
|
||||
padding: 12px 32px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
.primary {
|
||||
background-color: #1d9bf0;
|
||||
color: white;
|
||||
}
|
||||
.secondary {
|
||||
}
|
||||
.back-button:hover {
|
||||
background-color: #1a8cd8;
|
||||
}
|
||||
.back-button:active {
|
||||
background-color: #1681c4;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.warning-text {
|
||||
padding: 0 10px;
|
||||
}
|
||||
.blocked-site {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">${containerContents}</div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import path from 'node:path'
|
||||
import {fileURLToPath} from 'node:url'
|
||||
|
||||
import i18n from 'i18n'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
i18n.configure({
|
||||
locales: ['en', 'es', 'fr'],
|
||||
defaultLocale: 'en',
|
||||
directory: path.join(__dirname, '../locales'),
|
||||
})
|
||||
|
||||
export default i18n
|
||||
@@ -7,6 +7,7 @@ import {createHttpTerminator, type HttpTerminator} from 'http-terminator'
|
||||
|
||||
import {type Config} from './config.js'
|
||||
import {AppContext} from './context.js'
|
||||
import i18n from './i18n.js'
|
||||
import {default as routes, errorHandler} from './routes/index.js'
|
||||
|
||||
export * from './config.js'
|
||||
@@ -25,6 +26,7 @@ export class LinkService {
|
||||
static async create(cfg: Config): Promise<LinkService> {
|
||||
let app = express()
|
||||
app.use(cors())
|
||||
app.use(i18n.init)
|
||||
|
||||
const ctx = await AppContext.fromConfig(cfg)
|
||||
app = routes(ctx, app)
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
import {subsystemLogger} from '@atproto/common'
|
||||
import {type Logger} from 'pino'
|
||||
|
||||
export const httpLogger = subsystemLogger('bskylink')
|
||||
export const dbLogger = subsystemLogger('bskylink:db')
|
||||
export const httpLogger: Logger = subsystemLogger('bskylink')
|
||||
export const dbLogger: Logger = subsystemLogger('bskylink:db')
|
||||
export const redirectLogger: Logger = subsystemLogger('bskylink:redirect')
|
||||
|
||||
redirectLogger.info = (
|
||||
orig =>
|
||||
(...args: any[]) => {
|
||||
const [msg, ...rest] = args
|
||||
orig.apply(redirectLogger, [String(msg), ...rest])
|
||||
console.log('[bskylink:redirect]', ...args)
|
||||
}
|
||||
)(redirectLogger.info) as typeof redirectLogger.info
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import assert from 'node:assert'
|
||||
|
||||
import bodyParser from 'body-parser'
|
||||
import {Express, Request} from 'express'
|
||||
import {type Express, type Request} from 'express'
|
||||
|
||||
import {AppContext} from '../context.js'
|
||||
import {type AppContext} from '../context.js'
|
||||
import {LinkType} from '../db/schema.js'
|
||||
import {randomId} from '../util.js'
|
||||
import {handler} from './util.js'
|
||||
@@ -83,18 +83,21 @@ const getUrl = (ctx: AppContext, req: Request, id: string) => {
|
||||
: `https://${req.headers.host}`
|
||||
return `${baseUrl}/${id}`
|
||||
}
|
||||
const baseUrl = ctx.cfg.service.hostnames.includes(req.headers.host)
|
||||
? `https://${req.headers.host}`
|
||||
const host = req.headers.host ?? ''
|
||||
const baseUrl = ctx.cfg.service.hostnamesSet.has(host)
|
||||
? `https://${host}`
|
||||
: `https://${ctx.cfg.service.hostnames[0]}`
|
||||
return `${baseUrl}/${id}`
|
||||
}
|
||||
|
||||
const normalizedPathFromParts = (parts: string[]): string => {
|
||||
// When given ['path1', 'path2', 'te:fg'], output should be
|
||||
// /path1/path2/te:fg
|
||||
return (
|
||||
'/' +
|
||||
parts
|
||||
.map(encodeURIComponent)
|
||||
.map(part => part.replaceAll('%3A', ':')) // preserve colons
|
||||
.map(part => part.replace(/%3A/g, ':')) // preserve colons
|
||||
.join('/')
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import assert from 'node:assert'
|
||||
|
||||
import {DAY, SECOND} from '@atproto/common'
|
||||
import escapeHTML from 'escape-html'
|
||||
import {type Express} from 'express'
|
||||
|
||||
import {type AppContext} from '../context.js'
|
||||
import {linkRedirectContents} from '../html/linkRedirectContents.js'
|
||||
import {linkWarningContents} from '../html/linkWarningContents.js'
|
||||
import {linkWarningLayout} from '../html/linkWarningLayout.js'
|
||||
import {redirectLogger} from '../logger.js'
|
||||
import {handler} from './util.js'
|
||||
|
||||
const INTERNAL_IP_REGEX = new RegExp(
|
||||
@@ -39,14 +42,54 @@ export default function (ctx: AppContext, app: Express) {
|
||||
return res.status(302).end()
|
||||
}
|
||||
|
||||
// Default to a max age header
|
||||
res.setHeader('Cache-Control', `max-age=${(7 * DAY) / SECOND}`)
|
||||
res.type('html')
|
||||
res.status(200)
|
||||
res.type('html')
|
||||
|
||||
const escaped = escapeHTML(url.href)
|
||||
return res.send(
|
||||
`<html><head><meta http-equiv="refresh" content="0; URL='${escaped}'" /><style>:root { color-scheme: light dark; }</style></head></html>`,
|
||||
)
|
||||
let html: string | undefined
|
||||
|
||||
if (ctx.cfg.service.safelinkEnabled) {
|
||||
const rule = await ctx.safelinkClient.tryFindRule(link)
|
||||
if (rule !== 'ok') {
|
||||
switch (rule.action) {
|
||||
case 'whitelist':
|
||||
redirectLogger.info({rule}, 'Whitelist rule matched')
|
||||
break
|
||||
case 'block':
|
||||
html = linkWarningLayout(
|
||||
'Blocked Link Warning',
|
||||
linkWarningContents(req, {
|
||||
type: 'block',
|
||||
link: url.href,
|
||||
}),
|
||||
)
|
||||
res.setHeader('Cache-Control', 'no-store')
|
||||
redirectLogger.info({rule}, 'Block rule matched')
|
||||
break
|
||||
case 'warn':
|
||||
html = linkWarningLayout(
|
||||
'Malicious Link Warning',
|
||||
linkWarningContents(req, {
|
||||
type: 'warn',
|
||||
link: url.href,
|
||||
}),
|
||||
)
|
||||
res.setHeader('Cache-Control', 'no-store')
|
||||
redirectLogger.info({rule}, 'Warn rule matched')
|
||||
break
|
||||
default:
|
||||
redirectLogger.warn({rule}, 'Unknown rule matched')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If there is no html defined yet, we will create a redirect html
|
||||
if (!html) {
|
||||
html = linkRedirectContents(url.href)
|
||||
}
|
||||
|
||||
return res.end(html)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import assert from 'node:assert'
|
||||
import {AddressInfo} from 'node:net'
|
||||
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 () => {
|
||||
@@ -15,6 +17,10 @@ describe('link service', async () => {
|
||||
appHostname: 'test.bsky.app',
|
||||
dbPostgresSchema: 'link_test',
|
||||
dbPostgresUrl: process.env.DB_POSTGRES_URL,
|
||||
safelinkEnabled: true,
|
||||
ozoneUrl: 'http://localhost:2583',
|
||||
ozoneAgentHandle: 'mod-authority.test',
|
||||
ozoneAgentPass: 'hunter2',
|
||||
})
|
||||
const migrateDb = Database.postgres({
|
||||
url: cfg.db.url,
|
||||
@@ -26,8 +32,85 @@ describe('link service', async () => {
|
||||
await linkService.start()
|
||||
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({
|
||||
$type: 'tools.ozone.safelink.defs#event',
|
||||
id: 1,
|
||||
eventType: ToolsOzoneSafelinkDefs.ADDRULE,
|
||||
url: 'https://en.wikipedia.org/wiki/Fight_Club',
|
||||
pattern: ToolsOzoneSafelinkDefs.URL,
|
||||
action: ToolsOzoneSafelinkDefs.WARN,
|
||||
reason: ToolsOzoneSafelinkDefs.SPAM,
|
||||
createdBy: 'did:example:admin',
|
||||
createdAt: now,
|
||||
comment: 'Do not talk about Fight Club',
|
||||
})
|
||||
linkService.ctx.cfg.eventCache.smartUpdate({
|
||||
$type: 'tools.ozone.safelink.defs#event',
|
||||
id: 2,
|
||||
eventType: ToolsOzoneSafelinkDefs.ADDRULE,
|
||||
url: 'https://gist.github.com/MattIPv4/045239bc27b16b2bcf7a3a9a4648c08a',
|
||||
pattern: ToolsOzoneSafelinkDefs.URL,
|
||||
action: ToolsOzoneSafelinkDefs.BLOCK,
|
||||
reason: ToolsOzoneSafelinkDefs.SPAM,
|
||||
createdBy: 'did:example:admin',
|
||||
createdAt: now,
|
||||
comment: 'All Bs',
|
||||
})
|
||||
linkService.ctx.cfg.eventCache.smartUpdate({
|
||||
$type: 'tools.ozone.safelink.defs#event',
|
||||
id: 3,
|
||||
eventType: ToolsOzoneSafelinkDefs.ADDRULE,
|
||||
url: 'https://en.wikipedia.org',
|
||||
pattern: ToolsOzoneSafelinkDefs.DOMAIN,
|
||||
action: ToolsOzoneSafelinkDefs.WHITELIST,
|
||||
reason: ToolsOzoneSafelinkDefs.NONE,
|
||||
createdBy: 'did:example:admin',
|
||||
createdAt: now,
|
||||
comment: 'Whitelisting the knowledge base of the internet',
|
||||
})
|
||||
linkService.ctx.cfg.eventCache.smartUpdate({
|
||||
$type: 'tools.ozone.safelink.defs#event',
|
||||
id: 4,
|
||||
eventType: ToolsOzoneSafelinkDefs.ADDRULE,
|
||||
url: 'https://www.instagram.com/teamseshbones/?hl=en',
|
||||
pattern: ToolsOzoneSafelinkDefs.URL,
|
||||
action: ToolsOzoneSafelinkDefs.BLOCK,
|
||||
reason: ToolsOzoneSafelinkDefs.SPAM,
|
||||
createdBy: 'did:example:admin',
|
||||
createdAt: now,
|
||||
comment: 'BONES has been erroneously blocked for the sake of this test',
|
||||
})
|
||||
const later = new Date(Date.now() + 1000).toISOString()
|
||||
linkService.ctx.cfg.eventCache.smartUpdate({
|
||||
$type: 'tools.ozone.safelink.defs#event',
|
||||
id: 5,
|
||||
eventType: ToolsOzoneSafelinkDefs.REMOVERULE,
|
||||
url: 'https://www.instagram.com/teamseshbones/?hl=en',
|
||||
pattern: ToolsOzoneSafelinkDefs.URL,
|
||||
action: ToolsOzoneSafelinkDefs.REMOVERULE,
|
||||
reason: ToolsOzoneSafelinkDefs.NONE,
|
||||
createdBy: 'did:example:admin',
|
||||
createdAt: later,
|
||||
comment:
|
||||
'BONES has been resurrected to bring good music to the world once again',
|
||||
})
|
||||
linkService.ctx.cfg.eventCache.smartUpdate({
|
||||
$type: 'tools.ozone.safelink.defs#event',
|
||||
id: 6,
|
||||
eventType: ToolsOzoneSafelinkDefs.ADDRULE,
|
||||
url: 'https://www.leagueoflegends.com/en-us/',
|
||||
pattern: ToolsOzoneSafelinkDefs.URL,
|
||||
action: ToolsOzoneSafelinkDefs.WARN,
|
||||
reason: ToolsOzoneSafelinkDefs.SPAM,
|
||||
createdBy: 'did:example:admin',
|
||||
createdAt: now,
|
||||
comment:
|
||||
'Could be quite the mistake to get into this addicting game, but we will warn instead of block',
|
||||
})
|
||||
})
|
||||
after(async () => {
|
||||
await linkService?.destroy()
|
||||
})
|
||||
@@ -76,6 +159,80 @@ describe('link service', async () => {
|
||||
assert.strictEqual(json.message, 'Link not found')
|
||||
})
|
||||
|
||||
it('League of Legends warned', async () => {
|
||||
const urlToRedirect = 'https://www.leagueoflegends.com/en-us/'
|
||||
const url = new URL(`${baseUrl}/redirect`)
|
||||
url.searchParams.set('u', urlToRedirect)
|
||||
const res = await fetch(url, {redirect: 'manual'})
|
||||
assert.strictEqual(res.status, 200)
|
||||
const html = await res.text()
|
||||
assert.match(
|
||||
html,
|
||||
new RegExp(urlToRedirect.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
|
||||
)
|
||||
// League of Legends is set to WARN, not BLOCK, so expect a warning (blocked-site div present)
|
||||
assert.match(
|
||||
html,
|
||||
/Warning: Malicious Link/,
|
||||
'Expected warning not found in HTML',
|
||||
)
|
||||
})
|
||||
|
||||
it('Wikipedia whitelisted, url restricted. Redirect safely since wikipedia is whitelisted', async () => {
|
||||
const urlToRedirect = 'https://en.wikipedia.org/wiki/Fight_Club'
|
||||
const url = new URL(`${baseUrl}/redirect`)
|
||||
url.searchParams.set('u', urlToRedirect)
|
||||
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, '\\$&')),
|
||||
)
|
||||
// Wikipedia domain is whitelisted, so no blocked-site div should be present
|
||||
assert.doesNotMatch(html, /"blocked-site"/)
|
||||
})
|
||||
|
||||
it('Unsafe redirect with block rule, due to the content of webpage.', async () => {
|
||||
const urlToRedirect =
|
||||
'https://gist.github.com/MattIPv4/045239bc27b16b2bcf7a3a9a4648c08a'
|
||||
const url = new URL(`${baseUrl}/redirect`)
|
||||
url.searchParams.set('u', urlToRedirect)
|
||||
const res = await fetch(url, {redirect: 'manual'})
|
||||
assert.strictEqual(res.status, 200)
|
||||
const html = await res.text()
|
||||
assert.match(
|
||||
html,
|
||||
new RegExp(urlToRedirect.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
|
||||
)
|
||||
assert.match(
|
||||
html,
|
||||
/"blocked-site"/,
|
||||
'Expected blocked-site div not found in HTML',
|
||||
)
|
||||
})
|
||||
|
||||
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(
|
||||
'https://www.instagram.com/teamseshbones/?hl=en',
|
||||
)
|
||||
assert(result, 'Expected event not found in eventCache')
|
||||
assert.strictEqual(result.eventType, ToolsOzoneSafelinkDefs.REMOVERULE)
|
||||
const urlToRedirect = 'https://www.instagram.com/teamseshbones/?hl=en'
|
||||
const url = new URL(`${baseUrl}/redirect`)
|
||||
url.searchParams.set('u', urlToRedirect)
|
||||
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, '\\$&')),
|
||||
)
|
||||
})
|
||||
|
||||
async function getRedirect(link: string): Promise<[number, string]> {
|
||||
const url = new URL(link)
|
||||
const base = new URL(baseUrl)
|
||||
@@ -121,3 +278,83 @@ describe('link service', async () => {
|
||||
return payload.url
|
||||
}
|
||||
})
|
||||
|
||||
describe('link service no safelink', async () => {
|
||||
let linkService: LinkService
|
||||
let baseUrl: string
|
||||
before(async () => {
|
||||
const env = readEnv()
|
||||
const cfg = envToCfg({
|
||||
...env,
|
||||
hostnames: ['test.bsky.link'],
|
||||
appHostname: 'test.bsky.app',
|
||||
dbPostgresSchema: 'link_test',
|
||||
dbPostgresUrl: process.env.DB_POSTGRES_URL,
|
||||
safelinkEnabled: false,
|
||||
ozoneUrl: 'http://localhost:2583',
|
||||
ozoneAgentHandle: 'mod-authority.test',
|
||||
ozoneAgentPass: 'hunter2',
|
||||
})
|
||||
const migrateDb = Database.postgres({
|
||||
url: cfg.db.url,
|
||||
schema: cfg.db.schema,
|
||||
})
|
||||
await migrateDb.migrateToLatestOrThrow()
|
||||
await migrateDb.close()
|
||||
linkService = await LinkService.create(cfg)
|
||||
await linkService.start()
|
||||
const {port} = linkService.server?.address() as AddressInfo
|
||||
baseUrl = `http://localhost:${port}`
|
||||
})
|
||||
after(async () => {
|
||||
await linkService?.destroy()
|
||||
})
|
||||
it('Wikipedia whitelisted, url restricted. Safelink is disabled, so redirect is always safe', async () => {
|
||||
const urlToRedirect = 'https://en.wikipedia.org/wiki/Fight_Club'
|
||||
const url = new URL(`${baseUrl}/redirect`)
|
||||
url.searchParams.set('u', urlToRedirect)
|
||||
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"/)
|
||||
})
|
||||
|
||||
it('Unsafe redirect with block rule, but safelink is disabled so redirect is always safe', async () => {
|
||||
const urlToRedirect =
|
||||
'https://gist.github.com/MattIPv4/045239bc27b16b2bcf7a3a9a4648c08a'
|
||||
const url = new URL(`${baseUrl}/redirect`)
|
||||
url.searchParams.set('u', urlToRedirect)
|
||||
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"/)
|
||||
})
|
||||
|
||||
it('Rule adjustment, safe redirect, safelink is disabled so always safe', async () => {
|
||||
const urlToRedirect = 'https://www.instagram.com/teamseshbones/?hl=en'
|
||||
const url = new URL(`${baseUrl}/redirect`)
|
||||
url.searchParams.set('u', urlToRedirect)
|
||||
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"/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"lib": ["ES2021.String"]
|
||||
},
|
||||
"include": ["./src/index.ts", "./src/bin.ts"]
|
||||
}
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
||||
|
||||
@@ -2,27 +2,65 @@
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
"@atproto/common-web@^0.3.0":
|
||||
version "0.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.3.0.tgz#36da8c2c31d8cf8a140c3c8f03223319bf4430bb"
|
||||
integrity sha512-67VnV6JJyX+ZWyjV7xFQMypAgDmjVaR9ZCuU/QW+mqlqI7fex2uL4Fv+7/jHadgzhuJHVd6OHOvNn0wR5WZYtA==
|
||||
"@atproto/api@^0.16.7":
|
||||
version "0.16.7"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.16.7.tgz#eb0c520dbdaf74ba6f5ad7f9c6afe2d1389b8a0a"
|
||||
integrity sha512-EdVWkEgaEQm1LEiiP1fW/XXXpMNmtvT5c9+cZVRiwYc4rTB66WIJJWqmaMT/tB7nccMkFjr6FtwObq5LewWfgw==
|
||||
dependencies:
|
||||
"@atproto/common-web" "^0.4.2"
|
||||
"@atproto/lexicon" "^0.5.0"
|
||||
"@atproto/syntax" "^0.4.1"
|
||||
"@atproto/xrpc" "^0.7.4"
|
||||
await-lock "^2.2.2"
|
||||
multiformats "^9.9.0"
|
||||
tlds "^1.234.0"
|
||||
zod "^3.23.8"
|
||||
|
||||
"@atproto/common-web@^0.4.2":
|
||||
version "0.4.2"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.2.tgz#6e3add6939da93d3dfbc8f87e26dc4f57fad7259"
|
||||
integrity sha512-vrXwGNoFGogodjQvJDxAeP3QbGtawgZute2ed1XdRO0wMixLk3qewtikZm06H259QDJVu6voKC5mubml+WgQUw==
|
||||
dependencies:
|
||||
graphemer "^1.4.0"
|
||||
multiformats "^9.9.0"
|
||||
uint8arrays "3.0.0"
|
||||
zod "^3.21.4"
|
||||
zod "^3.23.8"
|
||||
|
||||
"@atproto/common@^0.4.0":
|
||||
version "0.4.0"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.4.0.tgz#d77696c7eb545426df727837d9ee333b429fe7ef"
|
||||
integrity sha512-yOXuPlCjT/OK9j+neIGYn9wkxx/AlxQSucysAF0xgwu0Ji8jAtKBf9Jv6R5ObYAjAD/kVUvEYumle+Yq/R9/7g==
|
||||
"@atproto/common@^0.4.11":
|
||||
version "0.4.11"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.4.11.tgz#9291b7c26f8b3507e280f7ecbdf1695ab5ea62f6"
|
||||
integrity sha512-Knv0viYXNMfCdIE7jLUiWJKnnMfEwg+vz2epJQi8WOjqtqCFb3W/3Jn72ZiuovIfpdm13MaOiny6w2NErUQC6g==
|
||||
dependencies:
|
||||
"@atproto/common-web" "^0.3.0"
|
||||
"@atproto/common-web" "^0.4.2"
|
||||
"@ipld/dag-cbor" "^7.0.3"
|
||||
cbor-x "^1.5.1"
|
||||
iso-datestring-validator "^2.2.2"
|
||||
multiformats "^9.9.0"
|
||||
pino "^8.15.0"
|
||||
pino "^8.21.0"
|
||||
|
||||
"@atproto/lexicon@^0.5.0":
|
||||
version "0.5.0"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.5.0.tgz#4d2be425361f9ac7f9754b8a1ccba29ddf0b9460"
|
||||
integrity sha512-3aAzEAy9EAPs3CxznzMhEcqDd7m3vz1eze/ya9/ThbB7yleqJIhz5GY2q76tCCwHPhn5qDDMhlA9kKV6fG23gA==
|
||||
dependencies:
|
||||
"@atproto/common-web" "^0.4.2"
|
||||
"@atproto/syntax" "^0.4.1"
|
||||
iso-datestring-validator "^2.2.2"
|
||||
multiformats "^9.9.0"
|
||||
zod "^3.23.8"
|
||||
|
||||
"@atproto/syntax@^0.4.1":
|
||||
version "0.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.4.1.tgz#f77bc610ae0914449ff3f4731861e3da429915f5"
|
||||
integrity sha512-CJdImtLAiFO+0z3BWTtxwk6aY5w4t8orHTMVJgkf++QRJWTxPbIFko/0hrkADB7n2EruDxDSeAgfUGehpH6ngw==
|
||||
|
||||
"@atproto/xrpc@^0.7.4":
|
||||
version "0.7.4"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.7.4.tgz#030342548797c1f344968c457a8659dbb60a2d60"
|
||||
integrity sha512-sDi68+QE1XHegTaNAndlX41Gp827pouSzSs8CyAwhrqZdsJUxE3P7TMtrA0z+zAjvxVyvzscRc0TsN/fGUGrhw==
|
||||
dependencies:
|
||||
"@atproto/lexicon" "^0.5.0"
|
||||
zod "^3.23.8"
|
||||
|
||||
"@cbor-extract/cbor-extract-darwin-arm64@2.2.0":
|
||||
version "2.2.0"
|
||||
@@ -54,6 +92,13 @@
|
||||
resolved "https://registry.yarnpkg.com/@cbor-extract/cbor-extract-win32-x64/-/cbor-extract-win32-x64-2.2.0.tgz#4b3f07af047f984c082de34b116e765cb9af975f"
|
||||
integrity sha512-l2M+Z8DO2vbvADOBNLbbh9y5ST1RY5sqkWOg/58GkUPBYou/cuNZ68SGQ644f1CvZ8kcOxyZtw06+dxWHIoN/w==
|
||||
|
||||
"@cspotcode/source-map-support@^0.8.0":
|
||||
version "0.8.1"
|
||||
resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1"
|
||||
integrity sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==
|
||||
dependencies:
|
||||
"@jridgewell/trace-mapping" "0.3.9"
|
||||
|
||||
"@ipld/dag-cbor@^7.0.3":
|
||||
version "7.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@ipld/dag-cbor/-/dag-cbor-7.0.3.tgz#aa31b28afb11a807c3d627828a344e5521ac4a1e"
|
||||
@@ -62,6 +107,95 @@
|
||||
cborg "^1.6.0"
|
||||
multiformats "^9.5.4"
|
||||
|
||||
"@jridgewell/resolve-uri@^3.0.3":
|
||||
version "3.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6"
|
||||
integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==
|
||||
|
||||
"@jridgewell/sourcemap-codec@^1.4.10":
|
||||
version "1.5.5"
|
||||
resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba"
|
||||
integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==
|
||||
|
||||
"@jridgewell/trace-mapping@0.3.9":
|
||||
version "0.3.9"
|
||||
resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz#6534fd5933a53ba7cbf3a17615e273a0d1273ff9"
|
||||
integrity sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==
|
||||
dependencies:
|
||||
"@jridgewell/resolve-uri" "^3.0.3"
|
||||
"@jridgewell/sourcemap-codec" "^1.4.10"
|
||||
|
||||
"@messageformat/core@^3.0.0":
|
||||
version "3.4.0"
|
||||
resolved "https://registry.yarnpkg.com/@messageformat/core/-/core-3.4.0.tgz#2814c23383dec7bddf535d54f2a03e410165ca9f"
|
||||
integrity sha512-NgCFubFFIdMWJGN5WuQhHCNmzk7QgiVfrViFxcS99j7F5dDS5EP6raR54I+2ydhe4+5/XTn/YIEppFaqqVWHsw==
|
||||
dependencies:
|
||||
"@messageformat/date-skeleton" "^1.0.0"
|
||||
"@messageformat/number-skeleton" "^1.0.0"
|
||||
"@messageformat/parser" "^5.1.0"
|
||||
"@messageformat/runtime" "^3.0.1"
|
||||
make-plural "^7.0.0"
|
||||
safe-identifier "^0.4.1"
|
||||
|
||||
"@messageformat/date-skeleton@^1.0.0":
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@messageformat/date-skeleton/-/date-skeleton-1.1.0.tgz#3bad068cbf5873d14592cfc7a73dd4d8615e2739"
|
||||
integrity sha512-rmGAfB1tIPER+gh3p/RgA+PVeRE/gxuQ2w4snFWPF5xtb5mbWR7Cbw7wCOftcUypbD6HVoxrVdyyghPm3WzP5A==
|
||||
|
||||
"@messageformat/number-skeleton@^1.0.0":
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@messageformat/number-skeleton/-/number-skeleton-1.2.0.tgz#e7c245c41a1b2722bc59dad68f4d454f761bc9b4"
|
||||
integrity sha512-xsgwcL7J7WhlHJ3RNbaVgssaIwcEyFkBqxHdcdaiJzwTZAWEOD8BuUFxnxV9k5S0qHN3v/KzUpq0IUpjH1seRg==
|
||||
|
||||
"@messageformat/parser@^5.1.0":
|
||||
version "5.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@messageformat/parser/-/parser-5.1.1.tgz#ca7d6c18e9f3f6b6bc984a465dac16da00106055"
|
||||
integrity sha512-3p0YRGCcTUCYvBKLIxtDDyrJ0YijGIwrTRu1DT8gIviIDZru8H23+FkY6MJBzM1n9n20CiM4VeDYuBsrrwnLjg==
|
||||
dependencies:
|
||||
moo "^0.5.1"
|
||||
|
||||
"@messageformat/runtime@^3.0.1":
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@messageformat/runtime/-/runtime-3.0.1.tgz#94d1f6c43265c28ef7aed98ecfcc0968c6c849ac"
|
||||
integrity sha512-6RU5ol2lDtO8bD9Yxe6CZkl0DArdv0qkuoZC+ZwowU+cdRlVE1157wjCmlA5Rsf1Xc/brACnsZa5PZpEDfTFFg==
|
||||
dependencies:
|
||||
make-plural "^7.0.0"
|
||||
|
||||
"@tsconfig/node10@^1.0.7":
|
||||
version "1.0.11"
|
||||
resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.11.tgz#6ee46400685f130e278128c7b38b7e031ff5b2f2"
|
||||
integrity sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==
|
||||
|
||||
"@tsconfig/node12@^1.0.7":
|
||||
version "1.0.11"
|
||||
resolved "https://registry.yarnpkg.com/@tsconfig/node12/-/node12-1.0.11.tgz#ee3def1f27d9ed66dac6e46a295cffb0152e058d"
|
||||
integrity sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==
|
||||
|
||||
"@tsconfig/node14@^1.0.0":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@tsconfig/node14/-/node14-1.0.3.tgz#e4386316284f00b98435bf40f72f75a09dabf6c1"
|
||||
integrity sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==
|
||||
|
||||
"@tsconfig/node16@^1.0.2":
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.4.tgz#0b92dcc0cc1c81f6f306a381f28e31b1a56536e9"
|
||||
integrity sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==
|
||||
|
||||
"@types/body-parser@*", "@types/body-parser@^1.19.5":
|
||||
version "1.19.6"
|
||||
resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.6.tgz#1859bebb8fd7dac9918a45d54c1971ab8b5af474"
|
||||
integrity sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==
|
||||
dependencies:
|
||||
"@types/connect" "*"
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/connect@*":
|
||||
version "3.4.38"
|
||||
resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.38.tgz#5ba7f3bc4fbbdeaff8dded952e5ff2cc53f8d858"
|
||||
integrity sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/cors@^2.8.17":
|
||||
version "2.8.17"
|
||||
resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.17.tgz#5d718a5e494a8166f569d986794e49c48b216b2b"
|
||||
@@ -74,6 +208,41 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/escape-html/-/escape-html-1.0.4.tgz#dc7c166b76c7b03b27e32f80edf01d91eb5d9af2"
|
||||
integrity sha512-qZ72SFTgUAZ5a7Tj6kf2SHLetiH5S6f8G5frB2SPQ3EyF02kxdyBFf4Tz4banE3xCgGnKgWLt//a6VuYHKYJTg==
|
||||
|
||||
"@types/express-serve-static-core@^4.17.33":
|
||||
version "4.19.6"
|
||||
resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz#e01324c2a024ff367d92c66f48553ced0ab50267"
|
||||
integrity sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
"@types/qs" "*"
|
||||
"@types/range-parser" "*"
|
||||
"@types/send" "*"
|
||||
|
||||
"@types/express@^4.17.21":
|
||||
version "4.17.23"
|
||||
resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.23.tgz#35af3193c640bfd4d7fe77191cd0ed411a433bef"
|
||||
integrity sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==
|
||||
dependencies:
|
||||
"@types/body-parser" "*"
|
||||
"@types/express-serve-static-core" "^4.17.33"
|
||||
"@types/qs" "*"
|
||||
"@types/serve-static" "*"
|
||||
|
||||
"@types/http-errors@*":
|
||||
version "2.0.5"
|
||||
resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.5.tgz#5b749ab2b16ba113423feb1a64a95dcd30398472"
|
||||
integrity sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==
|
||||
|
||||
"@types/i18n@^0.13.12":
|
||||
version "0.13.12"
|
||||
resolved "https://registry.yarnpkg.com/@types/i18n/-/i18n-0.13.12.tgz#b457715766c63d8ffdfc51dd4fc1f72728e9d38f"
|
||||
integrity sha512-iAd2QjKh+0ToBXocmCS3m38GskiaGzmSV1MTQz2GaOraqSqBiLf46J7u3EGINl+st+Uk4lO3OL7QyIjTJlrWIg==
|
||||
|
||||
"@types/mime@^1":
|
||||
version "1.3.5"
|
||||
resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.5.tgz#1ef302e01cf7d2b5a0fa526790c9123bf1d06690"
|
||||
integrity sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==
|
||||
|
||||
"@types/node@*":
|
||||
version "20.14.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.14.2.tgz#a5f4d2bcb4b6a87bffcaa717718c5a0f208f4a18"
|
||||
@@ -90,6 +259,33 @@
|
||||
pg-protocol "*"
|
||||
pg-types "^4.0.1"
|
||||
|
||||
"@types/qs@*":
|
||||
version "6.14.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.14.0.tgz#d8b60cecf62f2db0fb68e5e006077b9178b85de5"
|
||||
integrity sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==
|
||||
|
||||
"@types/range-parser@*":
|
||||
version "1.2.7"
|
||||
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb"
|
||||
integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==
|
||||
|
||||
"@types/send@*":
|
||||
version "0.17.5"
|
||||
resolved "https://registry.yarnpkg.com/@types/send/-/send-0.17.5.tgz#d991d4f2b16f2b1ef497131f00a9114290791e74"
|
||||
integrity sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==
|
||||
dependencies:
|
||||
"@types/mime" "^1"
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/serve-static@*":
|
||||
version "1.15.8"
|
||||
resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.8.tgz#8180c3fbe4a70e8f00b9f70b9ba7f08f35987877"
|
||||
integrity sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==
|
||||
dependencies:
|
||||
"@types/http-errors" "*"
|
||||
"@types/node" "*"
|
||||
"@types/send" "*"
|
||||
|
||||
abort-controller@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392"
|
||||
@@ -105,6 +301,23 @@ accepts@~1.3.8:
|
||||
mime-types "~2.1.34"
|
||||
negotiator "0.6.3"
|
||||
|
||||
acorn-walk@^8.1.1:
|
||||
version "8.3.4"
|
||||
resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.3.4.tgz#794dd169c3977edf4ba4ea47583587c5866236b7"
|
||||
integrity sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==
|
||||
dependencies:
|
||||
acorn "^8.11.0"
|
||||
|
||||
acorn@^8.11.0, acorn@^8.4.1:
|
||||
version "8.15.0"
|
||||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816"
|
||||
integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==
|
||||
|
||||
arg@^4.1.0:
|
||||
version "4.1.3"
|
||||
resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089"
|
||||
integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==
|
||||
|
||||
array-flatten@1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2"
|
||||
@@ -115,6 +328,11 @@ atomic-sleep@^1.0.0:
|
||||
resolved "https://registry.yarnpkg.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz#eb85b77a601fc932cfe432c5acd364a9e2c9075b"
|
||||
integrity sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==
|
||||
|
||||
await-lock@^2.2.2:
|
||||
version "2.2.2"
|
||||
resolved "https://registry.yarnpkg.com/await-lock/-/await-lock-2.2.2.tgz#a95a9b269bfd2f69d22b17a321686f551152bcef"
|
||||
integrity sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==
|
||||
|
||||
base64-js@^1.3.1:
|
||||
version "1.5.1"
|
||||
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
|
||||
@@ -223,6 +441,11 @@ cors@^2.8.5:
|
||||
object-assign "^4"
|
||||
vary "^1"
|
||||
|
||||
create-require@^1.1.0:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333"
|
||||
integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==
|
||||
|
||||
debug@2.6.9:
|
||||
version "2.6.9"
|
||||
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
|
||||
@@ -230,6 +453,13 @@ debug@2.6.9:
|
||||
dependencies:
|
||||
ms "2.0.0"
|
||||
|
||||
debug@^4.3.3:
|
||||
version "4.4.1"
|
||||
resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.1.tgz#e5a8bc6cbc4c6cd3e64308b0693a3d4fa550189b"
|
||||
integrity sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==
|
||||
dependencies:
|
||||
ms "^2.1.3"
|
||||
|
||||
define-data-property@^1.1.4:
|
||||
version "1.1.4"
|
||||
resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e"
|
||||
@@ -259,6 +489,11 @@ detect-libc@^2.0.1:
|
||||
resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.3.tgz#f0cd503b40f9939b894697d19ad50895e30cf700"
|
||||
integrity sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==
|
||||
|
||||
diff@^4.0.1:
|
||||
version "4.0.2"
|
||||
resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"
|
||||
integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==
|
||||
|
||||
ee-first@1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
|
||||
@@ -446,6 +681,18 @@ http-terminator@^3.2.0:
|
||||
roarr "^7.0.4"
|
||||
type-fest "^2.3.3"
|
||||
|
||||
i18n@^0.15.1:
|
||||
version "0.15.1"
|
||||
resolved "https://registry.yarnpkg.com/i18n/-/i18n-0.15.1.tgz#68fb8993c461cc440bc2485d82f72019f2b92de8"
|
||||
integrity sha512-yue187t8MqUPMHdKjiZGrX+L+xcUsDClGO0Cz4loaKUOK9WrGw5pgan4bv130utOwX7fHE9w2iUeHFalVQWkXA==
|
||||
dependencies:
|
||||
"@messageformat/core" "^3.0.0"
|
||||
debug "^4.3.3"
|
||||
fast-printf "^1.6.9"
|
||||
make-plural "^7.0.0"
|
||||
math-interval-parser "^2.0.1"
|
||||
mustache "^4.2.0"
|
||||
|
||||
iconv-lite@0.4.24:
|
||||
version "0.4.24"
|
||||
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
|
||||
@@ -478,6 +725,26 @@ kysely@^0.27.3:
|
||||
resolved "https://registry.yarnpkg.com/kysely/-/kysely-0.27.3.tgz#6cc6c757040500b43c4ac596cdbb12be400ee276"
|
||||
integrity sha512-lG03Ru+XyOJFsjH3OMY6R/9U38IjDPfnOfDgO3ynhbDr+Dz8fak+X6L62vqu3iybQnj+lG84OttBuU9KY3L9kA==
|
||||
|
||||
lru-cache@^11.1.0:
|
||||
version "11.1.0"
|
||||
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.1.0.tgz#afafb060607108132dbc1cf8ae661afb69486117"
|
||||
integrity sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==
|
||||
|
||||
make-error@^1.1.1:
|
||||
version "1.3.6"
|
||||
resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2"
|
||||
integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==
|
||||
|
||||
make-plural@^7.0.0:
|
||||
version "7.4.0"
|
||||
resolved "https://registry.yarnpkg.com/make-plural/-/make-plural-7.4.0.tgz#fa6990dd550dea4de6b20163f74e5ed83d8a8d6d"
|
||||
integrity sha512-4/gC9KVNTV6pvYg2gFeQYTW3mWaoJt7WZE5vrp1KnQDgW92JtYZnzmZT81oj/dUTqAIu0ufI2x3dkgu3bB1tYg==
|
||||
|
||||
math-interval-parser@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/math-interval-parser/-/math-interval-parser-2.0.1.tgz#e22cd6d15a0a7f4c03aec560db76513da615bed4"
|
||||
integrity sha512-VmlAmb0UJwlvMyx8iPhXUDnVW1F9IrGEd9CIOmv+XL8AErCUUuozoDMrgImvnYt2A+53qVX/tPW6YJurMKYsvA==
|
||||
|
||||
media-typer@0.3.0:
|
||||
version "0.3.0"
|
||||
resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
|
||||
@@ -510,12 +777,17 @@ mime@1.6.0:
|
||||
resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
|
||||
integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==
|
||||
|
||||
moo@^0.5.1:
|
||||
version "0.5.2"
|
||||
resolved "https://registry.yarnpkg.com/moo/-/moo-0.5.2.tgz#f9fe82473bc7c184b0d32e2215d3f6e67278733c"
|
||||
integrity sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==
|
||||
|
||||
ms@2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
|
||||
integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==
|
||||
|
||||
ms@2.1.3:
|
||||
ms@2.1.3, ms@^2.1.3:
|
||||
version "2.1.3"
|
||||
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
|
||||
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
|
||||
@@ -530,6 +802,11 @@ multiformats@^9.4.2, multiformats@^9.5.4, multiformats@^9.9.0:
|
||||
resolved "https://registry.yarnpkg.com/multiformats/-/multiformats-9.9.0.tgz#c68354e7d21037a8f1f8833c8ccd68618e8f1d37"
|
||||
integrity sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==
|
||||
|
||||
mustache@^4.2.0:
|
||||
version "4.2.0"
|
||||
resolved "https://registry.yarnpkg.com/mustache/-/mustache-4.2.0.tgz#e5892324d60a12ec9c2a73359edca52972bf6f64"
|
||||
integrity sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==
|
||||
|
||||
negotiator@0.6.3:
|
||||
version "0.6.3"
|
||||
resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd"
|
||||
@@ -690,7 +967,7 @@ pino-std-serializers@^7.0.0:
|
||||
resolved "https://registry.yarnpkg.com/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz#7c625038b13718dbbd84ab446bd673dc52259e3b"
|
||||
integrity sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==
|
||||
|
||||
pino@^8.15.0:
|
||||
pino@^8.21.0:
|
||||
version "8.21.0"
|
||||
resolved "https://registry.yarnpkg.com/pino/-/pino-8.21.0.tgz#e1207f3675a2722940d62da79a7a55a98409f00d"
|
||||
integrity sha512-ip4qdzjkAyDDZklUaZkcRFb2iA118H9SgRh8yzTkSQK8HilsOJF7rSY8HoW5+I0M46AZgX/pxbprf2vvzQCE0Q==
|
||||
@@ -848,6 +1125,11 @@ safe-buffer@5.2.1, safe-buffer@~5.2.0:
|
||||
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
|
||||
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
|
||||
|
||||
safe-identifier@^0.4.1:
|
||||
version "0.4.2"
|
||||
resolved "https://registry.yarnpkg.com/safe-identifier/-/safe-identifier-0.4.2.tgz#cf6bfca31c2897c588092d1750d30ef501d59fcb"
|
||||
integrity sha512-6pNbSMW6OhAi9j+N8V+U715yBQsaWJ7eyEUaOrawX+isg5ZxhUlV1NipNtgaKHmFGiABwt+ZF04Ii+3Xjkg+8w==
|
||||
|
||||
safe-stable-stringify@^2.3.1, safe-stable-stringify@^2.4.3:
|
||||
version "2.4.3"
|
||||
resolved "https://registry.yarnpkg.com/safe-stable-stringify/-/safe-stable-stringify-2.4.3.tgz#138c84b6f6edb3db5f8ef3ef7115b8f55ccbf886"
|
||||
@@ -964,11 +1246,35 @@ thread-stream@^3.0.0:
|
||||
dependencies:
|
||||
real-require "^0.2.0"
|
||||
|
||||
tlds@^1.234.0:
|
||||
version "1.260.0"
|
||||
resolved "https://registry.yarnpkg.com/tlds/-/tlds-1.260.0.tgz#666c2584cf627b4296e888b799ec341be42821ac"
|
||||
integrity sha512-78+28EWBhCEE7qlyaHA9OR3IPvbCLiDh3Ckla593TksfFc9vfTsgvH7eS+dr3o9qr31gwGbogcI16yN91PoRjQ==
|
||||
|
||||
toidentifier@1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35"
|
||||
integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==
|
||||
|
||||
ts-node@^10.9.2:
|
||||
version "10.9.2"
|
||||
resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.2.tgz#70f021c9e185bccdca820e26dc413805c101c71f"
|
||||
integrity sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==
|
||||
dependencies:
|
||||
"@cspotcode/source-map-support" "^0.8.0"
|
||||
"@tsconfig/node10" "^1.0.7"
|
||||
"@tsconfig/node12" "^1.0.7"
|
||||
"@tsconfig/node14" "^1.0.0"
|
||||
"@tsconfig/node16" "^1.0.2"
|
||||
acorn "^8.4.1"
|
||||
acorn-walk "^8.1.1"
|
||||
arg "^4.1.0"
|
||||
create-require "^1.1.0"
|
||||
diff "^4.0.1"
|
||||
make-error "^1.1.1"
|
||||
v8-compile-cache-lib "^3.0.1"
|
||||
yn "3.1.1"
|
||||
|
||||
type-fest@^2.3.3:
|
||||
version "2.19.0"
|
||||
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-2.19.0.tgz#88068015bb33036a598b952e55e9311a60fd3a9b"
|
||||
@@ -1016,6 +1322,11 @@ utils-merge@1.0.1:
|
||||
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
|
||||
integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==
|
||||
|
||||
v8-compile-cache-lib@^3.0.1:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf"
|
||||
integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==
|
||||
|
||||
vary@^1, vary@~1.1.2:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
|
||||
@@ -1026,7 +1337,12 @@ xtend@^4.0.0:
|
||||
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
|
||||
integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
|
||||
|
||||
zod@^3.21.4:
|
||||
version "3.23.8"
|
||||
resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d"
|
||||
integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==
|
||||
yn@3.1.1:
|
||||
version "3.1.1"
|
||||
resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50"
|
||||
integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==
|
||||
|
||||
zod@^3.23.8:
|
||||
version "3.25.76"
|
||||
resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34"
|
||||
integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
|
||||
"github.com/flosch/pongo2/v6"
|
||||
)
|
||||
|
||||
func init() {
|
||||
pongo2.RegisterFilter("canonicalize_url", filterCanonicalizeURL)
|
||||
}
|
||||
|
||||
func filterCanonicalizeURL(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, *pongo2.Error) {
|
||||
urlStr := in.String()
|
||||
|
||||
parsedURL, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
// If parsing fails, return the original URL
|
||||
return in, nil
|
||||
}
|
||||
|
||||
// Remove query parameters and fragment
|
||||
parsedURL.RawQuery = ""
|
||||
parsedURL.Fragment = ""
|
||||
|
||||
// Return the cleaned URL
|
||||
return pongo2.AsValue(parsedURL.String()), nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/flosch/pongo2/v6"
|
||||
)
|
||||
|
||||
func TestCanonicalizeURLFilter(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "clean URL",
|
||||
input: "https://bsky.app/profile/user",
|
||||
expected: "https://bsky.app/profile/user",
|
||||
},
|
||||
{
|
||||
name: "URL with query params",
|
||||
input: "https://bsky.app/profile/user?utm_source=test",
|
||||
expected: "https://bsky.app/profile/user",
|
||||
},
|
||||
{
|
||||
name: "URL with multiple params",
|
||||
input: "https://bsky.app/profile/user?utm_source=twitter&utm_campaign=test",
|
||||
expected: "https://bsky.app/profile/user",
|
||||
},
|
||||
{
|
||||
name: "URL with fragment",
|
||||
input: "https://bsky.app/profile/user#section",
|
||||
expected: "https://bsky.app/profile/user",
|
||||
},
|
||||
{
|
||||
name: "URL with both params and fragment",
|
||||
input: "https://bsky.app/profile/user?param=1#section",
|
||||
expected: "https://bsky.app/profile/user",
|
||||
},
|
||||
{
|
||||
name: "malformed URL",
|
||||
input: "not-a-url",
|
||||
expected: "not-a-url", // Should return original on error
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
inputValue := pongo2.AsValue(tt.input)
|
||||
result, err := filterCanonicalizeURL(inputValue, nil)
|
||||
if err != nil {
|
||||
t.Errorf("filterCanonicalizeURL() error = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if result.String() != tt.expected {
|
||||
t.Errorf("filterCanonicalizeURL() = %v, want %v", result.String(), tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -331,6 +331,9 @@ func serve(cctx *cli.Context) error {
|
||||
e.GET("/starter-pack-short/:code", server.WebGeneric)
|
||||
e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack)
|
||||
|
||||
// bookmarks
|
||||
e.GET("/saved", server.WebGeneric)
|
||||
|
||||
// ipcc
|
||||
e.GET("/ipcc", server.WebIpCC)
|
||||
|
||||
@@ -606,10 +609,10 @@ type IPCCRequest struct {
|
||||
type IPCCResponse struct {
|
||||
CC string `json:"countryCode"`
|
||||
AgeRestrictedGeo bool `json:"isAgeRestrictedGeo,omitempty"`
|
||||
AgeBlockedGeo bool `json:"isAgeBlockedGeo,omitempty"`
|
||||
}
|
||||
|
||||
// IP address data is powered by IPinfo
|
||||
// https://ipinfo.io
|
||||
// This product includes GeoLite2 Data created by MaxMind, available from https://www.maxmind.com.
|
||||
func (srv *Server) WebIpCC(c echo.Context) error {
|
||||
realIP := c.RealIP()
|
||||
addr, err := netip.ParseAddr(realIP)
|
||||
|
||||
@@ -95,6 +95,7 @@
|
||||
<meta name="application-name" content="Bluesky">
|
||||
<meta name="generator" content="bskyweb">
|
||||
<meta property="og:site_name" content="Bluesky Social" />
|
||||
<meta name="twitter:site" content="@bluesky" />
|
||||
<link type="application/activity+json" href="" />
|
||||
|
||||
{% block html_head_extra -%}{%- endblock %}
|
||||
|
||||
@@ -12,11 +12,11 @@
|
||||
|
||||
<meta property="og:url" content="https://bsky.app" />
|
||||
<meta name="twitter:url" content="https://bsky.app" />
|
||||
<link rel="canonical" href="https://bsky.app" />
|
||||
|
||||
<meta property="og:image" content="https://bsky.app/static/social-card-default-gradient.png" />
|
||||
<meta property="twitter:image" content="https://bsky.app/static/social-card-default-gradient.png" />
|
||||
|
||||
<meta name="twitter:site" content="@bluesky" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
{%- endblock %}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<meta property="profile:username" content="{{ profileView.Handle }}">
|
||||
{%- if requestURI %}
|
||||
<meta property="og:url" content="{{ requestURI }}">
|
||||
<link rel="canonical" href="{{ requestURI|canonicalize_url }}" />
|
||||
{% endif -%}
|
||||
{%- if postView.Author.DisplayName %}
|
||||
<meta property="og:title" content="{{ postView.Author.DisplayName }} (@{{ postView.Author.Handle }})">
|
||||
@@ -23,15 +24,18 @@
|
||||
{%- if postText %}
|
||||
<meta name="description" content="{{ postText }}">
|
||||
<meta property="og:description" content="{{ postText }}">
|
||||
<meta property="twitter:description" content="{{ postText }}">
|
||||
{% endif -%}
|
||||
{%- if imgThumbUrls %}
|
||||
{% for imgThumbUrl in imgThumbUrls %}
|
||||
<meta property="og:image" content="{{ imgThumbUrl }}">
|
||||
<meta property="twitter:image" content="{{ imgThumbUrl }}">
|
||||
{% endfor %}
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
{%- elif postView.Author.Avatar %}
|
||||
{# Don't use avatar image in cards; usually looks bad #}
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta property="og:image" content="{{ postView.Author.Avatar }}">
|
||||
<meta property="twitter:image" content="{{ postView.Author.Avatar }}">
|
||||
{% endif %}
|
||||
<meta name="twitter:label1" content="Posted At">
|
||||
<meta name="twitter:value1" content="{{ postView.IndexedAt }}">
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<meta property="profile:username" content="{{ profileView.Handle }}">
|
||||
{%- if requestURI %}
|
||||
<meta property="og:url" content="{{ requestURI }}">
|
||||
<link rel="canonical" href="{{ requestURI|canonicalize_url }}" />
|
||||
{% endif -%}
|
||||
{%- if profileView.DisplayName %}
|
||||
<meta property="og:title" content="{{ profileView.DisplayName }} (@{{ profileView.Handle }})">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {AtUri, BskyAgent} from '@atproto/api'
|
||||
import {TestBsky, TestNetwork} from '@atproto/dev-env'
|
||||
import {type TestBsky, TestNetwork} from '@atproto/dev-env'
|
||||
import fs from 'fs'
|
||||
import net from 'net'
|
||||
import path from 'path'
|
||||
@@ -13,6 +13,7 @@ export interface TestUser {
|
||||
}
|
||||
|
||||
export interface TestPDS {
|
||||
appviewDid: string
|
||||
pdsUrl: string
|
||||
mocker: Mocker
|
||||
close: () => Promise<void>
|
||||
@@ -112,6 +113,7 @@ export async function createServer(
|
||||
)
|
||||
|
||||
return {
|
||||
appviewDid: testNet.bsky.serverDid,
|
||||
pdsUrl,
|
||||
mocker: new Mocker(testNet, pdsUrl, pic),
|
||||
async close() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.107.0",
|
||||
"version": "1.108.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -21,7 +21,7 @@
|
||||
"scripts": {
|
||||
"prepare": "is-ci || husky install",
|
||||
"postinstall": "patch-package && yarn intl:compile-if-needed",
|
||||
"prebuild": "expo prebuild --clean",
|
||||
"prebuild": "EXPO_NO_GIT_STATUS=1 expo prebuild --clean",
|
||||
"android": "expo run:android",
|
||||
"android:prod": "expo run:android --variant release",
|
||||
"android:profile": "BSKY_PROFILE=1 expo run:android --variant release",
|
||||
@@ -71,7 +71,7 @@
|
||||
"icons:optimize": "svgo -f ./assets/icons"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.16.2",
|
||||
"@atproto/api": "^0.16.7",
|
||||
"@bitdrift/react-native": "^0.6.8",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||
@@ -92,7 +92,7 @@
|
||||
"@lingui/react": "^4.14.1",
|
||||
"@mattermost/react-native-paste-input": "mattermost/react-native-paste-input",
|
||||
"@miblanchard/react-native-slider": "^2.6.0",
|
||||
"@mozzius/expo-dynamic-app-icon": "1.5.0",
|
||||
"@mozzius/expo-dynamic-app-icon": "^1.7.1",
|
||||
"@react-native-async-storage/async-storage": "2.1.2",
|
||||
"@react-native-menu/menu": "^1.2.3",
|
||||
"@react-native-picker/picker": "2.11.0",
|
||||
@@ -146,11 +146,12 @@
|
||||
"expo-image": "^2.4.0",
|
||||
"expo-image-crop-tool": "^0.1.8",
|
||||
"expo-image-manipulator": "~13.1.7",
|
||||
"expo-image-picker": "~16.1.4",
|
||||
"expo-image-picker": "17.0.5",
|
||||
"expo-intent-launcher": "^12.1.5",
|
||||
"expo-linear-gradient": "~14.1.5",
|
||||
"expo-linking": "~7.1.5",
|
||||
"expo-localization": "~16.1.5",
|
||||
"expo-location": "~18.1.6",
|
||||
"expo-media-library": "~17.1.7",
|
||||
"expo-notifications": "~0.31.3",
|
||||
"expo-screen-orientation": "~8.1.7",
|
||||
@@ -224,7 +225,7 @@
|
||||
"zod": "^3.20.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@atproto/dev-env": "^0.3.160",
|
||||
"@atproto/dev-env": "^0.3.172",
|
||||
"@babel/core": "^7.26.0",
|
||||
"@babel/preset-env": "^7.26.0",
|
||||
"@babel/runtime": "^7.26.0",
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
diff --git a/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/MediaHandler.kt b/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/MediaHandler.kt
|
||||
index c863fb8..cde8859 100644
|
||||
--- a/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/MediaHandler.kt
|
||||
+++ b/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/MediaHandler.kt
|
||||
@@ -101,16 +101,30 @@ internal class MediaHandler(
|
||||
val fileData = getAdditionalFileData(sourceUri)
|
||||
val mimeType = getType(context.contentResolver, sourceUri)
|
||||
|
||||
+ // Extract basic metadata
|
||||
+ var width = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)
|
||||
+ var height = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)
|
||||
+ val rotation = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)
|
||||
+
|
||||
+ // Android returns the encoded width/height which do not take the display rotation into
|
||||
+ // account. For videos recorded in portrait mode the encoded dimensions are often landscape
|
||||
+ // (e.g. 1920x1080) paired with a 90°/270° rotation flag. iOS adjusts these values before
|
||||
+ // reporting them, so to keep the behaviour consistent across platforms we swap the width
|
||||
+ // and height when the rotation indicates the video should be displayed in portrait.
|
||||
+ if (rotation % 180 != 0) {
|
||||
+ width = height.also { height = width }
|
||||
+ }
|
||||
+
|
||||
return ImagePickerAsset(
|
||||
type = MediaType.VIDEO,
|
||||
uri = outputUri.toString(),
|
||||
- width = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH),
|
||||
- height = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT),
|
||||
+ width = width,
|
||||
+ height = height,
|
||||
fileName = fileData?.fileName,
|
||||
fileSize = fileData?.fileSize,
|
||||
mimeType = mimeType,
|
||||
duration = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_DURATION),
|
||||
- rotation = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION),
|
||||
+ rotation = rotation,
|
||||
assetId = sourceUri.getMediaStoreAssetId()
|
||||
)
|
||||
} catch (cause: FailedToExtractVideoMetadataException) {
|
||||
@@ -1,5 +0,0 @@
|
||||
# Expo Image Picker patch
|
||||
|
||||
Cherry-picked https://github.com/expo/expo/pull/37849
|
||||
|
||||
Remove when we update to a version that includes this commit.
|
||||
@@ -32,8 +32,8 @@ import {Provider as DialogStateProvider} from '#/state/dialogs'
|
||||
import {Provider as EmailVerificationProvider} from '#/state/email-verification'
|
||||
import {listenSessionDropped} from '#/state/events'
|
||||
import {
|
||||
beginResolveGeolocation,
|
||||
ensureGeolocationResolved,
|
||||
beginResolveGeolocationConfig,
|
||||
ensureGeolocationConfigIsResolved,
|
||||
Provider as GeolocationProvider,
|
||||
} from '#/state/geolocation'
|
||||
import {GlobalGestureEventsProvider} from '#/state/global-gesture-events'
|
||||
@@ -91,7 +91,7 @@ if (isAndroid) {
|
||||
/**
|
||||
* Begin geolocation ASAP
|
||||
*/
|
||||
beginResolveGeolocation()
|
||||
beginResolveGeolocationConfig()
|
||||
|
||||
function InnerApp() {
|
||||
const [isReady, setIsReady] = React.useState(false)
|
||||
@@ -203,9 +203,10 @@ function App() {
|
||||
const [isReady, setReady] = useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
Promise.all([initPersistedState(), ensureGeolocationResolved()]).then(() =>
|
||||
setReady(true),
|
||||
)
|
||||
Promise.all([
|
||||
initPersistedState(),
|
||||
ensureGeolocationConfigIsResolved(),
|
||||
]).then(() => setReady(true))
|
||||
}, [])
|
||||
|
||||
if (!isReady) {
|
||||
|
||||
@@ -21,8 +21,8 @@ import {Provider as DialogStateProvider} from '#/state/dialogs'
|
||||
import {Provider as EmailVerificationProvider} from '#/state/email-verification'
|
||||
import {listenSessionDropped} from '#/state/events'
|
||||
import {
|
||||
beginResolveGeolocation,
|
||||
ensureGeolocationResolved,
|
||||
beginResolveGeolocationConfig,
|
||||
ensureGeolocationConfigIsResolved,
|
||||
Provider as GeolocationProvider,
|
||||
} from '#/state/geolocation'
|
||||
import {Provider as HomeBadgeProvider} from '#/state/home-badge'
|
||||
@@ -69,7 +69,7 @@ import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottom
|
||||
/**
|
||||
* Begin geolocation ASAP
|
||||
*/
|
||||
beginResolveGeolocation()
|
||||
beginResolveGeolocationConfig()
|
||||
|
||||
function InnerApp() {
|
||||
const [isReady, setIsReady] = React.useState(false)
|
||||
@@ -178,9 +178,10 @@ function App() {
|
||||
const [isReady, setReady] = useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
Promise.all([initPersistedState(), ensureGeolocationResolved()]).then(() =>
|
||||
setReady(true),
|
||||
)
|
||||
Promise.all([
|
||||
initPersistedState(),
|
||||
ensureGeolocationConfigIsResolved(),
|
||||
]).then(() => setReady(true))
|
||||
}, [])
|
||||
|
||||
if (!isReady) {
|
||||
|
||||
@@ -55,7 +55,6 @@ import {DebugModScreen} from '#/view/screens/DebugMod'
|
||||
import {FeedsScreen} from '#/view/screens/Feeds'
|
||||
import {HomeScreen} from '#/view/screens/Home'
|
||||
import {ListsScreen} from '#/view/screens/Lists'
|
||||
import {LogScreen} from '#/view/screens/Log'
|
||||
import {ModerationBlockedAccounts} from '#/view/screens/ModerationBlockedAccounts'
|
||||
import {ModerationModlistsScreen} from '#/view/screens/ModerationModlists'
|
||||
import {ModerationMutedAccounts} from '#/view/screens/ModerationMutedAccounts'
|
||||
@@ -72,8 +71,10 @@ import {SupportScreen} from '#/view/screens/Support'
|
||||
import {TermsOfServiceScreen} from '#/view/screens/TermsOfService'
|
||||
import {BottomBar} from '#/view/shell/bottom-bar/BottomBar'
|
||||
import {createNativeStackNavigatorWithAuth} from '#/view/shell/createNativeStackNavigatorWithAuth'
|
||||
import {BookmarksScreen} from '#/screens/Bookmarks'
|
||||
import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTesterScreen'
|
||||
import HashtagScreen from '#/screens/Hashtag'
|
||||
import {LogScreen} from '#/screens/Log'
|
||||
import {MessagesScreen} from '#/screens/Messages/ChatList'
|
||||
import {MessagesConversationScreen} from '#/screens/Messages/Conversation'
|
||||
import {MessagesInboxScreen} from '#/screens/Messages/Inbox'
|
||||
@@ -600,6 +601,14 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
|
||||
requireAuth: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Bookmarks"
|
||||
getComponent={() => BookmarksScreen}
|
||||
options={{
|
||||
title: title(msg`Saved Posts`),
|
||||
requireAuth: true,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -70,9 +70,21 @@ export const atoms = {
|
||||
overflow_visible: {
|
||||
overflow: 'visible',
|
||||
},
|
||||
overflow_x_visible: {
|
||||
overflowX: 'visible',
|
||||
},
|
||||
overflow_y_visible: {
|
||||
overflowY: 'visible',
|
||||
},
|
||||
overflow_hidden: {
|
||||
overflow: 'hidden',
|
||||
},
|
||||
overflow_x_hidden: {
|
||||
overflowX: 'hidden',
|
||||
},
|
||||
overflow_y_hidden: {
|
||||
overflowY: 'hidden',
|
||||
},
|
||||
/**
|
||||
* @platform web
|
||||
*/
|
||||
@@ -363,6 +375,14 @@ export const atoms = {
|
||||
border_r_0: {
|
||||
borderRightWidth: 0,
|
||||
},
|
||||
border_x_0: {
|
||||
borderLeftWidth: 0,
|
||||
borderRightWidth: 0,
|
||||
},
|
||||
border_y_0: {
|
||||
borderTopWidth: 0,
|
||||
borderBottomWidth: 0,
|
||||
},
|
||||
border: {
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
@@ -378,6 +398,14 @@ export const atoms = {
|
||||
border_r: {
|
||||
borderRightWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
border_x: {
|
||||
borderLeftWidth: StyleSheet.hairlineWidth,
|
||||
borderRightWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
border_y: {
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
border_transparent: {
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
@@ -988,6 +1016,9 @@ export const atoms = {
|
||||
block: web({
|
||||
display: 'block',
|
||||
}),
|
||||
contents: web({
|
||||
display: 'contents',
|
||||
}),
|
||||
|
||||
/*
|
||||
* Transition
|
||||
@@ -1032,6 +1063,7 @@ export const atoms = {
|
||||
}),
|
||||
fade_out: web({
|
||||
animation: 'fadeOut ease-out 0.15s',
|
||||
animationFillMode: 'forwards',
|
||||
}),
|
||||
zoom_in: web({
|
||||
animation: 'zoomIn ease-out 0.1s',
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import {useEffect} from 'react'
|
||||
import {ScrollView, View} from 'react-native'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {useDeviceGeolocationApi} from '#/state/geolocation'
|
||||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {DeviceLocationRequestDialog} from '#/components/dialogs/DeviceLocationRequestDialog'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import {Full as Logo, Mark} from '#/components/icons/Logo'
|
||||
import {PinLocation_Stroke2_Corner0_Rounded as LocationIcon} from '#/components/icons/PinLocation'
|
||||
import {SimpleInlineLinkText as InlineLinkText} from '#/components/Link'
|
||||
import {Outlet as PortalOutlet} from '#/components/Portal'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {BottomSheetOutlet} from '#/../modules/bottom-sheet'
|
||||
|
||||
export function BlockedGeoOverlay() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {gtPhone} = useBreakpoints()
|
||||
const insets = useSafeAreaInsets()
|
||||
const geoDialog = Dialog.useDialogControl()
|
||||
const {setDeviceGeolocation} = useDeviceGeolocationApi()
|
||||
|
||||
useEffect(() => {
|
||||
// just counting overall hits here
|
||||
logger.metric(`blockedGeoOverlay:shown`, {})
|
||||
}, [])
|
||||
|
||||
const textStyles = [a.text_md, a.leading_normal]
|
||||
const links = {
|
||||
blog: {
|
||||
to: `https://bsky.social/about/blog/08-22-2025-mississippi-hb1126`,
|
||||
label: _(msg`Read our blog post`),
|
||||
overridePresentation: false,
|
||||
disableMismatchWarning: true,
|
||||
style: textStyles,
|
||||
},
|
||||
}
|
||||
|
||||
const blocks = [
|
||||
_(msg`Unfortunately, Bluesky is unavailable in Mississippi right now.`),
|
||||
_(
|
||||
msg`A new Mississippi law requires us to implement age verification for all users before they can access Bluesky. We think this law creates challenges that go beyond its child safety goals, and creates significant barriers that limit free speech and disproportionately harm smaller platforms and emerging technologies.`,
|
||||
),
|
||||
_(
|
||||
msg`As a small team, we cannot justify building the expensive infrastructure this requirement demands while legal challenges to this law are pending.`,
|
||||
),
|
||||
_(
|
||||
msg`For now, we have made the difficult decision to block access to Bluesky in the state of Mississippi.`,
|
||||
),
|
||||
<>
|
||||
To learn more, read our{' '}
|
||||
<InlineLinkText {...links.blog}>blog post</InlineLinkText>.
|
||||
</>,
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
a.px_2xl,
|
||||
{
|
||||
paddingTop: isWeb ? a.p_5xl.padding : insets.top + a.p_2xl.padding,
|
||||
paddingBottom: 100,
|
||||
},
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.mx_auto,
|
||||
web({
|
||||
maxWidth: 380,
|
||||
paddingTop: gtPhone ? '8vh' : undefined,
|
||||
}),
|
||||
]}>
|
||||
<View style={[a.align_start]}>
|
||||
<View
|
||||
style={[
|
||||
a.pl_md,
|
||||
a.pr_lg,
|
||||
a.py_sm,
|
||||
a.rounded_full,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_xs,
|
||||
{
|
||||
backgroundColor: t.palette.primary_25,
|
||||
},
|
||||
]}>
|
||||
<Mark fill={t.palette.primary_600} width={14} />
|
||||
<Text
|
||||
style={[
|
||||
a.font_bold,
|
||||
{
|
||||
color: t.palette.primary_600,
|
||||
},
|
||||
]}>
|
||||
<Trans>Announcement</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[a.gap_lg, {paddingTop: 32}]}>
|
||||
{blocks.map((block, index) => (
|
||||
<Text key={index} style={[textStyles]}>
|
||||
{block}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{!isWeb && (
|
||||
<>
|
||||
<View style={[a.pt_2xl]}>
|
||||
<Divider />
|
||||
</View>
|
||||
|
||||
<View style={[a.mt_xl, a.align_start]}>
|
||||
<Text
|
||||
style={[a.text_lg, a.font_heavy, a.leading_snug, a.pb_xs]}>
|
||||
<Trans>Not in Mississippi?</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
a.text_sm,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_medium,
|
||||
a.pb_md,
|
||||
]}>
|
||||
<Trans>
|
||||
Confirm your location with GPS. Your location data is not
|
||||
tracked and does not leave your device.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Button
|
||||
label={_(msg`Confirm your location`)}
|
||||
onPress={() => geoDialog.open()}
|
||||
size="small"
|
||||
color="primary_subtle">
|
||||
<ButtonIcon icon={LocationIcon} />
|
||||
<ButtonText>
|
||||
<Trans>Confirm your location</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<DeviceLocationRequestDialog
|
||||
control={geoDialog}
|
||||
onLocationAcquired={props => {
|
||||
if (props.geolocationStatus.isAgeBlockedGeo) {
|
||||
props.disableDialogAction()
|
||||
props.setDialogError(
|
||||
_(
|
||||
msg`We're sorry, but based on your device's location, you are currently located in a region where we cannot provide access at this time.`,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
props.closeDialog(() => {
|
||||
// set this after close!
|
||||
setDeviceGeolocation({
|
||||
countryCode: props.geolocationStatus.countryCode,
|
||||
regionCode: props.geolocationStatus.regionCode,
|
||||
})
|
||||
Toast.show(_(msg`Thanks! You're all set.`), {
|
||||
type: 'success',
|
||||
})
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<View style={[{paddingTop: 48}]}>
|
||||
<Logo width={120} textFill={t.atoms.text.color} />
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/*
|
||||
* While this blocking overlay is up, other dialogs in the shell
|
||||
* are not mounted, so it _should_ be safe to use these here
|
||||
* without fear of other modals showing up.
|
||||
*/}
|
||||
<BottomSheetOutlet />
|
||||
<PortalOutlet />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -12,9 +12,13 @@ import {
|
||||
import {
|
||||
KeyboardAwareScrollView,
|
||||
useKeyboardHandler,
|
||||
useReanimatedKeyboardAnimation,
|
||||
} from 'react-native-keyboard-controller'
|
||||
import {runOnJS} from 'react-native-reanimated'
|
||||
import {type ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/hook/commonTypes'
|
||||
import Animated, {
|
||||
runOnJS,
|
||||
type ScrollEvent,
|
||||
useAnimatedStyle,
|
||||
} from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -26,7 +30,7 @@ import {isAndroid, isIOS} from '#/platform/detection'
|
||||
import {useA11y} from '#/state/a11y'
|
||||
import {useDialogStateControlContext} from '#/state/dialogs'
|
||||
import {List, type ListMethods, type ListProps} from '#/view/com/util/List'
|
||||
import {atoms as a, tokens, useTheme} from '#/alf'
|
||||
import {atoms as a, ios, platform, tokens, useTheme} from '#/alf'
|
||||
import {useThemeName} from '#/alf/util/useColorModeTheme'
|
||||
import {Context, useDialogContext} from '#/components/Dialog/context'
|
||||
import {
|
||||
@@ -256,6 +260,7 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
|
||||
contentContainerStyle,
|
||||
]}
|
||||
ref={ref}
|
||||
showsVerticalScrollIndicator={isAndroid ? false : undefined}
|
||||
{...props}
|
||||
bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
|
||||
bottomOffset={30}
|
||||
@@ -275,12 +280,15 @@ export const InnerFlatList = React.forwardRef<
|
||||
ListProps<any> & {
|
||||
webInnerStyle?: StyleProp<ViewStyle>
|
||||
webInnerContentContainerStyle?: StyleProp<ViewStyle>
|
||||
footer?: React.ReactNode
|
||||
}
|
||||
>(function InnerFlatList({style, ...props}, ref) {
|
||||
>(function InnerFlatList({footer, style, ...props}, ref) {
|
||||
const insets = useSafeAreaInsets()
|
||||
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
|
||||
|
||||
const onScroll = (e: ReanimatedScrollEvent) => {
|
||||
useEnableKeyboardController(isIOS)
|
||||
|
||||
const onScroll = (e: ScrollEvent) => {
|
||||
'worklet'
|
||||
if (!isAndroid) {
|
||||
return
|
||||
@@ -300,13 +308,54 @@ export const InnerFlatList = React.forwardRef<
|
||||
bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
|
||||
ListFooterComponent={<View style={{height: insets.bottom + 100}} />}
|
||||
ref={ref}
|
||||
showsVerticalScrollIndicator={isAndroid ? false : undefined}
|
||||
{...props}
|
||||
style={[style]}
|
||||
style={[a.h_full, style]}
|
||||
/>
|
||||
{footer}
|
||||
</ScrollProvider>
|
||||
)
|
||||
})
|
||||
|
||||
export function FlatListFooter({children}: {children: React.ReactNode}) {
|
||||
const t = useTheme()
|
||||
const {top, bottom} = useSafeAreaInsets()
|
||||
const {height} = useReanimatedKeyboardAnimation()
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
if (!isIOS) return {}
|
||||
return {
|
||||
transform: [{translateY: Math.min(0, height.get() + bottom - 10)}],
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.bottom_0,
|
||||
a.w_full,
|
||||
a.z_10,
|
||||
a.border_t,
|
||||
t.atoms.bg,
|
||||
t.atoms.border_contrast_low,
|
||||
a.px_lg,
|
||||
a.pt_md,
|
||||
{
|
||||
paddingBottom: platform({
|
||||
ios: tokens.space.md + bottom,
|
||||
android: tokens.space.md + bottom + top,
|
||||
}),
|
||||
},
|
||||
// TODO: had to admit defeat here, but we should
|
||||
// try and get this to work for Android as well -sfn
|
||||
ios(animatedStyle),
|
||||
]}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
export function Handle({difference = false}: {difference?: boolean}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||