Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d840faacc | |||
| ded615d9f9 |
@@ -31,7 +31,6 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
],
|
||||
'bsky-internal/use-exact-imports': 'error',
|
||||
'bsky-internal/use-typed-gates': 'error',
|
||||
'simple-import-sort/imports': [
|
||||
'warn',
|
||||
|
||||
@@ -34,9 +34,6 @@ jobs:
|
||||
node-version-file: .nvmrc
|
||||
cache: yarn
|
||||
|
||||
- name: 🪛 Setup jq
|
||||
uses: dcarbone/install-jq-action@v2
|
||||
|
||||
- name: 🔨 Setup EAS
|
||||
uses: expo/expo-github-action@v8
|
||||
with:
|
||||
@@ -99,17 +96,13 @@ jobs:
|
||||
name: build-${{ steps.timestamp.outputs.time }}.apk
|
||||
path: build.apk
|
||||
|
||||
- name: 📚 Get version from package.json
|
||||
id: get-build-info
|
||||
run: bash scripts/setGitHubOutput.sh
|
||||
|
||||
- name: 🔔 Notify Slack of Production Build
|
||||
if: ${{ inputs.profile == 'production' }}
|
||||
uses: slackapi/slack-github-action@v1.25.0
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"text": "Android production build for Google Play Store submission is ready!\n```Artifact: ${{ steps.upload-artifact-production.outputs.artifact-url }}\nVersion Number: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}\nBuild Number: ${{ steps.get-build-info.outputs.BSKY_ANDROID_VERSION_CODE }}```"
|
||||
"text": "Android build is ready for submission. This is a production build! Download the artifact here: ${{ steps.upload-artifact-production.outputs.artifact-url }}"
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
@@ -147,7 +140,7 @@ jobs:
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"text": "Android production build for GitHub/Obtanium is ready!\n```Artifact: ${{ steps.upload-artifact-production-apk.outputs.artifact-url }}\nVersion Number: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}\nBuild Number: ${{ steps.get-build-info.outputs.BSKY_ANDROID_VERSION_CODE }}```"
|
||||
"text": "Android production APK build is ready for download. This is a production build, and you should add it to the GitHub release! Download the artifact here: ${{ steps.upload-artifact-production-apk.outputs.artifact-url }}"
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
|
||||
@@ -34,9 +34,6 @@ jobs:
|
||||
node-version-file: .nvmrc
|
||||
cache: yarn
|
||||
|
||||
- name: 🪛 Setup jq
|
||||
uses: dcarbone/install-jq-action@v2
|
||||
|
||||
- name: 🔨 Setup EAS
|
||||
uses: expo/expo-github-action@v8
|
||||
with:
|
||||
@@ -84,22 +81,6 @@ jobs:
|
||||
- name: 🚀 Deploy
|
||||
run: eas submit -p ios --non-interactive --path build.ipa
|
||||
|
||||
- name: 📚 Get version from package.json
|
||||
id: get-build-info
|
||||
run: bash scripts/setGitHubOutput.sh
|
||||
|
||||
- name: 🔔 Notify Slack of Production Build
|
||||
if: ${{ inputs.profile == 'production' }}
|
||||
uses: slackapi/slack-github-action@v1.25.0
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"text": "iOS production build for App Store submission is ready!\n```Artifact: Check TestFlight to know when it is available\nVersion Number: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}\nBuild Number: ${{ steps.get-build-info.outputs.BSKY_IOS_BUILD_NUMBER }}```"
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
|
||||
|
||||
- name: ⬇️ Restore Cache
|
||||
id: get-base-commit
|
||||
uses: actions/cache@v4
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
npx lint-staged
|
||||
yarn lint-staged
|
||||
|
||||
@@ -36,15 +36,12 @@ appId: xyz.blueskyweb.app
|
||||
id: "viewHeaderDrawerBtn"
|
||||
- tapOn:
|
||||
id: "menuItemButton-Feeds"
|
||||
- tapOn:
|
||||
id: "editFeedsBtn"
|
||||
- tapOn: "Edit Saved Feeds"
|
||||
- tapOn:
|
||||
label: "Tap on down arrow"
|
||||
point: "79%,23%"
|
||||
- tapOn:
|
||||
id: "viewHeaderDrawerBtn"
|
||||
- tapOn:
|
||||
id: "viewHeaderDrawerBtn"
|
||||
id: "bottomBarHomeBtn"
|
||||
- assertVisible:
|
||||
id: "homeScreenFeedTabs-selector-0"
|
||||
text: "alice-favs"
|
||||
@@ -57,15 +54,11 @@ appId: xyz.blueskyweb.app
|
||||
id: "viewHeaderDrawerBtn"
|
||||
- tapOn:
|
||||
id: "menuItemButton-Feeds"
|
||||
- tapOn:
|
||||
id: "editFeedsBtn"
|
||||
- tapOn:
|
||||
label: "Tap on down arrow"
|
||||
point: "79%,23%"
|
||||
- tapOn:
|
||||
id: "viewHeaderDrawerBtn"
|
||||
- tapOn:
|
||||
id: "viewHeaderDrawerBtn"
|
||||
id: "bottomBarHomeBtn"
|
||||
- assertVisible:
|
||||
id: "homeScreenFeedTabs-selector-0"
|
||||
text: "Following"
|
||||
@@ -78,15 +71,11 @@ appId: xyz.blueskyweb.app
|
||||
id: "viewHeaderDrawerBtn"
|
||||
- tapOn:
|
||||
id: "menuItemButton-Feeds"
|
||||
- tapOn:
|
||||
id: "editFeedsBtn"
|
||||
- tapOn:
|
||||
label: "Tap on unpin"
|
||||
point: "91%,23%"
|
||||
- tapOn:
|
||||
id: "viewHeaderDrawerBtn"
|
||||
- tapOn:
|
||||
id: "viewHeaderDrawerBtn"
|
||||
id: "bottomBarHomeBtn"
|
||||
- assertVisible:
|
||||
id: "homeScreenFeedTabs-selector-0"
|
||||
text: "alice-favs"
|
||||
|
||||
@@ -28,8 +28,6 @@ appId: xyz.blueskyweb.app
|
||||
- tapOn: "Pin to Home"
|
||||
- tapOn:
|
||||
id: "bottomBarHomeBtn"
|
||||
- tapOn:
|
||||
id: "viewHeaderDrawerBtn"
|
||||
- assertNotVisible: "Feeds ✨"
|
||||
|
||||
- tapOn:
|
||||
@@ -52,7 +50,7 @@ appId: xyz.blueskyweb.app
|
||||
text: "1"
|
||||
- tapOn:
|
||||
id: "repostBtn"
|
||||
- tapOn: "Remove repost"
|
||||
- tapOn: "Undo repost"
|
||||
- assertNotVisible:
|
||||
id: "repostCount"
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Remove this test when the old onboarding is deprecated
|
||||
appId: xyz.blueskyweb.app
|
||||
---
|
||||
- runScript:
|
||||
file: ../setupServer.js
|
||||
env:
|
||||
SERVER_PATH: "?users"
|
||||
- runFlow:
|
||||
file: ../setupApp.yml
|
||||
- tapOn:
|
||||
id: "e2eSignInAlice"
|
||||
- tapOn:
|
||||
id: "e2eStartLongboarding"
|
||||
- tapOn: "Continue to next step"
|
||||
- tapOn: "Continue to the next step without following any accounts"
|
||||
- tapOn: Show replies in Following feed
|
||||
- tapOn: Show quote-posts in Following feed
|
||||
- tapOn: Show re-posts in Following feed
|
||||
- tapOn: Show replies in Following feed
|
||||
- waitForAnimationToEnd
|
||||
- tapOn: Continue to next step
|
||||
- waitForAnimationToEnd
|
||||
- tapOn: "Continue to the next step"
|
||||
- waitForAnimationToEnd
|
||||
- tapOn: Continue to next step
|
||||
- waitForAnimationToEnd
|
||||
- tapOn: Continue to next step
|
||||
- waitForAnimationToEnd
|
||||
- tapOn: "Complete onboarding and start using your account"
|
||||
- waitForAnimationToEnd
|
||||
- assertVisible: Following
|
||||
@@ -58,7 +58,7 @@ appId: xyz.blueskyweb.app
|
||||
id: "repostBtn"
|
||||
childOf:
|
||||
id: "postThreadItem-by-bob.test"
|
||||
- tapOn: "Remove repost"
|
||||
- tapOn: "Undo repost"
|
||||
- assertNotVisible:
|
||||
id: "repostCount-expanded"
|
||||
|
||||
@@ -77,7 +77,7 @@ appId: xyz.blueskyweb.app
|
||||
id: "repostBtn"
|
||||
childOf:
|
||||
id: "postThreadItem-by-carla.test"
|
||||
- tapOn: "Remove repost"
|
||||
- tapOn: "Undo repost"
|
||||
- assertNotVisible:
|
||||
id: "repostCount"
|
||||
childOf:
|
||||
|
||||
@@ -6,6 +6,7 @@ import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles'
|
||||
import {enforceLen} from '../../src/lib/strings/helpers'
|
||||
import {detectLinkables} from '../../src/lib/strings/rich-text-detection'
|
||||
import {shortenLinks} from '../../src/lib/strings/rich-text-manip'
|
||||
import {ago} from '../../src/lib/strings/time'
|
||||
import {
|
||||
makeRecordUri,
|
||||
toNiceDomain,
|
||||
@@ -141,6 +142,79 @@ describe('makeRecordUri', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// FIXME: Reenable after fixing non-deterministic test.
|
||||
describe.skip('ago', () => {
|
||||
const oneYearDate = new Date(
|
||||
new Date().setMonth(new Date().getMonth() - 11),
|
||||
).setDate(new Date().getDate() - 28)
|
||||
|
||||
const inputs = [
|
||||
1671461038,
|
||||
'04 Dec 1995 00:12:00 GMT',
|
||||
new Date(),
|
||||
new Date().setSeconds(new Date().getSeconds() - 10),
|
||||
new Date().setMinutes(new Date().getMinutes() - 10),
|
||||
new Date().setHours(new Date().getHours() - 1),
|
||||
new Date().setDate(new Date().getDate() - 1),
|
||||
new Date().setDate(new Date().getDate() - 20),
|
||||
new Date().setDate(new Date().getDate() - 25),
|
||||
new Date().setDate(new Date().getDate() - 28),
|
||||
new Date().setDate(new Date().getDate() - 29),
|
||||
new Date().setDate(new Date().getDate() - 30),
|
||||
new Date().setMonth(new Date().getMonth() - 1),
|
||||
new Date(new Date().setMonth(new Date().getMonth() - 1)).setDate(
|
||||
new Date().getDate() - 20,
|
||||
),
|
||||
new Date(new Date().setMonth(new Date().getMonth() - 1)).setDate(
|
||||
new Date().getDate() - 25,
|
||||
),
|
||||
new Date(new Date().setMonth(new Date().getMonth() - 1)).setDate(
|
||||
new Date().getDate() - 28,
|
||||
),
|
||||
new Date(new Date().setMonth(new Date().getMonth() - 1)).setDate(
|
||||
new Date().getDate() - 29,
|
||||
),
|
||||
new Date().setMonth(new Date().getMonth() - 11),
|
||||
new Date(new Date().setMonth(new Date().getMonth() - 11)).setDate(
|
||||
new Date().getDate() - 20,
|
||||
),
|
||||
new Date(new Date().setMonth(new Date().getMonth() - 11)).setDate(
|
||||
new Date().getDate() - 25,
|
||||
),
|
||||
oneYearDate,
|
||||
]
|
||||
const outputs = [
|
||||
new Date(1671461038).toLocaleDateString(),
|
||||
new Date('04 Dec 1995 00:12:00 GMT').toLocaleDateString(),
|
||||
'now',
|
||||
'10s',
|
||||
'10m',
|
||||
'1h',
|
||||
'1d',
|
||||
'20d',
|
||||
'25d',
|
||||
'28d',
|
||||
'29d',
|
||||
'1mo',
|
||||
'1mo',
|
||||
'1mo',
|
||||
'1mo',
|
||||
'2mo',
|
||||
'2mo',
|
||||
'11mo',
|
||||
'11mo',
|
||||
'11mo',
|
||||
new Date(oneYearDate).toLocaleDateString(),
|
||||
]
|
||||
|
||||
it('correctly calculates how much time passed, in a string', () => {
|
||||
for (let i = 0; i < inputs.length; i++) {
|
||||
const result = ago(inputs[i])
|
||||
expect(result).toEqual(outputs[i])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('makeValidHandle', () => {
|
||||
const inputs = [
|
||||
'test-handle-123',
|
||||
|
||||
@@ -204,6 +204,7 @@ module.exports = function (config) {
|
||||
],
|
||||
'./plugins/withAndroidManifestPlugin.js',
|
||||
'./plugins/withAndroidManifestFCMIconPlugin.js',
|
||||
'./plugins/withAndroidManifestLaunchModePlugin.js',
|
||||
'./plugins/withAndroidStylesWindowBackgroundPlugin.js',
|
||||
'./plugins/withAndroidStylesAccentColorPlugin.js',
|
||||
'./plugins/withAndroidSplashScreenStatusBarTranslucentPlugin.js',
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 21a1 1 0 0 1-.707-.293l-6-6a1 1 0 1 1 1.414-1.414L11 17.586V4a1 1 0 1 1 2 0v13.586l4.293-4.293a1 1 0 0 1 1.414 1.414l-6 6A1 1 0 0 1 12 21Z" clip-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 285 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M4 5a1 1 0 0 0 0 2h16a1 1 0 1 0 0-2H4Zm0 12a1 1 0 1 0 0 2h3a1 1 0 1 0 0-2H4Zm-1-5a1 1 0 0 1 1-1h5a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1Zm14-3c.552 0 1 .41 1 .917V13.5h3.583c.507 0 .917.448.917 1s-.41 1-.917 1H18v3.583c0 .507-.448.917-1 .917s-1-.41-1-.917V15.5h-3.583c-.507 0-.917-.448-.917-1s.41-1 .917-1H16V9.917C16 9.41 16.448 9 17 9Z" clip-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 471 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M2 6a1 1 0 0 1 1-1h18a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1Zm0 6a1 1 0 0 1 1-1h18a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1Zm0 6a1 1 0 0 1 1-1h18a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1Z" clip-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 299 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#FFC404" fill-rule="evenodd" d="M11.183 8.561c0 .544.348.984.892.984.545 0 .893-.44.893-.985V6.985c0-.544-.348-.985-.893-.985-.543 0-.892.44-.892.985v1.576Zm5.94 7.481c0 .539-.438.942-.976.942H8.004c-.538 0-.975-.411-.975-.95 0-2.782 2.264-5.021 5.046-5.021 2.783 0 5.047 2.247 5.047 5.03Zm-.43-4.584a.983.983 0 0 1 0-1.393l1.114-1.114a.985.985 0 0 1 1.393 1.393l-1.114 1.114a.985.985 0 0 1-1.393 0Zm2.897 3.741h1.575c.544 0 .985.349.985.892 0 .544-.44.892-.985.892h-1.67a.872.872 0 0 1-.89-.887c0-.543.44-.897.985-.897Zm-14.045.893c0-.544-.44-.892-.985-.892H2.985c-.544 0-.985.349-.985.892 0 .544.44.892.985.892H4.56c.545 0 .985-.349.985-.892Zm1.913-6.027a.985.985 0 0 1-1.393 1.393L4.95 10.344A.985.985 0 0 1 6.344 8.95l1.114 1.114Z" clip-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 848 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M6.5 3a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1v3.997a6.25 6.25 0 0 0 1.83 4.42l.377.376A1 1 0 0 1 20 12.5V15a1 1 0 0 1-1 1h-6v5a1 1 0 1 1-2 0v-5H5a1 1 0 0 1-1-1v-2.5a1 1 0 0 1 .293-.707l.376-.377A6.25 6.25 0 0 0 6.5 6.996V3.001Zm2 1v2.997a8.25 8.25 0 0 1-2.416 5.834L6 12.914V14h12v-1.086l-.084-.083A8.25 8.25 0 0 1 15.5 6.997V4h-7Z" clip-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 465 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M7 3a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2H8v2a1 1 0 1 1-2 0V8H4a1 1 0 0 1 0-2h2V4a1 1 0 0 1 1-1Zm6 4a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm4-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4ZM3 14a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-6Zm2 1v4h4v-4H5Zm9.171-.829a1 1 0 0 1 1.415 0L17 15.585l1.414-1.414a1 1 0 1 1 1.414 1.414L18.414 17l1.414 1.414a1 1 0 0 1-1.414 1.414L17 18.414l-1.415 1.414a1 1 0 0 1-1.414-1.414l1.415-1.415-1.415-1.414a1 1 0 0 1 0-1.414Z" clip-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 590 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12.902 1.568a1 1 0 0 0-1.804 0L8.455 7.085l-6.085.799a1 1 0 0 0-.557 1.718l4.45 4.207-1.117 6.008a1 1 0 0 0 1.458 1.063L12 17.962l5.396 2.918a1 1 0 0 0 1.459-1.063l-1.117-6.008 4.45-4.207a1 1 0 0 0-.558-1.718l-6.085-.8-2.643-5.516Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 335 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 1a1 1 0 0 1 .902.568l2.643 5.517 6.085.799a1 1 0 0 1 .557 1.718l-4.45 4.207 1.118 6.008a1 1 0 0 1-1.46 1.063L12 17.962 6.604 20.88a1 1 0 0 1-1.459-1.063l1.117-6.008-4.45-4.207a1 1 0 0 1 .558-1.718l6.085-.8 2.643-5.516A1 1 0 0 1 12 1Zm0 3.315-1.975 4.123a1 1 0 0 1-.772.56l-4.538.595 3.317 3.137a1 1 0 0 1 .296.91l-.834 4.485 4.03-2.179a1 1 0 0 1 .952 0l4.03 2.179-.834-4.485a1 1 0 0 1 .296-.91l3.317-3.137-4.538-.596a1 1 0 0 1-.772-.56L12 4.316Z" clip-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 592 B |
@@ -207,7 +207,6 @@ func serve(cctx *cli.Context) error {
|
||||
e.GET("/profile/:handleOrDID", server.WebProfile)
|
||||
e.GET("/profile/:handleOrDID/follows", server.WebGeneric)
|
||||
e.GET("/profile/:handleOrDID/followers", server.WebGeneric)
|
||||
e.GET("/profile/:handleOrDID/known-followers", server.WebGeneric)
|
||||
e.GET("/profile/:handleOrDID/lists/:rkey", server.WebGeneric)
|
||||
e.GET("/profile/:handleOrDID/feed/:rkey", server.WebGeneric)
|
||||
e.GET("/profile/:handleOrDID/feed/:rkey/liked-by", server.WebGeneric)
|
||||
|
||||
@@ -14,20 +14,15 @@ diff results in incompatible native changes, a new client build will automatical
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Find the latest production build number for both iOS and Android in Slack. These are listed in #client-builds
|
||||
- Production builds always send the Version Number and Build Number in the Slack message. Search for the latest
|
||||
production version number, and you should find the correct information.
|
||||
|
||||

|
||||
|
||||
- It may also be useful to check the current production clients for these values. This will also help for testing. Note
|
||||
that you will need to _fully_ remove the existing internal client build from your device, otherwise the given values in
|
||||
the app may differ from the actual production values.
|
||||
- Remove any internal client from your device and download the client from the App Store/Google Play. This will help for
|
||||
testing as well as retrieving the build number.
|
||||
- You should have signed in to EAS locally through npx eas login. You will need to modify the build number in a
|
||||
subsequent step.
|
||||
- Identify the build number of the production app you want to deploy an update for. iOS and Android build numbers are
|
||||
divergent, so you will need to find both
|
||||
|
||||

|
||||
|
||||
- You should have signed in to EAS locally through npx eas login. You will need to modify the build number in a
|
||||
subsequent step.
|
||||
- Ensure that the commit the initial client was cut from is properly tagged in git. The tag should be in the format of 1.X.0
|
||||
- Note: If the commit is not properly tagged, then the OTA deployment will simply fail since the GitHub Action will
|
||||
not be able to find a commit to fingerprint and diff against.
|
||||
@@ -43,17 +38,14 @@ to create your branch from, this should be properly set.
|
||||
|
||||
### Deployment
|
||||
|
||||
- Update the build number through EAS to match the build numbers of the
|
||||
production iOS/Android apps
|
||||
- Update the build number through EAS
|
||||
- Note: This isn’t strictly necessary, but having a step that takes you off of GitHub and into the terminal provides
|
||||
a little “friction” to avoid fat fingering a release. Since there are legitimate reasons to just “click and deploy”
|
||||
for internal builds, I felt it useful to make sure it doesn’t accidentally become a prod deployment.
|
||||
- Set the build numbers to the values found in the prerequisite steps. Again, this should be the
|
||||
- Set the build number to the appropriate build number found in the prerequisite steps. Again, this should be the
|
||||
build number for the current production release you want to deploy for.
|
||||
- `npx eas build:version:set -p ios`
|
||||
- `npx eas build:version:set -p android`
|
||||
- These steps should spit out what the current build number is, save those values
|
||||
for later too
|
||||
- Run the deployment
|
||||
- Navigate to https://github.com/bluesky-social/social-app/actions/workflows/bundle-deploy-eas-update.yml
|
||||
- Select the “Run Workflow” dropdown
|
||||
@@ -87,9 +79,3 @@ In about five minutes, the new deployment should be available for download. To t
|
||||
- Launch the app once and wait approximately 15 seconds
|
||||
- Relaunch the app
|
||||
- Check the Settings page and scroll to the bottom. The commit hash should now be the latest commit on your deployed branch.
|
||||
|
||||
### Post Deployment
|
||||
|
||||
- Reset both platforms build numbers to what they were before the OTA
|
||||
deployment. These values should have been logged by the EAS CLI when you
|
||||
reset them to the production values prior to OTA.
|
||||
|
||||
|
Before Width: | Height: | Size: 99 KiB |
@@ -3,7 +3,6 @@
|
||||
module.exports = {
|
||||
rules: {
|
||||
'avoid-unwrapped-text': require('./avoid-unwrapped-text'),
|
||||
'use-exact-imports': require('./use-exact-imports'),
|
||||
'use-typed-gates': require('./use-typed-gates'),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
/* eslint-disable bsky-internal/use-exact-imports */
|
||||
const BANNED_IMPORTS = [
|
||||
'@fortawesome/free-regular-svg-icons',
|
||||
'@fortawesome/free-solid-svg-icons',
|
||||
]
|
||||
|
||||
exports.create = function create(context) {
|
||||
return {
|
||||
Literal(node) {
|
||||
if (typeof node.value !== 'string') {
|
||||
return
|
||||
}
|
||||
if (BANNED_IMPORTS.includes(node.value)) {
|
||||
context.report({
|
||||
node,
|
||||
message:
|
||||
'Import the specific thing you want instead of the entire package',
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import UserNotifications
|
||||
import UIKit
|
||||
|
||||
let APP_GROUP = "group.app.bsky"
|
||||
|
||||
@@ -32,12 +31,7 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
}
|
||||
|
||||
func mutateWithBadge(_ content: UNMutableNotificationContent) {
|
||||
var count = prefs?.integer(forKey: "badgeCount") ?? 0
|
||||
count += 1
|
||||
|
||||
// Set the new badge number for the notification, then store that value for using later
|
||||
content.badge = NSNumber(value: count)
|
||||
prefs?.setValue(count, forKey: "badgeCount")
|
||||
content.badge = 1
|
||||
}
|
||||
|
||||
func mutateWithChatMessage(_ content: UNMutableNotificationContent) {
|
||||
|
||||
@@ -66,9 +66,5 @@ class ExpoBackgroundNotificationHandlerModule : Module() {
|
||||
AsyncFunction("removeManyFromStringArrayAsync") { forKey: String, strings: Array<String> ->
|
||||
NotificationPrefs(appContext.reactContext).removeManyFromStringArray(forKey, strings)
|
||||
}
|
||||
|
||||
AsyncFunction("setBadgeCountAsync") { _: Int ->
|
||||
// This does nothing on Android
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,7 @@ let DEFAULTS: [String:Any] = [
|
||||
"playSoundQuote": false,
|
||||
"playSoundReply": false,
|
||||
"playSoundRepost": false,
|
||||
"mutedThreads": [:] as! [String:[String]],
|
||||
"badgeCount": 0,
|
||||
"mutedThreads": [:] as! [String:[String]]
|
||||
]
|
||||
|
||||
/*
|
||||
@@ -113,9 +112,5 @@ public class ExpoBackgroundNotificationHandlerModule: Module {
|
||||
userDefaults?.setValue(curr, forKey: forKey)
|
||||
}
|
||||
}
|
||||
|
||||
AsyncFunction("setBadgeCountAsync") { (count: Int) in
|
||||
userDefaults?.setValue(count, forKey: "badgeCount")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@ export type ExpoBackgroundNotificationHandlerModule = {
|
||||
forKey: keyof BackgroundNotificationHandlerPreferences,
|
||||
value: string[],
|
||||
) => Promise<void>
|
||||
setBadgeCountAsync: (count: number) => Promise<void>
|
||||
}
|
||||
|
||||
// TODO there are more preferences in the native code, however they have not been added here yet.
|
||||
|
||||
@@ -24,5 +24,4 @@ export const BackgroundNotificationHandler = {
|
||||
removeFromStringArrayAsync: async (_: string, __: string) => {},
|
||||
addManyToStringArrayAsync: async (_: string, __: string[]) => {},
|
||||
removeManyFromStringArrayAsync: async (_: string, __: string[]) => {},
|
||||
setBadgeCountAsync: async (_: number) => {},
|
||||
} as ExpoBackgroundNotificationHandlerModule
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"platforms": ["ios"],
|
||||
"ios": {
|
||||
"modules": ["ExpoBlueskyTranslateModule"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
isAvailable,
|
||||
isLanguageSupported,
|
||||
NativeTranslationModule,
|
||||
NativeTranslationView,
|
||||
} from './src/ExpoBlueskyTranslateView'
|
||||
@@ -0,0 +1,20 @@
|
||||
import ExpoModulesCore
|
||||
import SwiftUI
|
||||
|
||||
// Thanks to Andrew Levy for this code snippet
|
||||
// https://github.com/andrew-levy/swiftui-react-native/blob/d3fbb2abf07601ff0d4b83055e7717bb980910d6/ios/Common/ExpoView%2BUIHostingController.swift
|
||||
|
||||
extension ExpoView {
|
||||
func setupHostingController(_ hostingController: UIHostingController<some View>) {
|
||||
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
hostingController.view.backgroundColor = .clear
|
||||
|
||||
addSubview(hostingController.view)
|
||||
NSLayoutConstraint.activate([
|
||||
hostingController.view.topAnchor.constraint(equalTo: self.topAnchor),
|
||||
hostingController.view.bottomAnchor.constraint(equalTo: self.bottomAnchor),
|
||||
hostingController.view.leftAnchor.constraint(equalTo: self.leftAnchor),
|
||||
hostingController.view.rightAnchor.constraint(equalTo: self.rightAnchor),
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'ExpoBlueskyTranslate'
|
||||
s.version = '1.0.0'
|
||||
s.summary = 'Uses SwiftUI translation to translate text.'
|
||||
s.description = 'Uses SwiftUI translation to translate text.'
|
||||
s.author = ''
|
||||
s.homepage = 'https://docs.expo.dev/modules/'
|
||||
s.platforms = { :ios => '13.4' }
|
||||
s.source = { git: '' }
|
||||
s.static_framework = true
|
||||
|
||||
s.dependency 'ExpoModulesCore'
|
||||
|
||||
# Swift/Objective-C compatibility
|
||||
s.pod_target_xcconfig = {
|
||||
'DEFINES_MODULE' => 'YES',
|
||||
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
||||
}
|
||||
|
||||
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
|
||||
end
|
||||
@@ -0,0 +1,18 @@
|
||||
import ExpoModulesCore
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
public class ExpoBlueskyTranslateModule: Module {
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("ExpoBlueskyTranslate")
|
||||
|
||||
AsyncFunction("presentAsync") { (text: String) in
|
||||
DispatchQueue.main.async { [weak state = TranslateViewState.shared] in
|
||||
state?.isPresented = true
|
||||
state?.text = text
|
||||
}
|
||||
}
|
||||
|
||||
View(ExpoBlueskyTranslateView.self) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import ExpoModulesCore
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
class TranslateViewState: ObservableObject {
|
||||
static var shared = TranslateViewState()
|
||||
|
||||
@Published var isPresented = false
|
||||
@Published var text = ""
|
||||
}
|
||||
|
||||
class ExpoBlueskyTranslateView: ExpoView {
|
||||
required init(appContext: AppContext? = nil) {
|
||||
if #available(iOS 14.0, *) {
|
||||
let hostingController = UIHostingController(rootView: TranslateView())
|
||||
super.init(appContext: appContext)
|
||||
setupHostingController(hostingController)
|
||||
} else {
|
||||
super.init(appContext: appContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import SwiftUI
|
||||
// conditionally import the Translation module
|
||||
#if canImport(Translation)
|
||||
import Translation
|
||||
#endif
|
||||
|
||||
struct TranslateView: View {
|
||||
@ObservedObject var state = TranslateViewState.shared
|
||||
|
||||
var body: some View {
|
||||
if #available(iOS 17.4, *) {
|
||||
VStack {
|
||||
UIViewRepresentableWrapper(view: UIView(frame: .zero))
|
||||
}
|
||||
.translationPresentation(
|
||||
isPresented: $state.isPresented,
|
||||
text: state.text
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct UIViewRepresentableWrapper: UIViewRepresentable {
|
||||
let view: UIView
|
||||
|
||||
func makeUIView(context: Context) -> UIView {
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UIView, context: Context) {}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export type ExpoBlueskyTranslateModule = {
|
||||
presentAsync: (text: string) => Promise<void>
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from 'react'
|
||||
import {Platform} from 'react-native'
|
||||
import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core'
|
||||
|
||||
import {ExpoBlueskyTranslateModule} from './ExpoBlueskyTranslate.types'
|
||||
|
||||
export const NativeTranslationModule =
|
||||
requireNativeModule<ExpoBlueskyTranslateModule>('ExpoBlueskyTranslate')
|
||||
|
||||
const NativeView: React.ComponentType = requireNativeViewManager(
|
||||
'ExpoBlueskyTranslate',
|
||||
)
|
||||
|
||||
export function NativeTranslationView() {
|
||||
return <NativeView />
|
||||
}
|
||||
|
||||
// can be something like "17.5.1", so just take the first two parts
|
||||
const version = String(Platform.Version).split('.').slice(0, 2).join('.')
|
||||
|
||||
export const isAvailable = Number(version) >= 17.4
|
||||
|
||||
// https://en.wikipedia.org/wiki/Translate_(Apple)#Languages
|
||||
const SUPPORTED_LANGUAGES = [
|
||||
'ar',
|
||||
'zh',
|
||||
'zh',
|
||||
'nl',
|
||||
'en',
|
||||
'en',
|
||||
'fr',
|
||||
'de',
|
||||
'id',
|
||||
'it',
|
||||
'ja',
|
||||
'ko',
|
||||
'pl',
|
||||
'pt',
|
||||
'ru',
|
||||
'es',
|
||||
'th',
|
||||
'tr',
|
||||
'uk',
|
||||
'vi',
|
||||
]
|
||||
|
||||
export function isLanguageSupported(lang?: string) {
|
||||
// If the language is not provided, we assume it is supported
|
||||
if (!lang) return true
|
||||
return SUPPORTED_LANGUAGES.includes(lang)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export const NativeTranslationModule = {
|
||||
presentAsync: async (_: string) => {},
|
||||
}
|
||||
|
||||
export function NativeTranslationView() {
|
||||
return null
|
||||
}
|
||||
|
||||
export const isAvailable = false
|
||||
|
||||
export function isLanguageSupported(_lang?: string) {
|
||||
return false
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.87.0",
|
||||
"version": "1.85.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -49,7 +49,7 @@
|
||||
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.12.18",
|
||||
"@atproto/api": "^0.12.14",
|
||||
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||
@@ -116,6 +116,7 @@
|
||||
"expo-build-properties": "^0.12.1",
|
||||
"expo-camera": "~15.0.9",
|
||||
"expo-clipboard": "^6.0.3",
|
||||
"expo-constants": "~16.0.1",
|
||||
"expo-dev-client": "^4.0.14",
|
||||
"expo-device": "~6.0.2",
|
||||
"expo-file-system": "^17.0.1",
|
||||
@@ -128,7 +129,7 @@
|
||||
"expo-localization": "~15.0.3",
|
||||
"expo-media-library": "~16.0.3",
|
||||
"expo-navigation-bar": "~3.0.4",
|
||||
"expo-notifications": "~0.28.7",
|
||||
"expo-notifications": "~0.28.3",
|
||||
"expo-sharing": "^12.0.1",
|
||||
"expo-splash-screen": "~0.27.4",
|
||||
"expo-status-bar": "~1.12.1",
|
||||
@@ -271,9 +272,6 @@
|
||||
"resolutions": {
|
||||
"@types/react": "^18",
|
||||
"**/zeed-dom": "0.10.9",
|
||||
"**/zod": "3.23.8",
|
||||
"**/expo-constants": "16.0.1",
|
||||
"**/expo-device": "6.0.2",
|
||||
"@react-native/babel-preset": "0.74.1"
|
||||
},
|
||||
"jest": {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
diff --git a/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt b/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt
|
||||
index 1520465..6ea988a 100644
|
||||
index 26c52af..b949a4c 100644
|
||||
--- a/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt
|
||||
+++ b/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt
|
||||
@@ -42,7 +42,7 @@ class HapticsModule : Module() {
|
||||
|
||||
|
||||
private fun vibrate(type: HapticsVibrationType) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
- vibrator.vibrate(VibrationEffect.createWaveform(type.timings, type.amplitudes, -1))
|
||||
@@ -1,5 +0,0 @@
|
||||
## expo-modules-core Patch
|
||||
|
||||
This patch fixes crashes seen in some Android clients when using intents to open the app. See https://github.com/expo/expo/pull/29513.
|
||||
|
||||
Do not remove this patch until that PR lands in Expo and is released.
|
||||
@@ -1,14 +0,0 @@
|
||||
diff --git a/node_modules/expo-modules-core/android/src/main/java/expo/modules/adapters/react/NativeModulesProxy.java b/node_modules/expo-modules-core/android/src/main/java/expo/modules/adapters/react/NativeModulesProxy.java
|
||||
index bb74e80..0aa0202 100644
|
||||
--- a/node_modules/expo-modules-core/android/src/main/java/expo/modules/adapters/react/NativeModulesProxy.java
|
||||
+++ b/node_modules/expo-modules-core/android/src/main/java/expo/modules/adapters/react/NativeModulesProxy.java
|
||||
@@ -90,8 +90,8 @@ public class NativeModulesProxy extends ReactContextBaseJavaModule {
|
||||
mModuleRegistry.ensureIsInitialized();
|
||||
|
||||
KotlinInteropModuleRegistry kotlinModuleRegistry = getKotlinInteropModuleRegistry();
|
||||
- kotlinModuleRegistry.emitOnCreate();
|
||||
kotlinModuleRegistry.installJSIInterop();
|
||||
+ kotlinModuleRegistry.emitOnCreate();
|
||||
|
||||
Map<String, Object> constants = new HashMap<>(3);
|
||||
constants.put(MODULES_CONSTANTS_KEY, new HashMap<>());
|
||||
@@ -1,54 +1,29 @@
|
||||
diff --git a/node_modules/expo-notifications/android/build.gradle b/node_modules/expo-notifications/android/build.gradle
|
||||
index b863077..8b5209e 100644
|
||||
index d233e1f..cc2f856 100644
|
||||
--- a/node_modules/expo-notifications/android/build.gradle
|
||||
+++ b/node_modules/expo-notifications/android/build.gradle
|
||||
@@ -32,6 +32,7 @@ dependencies {
|
||||
api 'com.google.firebase:firebase-messaging:22.0.0'
|
||||
|
||||
|
||||
api 'me.leolin:ShortcutBadger:1.1.22@aar'
|
||||
+ implementation project(':expo-background-notification-handler')
|
||||
|
||||
|
||||
if (project.findProject(':expo-modules-test-core')) {
|
||||
testImplementation project(':expo-modules-test-core')
|
||||
diff --git a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/badge/BadgeHelper.kt b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/badge/BadgeHelper.kt
|
||||
index 63a46c5..f911834 100644
|
||||
--- a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/badge/BadgeHelper.kt
|
||||
+++ b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/badge/BadgeHelper.kt
|
||||
@@ -1,5 +1,6 @@
|
||||
package expo.modules.notifications.badge
|
||||
|
||||
+import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import me.leolin.shortcutbadger.ShortcutBadgeException
|
||||
@@ -12,7 +13,12 @@ object BadgeHelper {
|
||||
|
||||
fun setBadgeCount(context: Context, badgeCount: Int): Boolean {
|
||||
return try {
|
||||
- ShortcutBadger.applyCountOrThrow(context.applicationContext, badgeCount)
|
||||
+ if (badgeCount == 0) {
|
||||
+ val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
+ notificationManager.cancelAll()
|
||||
+ } else {
|
||||
+ ShortcutBadger.applyCountOrThrow(context.applicationContext, badgeCount)
|
||||
+ }
|
||||
BadgeHelper.badgeCount = badgeCount
|
||||
true
|
||||
} catch (e: ShortcutBadgeException) {
|
||||
diff --git a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/JSONNotificationContentBuilder.java b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/JSONNotificationContentBuilder.java
|
||||
index 0af7fe0..8f2c8d8 100644
|
||||
--- a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/JSONNotificationContentBuilder.java
|
||||
+++ b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/JSONNotificationContentBuilder.java
|
||||
@@ -14,6 +14,7 @@ import expo.modules.notifications.notifications.enums.NotificationPriority;
|
||||
import expo.modules.notifications.notifications.model.NotificationContent;
|
||||
|
||||
|
||||
public class JSONNotificationContentBuilder extends NotificationContent.Builder {
|
||||
+ private static final String CHANNEL_ID_KEY = "channelId";
|
||||
private static final String TITLE_KEY = "title";
|
||||
private static final String TEXT_KEY = "message";
|
||||
private static final String SUBTITLE_KEY = "subtitle";
|
||||
@@ -36,6 +37,7 @@ public class JSONNotificationContentBuilder extends NotificationContent.Builder
|
||||
|
||||
|
||||
public NotificationContent.Builder setPayload(JSONObject payload) {
|
||||
this.setTitle(getTitle(payload))
|
||||
+ .setChannelId(getChannelId(payload))
|
||||
@@ -58,7 +33,7 @@ index 0af7fe0..8f2c8d8 100644
|
||||
@@ -60,6 +62,14 @@ public class JSONNotificationContentBuilder extends NotificationContent.Builder
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
+ protected String getChannelId(JSONObject payload) {
|
||||
+ try {
|
||||
+ return payload.getString(CHANNEL_ID_KEY);
|
||||
@@ -71,7 +46,7 @@ index 0af7fe0..8f2c8d8 100644
|
||||
try {
|
||||
return payload.getString(TITLE_KEY);
|
||||
diff --git a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/model/NotificationContent.java b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/model/NotificationContent.java
|
||||
index f1fed19..012757b 100644
|
||||
index f1fed19..80afe9e 100644
|
||||
--- a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/model/NotificationContent.java
|
||||
+++ b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/model/NotificationContent.java
|
||||
@@ -20,6 +20,7 @@ import expo.modules.notifications.notifications.enums.NotificationPriority;
|
||||
@@ -85,10 +60,10 @@ index f1fed19..012757b 100644
|
||||
@@ -50,6 +51,11 @@ public class NotificationContent implements Parcelable, Serializable {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+ @Nullable
|
||||
+ public String getChannelId() {
|
||||
+ return mChannelId;
|
||||
+ return mTitle;
|
||||
+ }
|
||||
+
|
||||
@Nullable
|
||||
@@ -96,14 +71,14 @@ index f1fed19..012757b 100644
|
||||
return mTitle;
|
||||
@@ -121,6 +127,7 @@ public class NotificationContent implements Parcelable, Serializable {
|
||||
}
|
||||
|
||||
|
||||
protected NotificationContent(Parcel in) {
|
||||
+ mChannelId = in.readString();
|
||||
mTitle = in.readString();
|
||||
mText = in.readString();
|
||||
mSubtitle = in.readString();
|
||||
@@ -146,6 +153,7 @@ public class NotificationContent implements Parcelable, Serializable {
|
||||
|
||||
|
||||
@Override
|
||||
public void writeToParcel(Parcel dest, int flags) {
|
||||
+ dest.writeString(mChannelId);
|
||||
@@ -112,7 +87,7 @@ index f1fed19..012757b 100644
|
||||
dest.writeString(mSubtitle);
|
||||
@@ -166,6 +174,7 @@ public class NotificationContent implements Parcelable, Serializable {
|
||||
private static final long serialVersionUID = 397666843266836802L;
|
||||
|
||||
|
||||
private void writeObject(java.io.ObjectOutputStream out) throws IOException {
|
||||
+ out.writeObject(mChannelId);
|
||||
out.writeObject(mTitle);
|
||||
@@ -120,7 +95,7 @@ index f1fed19..012757b 100644
|
||||
out.writeObject(mSubtitle);
|
||||
@@ -190,6 +199,7 @@ public class NotificationContent implements Parcelable, Serializable {
|
||||
}
|
||||
|
||||
|
||||
private void readObject(java.io.ObjectInputStream in) throws IOException, ClassNotFoundException {
|
||||
+ mChannelId = (String) in.readObject();
|
||||
mTitle = (String) in.readObject();
|
||||
@@ -128,7 +103,7 @@ index f1fed19..012757b 100644
|
||||
mSubtitle = (String) in.readObject();
|
||||
@@ -240,6 +250,7 @@ public class NotificationContent implements Parcelable, Serializable {
|
||||
}
|
||||
|
||||
|
||||
public static class Builder {
|
||||
+ private String mChannelId;
|
||||
private String mTitle;
|
||||
@@ -137,7 +112,7 @@ index f1fed19..012757b 100644
|
||||
@@ -260,6 +271,11 @@ public class NotificationContent implements Parcelable, Serializable {
|
||||
useDefaultVibrationPattern();
|
||||
}
|
||||
|
||||
|
||||
+ public Builder setChannelId(String channelId) {
|
||||
+ mChannelId = channelId;
|
||||
+ return this;
|
||||
@@ -147,7 +122,7 @@ index f1fed19..012757b 100644
|
||||
mTitle = title;
|
||||
return this;
|
||||
@@ -336,6 +352,7 @@ public class NotificationContent implements Parcelable, Serializable {
|
||||
|
||||
|
||||
public NotificationContent build() {
|
||||
NotificationContent content = new NotificationContent();
|
||||
+ content.mChannelId = mChannelId;
|
||||
@@ -159,16 +134,16 @@ index 6bd9928..ee93d70 100644
|
||||
--- a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/presentation/builders/ExpoNotificationBuilder.java
|
||||
+++ b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/presentation/builders/ExpoNotificationBuilder.java
|
||||
@@ -48,6 +48,10 @@ public class ExpoNotificationBuilder extends ChannelAwareNotificationBuilder {
|
||||
|
||||
|
||||
NotificationContent content = getNotificationContent();
|
||||
|
||||
|
||||
+ if (content.getChannelId() != null) {
|
||||
+ builder.setChannelId(content.getChannelId());
|
||||
+ }
|
||||
+
|
||||
builder.setAutoCancel(content.isAutoDismiss());
|
||||
builder.setOngoing(content.isSticky());
|
||||
|
||||
|
||||
diff --git a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/service/delegates/FirebaseMessagingDelegate.kt b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/service/delegates/FirebaseMessagingDelegate.kt
|
||||
index 55b3a8d..1b99d5b 100644
|
||||
--- a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/service/delegates/FirebaseMessagingDelegate.kt
|
||||
@@ -183,7 +158,7 @@ index 55b3a8d..1b99d5b 100644
|
||||
import org.json.JSONObject
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.*
|
||||
|
||||
|
||||
-open class FirebaseMessagingDelegate(protected val context: Context) : FirebaseMessagingDelegate {
|
||||
+open class FirebaseMessagingDelegate(protected val context: Context) : FirebaseMessagingDelegate, BackgroundNotificationHandlerInterface {
|
||||
companion object {
|
||||
@@ -191,7 +166,7 @@ index 55b3a8d..1b99d5b 100644
|
||||
// than by static properties. Fortunately, using weak references we can
|
||||
@@ -89,12 +92,21 @@ open class FirebaseMessagingDelegate(protected val context: Context) : FirebaseM
|
||||
fun getBackgroundTasks() = sBackgroundTaskConsumerReferences.values.mapNotNull { it.get() }
|
||||
|
||||
|
||||
override fun onMessageReceived(remoteMessage: RemoteMessage) {
|
||||
- NotificationsService.receive(context, createNotification(remoteMessage))
|
||||
- getBackgroundTasks().forEach {
|
||||
@@ -206,7 +181,7 @@ index 55b3a8d..1b99d5b 100644
|
||||
+ }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+ override fun showMessage(remoteMessage: RemoteMessage) {
|
||||
+ NotificationsService.receive(context, createNotification(remoteMessage))
|
||||
+ }
|
||||
@@ -7,8 +7,3 @@ in `onMessageReceived` are sent to the module for handling.
|
||||
|
||||
It also allows us to set the Android notification channel ID from the notification `data`, rather
|
||||
than the `notification` object in the payload.
|
||||
|
||||
### `setBadgeCountAsync` fix on Android
|
||||
|
||||
`ShortcutBadger`'s `setCount` doesn't work for clearing the badge on Android for some reason. Instead, let's use the
|
||||
Android API for clearing the badge.
|
||||
@@ -1,11 +1,11 @@
|
||||
diff --git a/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift b/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift
|
||||
index b85291e..546709d 100644
|
||||
index b85291e..07a5d3c 100644
|
||||
--- a/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift
|
||||
+++ b/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift
|
||||
@@ -78,13 +78,20 @@ public final class ExpoUpdatesUpdate: Update {
|
||||
status = UpdateStatus.StatusPending
|
||||
}
|
||||
|
||||
|
||||
+ // Instead of relying on various hacks to get the correct format for the specific
|
||||
+ // platform on the backend, we can just add this little patch..
|
||||
+ let dateFormatter = DateFormatter()
|
||||
@@ -1,29 +1,3 @@
|
||||
diff --git a/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm b/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm
|
||||
index b0d71dc..9974932 100644
|
||||
--- a/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm
|
||||
+++ b/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm
|
||||
@@ -377,10 +377,6 @@ - (void)textInputDidBeginEditing
|
||||
self.backedTextInputView.attributedText = [NSAttributedString new];
|
||||
}
|
||||
|
||||
- if (_selectTextOnFocus) {
|
||||
- [self.backedTextInputView selectAll:nil];
|
||||
- }
|
||||
-
|
||||
[_eventDispatcher sendTextEventWithType:RCTTextEventTypeFocus
|
||||
reactTag:self.reactTag
|
||||
text:[self.backedTextInputView.attributedText.string copy]
|
||||
@@ -611,6 +607,10 @@ - (UIView *)reactAccessibilityElement
|
||||
- (void)reactFocus
|
||||
{
|
||||
[self.backedTextInputView reactFocus];
|
||||
+
|
||||
+ if (_selectTextOnFocus) {
|
||||
+ [self.backedTextInputView selectAll:nil];
|
||||
+ }
|
||||
}
|
||||
|
||||
- (void)reactBlur
|
||||
diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
|
||||
index e9b330f..1ecdf0a 100644
|
||||
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
|
||||
@@ -31,7 +5,7 @@ index e9b330f..1ecdf0a 100644
|
||||
@@ -16,4 +16,6 @@
|
||||
@property (nonatomic, copy) RCTDirectEventBlock onRefresh;
|
||||
@property (nonatomic, weak) UIScrollView *scrollView;
|
||||
|
||||
|
||||
+- (void)forwarderBeginRefreshing;
|
||||
+
|
||||
@end
|
||||
@@ -42,7 +16,7 @@ index b09e653..4c32b31 100644
|
||||
@@ -198,9 +198,53 @@ - (void)refreshControlValueChanged
|
||||
[self setCurrentRefreshingState:super.refreshing];
|
||||
_refreshingProgrammatically = NO;
|
||||
|
||||
|
||||
+ if (@available(iOS 17.4, *)) {
|
||||
+ if (_currentRefreshingState) {
|
||||
+ UIImpactFeedbackGenerator *feedbackGenerator = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight];
|
||||
@@ -55,7 +29,7 @@ index b09e653..4c32b31 100644
|
||||
_onRefresh(nil);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+/*
|
||||
+ This method is used by Bluesky's ExpoScrollForwarder. This allows other React Native
|
||||
+ libraries to perform a refresh of a scrollview and access the refresh control's onRefresh
|
||||
@@ -64,15 +38,15 @@ index b09e653..4c32b31 100644
|
||||
+- (void)forwarderBeginRefreshing
|
||||
+{
|
||||
+ _refreshingProgrammatically = NO;
|
||||
+
|
||||
+
|
||||
+ [self sizeToFit];
|
||||
+
|
||||
+
|
||||
+ if (!self.scrollView) {
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+
|
||||
+ UIScrollView *scrollView = (UIScrollView *)self.scrollView;
|
||||
+
|
||||
+
|
||||
+ [UIView animateWithDuration:0.3
|
||||
+ delay:0
|
||||
+ options:UIViewAnimationOptionBeginFromCurrentState
|
||||
@@ -84,7 +58,7 @@ index b09e653..4c32b31 100644
|
||||
+ completion:^(__unused BOOL finished) {
|
||||
+ [super beginRefreshing];
|
||||
+ [self setCurrentRefreshingState:super.refreshing];
|
||||
+
|
||||
+
|
||||
+ if (self->_onRefresh) {
|
||||
+ self->_onRefresh(nil);
|
||||
+ }
|
||||
@@ -93,19 +67,3 @@ index b09e653..4c32b31 100644
|
||||
+}
|
||||
+
|
||||
@end
|
||||
diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/core/JavaTimerManager.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/core/JavaTimerManager.java
|
||||
index 5f5e1ab..aac00b6 100644
|
||||
--- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/core/JavaTimerManager.java
|
||||
+++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/core/JavaTimerManager.java
|
||||
@@ -99,8 +99,9 @@ public class JavaTimerManager {
|
||||
}
|
||||
|
||||
// If the JS thread is busy for multiple frames we cancel any other pending runnable.
|
||||
- if (mCurrentIdleCallbackRunnable != null) {
|
||||
- mCurrentIdleCallbackRunnable.cancel();
|
||||
+ IdleCallbackRunnable currentRunnable = mCurrentIdleCallbackRunnable;
|
||||
+ if (currentRunnable != null) {
|
||||
+ currentRunnable.cancel();
|
||||
}
|
||||
|
||||
mCurrentIdleCallbackRunnable = new IdleCallbackRunnable(frameTimeNanos);
|
||||
|
||||
@@ -11,10 +11,3 @@ in the RN repo: https://github.com/facebook/react-native/issues/43388
|
||||
Patching `RCTRefreshControl.m` and `RCTRefreshControl.h` to add a new `forwarderBeginRefreshing` method to the class.
|
||||
This method is used by `ExpoScrollForwarder` to initiate a refresh of the underlying `UIScrollView` from inside that
|
||||
module.
|
||||
|
||||
|
||||
## TextInput Patch - `selectTextOnFocus` fix
|
||||
|
||||
Patching `RCTBaseTextInputView.m` to fix an issue where `selectTextOnFocus` does not work as expected on iOS 17. This
|
||||
patch _only_ fixes the Paper version of `TextInput`. If we migrate to Fabric and the fix has not been made upstream,
|
||||
we can apply the same fix. See https://github.com/facebook/react-native/pull/44307.
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
const {withAndroidManifest} = require('expo/config-plugins')
|
||||
|
||||
module.exports = function withAndroidManifestLaunchModePlugin(appConfig) {
|
||||
return withAndroidManifest(appConfig, function (decoratedAppConfig) {
|
||||
try {
|
||||
const mainApplication =
|
||||
decoratedAppConfig.modResults.manifest.application[0]
|
||||
const mainActivity = mainApplication.activity.find(
|
||||
elem => elem.$['android:name'] === '.MainActivity',
|
||||
)
|
||||
mainActivity.$['android:launchMode'] = 'singleTop'
|
||||
} catch (e) {
|
||||
console.error(`withAndroidManifestLaunchModePlugin failed`, e)
|
||||
}
|
||||
return decoratedAppConfig
|
||||
})
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
#!/bin/bash
|
||||
outputIos=$(eas build:version:get -p ios)
|
||||
outputAndroid=$(eas build:version:get -p android)
|
||||
BSKY_IOS_BUILD_NUMBER=${outputIos#*buildNumber - }
|
||||
BSKY_ANDROID_VERSION_CODE=${outputAndroid#*versionCode - }
|
||||
|
||||
echo PACKAGE_VERSION="$(jq -r '.version' package.json)" > "$GITHUB_OUTPUT"
|
||||
echo BSKY_IOS_BUILD_NUMBER=$BSKY_IOS_BUILD_NUMBER >> "$GITHUB_OUTPUT"
|
||||
echo BSKY_ANDROID_VERSION_CODE=$BSKY_ANDROID_VERSION_CODE >> "$GITHUB_OUTPUT"
|
||||
@@ -14,11 +14,7 @@ import * as SplashScreen from 'expo-splash-screen'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {
|
||||
initialize,
|
||||
Provider as StatsigProvider,
|
||||
tryFetchGates,
|
||||
} from '#/lib/statsig/statsig'
|
||||
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
import {MessagesProvider} from '#/state/messages'
|
||||
import {init as initPersistedState} from '#/state/persisted'
|
||||
@@ -73,9 +69,6 @@ function InnerApp() {
|
||||
try {
|
||||
if (account) {
|
||||
await resumeSession(account)
|
||||
} else {
|
||||
await initialize()
|
||||
await tryFetchGates(undefined, 'prefer-fresh-gates')
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(`session: resume failed`, {message: e})
|
||||
@@ -150,7 +143,7 @@ function App() {
|
||||
* that is set up in the InnerApp component above.
|
||||
*/
|
||||
return (
|
||||
<KeyboardProvider enabled={false} statusBarTranslucent={true}>
|
||||
<KeyboardProvider enabled={true} statusBarTranslucent={true}>
|
||||
<SessionProvider>
|
||||
<ShellStateProvider>
|
||||
<PrefsStateProvider>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {buildStateObject} from 'lib/routes/helpers'
|
||||
import {
|
||||
AllNavigatorParams,
|
||||
BottomTabNavigatorParams,
|
||||
FeedsTabNavigatorParams,
|
||||
FlatNavigatorParams,
|
||||
HomeTabNavigatorParams,
|
||||
MessagesTabNavigatorParams,
|
||||
@@ -41,7 +42,6 @@ import {PreferencesThreads} from 'view/screens/PreferencesThreads'
|
||||
import {SavedFeeds} from 'view/screens/SavedFeeds'
|
||||
import HashtagScreen from '#/screens/Hashtag'
|
||||
import {ModerationScreen} from '#/screens/Moderation'
|
||||
import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers'
|
||||
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
|
||||
import {init as initAnalytics} from './lib/analytics/analytics'
|
||||
import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration'
|
||||
@@ -91,6 +91,7 @@ const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
|
||||
|
||||
const HomeTab = createNativeStackNavigatorWithAuth<HomeTabNavigatorParams>()
|
||||
const SearchTab = createNativeStackNavigatorWithAuth<SearchTabNavigatorParams>()
|
||||
const FeedsTab = createNativeStackNavigatorWithAuth<FeedsTabNavigatorParams>()
|
||||
const NotificationsTab =
|
||||
createNativeStackNavigatorWithAuth<NotificationsTabNavigatorParams>()
|
||||
const MyProfileTab =
|
||||
@@ -170,13 +171,6 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
||||
title: title(msg`People followed by @${route.params.name}`),
|
||||
})}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ProfileKnownFollowers"
|
||||
getComponent={() => ProfileKnownFollowersScreen}
|
||||
options={({route}) => ({
|
||||
title: title(msg`Followers of @${route.params.name} that you know`),
|
||||
})}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ProfileList"
|
||||
getComponent={() => ProfileListScreen}
|
||||
@@ -312,7 +306,6 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
||||
getComponent={() => MessagesSettingsScreen}
|
||||
options={{title: title(msg`Chat settings`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen name="Feeds" getComponent={() => FeedsScreen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -337,6 +330,7 @@ function TabsNavigator() {
|
||||
tabBar={tabBar}>
|
||||
<Tab.Screen name="HomeTab" getComponent={() => HomeTabNavigator} />
|
||||
<Tab.Screen name="SearchTab" getComponent={() => SearchTabNavigator} />
|
||||
<Tab.Screen name="FeedsTab" getComponent={() => FeedsTabNavigator} />
|
||||
<Tab.Screen
|
||||
name="NotificationsTab"
|
||||
getComponent={() => NotificationsTabNavigator}
|
||||
@@ -390,6 +384,24 @@ function SearchTabNavigator() {
|
||||
)
|
||||
}
|
||||
|
||||
function FeedsTabNavigator() {
|
||||
const pal = usePalette('default')
|
||||
return (
|
||||
<FeedsTab.Navigator
|
||||
screenOptions={{
|
||||
animation: isAndroid ? 'ios' : undefined,
|
||||
animationDuration: 285,
|
||||
gestureEnabled: true,
|
||||
fullScreenGestureEnabled: true,
|
||||
headerShown: false,
|
||||
contentStyle: pal.view,
|
||||
}}>
|
||||
<FeedsTab.Screen name="Feeds" getComponent={() => FeedsScreen} />
|
||||
{commonScreens(FeedsTab as typeof HomeTab)}
|
||||
</FeedsTab.Navigator>
|
||||
)
|
||||
}
|
||||
|
||||
function NotificationsTabNavigator() {
|
||||
const pal = usePalette('default')
|
||||
return (
|
||||
@@ -493,6 +505,11 @@ const FlatNavigator = () => {
|
||||
getComponent={() => SearchScreen}
|
||||
options={{title: title(msg`Search`)}}
|
||||
/>
|
||||
<Flat.Screen
|
||||
name="Feeds"
|
||||
getComponent={() => FeedsScreen}
|
||||
options={{title: title(msg`Feeds`)}}
|
||||
/>
|
||||
<Flat.Screen
|
||||
name="Notifications"
|
||||
getComponent={() => NotificationsScreen}
|
||||
|
||||
@@ -267,9 +267,6 @@ export const atoms = {
|
||||
font_bold: {
|
||||
fontWeight: tokens.fontWeight.bold,
|
||||
},
|
||||
font_heavy: {
|
||||
fontWeight: tokens.fontWeight.heavy,
|
||||
},
|
||||
italic: {
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
|
||||
@@ -118,7 +118,6 @@ export const fontWeight = {
|
||||
normal: '400',
|
||||
semibold: '500',
|
||||
bold: '600',
|
||||
heavy: '700',
|
||||
} as const
|
||||
|
||||
export const gradients = {
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
import React from 'react'
|
||||
import {GestureResponderEvent, View} from 'react-native'
|
||||
import {AppBskyActorDefs, AppBskyFeedDefs, AtUri} from '@atproto/api'
|
||||
import {msg, plural, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {
|
||||
useAddSavedFeedsMutation,
|
||||
usePreferencesQuery,
|
||||
useRemoveFeedMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {useSession} from 'state/session'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
import {useTheme} from '#/alf'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {useRichText} from '#/components/hooks/useRichText'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
|
||||
import {Link as InternalLink} from '#/components/Link'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function Default({feed}: {feed: AppBskyFeedDefs.GeneratorView}) {
|
||||
return (
|
||||
<Link feed={feed}>
|
||||
<Outer>
|
||||
<Header>
|
||||
<Avatar src={feed.avatar} />
|
||||
<TitleAndByline title={feed.displayName} creator={feed.creator} />
|
||||
<Action uri={feed.uri} pin />
|
||||
</Header>
|
||||
<Description description={feed.description} />
|
||||
<Likes count={feed.likeCount || 0} />
|
||||
</Outer>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export function Link({
|
||||
children,
|
||||
feed,
|
||||
}: {
|
||||
children: React.ReactElement
|
||||
feed: AppBskyFeedDefs.GeneratorView
|
||||
}) {
|
||||
const href = React.useMemo(() => {
|
||||
const urip = new AtUri(feed.uri)
|
||||
const handleOrDid = feed.creator.handle || feed.creator.did
|
||||
return `/profile/${handleOrDid}/feed/${urip.rkey}`
|
||||
}, [feed])
|
||||
return <InternalLink to={href}>{children}</InternalLink>
|
||||
}
|
||||
|
||||
export function Outer({children}: {children: React.ReactNode}) {
|
||||
return <View style={[a.flex_1, a.gap_md]}>{children}</View>
|
||||
}
|
||||
|
||||
export function Header({children}: {children: React.ReactNode}) {
|
||||
return <View style={[a.flex_row, a.align_center, a.gap_md]}>{children}</View>
|
||||
}
|
||||
|
||||
export function Avatar({src}: {src: string | undefined}) {
|
||||
return <UserAvatar type="algo" size={40} avatar={src} />
|
||||
}
|
||||
|
||||
export function TitleAndByline({
|
||||
title,
|
||||
creator,
|
||||
}: {
|
||||
title: string
|
||||
creator: AppBskyActorDefs.ProfileViewBasic
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View style={[a.flex_1]}>
|
||||
<Text
|
||||
style={[a.text_md, a.font_bold, a.flex_1, a.leading_snug]}
|
||||
numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text
|
||||
style={[a.flex_1, a.leading_snug, t.atoms.text_contrast_medium]}
|
||||
numberOfLines={1}>
|
||||
<Trans>Feed by {sanitizeHandle(creator.handle, '@')}</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function Description({description}: {description?: string}) {
|
||||
const [rt, isResolving] = useRichText(description || '')
|
||||
if (!description) return null
|
||||
return isResolving ? (
|
||||
<RichText value={description} style={[a.leading_snug]} />
|
||||
) : (
|
||||
<RichText value={rt} style={[a.leading_snug]} />
|
||||
)
|
||||
}
|
||||
|
||||
export function Likes({count}: {count: number}) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
|
||||
{plural(count || 0, {
|
||||
one: 'Liked by # user',
|
||||
other: 'Liked by # users',
|
||||
})}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
export function Action({uri, pin}: {uri: string; pin?: boolean}) {
|
||||
const {hasSession} = useSession()
|
||||
if (!hasSession) return null
|
||||
return <ActionInner uri={uri} pin={pin} />
|
||||
}
|
||||
|
||||
function ActionInner({uri, pin}: {uri: string; pin?: boolean}) {
|
||||
const {_} = useLingui()
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const {isPending: isAddSavedFeedPending, mutateAsync: saveFeeds} =
|
||||
useAddSavedFeedsMutation()
|
||||
const {isPending: isRemovePending, mutateAsync: removeFeed} =
|
||||
useRemoveFeedMutation()
|
||||
const savedFeedConfig = React.useMemo(() => {
|
||||
return preferences?.savedFeeds?.find(
|
||||
feed => feed.type === 'feed' && feed.value === uri,
|
||||
)
|
||||
}, [preferences?.savedFeeds, uri])
|
||||
const removePromptControl = Prompt.usePromptControl()
|
||||
const isPending = isAddSavedFeedPending || isRemovePending
|
||||
|
||||
const toggleSave = React.useCallback(
|
||||
async (e: GestureResponderEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
try {
|
||||
if (savedFeedConfig) {
|
||||
await removeFeed(savedFeedConfig)
|
||||
} else {
|
||||
await saveFeeds([
|
||||
{
|
||||
type: 'feed',
|
||||
value: uri,
|
||||
pinned: pin || false,
|
||||
},
|
||||
])
|
||||
}
|
||||
Toast.show(_(msg`Feeds updated!`))
|
||||
} catch (e: any) {
|
||||
logger.error(e, {context: `FeedCard: failed to update feeds`, pin})
|
||||
Toast.show(_(msg`Failed to update feeds`))
|
||||
}
|
||||
},
|
||||
[_, pin, saveFeeds, removeFeed, uri, savedFeedConfig],
|
||||
)
|
||||
|
||||
const onPrompRemoveFeed = React.useCallback(
|
||||
async (e: GestureResponderEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
removePromptControl.open()
|
||||
},
|
||||
[removePromptControl],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
disabled={isPending}
|
||||
label={_(msg`Add this feed to your feeds`)}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
shape="square"
|
||||
onPress={savedFeedConfig ? onPrompRemoveFeed : toggleSave}>
|
||||
{savedFeedConfig ? (
|
||||
<ButtonIcon size="md" icon={isPending ? Loader : Trash} />
|
||||
) : (
|
||||
<ButtonIcon size="md" icon={isPending ? Loader : Plus} />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Prompt.Basic
|
||||
control={removePromptControl}
|
||||
title={_(msg`Remove from my feeds?`)}
|
||||
description={_(
|
||||
msg`Are you sure you want to remove this from your feeds?`,
|
||||
)}
|
||||
onConfirm={toggleSave}
|
||||
confirmButtonCta={_(msg`Remove`)}
|
||||
confirmButtonColor="negative"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export function KeyboardControllerPadding({
|
||||
maxHeight: _,
|
||||
}: {
|
||||
maxHeight?: number
|
||||
}) {
|
||||
return null
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import Animated, {
|
||||
useSharedValue,
|
||||
} from 'react-native-reanimated'
|
||||
|
||||
export function KeyboardControllerPadding({maxHeight}: {maxHeight?: number}) {
|
||||
export function KeyboardPadding({maxHeight}: {maxHeight?: number}) {
|
||||
const keyboardHeight = useSharedValue(0)
|
||||
|
||||
useKeyboardHandler(
|
||||
@@ -0,0 +1,3 @@
|
||||
export function KeyboardPadding({maxHeight: _}: {maxHeight?: number}) {
|
||||
return null
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
|
||||
import {msg, Plural, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Link, LinkProps} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
const AVI_SIZE = 30
|
||||
const AVI_BORDER = 1
|
||||
|
||||
/**
|
||||
* Shared logic to determine if `KnownFollowers` should be shown.
|
||||
*
|
||||
* Checks the # of actual returned users instead of the `count` value, because
|
||||
* `count` includes blocked users and `followers` does not.
|
||||
*/
|
||||
export function shouldShowKnownFollowers(
|
||||
knownFollowers?: AppBskyActorDefs.KnownFollowers,
|
||||
) {
|
||||
return knownFollowers && knownFollowers.followers.length > 0
|
||||
}
|
||||
|
||||
export function KnownFollowers({
|
||||
profile,
|
||||
moderationOpts,
|
||||
onLinkPress,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
moderationOpts: ModerationOpts
|
||||
onLinkPress?: LinkProps['onPress']
|
||||
}) {
|
||||
const cache = React.useRef<Map<string, AppBskyActorDefs.KnownFollowers>>(
|
||||
new Map(),
|
||||
)
|
||||
|
||||
/*
|
||||
* Results for `knownFollowers` are not sorted consistently, so when
|
||||
* revalidating we can see a flash of this data updating. This cache prevents
|
||||
* this happening for screens that remain in memory. When pushing a new
|
||||
* screen, or once this one is popped, this cache is empty, so new data is
|
||||
* displayed.
|
||||
*/
|
||||
if (profile.viewer?.knownFollowers && !cache.current.has(profile.did)) {
|
||||
cache.current.set(profile.did, profile.viewer.knownFollowers)
|
||||
}
|
||||
|
||||
const cachedKnownFollowers = cache.current.get(profile.did)
|
||||
|
||||
if (cachedKnownFollowers && shouldShowKnownFollowers(cachedKnownFollowers)) {
|
||||
return (
|
||||
<KnownFollowersInner
|
||||
profile={profile}
|
||||
cachedKnownFollowers={cachedKnownFollowers}
|
||||
moderationOpts={moderationOpts}
|
||||
onLinkPress={onLinkPress}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function KnownFollowersInner({
|
||||
profile,
|
||||
moderationOpts,
|
||||
cachedKnownFollowers,
|
||||
onLinkPress,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
moderationOpts: ModerationOpts
|
||||
cachedKnownFollowers: AppBskyActorDefs.KnownFollowers
|
||||
onLinkPress?: LinkProps['onPress']
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
const textStyle = [
|
||||
a.flex_1,
|
||||
a.text_sm,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_medium,
|
||||
]
|
||||
|
||||
const slice = cachedKnownFollowers.followers.slice(0, 3).map(f => {
|
||||
const moderation = moderateProfile(f, moderationOpts)
|
||||
return {
|
||||
profile: {
|
||||
...f,
|
||||
displayName: sanitizeDisplayName(
|
||||
f.displayName || f.handle,
|
||||
moderation.ui('displayName'),
|
||||
),
|
||||
},
|
||||
moderation,
|
||||
}
|
||||
})
|
||||
const count = cachedKnownFollowers.count
|
||||
|
||||
return (
|
||||
<Link
|
||||
label={_(
|
||||
msg`Press to view followers of this account that you also follow`,
|
||||
)}
|
||||
onPress={onLinkPress}
|
||||
to={makeProfileLink(profile, 'known-followers')}
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
a.gap_md,
|
||||
a.align_center,
|
||||
{marginLeft: -AVI_BORDER},
|
||||
]}>
|
||||
{({hovered, pressed}) => (
|
||||
<>
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
height: AVI_SIZE,
|
||||
width: AVI_SIZE + (slice.length - 1) * a.gap_md.gap,
|
||||
},
|
||||
pressed && {
|
||||
opacity: 0.5,
|
||||
},
|
||||
]}>
|
||||
{slice.map(({profile: prof, moderation}, i) => (
|
||||
<View
|
||||
key={prof.did}
|
||||
style={[
|
||||
a.absolute,
|
||||
a.rounded_full,
|
||||
{
|
||||
borderWidth: AVI_BORDER,
|
||||
borderColor: t.atoms.bg.backgroundColor,
|
||||
width: AVI_SIZE + AVI_BORDER * 2,
|
||||
height: AVI_SIZE + AVI_BORDER * 2,
|
||||
left: i * a.gap_md.gap,
|
||||
zIndex: AVI_BORDER - i,
|
||||
},
|
||||
]}>
|
||||
<UserAvatar
|
||||
size={AVI_SIZE}
|
||||
avatar={prof.avatar}
|
||||
moderation={moderation.ui('avatar')}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<Text
|
||||
style={[
|
||||
textStyle,
|
||||
hovered && {
|
||||
textDecorationLine: 'underline',
|
||||
textDecorationColor: t.atoms.text_contrast_medium.color,
|
||||
},
|
||||
pressed && {
|
||||
opacity: 0.5,
|
||||
},
|
||||
]}
|
||||
numberOfLines={2}>
|
||||
{count > 2 ? (
|
||||
<Trans>
|
||||
Followed by{' '}
|
||||
<Text key={slice[0].profile.did} style={textStyle}>
|
||||
{slice[0].profile.displayName}
|
||||
</Text>
|
||||
,{' '}
|
||||
<Text key={slice[1].profile.did} style={textStyle}>
|
||||
{slice[1].profile.displayName}
|
||||
</Text>
|
||||
, and{' '}
|
||||
<Plural value={count - 2} one="# other" other="# others" />
|
||||
</Trans>
|
||||
) : count === 2 ? (
|
||||
<Trans>
|
||||
Followed by{' '}
|
||||
<Text key={slice[0].profile.did} style={textStyle}>
|
||||
{slice[0].profile.displayName}
|
||||
</Text>{' '}
|
||||
and{' '}
|
||||
<Text key={slice[1].profile.did} style={textStyle}>
|
||||
{slice[1].profile.displayName}
|
||||
</Text>
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Followed by{' '}
|
||||
<Text key={slice[0].profile.did} style={textStyle}>
|
||||
{slice[0].profile.displayName}
|
||||
</Text>
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -12,8 +12,7 @@ import {
|
||||
isExternalUrl,
|
||||
linkRequiresWarning,
|
||||
} from '#/lib/strings/url-helpers'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {shouldClickOpenNewTab} from '#/platform/urls'
|
||||
import {isNative, isWeb} from '#/platform/detection'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {useOpenLink} from '#/state/preferences/in-app-browser'
|
||||
import {useNavigationDeduped} from 'lib/hooks/useNavigationDeduped'
|
||||
@@ -117,7 +116,16 @@ export function useLink({
|
||||
if (isExternal) {
|
||||
openLink(href)
|
||||
} else {
|
||||
const shouldOpenInNewTab = shouldClickOpenNewTab(e)
|
||||
/**
|
||||
* A `GestureResponderEvent`, but cast to `any` to avoid using a bunch
|
||||
* of @ts-ignore below.
|
||||
*/
|
||||
const event = e as any
|
||||
const isMiddleClick = isWeb && event.button === 1
|
||||
const isMetaKey =
|
||||
isWeb &&
|
||||
(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey)
|
||||
const shouldOpenInNewTab = isMetaKey || isMiddleClick
|
||||
|
||||
if (isBskyDownloadUrl(href)) {
|
||||
shareUrl(BSKY_DOWNLOAD_URL)
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {differenceInSeconds} from 'date-fns'
|
||||
|
||||
import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {HITSLOP_10} from 'lib/constants'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {Newskie} from '#/components/icons/Newskie'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function NewskieDialog({
|
||||
profile,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const control = useDialogControl()
|
||||
const profileName = React.useMemo(() => {
|
||||
const name = profile.displayName || profile.handle
|
||||
if (!moderationOpts) return name
|
||||
const moderation = moderateProfile(profile, moderationOpts)
|
||||
return sanitizeDisplayName(name, moderation.ui('displayName'))
|
||||
}, [moderationOpts, profile])
|
||||
const [now] = React.useState(() => Date.now())
|
||||
const timeAgo = useGetTimeAgo()
|
||||
const createdAt = profile.createdAt as string | undefined
|
||||
const daysOld = React.useMemo(() => {
|
||||
if (!createdAt) return Infinity
|
||||
return differenceInSeconds(now, new Date(createdAt)) / 86400
|
||||
}, [createdAt, now])
|
||||
|
||||
if (!createdAt || daysOld > 7) return null
|
||||
|
||||
return (
|
||||
<View style={[a.pr_2xs]}>
|
||||
<Button
|
||||
label={_(
|
||||
msg`This user is new here. Press for more info about when they joined.`,
|
||||
)}
|
||||
hitSlop={HITSLOP_10}
|
||||
onPress={control.open}>
|
||||
{({hovered, pressed}) => (
|
||||
<Newskie
|
||||
size="lg"
|
||||
fill="#FFC404"
|
||||
style={{
|
||||
opacity: hovered || pressed ? 0.5 : 1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Handle />
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`New user info dialog`)}
|
||||
style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}>
|
||||
<View style={[a.gap_sm]}>
|
||||
<Text style={[a.font_bold, a.text_xl]}>
|
||||
<Trans>Say hello!</Trans>
|
||||
</Text>
|
||||
<Text style={[a.text_md]}>
|
||||
<Trans>
|
||||
{profileName} joined Bluesky{' '}
|
||||
{timeAgo(createdAt, now, {format: 'long'})} ago
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import {flip, offset, shift, size, useFloating} from '@floating-ui/react-dom'
|
||||
import {msg, plural} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {getModerationCauseKey} from '#/lib/moderation'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
@@ -23,16 +22,11 @@ import {useFollowMethods} from '#/components/hooks/useFollowMethods'
|
||||
import {useRichText} from '#/components/hooks/useRichText'
|
||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
import {
|
||||
KnownFollowers,
|
||||
shouldShowKnownFollowers,
|
||||
} from '#/components/KnownFollowers'
|
||||
import {InlineLinkText, Link} from '#/components/Link'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Portal} from '#/components/Portal'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {ProfileLabel} from '../moderation/ProfileHeaderAlerts'
|
||||
import {ProfileHoverCardProps} from './types'
|
||||
|
||||
const floatingMiddlewares = [
|
||||
@@ -51,23 +45,10 @@ const floatingMiddlewares = [
|
||||
]
|
||||
|
||||
export function ProfileHoverCard(props: ProfileHoverCardProps) {
|
||||
const prefetchProfileQuery = usePrefetchProfileQuery()
|
||||
const prefetchedProfile = React.useRef(false)
|
||||
const onPointerMove = () => {
|
||||
if (!prefetchedProfile.current) {
|
||||
prefetchedProfile.current = true
|
||||
prefetchProfileQuery(props.did)
|
||||
}
|
||||
}
|
||||
|
||||
if (props.disable || isTouchDevice) {
|
||||
return props.children
|
||||
} else {
|
||||
return (
|
||||
<View onPointerMove={onPointerMove}>
|
||||
<ProfileHoverCardInner {...props} />
|
||||
</View>
|
||||
)
|
||||
return <ProfileHoverCardInner {...props} />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,10 +370,7 @@ function Inner({
|
||||
profile: profileShadow,
|
||||
logContext: 'ProfileHoverCard',
|
||||
})
|
||||
const isBlockedUser =
|
||||
profile.viewer?.blocking ||
|
||||
profile.viewer?.blockedBy ||
|
||||
profile.viewer?.blockingByList
|
||||
const blockHide = profile.viewer?.blocking || profile.viewer?.blockedBy
|
||||
const following = formatCount(profile.followsCount || 0)
|
||||
const followers = formatCount(profile.followersCount || 0)
|
||||
const pluralizedFollowers = plural(profile.followersCount || 0, {
|
||||
@@ -423,41 +401,29 @@ function Inner({
|
||||
/>
|
||||
</Link>
|
||||
|
||||
{!isMe &&
|
||||
(isBlockedUser ? (
|
||||
<Link
|
||||
to={profileURL}
|
||||
label={_(msg`View blocked user's profile`)}
|
||||
onPress={hide}
|
||||
size="small"
|
||||
color="secondary"
|
||||
variant="solid"
|
||||
style={[a.rounded_full]}>
|
||||
<ButtonText>{_(msg`View profile`)}</ButtonText>
|
||||
</Link>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
color={profileShadow.viewer?.following ? 'secondary' : 'primary'}
|
||||
variant="solid"
|
||||
label={
|
||||
profileShadow.viewer?.following
|
||||
? _(msg`Following`)
|
||||
: _(msg`Follow`)
|
||||
}
|
||||
style={[a.rounded_full]}
|
||||
onPress={profileShadow.viewer?.following ? unfollow : follow}>
|
||||
<ButtonIcon
|
||||
position="left"
|
||||
icon={profileShadow.viewer?.following ? Check : Plus}
|
||||
/>
|
||||
<ButtonText>
|
||||
{profileShadow.viewer?.following
|
||||
? _(msg`Following`)
|
||||
: _(msg`Follow`)}
|
||||
</ButtonText>
|
||||
</Button>
|
||||
))}
|
||||
{!isMe && (
|
||||
<Button
|
||||
size="small"
|
||||
color={profileShadow.viewer?.following ? 'secondary' : 'primary'}
|
||||
variant="solid"
|
||||
label={
|
||||
profileShadow.viewer?.following
|
||||
? _(msg`Following`)
|
||||
: _(msg`Follow`)
|
||||
}
|
||||
style={[a.rounded_full]}
|
||||
onPress={profileShadow.viewer?.following ? unfollow : follow}>
|
||||
<ButtonIcon
|
||||
position="left"
|
||||
icon={profileShadow.viewer?.following ? Check : Plus}
|
||||
/>
|
||||
<ButtonText>
|
||||
{profileShadow.viewer?.following
|
||||
? _(msg`Following`)
|
||||
: _(msg`Follow`)}
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Link to={profileURL} label={_(msg`View profile`)} onPress={hide}>
|
||||
@@ -473,19 +439,7 @@ function Inner({
|
||||
</View>
|
||||
</Link>
|
||||
|
||||
{isBlockedUser && (
|
||||
<View style={[a.flex_row, a.flex_wrap, a.gap_xs]}>
|
||||
{moderation.ui('profileView').alerts.map(cause => (
|
||||
<ProfileLabel
|
||||
key={getModerationCauseKey(cause)}
|
||||
cause={cause}
|
||||
disableDetailsDialog
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!isBlockedUser && (
|
||||
{!blockHide && (
|
||||
<>
|
||||
<View style={[a.flex_row, a.flex_wrap, a.gap_md, a.pt_xs]}>
|
||||
<InlineLinkText
|
||||
@@ -519,17 +473,6 @@ function Inner({
|
||||
/>
|
||||
</View>
|
||||
) : undefined}
|
||||
|
||||
{!isMe &&
|
||||
shouldShowKnownFollowers(profile.viewer?.knownFollowers) && (
|
||||
<View style={[a.flex_row, a.align_center, a.gap_sm, a.pt_md]}>
|
||||
<KnownFollowers
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
onLinkPress={hide}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React from 'react'
|
||||
import {GestureResponderEvent, View} from 'react-native'
|
||||
import {View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button, ButtonColor, ButtonProps, ButtonText} from '#/components/Button'
|
||||
import {Button, ButtonColor, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
@@ -136,7 +136,7 @@ export function Action({
|
||||
* Note: The dialog will close automatically when the action is pressed, you
|
||||
* should NOT close the dialog as a side effect of this method.
|
||||
*/
|
||||
onPress: ButtonProps['onPress']
|
||||
onPress: () => void
|
||||
color?: ButtonColor
|
||||
/**
|
||||
* Optional i18n string. If undefined, it will default to "Confirm".
|
||||
@@ -147,12 +147,9 @@ export function Action({
|
||||
const {_} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {close} = Dialog.useDialogContext()
|
||||
const handleOnPress = React.useCallback(
|
||||
(e: GestureResponderEvent) => {
|
||||
close(() => onPress?.(e))
|
||||
},
|
||||
[close, onPress],
|
||||
)
|
||||
const handleOnPress = React.useCallback(() => {
|
||||
close(onPress)
|
||||
}, [close, onPress])
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -189,7 +186,7 @@ export function Basic({
|
||||
* Note: The dialog will close automatically when the action is pressed, you
|
||||
* should NOT close the dialog as a side effect of this method.
|
||||
*/
|
||||
onConfirm: ButtonProps['onPress']
|
||||
onConfirm: () => void
|
||||
confirmButtonColor?: ButtonColor
|
||||
showCancel?: boolean
|
||||
}>) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import * as Dialog from '#/components/Dialog'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||
import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
|
||||
import {KeyboardPadding} from '#/components/KeyboardPadding'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {ReportDialogProps} from './types'
|
||||
@@ -221,6 +222,7 @@ export function SubmitView({
|
||||
{submitting && <ButtonIcon icon={Loader} />}
|
||||
</Button>
|
||||
</View>
|
||||
<KeyboardPadding />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Has
|
||||
import {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/PageText'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
import {KeyboardPadding} from '#/components/KeyboardPadding'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {Text} from '#/components/Typography'
|
||||
@@ -256,6 +257,7 @@ function MutedWordsInner() {
|
||||
</View>
|
||||
|
||||
<Dialog.Close />
|
||||
<KeyboardPadding maxHeight={100} />
|
||||
</Dialog.ScrollableInner>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ export function LeaveConvoPrompt({
|
||||
)}
|
||||
confirmButtonCta={_(msg`Leave`)}
|
||||
confirmButtonColor="negative"
|
||||
onConfirm={() => leaveConvo()}
|
||||
onConfirm={leaveConvo}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {KeyboardControllerPadding} from '#/components/KeyboardControllerPadding'
|
||||
import {Button, ButtonIcon, ButtonText} from '../Button'
|
||||
import {Divider} from '../Divider'
|
||||
import {ChevronLeft_Stroke2_Corner0_Rounded as Chevron} from '../icons/Chevron'
|
||||
@@ -48,7 +47,6 @@ let ReportDialog = ({
|
||||
<Dialog.ScrollableInner label={_(msg`Report this message`)}>
|
||||
<DialogInner params={params} />
|
||||
<Dialog.Close />
|
||||
<KeyboardControllerPadding />
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
|
||||
@@ -395,13 +395,11 @@ function ProfileCard({
|
||||
/>
|
||||
<View style={[a.flex_1, a.gap_2xs]}>
|
||||
<Text
|
||||
style={[t.atoms.text, a.font_bold, a.leading_tight]}
|
||||
style={[t.atoms.text, a.font_bold, a.leading_snug]}
|
||||
numberOfLines={1}>
|
||||
{displayName}
|
||||
</Text>
|
||||
<Text
|
||||
style={[a.leading_tight, t.atoms.text_contrast_high]}
|
||||
numberOfLines={2}>
|
||||
<Text style={t.atoms.text_contrast_high} numberOfLines={2}>
|
||||
{!enabled ? <Trans>{handle} can't be messaged</Trans> : handle}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -14,25 +14,9 @@ export function SendViaChatDialog({
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
onSelectChat: (chatId: string) => void
|
||||
}) {
|
||||
return (
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
testID="sendViaChatChatDialog"
|
||||
nativeOptions={{sheet: {snapPoints: ['100%']}}}>
|
||||
<SendViaChatDialogInner control={control} onSelectChat={onSelectChat} />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function SendViaChatDialogInner({
|
||||
control,
|
||||
onSelectChat,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
onSelectChat: (chatId: string) => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
|
||||
const {mutate: createChat} = useGetConvoForMembers({
|
||||
onSuccess: data => {
|
||||
onSelectChat(data.convo.id)
|
||||
@@ -55,10 +39,15 @@ function SendViaChatDialogInner({
|
||||
)
|
||||
|
||||
return (
|
||||
<SearchablePeopleList
|
||||
title={_(msg`Send post to...`)}
|
||||
onSelectChat={onCreateChat}
|
||||
showRecentConvos
|
||||
/>
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
testID="sendViaChatChatDialog"
|
||||
nativeOptions={{sheet: {snapPoints: ['100%']}}}>
|
||||
<SearchablePeopleList
|
||||
title={_(msg`Send post to...`)}
|
||||
onSelectChat={onCreateChat}
|
||||
showRecentConvos
|
||||
/>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import {Keyboard, View} from 'react-native'
|
||||
import {View} from 'react-native'
|
||||
import DatePicker from 'react-native-date-picker'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -49,10 +49,7 @@ export function DateField({
|
||||
<DateFieldButton
|
||||
label={label}
|
||||
value={value}
|
||||
onPress={() => {
|
||||
Keyboard.dismiss()
|
||||
control.open()
|
||||
}}
|
||||
onPress={control.open}
|
||||
isInvalid={isInvalid}
|
||||
accessibilityHint={accessibilityHint}
|
||||
/>
|
||||
|
||||
@@ -7,7 +7,3 @@ export const ArrowTopRight_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
export const ArrowLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M3 12a1 1 0 0 1 .293-.707l6-6a1 1 0 0 1 1.414 1.414L6.414 11H20a1 1 0 1 1 0 2H6.414l4.293 4.293a1 1 0 0 1-1.414 1.414l-6-6A1 1 0 0 1 3 12Z',
|
||||
})
|
||||
|
||||
export const ArrowBottom_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 21a1 1 0 0 1-.707-.293l-6-6a1 1 0 1 1 1.414-1.414L11 17.586V4a1 1 0 1 1 2 0v13.586l4.293-4.293a1 1 0 0 1 1.414 1.414l-6 6A1 1 0 0 1 12 21Z',
|
||||
})
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
/*
|
||||
* This icon is off-menu, not part of the icon set
|
||||
*/
|
||||
|
||||
export const ListPlus_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M4 5a1 1 0 0 0 0 2h16a1 1 0 1 0 0-2H4Zm0 12a1 1 0 1 0 0 2h3a1 1 0 1 0 0-2H4Zm-1-5a1 1 0 0 1 1-1h5a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1Zm14-3c.552 0 1 .41 1 .917V13.5h3.583c.507 0 .917.448.917 1s-.41 1-.917 1H18v3.583c0 .507-.448.917-1 .917s-1-.41-1-.917V15.5h-3.583c-.507 0-.917-.448-.917-1s.41-1 .917-1H16V9.917C16 9.41 16.448 9 17 9Z',
|
||||
})
|
||||
@@ -1,5 +0,0 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Menu_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M2 6a1 1 0 0 1 1-1h18a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1Zm0 6a1 1 0 0 1 1-1h18a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1Zm0 6a1 1 0 0 1 1-1h18a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1Z',
|
||||
})
|
||||
@@ -1,5 +0,0 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Newskie = createSinglePathSVG({
|
||||
path: 'M11.183 8.561c0 .544.348.984.892.984.545 0 .893-.44.893-.985V6.985c0-.544-.348-.985-.893-.985-.543 0-.892.44-.892.985v1.576Zm5.94 7.481c0 .539-.438.942-.976.942H8.004c-.538 0-.975-.411-.975-.95 0-2.782 2.264-5.021 5.046-5.021 2.783 0 5.047 2.247 5.047 5.03Zm-.43-4.584a.983.983 0 0 1 0-1.393l1.114-1.114a.985.985 0 0 1 1.393 1.393l-1.114 1.114a.985.985 0 0 1-1.393 0Zm2.897 3.741h1.575c.544 0 .985.349.985.892 0 .544-.44.892-.985.892h-1.67a.872.872 0 0 1-.89-.887c0-.543.44-.897.985-.897Zm-14.045.893c0-.544-.44-.892-.985-.892H2.985c-.544 0-.985.349-.985.892 0 .544.44.892.985.892H4.56c.545 0 .985-.349.985-.892Zm1.913-6.027a.985.985 0 0 1-1.393 1.393L4.95 10.344A.985.985 0 0 1 6.344 8.95l1.114 1.114Z',
|
||||
})
|
||||
@@ -1,5 +0,0 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Pin_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M6.5 3a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1v3.997a6.25 6.25 0 0 0 1.83 4.42l.377.376A1 1 0 0 1 20 12.5V15a1 1 0 0 1-1 1h-6v5a1 1 0 1 1-2 0v-5H5a1 1 0 0 1-1-1v-2.5a1 1 0 0 1 .293-.707l.376-.377A6.25 6.25 0 0 0 6.5 6.996V3.001Zm2 1v2.997a8.25 8.25 0 0 1-2.416 5.834L6 12.914V14h12v-1.086l-.084-.083A8.25 8.25 0 0 1 15.5 6.997V4h-7Z',
|
||||
})
|
||||
@@ -1,5 +0,0 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Shapes_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M7 3a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2H8v2a1 1 0 1 1-2 0V8H4a1 1 0 0 1 0-2h2V4a1 1 0 0 1 1-1Zm6 4a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm4-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4ZM3 14a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-6Zm2 1v4h4v-4H5Zm9.171-.829a1 1 0 0 1 1.415 0L17 15.585l1.414-1.414a1 1 0 1 1 1.414 1.414L18.414 17l1.414 1.414a1 1 0 0 1-1.414 1.414L17 18.414l-1.415 1.414a1 1 0 0 1-1.414-1.414l1.415-1.415-1.415-1.414a1 1 0 0 1 0-1.414Z',
|
||||
})
|
||||
@@ -1,9 +0,0 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Star_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 1a1 1 0 0 1 .902.568l2.643 5.517 6.085.799a1 1 0 0 1 .557 1.718l-4.45 4.207 1.118 6.008a1 1 0 0 1-1.46 1.063L12 17.962 6.604 20.88a1 1 0 0 1-1.459-1.063l1.117-6.008-4.45-4.207a1 1 0 0 1 .558-1.718l6.085-.8 2.643-5.516A1 1 0 0 1 12 1Zm0 3.315-1.975 4.123a1 1 0 0 1-.772.56l-4.538.595 3.317 3.137a1 1 0 0 1 .296.91l-.834 4.485 4.03-2.179a1 1 0 0 1 .952 0l4.03 2.179-.834-4.485a1 1 0 0 1 .296-.91l3.317-3.137-4.538-.596a1 1 0 0 1-.772-.56L12 4.316Z',
|
||||
})
|
||||
|
||||
export const Star_Filled_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12.902 1.568a1 1 0 0 0-1.804 0L8.455 7.085l-6.085.799a1 1 0 0 0-.557 1.718l4.45 4.207-1.117 6.008a1 1 0 0 0 1.458 1.063L12 17.962l5.396 2.918a1 1 0 0 0 1.459-1.063l-1.117-6.008 4.45-4.207a1 1 0 0 0-.558-1.718l-6.085-.8-2.643-5.516Z',
|
||||
})
|
||||
@@ -14,6 +14,7 @@ import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {KeyboardPadding} from '#/components/KeyboardPadding'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {Divider} from '../Divider'
|
||||
@@ -109,6 +110,7 @@ function LabelsOnMeDialogInner(props: LabelsOnMeDialogProps) {
|
||||
</>
|
||||
)}
|
||||
<Dialog.Close />
|
||||
<KeyboardPadding />
|
||||
</Dialog.ScrollableInner>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -43,13 +43,7 @@ export function ProfileHeaderAlerts({
|
||||
)
|
||||
}
|
||||
|
||||
export function ProfileLabel({
|
||||
cause,
|
||||
disableDetailsDialog,
|
||||
}: {
|
||||
cause: ModerationCause
|
||||
disableDetailsDialog?: boolean
|
||||
}) {
|
||||
function ProfileLabel({cause}: {cause: ModerationCause}) {
|
||||
const t = useTheme()
|
||||
const control = useModerationDetailsDialogControl()
|
||||
const desc = useModerationCauseDescription(cause)
|
||||
@@ -57,7 +51,6 @@ export function ProfileLabel({
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
disabled={disableDetailsDialog}
|
||||
label={desc.name}
|
||||
onPress={() => {
|
||||
control.open()
|
||||
@@ -94,9 +87,7 @@ export function ProfileLabel({
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{!disableDetailsDialog && (
|
||||
<ModerationDetailsDialog control={control} modcause={cause} />
|
||||
)}
|
||||
<ModerationDetailsDialog control={control} modcause={cause} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import {describe, expect, it} from '@jest/globals'
|
||||
import {MessageDescriptor} from '@lingui/core'
|
||||
import {addDays, subDays, subHours, subMinutes, subSeconds} from 'date-fns'
|
||||
|
||||
import {dateDiff} from '../useTimeAgo'
|
||||
|
||||
const lingui: any = (obj: MessageDescriptor) => obj.message
|
||||
|
||||
const base = new Date('2024-06-17T00:00:00Z')
|
||||
|
||||
describe('dateDiff', () => {
|
||||
it(`works with numbers`, () => {
|
||||
expect(dateDiff(subDays(base, 3), Number(base), {lingui})).toEqual('3d')
|
||||
})
|
||||
it(`works with strings`, () => {
|
||||
expect(dateDiff(subDays(base, 3), base.toString(), {lingui})).toEqual('3d')
|
||||
})
|
||||
it(`works with dates`, () => {
|
||||
expect(dateDiff(subDays(base, 3), base, {lingui})).toEqual('3d')
|
||||
})
|
||||
|
||||
it(`equal values return now`, () => {
|
||||
expect(dateDiff(base, base, {lingui})).toEqual('now')
|
||||
})
|
||||
it(`future dates return now`, () => {
|
||||
expect(dateDiff(addDays(base, 3), base, {lingui})).toEqual('now')
|
||||
})
|
||||
|
||||
it(`values < 5 seconds ago return now`, () => {
|
||||
const then = subSeconds(base, 4)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('now')
|
||||
})
|
||||
it(`values >= 5 seconds ago return seconds`, () => {
|
||||
const then = subSeconds(base, 5)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('5s')
|
||||
})
|
||||
|
||||
it(`values < 1 min return seconds`, () => {
|
||||
const then = subSeconds(base, 59)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('59s')
|
||||
})
|
||||
it(`values >= 1 min return minutes`, () => {
|
||||
const then = subSeconds(base, 60)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('1m')
|
||||
})
|
||||
it(`minutes round down`, () => {
|
||||
const then = subSeconds(base, 119)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('1m')
|
||||
})
|
||||
|
||||
it(`values < 1 hour return minutes`, () => {
|
||||
const then = subMinutes(base, 59)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('59m')
|
||||
})
|
||||
it(`values >= 1 hour return hours`, () => {
|
||||
const then = subMinutes(base, 60)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('1h')
|
||||
})
|
||||
it(`hours round down`, () => {
|
||||
const then = subMinutes(base, 119)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('1h')
|
||||
})
|
||||
|
||||
it(`values < 1 day return hours`, () => {
|
||||
const then = subHours(base, 23)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('23h')
|
||||
})
|
||||
it(`values >= 1 day return days`, () => {
|
||||
const then = subHours(base, 24)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('1d')
|
||||
})
|
||||
it(`days round down`, () => {
|
||||
const then = subHours(base, 47)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('1d')
|
||||
})
|
||||
|
||||
it(`values < 30 days return days`, () => {
|
||||
const then = subDays(base, 29)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('29d')
|
||||
})
|
||||
it(`values >= 30 days return months`, () => {
|
||||
const then = subDays(base, 30)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('1mo')
|
||||
})
|
||||
it(`months round down`, () => {
|
||||
const then = subDays(base, 59)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('1mo')
|
||||
})
|
||||
it(`values are rounded by increments of 30`, () => {
|
||||
const then = subDays(base, 61)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('2mo')
|
||||
})
|
||||
|
||||
it(`values < 360 days return months`, () => {
|
||||
const then = subDays(base, 359)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('11mo')
|
||||
})
|
||||
it(`values >= 360 days return the earlier value`, () => {
|
||||
const then = subDays(base, 360)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual(then.toLocaleDateString())
|
||||
})
|
||||
})
|
||||
@@ -1,34 +1,12 @@
|
||||
import {interpolate, useAnimatedStyle} from 'react-native-reanimated'
|
||||
|
||||
import {useMinimalShellMode} from '#/state/shell/minimal-mode'
|
||||
import {useMinimalShellMode as useMinimalShellModeState} from '#/state/shell/minimal-mode'
|
||||
import {useShellLayout} from '#/state/shell/shell-layout'
|
||||
|
||||
// Keep these separated so that we only pay for useAnimatedStyle that gets used.
|
||||
export function useMinimalShellMode() {
|
||||
const mode = useMinimalShellModeState()
|
||||
const {footerHeight, headerHeight} = useShellLayout()
|
||||
|
||||
export function useMinimalShellHeaderTransform() {
|
||||
const mode = useMinimalShellMode()
|
||||
const {headerHeight} = useShellLayout()
|
||||
|
||||
const headerTransform = useAnimatedStyle(() => {
|
||||
return {
|
||||
pointerEvents: mode.value === 0 ? 'auto' : 'none',
|
||||
opacity: Math.pow(1 - mode.value, 2),
|
||||
transform: [
|
||||
{
|
||||
translateY: interpolate(mode.value, [0, 1], [0, -headerHeight.value]),
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
return headerTransform
|
||||
}
|
||||
|
||||
export function useMinimalShellFooterTransform() {
|
||||
const mode = useMinimalShellMode()
|
||||
const {footerHeight} = useShellLayout()
|
||||
|
||||
const footerTransform = useAnimatedStyle(() => {
|
||||
const footerMinimalShellTransform = useAnimatedStyle(() => {
|
||||
return {
|
||||
pointerEvents: mode.value === 0 ? 'auto' : 'none',
|
||||
opacity: Math.pow(1 - mode.value, 2),
|
||||
@@ -39,13 +17,18 @@ export function useMinimalShellFooterTransform() {
|
||||
],
|
||||
}
|
||||
})
|
||||
return footerTransform
|
||||
}
|
||||
|
||||
export function useMinimalShellFabTransform() {
|
||||
const mode = useMinimalShellMode()
|
||||
|
||||
const fabTransform = useAnimatedStyle(() => {
|
||||
const headerMinimalShellTransform = useAnimatedStyle(() => {
|
||||
return {
|
||||
pointerEvents: mode.value === 0 ? 'auto' : 'none',
|
||||
opacity: Math.pow(1 - mode.value, 2),
|
||||
transform: [
|
||||
{
|
||||
translateY: interpolate(mode.value, [0, 1], [0, -headerHeight.value]),
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
const fabMinimalShellTransform = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [
|
||||
{
|
||||
@@ -54,5 +37,9 @@ export function useMinimalShellFabTransform() {
|
||||
],
|
||||
}
|
||||
})
|
||||
return fabTransform
|
||||
return {
|
||||
footerMinimalShellTransform,
|
||||
headerMinimalShellTransform,
|
||||
fabMinimalShellTransform,
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ export function useNavigationTabState() {
|
||||
const res = {
|
||||
isAtHome: getTabState(state, 'Home') !== TabState.Outside,
|
||||
isAtSearch: getTabState(state, 'Search') !== TabState.Outside,
|
||||
// FeedsTab no longer exists, but this check works for `Feeds` screen as well
|
||||
isAtFeeds: getTabState(state, 'Feeds') !== TabState.Outside,
|
||||
isAtNotifications:
|
||||
getTabState(state, 'Notifications') !== TabState.Outside,
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import {useCallback} from 'react'
|
||||
import {msg, plural} from '@lingui/macro'
|
||||
import {I18nContext, useLingui} from '@lingui/react'
|
||||
import {differenceInSeconds} from 'date-fns'
|
||||
|
||||
export type TimeAgoOptions = {
|
||||
lingui: I18nContext['_']
|
||||
format?: 'long' | 'short'
|
||||
}
|
||||
|
||||
export function useGetTimeAgo() {
|
||||
const {_} = useLingui()
|
||||
return useCallback(
|
||||
(
|
||||
earlier: number | string | Date,
|
||||
later: number | string | Date,
|
||||
options?: Omit<TimeAgoOptions, 'lingui'>,
|
||||
) => {
|
||||
return dateDiff(earlier, later, {lingui: _, format: options?.format})
|
||||
},
|
||||
[_],
|
||||
)
|
||||
}
|
||||
|
||||
const NOW = 5
|
||||
const MINUTE = 60
|
||||
const HOUR = MINUTE * 60
|
||||
const DAY = HOUR * 24
|
||||
const MONTH_30 = DAY * 30
|
||||
|
||||
/**
|
||||
* Returns the difference between `earlier` and `later` dates, formatted as a
|
||||
* natural language string.
|
||||
*
|
||||
* - All month are considered exactly 30 days.
|
||||
* - Dates assume `earlier` <= `later`, and will otherwise return 'now'.
|
||||
* - Differences >= 360 days are returned as the "M/D/YYYY" string
|
||||
* - All values round down
|
||||
*/
|
||||
export function dateDiff(
|
||||
earlier: number | string | Date,
|
||||
later: number | string | Date,
|
||||
options: TimeAgoOptions,
|
||||
): string {
|
||||
const _ = options.lingui
|
||||
const format = options?.format || 'short'
|
||||
const long = format === 'long'
|
||||
const diffSeconds = differenceInSeconds(new Date(later), new Date(earlier))
|
||||
|
||||
if (diffSeconds < NOW) {
|
||||
return _(msg`now`)
|
||||
} else if (diffSeconds < MINUTE) {
|
||||
return `${diffSeconds}${
|
||||
long ? ` ${plural(diffSeconds, {one: 'second', other: 'seconds'})}` : 's'
|
||||
}`
|
||||
} else if (diffSeconds < HOUR) {
|
||||
const diff = Math.floor(diffSeconds / MINUTE)
|
||||
return `${diff}${
|
||||
long ? ` ${plural(diff, {one: 'minute', other: 'minutes'})}` : 'm'
|
||||
}`
|
||||
} else if (diffSeconds < DAY) {
|
||||
const diff = Math.floor(diffSeconds / HOUR)
|
||||
return `${diff}${
|
||||
long ? ` ${plural(diff, {one: 'hour', other: 'hours'})}` : 'h'
|
||||
}`
|
||||
} else if (diffSeconds < MONTH_30) {
|
||||
const diff = Math.floor(diffSeconds / DAY)
|
||||
return `${diff}${
|
||||
long ? ` ${plural(diff, {one: 'day', other: 'days'})}` : 'd'
|
||||
}`
|
||||
} else {
|
||||
const diff = Math.floor(diffSeconds / MONTH_30)
|
||||
if (diff < 12) {
|
||||
return `${diff}${
|
||||
long ? ` ${plural(diff, {one: 'month', other: 'months'})}` : 'mo'
|
||||
}`
|
||||
} else {
|
||||
const str = new Date(earlier).toLocaleDateString()
|
||||
|
||||
if (long) {
|
||||
return _(msg`on ${str}`)
|
||||
}
|
||||
return str
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,8 @@ import {
|
||||
launchImageLibraryAsync,
|
||||
MediaTypeOptions,
|
||||
} from 'expo-image-picker'
|
||||
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
import {getDataUriSize} from './util'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
|
||||
export async function openPicker(opts?: ImagePickerOptions) {
|
||||
const response = await launchImageLibraryAsync({
|
||||
@@ -13,7 +12,6 @@ export async function openPicker(opts?: ImagePickerOptions) {
|
||||
mediaTypes: MediaTypeOptions.Images,
|
||||
quality: 1,
|
||||
...opts,
|
||||
legacy: true,
|
||||
})
|
||||
|
||||
if (response.assets && response.assets.length > 4) {
|
||||
|
||||
@@ -6,8 +6,7 @@ import {BskyAgent} from '@atproto/api'
|
||||
import {logger} from '#/logger'
|
||||
import {SessionAccount, useAgent, useSession} from '#/state/session'
|
||||
import {logEvent, useGate} from 'lib/statsig/statsig'
|
||||
import {devicePlatform, isAndroid, isNative} from 'platform/detection'
|
||||
import BackgroundNotificationHandler from '../../../modules/expo-background-notification-handler'
|
||||
import {devicePlatform, isNative} from 'platform/detection'
|
||||
|
||||
const SERVICE_DID = (serviceUrl?: string) =>
|
||||
serviceUrl?.includes('staging')
|
||||
@@ -43,7 +42,7 @@ async function getPushToken(skipPermissionCheck = false) {
|
||||
const granted =
|
||||
skipPermissionCheck || (await Notifications.getPermissionsAsync()).granted
|
||||
if (granted) {
|
||||
return Notifications.getDevicePushTokenAsync()
|
||||
Notifications.getDevicePushTokenAsync()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,22 +55,7 @@ export function useNotificationsRegistration() {
|
||||
return
|
||||
}
|
||||
|
||||
// HACK - see https://github.com/bluesky-social/social-app/pull/4467
|
||||
// An apparent regression in expo-notifications causes `addPushTokenListener` to not fire on Android whenever the
|
||||
// token changes by calling `getPushToken()`. This is a workaround to ensure we register the token once it is
|
||||
// generated on Android.
|
||||
if (isAndroid) {
|
||||
;(async () => {
|
||||
const token = await getPushToken()
|
||||
|
||||
// Token will be undefined if we don't have notifications permission
|
||||
if (token) {
|
||||
registerPushToken(agent, currentAccount, token)
|
||||
}
|
||||
})()
|
||||
} else {
|
||||
getPushToken()
|
||||
}
|
||||
getPushToken()
|
||||
|
||||
// According to the Expo docs, there is a chance that the token will change while the app is open in some rare
|
||||
// cases. This will fire `registerPushToken` whenever that happens.
|
||||
@@ -87,18 +71,14 @@ export function useNotificationsRegistration() {
|
||||
|
||||
export function useRequestNotificationsPermission() {
|
||||
const gate = useGate()
|
||||
const {currentAccount} = useSession()
|
||||
const agent = useAgent()
|
||||
|
||||
return async (
|
||||
context: 'StartOnboarding' | 'AfterOnboarding' | 'Login' | 'Home',
|
||||
) => {
|
||||
return async (context: 'StartOnboarding' | 'AfterOnboarding' | 'Login') => {
|
||||
const permissions = await Notifications.getPermissionsAsync()
|
||||
|
||||
if (
|
||||
!isNative ||
|
||||
permissions?.status === 'granted' ||
|
||||
(permissions?.status === 'denied' && !permissions.canAskAgain)
|
||||
permissions?.status === 'denied'
|
||||
) {
|
||||
return
|
||||
}
|
||||
@@ -114,9 +94,6 @@ export function useRequestNotificationsPermission() {
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (context === 'Home' && !currentAccount) {
|
||||
return
|
||||
}
|
||||
|
||||
const res = await Notifications.requestPermissionsAsync()
|
||||
logEvent('notifications:request', {
|
||||
@@ -126,38 +103,24 @@ export function useRequestNotificationsPermission() {
|
||||
|
||||
if (res.granted) {
|
||||
// This will fire a pushTokenEvent, which will handle registration of the token
|
||||
const token = await getPushToken(true)
|
||||
|
||||
// Same hack as above. We cannot rely on the `addPushTokenListener` to fire on Android due to an Expo bug, so we
|
||||
// will manually register it here. Note that this will occur only:
|
||||
// 1. right after the user signs in, leading to no `currentAccount` account being available - this will be instead
|
||||
// picked up from the useEffect above on `currentAccount` change
|
||||
// 2. right after onboarding. In this case, we _need_ this registration, since `currentAccount` will not change
|
||||
// and we need to ensure the token is registered right after permission is granted. `currentAccount` will already
|
||||
// be available in this case, so the registration will succeed.
|
||||
// We should remove this once expo-notifications (and possibly FCMv1) is fixed and the `addPushTokenListener` is
|
||||
// working again. See https://github.com/expo/expo/issues/28656
|
||||
if (isAndroid && currentAccount && token) {
|
||||
registerPushToken(agent, currentAccount, token)
|
||||
}
|
||||
getPushToken(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function decrementBadgeCount(by: number) {
|
||||
export async function decrementBadgeCount(by: number | 'reset' = 1) {
|
||||
if (!isNative) return
|
||||
|
||||
let count = await getBadgeCountAsync()
|
||||
count -= by
|
||||
if (count < 0) {
|
||||
count = 0
|
||||
const currCount = await getBadgeCountAsync()
|
||||
|
||||
if (by === 'reset') {
|
||||
await setBadgeCountAsync(0)
|
||||
return
|
||||
}
|
||||
|
||||
await BackgroundNotificationHandler.setBadgeCountAsync(count)
|
||||
await setBadgeCountAsync(count)
|
||||
}
|
||||
|
||||
export async function resetBadgeCount() {
|
||||
await BackgroundNotificationHandler.setBadgeCountAsync(0)
|
||||
await setBadgeCountAsync(0)
|
||||
let newCount = currCount - by
|
||||
if (newCount < 0) {
|
||||
newCount = 0
|
||||
}
|
||||
await setBadgeCountAsync(newCount)
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export function isStateAtTabRoot(state: State | undefined) {
|
||||
return (
|
||||
isTab(currentRoute.name, 'Home') ||
|
||||
isTab(currentRoute.name, 'Search') ||
|
||||
isTab(currentRoute.name, 'Messages') ||
|
||||
isTab(currentRoute.name, 'Feeds') ||
|
||||
isTab(currentRoute.name, 'Notifications') ||
|
||||
isTab(currentRoute.name, 'MyProfile')
|
||||
)
|
||||
|
||||
@@ -15,7 +15,6 @@ export type CommonNavigatorParams = {
|
||||
Profile: {name: string; hideBackButton?: boolean}
|
||||
ProfileFollowers: {name: string}
|
||||
ProfileFollows: {name: string}
|
||||
ProfileKnownFollowers: {name: string}
|
||||
ProfileList: {name: string; rkey: string}
|
||||
PostThread: {name: string; rkey: string}
|
||||
PostLikedBy: {name: string; rkey: string}
|
||||
@@ -41,12 +40,12 @@ export type CommonNavigatorParams = {
|
||||
Hashtag: {tag: string; author?: string}
|
||||
MessagesConversation: {conversation: string; embed?: string}
|
||||
MessagesSettings: undefined
|
||||
Feeds: undefined
|
||||
}
|
||||
|
||||
export type BottomTabNavigatorParams = CommonNavigatorParams & {
|
||||
HomeTab: undefined
|
||||
SearchTab: undefined
|
||||
FeedsTab: undefined
|
||||
NotificationsTab: undefined
|
||||
MyProfileTab: undefined
|
||||
MessagesTab: undefined
|
||||
@@ -60,6 +59,10 @@ export type SearchTabNavigatorParams = CommonNavigatorParams & {
|
||||
Search: {q?: string}
|
||||
}
|
||||
|
||||
export type FeedsTabNavigatorParams = CommonNavigatorParams & {
|
||||
Feeds: undefined
|
||||
}
|
||||
|
||||
export type NotificationsTabNavigatorParams = CommonNavigatorParams & {
|
||||
Notifications: undefined
|
||||
}
|
||||
@@ -86,6 +89,7 @@ export type AllNavigatorParams = CommonNavigatorParams & {
|
||||
Home: undefined
|
||||
SearchTab: undefined
|
||||
Search: {q?: string}
|
||||
FeedsTab: undefined
|
||||
Feeds: undefined
|
||||
NotificationsTab: undefined
|
||||
Notifications: undefined
|
||||
|
||||
@@ -17,7 +17,7 @@ export type LogEvents = {
|
||||
}
|
||||
'notifications:openApp': {}
|
||||
'notifications:request': {
|
||||
context: 'StartOnboarding' | 'AfterOnboarding' | 'Login' | 'Home'
|
||||
context: 'StartOnboarding' | 'AfterOnboarding' | 'Login'
|
||||
status: 'granted' | 'denied' | 'undetermined'
|
||||
}
|
||||
'state:background:sampled': {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export type Gate =
|
||||
// Keep this alphabetic please.
|
||||
| 'native_pwi_disabled'
|
||||
| 'request_notifications_permission_after_onboarding_v2'
|
||||
| 'show_avi_follow_button'
|
||||
| 'show_follow_back_label_v2'
|
||||
|
||||
@@ -14,8 +14,6 @@ import {useNonReactiveCallback} from '../hooks/useNonReactiveCallback'
|
||||
import {LogEvents} from './events'
|
||||
import {Gate} from './gates'
|
||||
|
||||
const SDK_KEY = 'client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV'
|
||||
|
||||
type StatsigUser = {
|
||||
userID: string | undefined
|
||||
// TODO: Remove when enough users have custom.platform:
|
||||
@@ -28,8 +26,6 @@ type StatsigUser = {
|
||||
bundleDate: number
|
||||
refSrc: string
|
||||
refUrl: string
|
||||
referrer: string
|
||||
referrerHostname: string
|
||||
appLanguage: string
|
||||
contentLanguages: string[]
|
||||
}
|
||||
@@ -37,29 +33,12 @@ type StatsigUser = {
|
||||
|
||||
let refSrc = ''
|
||||
let refUrl = ''
|
||||
let referrer = ''
|
||||
let referrerHostname = ''
|
||||
if (isWeb && typeof window !== 'undefined') {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
refSrc = params.get('ref_src') ?? ''
|
||||
refUrl = decodeURIComponent(params.get('ref_url') ?? '')
|
||||
}
|
||||
|
||||
if (
|
||||
isWeb &&
|
||||
typeof document !== 'undefined' &&
|
||||
document != null &&
|
||||
document.referrer
|
||||
) {
|
||||
try {
|
||||
const url = new URL(document.referrer)
|
||||
if (url.hostname !== 'bsky.app') {
|
||||
referrer = document.referrer
|
||||
referrerHostname = url.hostname
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export type {LogEvents}
|
||||
|
||||
function createStatsigOptions(prefetchUsers: StatsigUser[]) {
|
||||
@@ -219,8 +198,6 @@ function toStatsigUser(did: string | undefined): StatsigUser {
|
||||
custom: {
|
||||
refSrc,
|
||||
refUrl,
|
||||
referrer,
|
||||
referrerHostname,
|
||||
platform: Platform.OS as 'ios' | 'android' | 'web',
|
||||
bundleIdentifier: BUNDLE_IDENTIFIER,
|
||||
bundleDate: BUNDLE_DATE,
|
||||
@@ -253,7 +230,7 @@ AppState.addEventListener('change', (state: AppStateStatus) => {
|
||||
})
|
||||
|
||||
export async function tryFetchGates(
|
||||
did: string | undefined,
|
||||
did: string,
|
||||
strategy: 'prefer-low-latency' | 'prefer-fresh-gates',
|
||||
) {
|
||||
try {
|
||||
@@ -277,10 +254,6 @@ export async function tryFetchGates(
|
||||
}
|
||||
}
|
||||
|
||||
export function initialize() {
|
||||
return Statsig.initialize(SDK_KEY, null, createStatsigOptions([]))
|
||||
}
|
||||
|
||||
export function Provider({children}: {children: React.ReactNode}) {
|
||||
const {currentAccount, accounts} = useSession()
|
||||
const did = currentAccount?.did
|
||||
@@ -326,7 +299,7 @@ export function Provider({children}: {children: React.ReactNode}) {
|
||||
<GateCache.Provider value={gateCache}>
|
||||
<StatsigProvider
|
||||
key={did}
|
||||
sdkKey={SDK_KEY}
|
||||
sdkKey="client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV"
|
||||
mountKey={currentStatsigUser.userID}
|
||||
user={currentStatsigUser}
|
||||
// This isn't really blocking due to short initTimeoutMs above.
|
||||
|
||||
@@ -1,3 +1,45 @@
|
||||
const NOW = 5
|
||||
const MINUTE = 60
|
||||
const HOUR = MINUTE * 60
|
||||
const DAY = HOUR * 24
|
||||
const MONTH_30 = DAY * 30
|
||||
const MONTH = DAY * 30.41675 // This results in 365.001 days in a year, which is close enough for nearly all cases
|
||||
export function ago(date: number | string | Date): string {
|
||||
let ts: number
|
||||
if (typeof date === 'string') {
|
||||
ts = Number(new Date(date))
|
||||
} else if (date instanceof Date) {
|
||||
ts = Number(date)
|
||||
} else {
|
||||
ts = date
|
||||
}
|
||||
const diffSeconds = Math.floor((Date.now() - ts) / 1e3)
|
||||
if (diffSeconds < NOW) {
|
||||
return `now`
|
||||
} else if (diffSeconds < MINUTE) {
|
||||
return `${diffSeconds}s`
|
||||
} else if (diffSeconds < HOUR) {
|
||||
return `${Math.floor(diffSeconds / MINUTE)}m`
|
||||
} else if (diffSeconds < DAY) {
|
||||
return `${Math.floor(diffSeconds / HOUR)}h`
|
||||
} else if (diffSeconds < MONTH_30) {
|
||||
return `${Math.round(diffSeconds / DAY)}d`
|
||||
} else {
|
||||
let months = diffSeconds / MONTH
|
||||
if (months % 1 >= 0.9) {
|
||||
months = Math.ceil(months)
|
||||
} else {
|
||||
months = Math.floor(months)
|
||||
}
|
||||
|
||||
if (months < 12) {
|
||||
return `${months}mo`
|
||||
} else {
|
||||
return new Date(ts).toLocaleDateString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function niceDate(date: number | string | Date) {
|
||||
const d = new Date(date)
|
||||
return `${d.toLocaleDateString('en-us', {
|
||||
|
||||
@@ -116,7 +116,7 @@ export function sanitizeAppLanguageSetting(appLanguage: string): AppLanguage {
|
||||
const langs = appLanguage.split(',').filter(Boolean)
|
||||
|
||||
for (const lang of langs) {
|
||||
switch (fixLegacyLanguageCode(lang)) {
|
||||
switch (lang) {
|
||||
case 'en':
|
||||
return AppLanguage.en
|
||||
case 'ca':
|
||||
@@ -157,20 +157,3 @@ export function sanitizeAppLanguageSetting(appLanguage: string): AppLanguage {
|
||||
}
|
||||
return AppLanguage.en
|
||||
}
|
||||
|
||||
export function fixLegacyLanguageCode(code: string | null): string | null {
|
||||
// handle some legacy code conversions, see https://xml.coverpages.org/iso639a.html
|
||||
if (code === 'in') {
|
||||
// indonesian
|
||||
return 'id'
|
||||
}
|
||||
if (code === 'iw') {
|
||||
// hebrew
|
||||
return 'he'
|
||||
}
|
||||
if (code === 'ji') {
|
||||
// yiddish
|
||||
return 'yi'
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import '@formatjs/intl-locale/polyfill'
|
||||
import '@formatjs/intl-pluralrules/polyfill-force' // Don't remove -force because detection is very slow
|
||||
import '@formatjs/intl-pluralrules/polyfill'
|
||||
import '@formatjs/intl-pluralrules/locale-data/en'
|
||||
|
||||
import {useEffect} from 'react'
|
||||
|
||||