diff --git a/.eslintrc.js b/.eslintrc.js index 9d2b7bbb1a..f6407fa6fa 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -33,6 +33,7 @@ module.exports = { ], 'bsky-internal/use-exact-imports': 'error', 'bsky-internal/use-typed-gates': 'error', + 'bsky-internal/use-prefixed-imports': 'warn', 'simple-import-sort/imports': [ 'warn', { @@ -71,6 +72,19 @@ module.exports = { 'simple-import-sort/exports': 'warn', // TODO: Reenable when we figure out why it gets stuck on CI. // 'react-compiler/react-compiler': 'error', + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + name: '@atproto/api', + importNames: ['moderatePost'], + message: + 'Please use `moderatePost_wrapped` from `#/lib/moderatePost_wrapped` instead.', + }, + ], + }, + ], }, ignorePatterns: [ '**/__mocks__/*.ts', diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index c608f66000..0000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: '' -labels: bug -assignees: '' - ---- - -**Describe the bug** - - -**To Reproduce** - -Steps to reproduce the behavior: - -1. - -**Expected behavior** - - - -**Screenshots** - - - -**Details** - - - Platform: - - Platform version: - - App version: - -**Additional context** - - diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000000..4f3041b40e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,67 @@ +name: "Bug Report" +description: "Create a report for an issue you have experience in the app." +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report an issue you've found in the app! Before you submit this issue, please make + sure you've searched for similar issues that are already open and being tracked. If you find an open issue that + seems relevant to yours, it is best to leave a response there with your information instead of opening a new + issue, since it helps to consolidate the info in one place. + - type: textarea + attributes: + label: Steps to Reproduce + description: | + Please provide the steps that may reproduce the issue that you are experiencing. The more concise of a + reproduction that you provide, the easier it will be for us to identify the issue and fix it in a timely manner. + placeholder: | + 1. Go to '...' + 2. Click on '....' + 3. Scroll down to '....' + 4. See error + validations: + required: true + - type: textarea + attributes: + label: Attachments + description: | + If possible, please provide any images or videos that may help us understand the issue you are experiencing. + validations: + required: false + - type: dropdown + attributes: + label: What platform(s) does this occur on? + multiple: true + options: + - iOS + - Android + - Web (Desktop) + - Web (Mobile) + validations: + required: true + - type: input + attributes: + label: Device Info + description: | + Please provide the device information that you are using when experiencing this issue. This can include the + device model, operating system version, and any other relevant information. + validations: + required: false + - type: input + attributes: + label: What version of the app are you using? + description: | + You can easily find this information by visting the Settings screen in the app, and tapping on the version info + at the bottom of the screen. All the relevant information will be copied to your clipboard, and you will be + able to share it here. + validations: + required: true + - type: textarea + attributes: + label: Additional Information + description: | + Please provide any additional information that you think may be helpful in identifying the issue you are + experiencing. This can include any error messages, logs, or other information that you think may be relevant. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/bug_report_fabric.yml b/.github/ISSUE_TEMPLATE/bug_report_fabric.yml new file mode 100644 index 0000000000..83782bf69d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report_fabric.yml @@ -0,0 +1,72 @@ +name: "Bug Report - Fabric/New Architecture" +description: "Create a bug report for builds using the New Architecture. If you are not sure what that means, don't use this template, and instead use the default bug report template." +labels: ["bug", "fabric"] +body: + - type: checkboxes + attributes: + label: You're running a build that uses the React Native New Architecture, right? + description: This template is specifically for issues related to builds that use the React Native New Architecture. If you are not sure what that means, this template is probably not relevant to you, and you should use the default Bug Report template. + options: + - label: I am running a build that uses the New Architecture + required: true + - type: markdown + attributes: + value: | + Thanks for taking the time to report an issue you've found in the app! Before you submit this issue, please make + sure you've searched for similar issues that are already open and being tracked. If you find an open issue that + seems relevant to yours, it is best to leave a response there with your information instead of opening a new + issue, since it helps to consolidate the info in one place. + - type: textarea + attributes: + label: Steps to Reproduce + description: | + Please provide the steps that may reproduce the issue that you are experiencing. The more concise of a + reproduction that you provide, the easier it will be for us to identify the issue and fix it in a timely manner. + placeholder: | + 1. Go to '...' + 2. Click on '....' + 3. Scroll down to '....' + 4. See error + validations: + required: true + - type: textarea + attributes: + label: Attachments + description: | + If possible, please provide any images or videos that may help us understand the issue you are experiencing. + validations: + required: false + - type: dropdown + attributes: + label: What platform(s) does this occur on? + multiple: true + options: + - iOS + - Android + validations: + required: true + - type: input + attributes: + label: Device Info + description: | + Please provide the device information that you are using when experiencing this issue. This can include the + device model, operating system version, and any other relevant information. + validations: + required: false + - type: input + attributes: + label: What version of the app are you using? + description: | + You can easily find this information by visting the Settings screen in the app, and tapping on the version info + at the bottom of the screen. All the relevant information will be copied to your clipboard, and you will be + able to share it here. + validations: + required: true + - type: textarea + attributes: + label: Additional Information + description: | + Please provide any additional information that you think may be helpful in identifying the issue you are + experiencing. This can include any error messages, logs, or other information that you think may be relevant. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 0ac6b3c416..0000000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -name: Feature request -about: Suggest an idea for this project -title: '' -labels: feature-request -assignees: '' - ---- - -**Is your feature request related to a problem? Please describe.** - - - -**Describe the solution you'd like** - - - -**Describe alternatives you've considered** - - - -**Additional context** - - diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000000..f296e94360 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,45 @@ +name: "Feature Request" +description: "Suggest an idea for the Bluesky app." +labels: ["feature-request"] +body: + - type: markdown + attributes: + value: | + Thanks for submitting a feature request! Before you submit this request, please make sure you've searched for + similar requests that are already open and being tracked. If you find an open request that seems relevant to + yours, it is best to leave a response there with your information instead of opening a new request, since it + helps to consolidate the info in one place. + - type: textarea + attributes: + label: Describe the Feature + description: | + Please provide a detailed description of the feature you would like to see implemented in the app. The more + information you provide, the easier it will be for us to understand the feature you are requesting and to + implement it in a timely manner. + validations: + required: true + - type: textarea + attributes: + label: Attachments + description: | + If helpful, provide some images or videos of similar features in other apps that you think would be helpful + for us to understand the feature you are requesting, or places within the app that you think the feature belongs + in or is missing from. + validations: + required: false + - type: textarea + attributes: + label: Describe Alternatives + description: | + If there are any alternative solutions or features that you think would be helpful to implement in place of the + feature you are requesting, please provide a description of those as well. + validations: + required: false + - type: textarea + attributes: + label: Additional Context + description: | + If there is any additional context or information that you think would be helpful for us to know about the + feature you are requesting, please provide that information here. + validations: + required: false diff --git a/.github/workflows/golang-test-lint.yml b/.github/workflows/golang-test-lint.yml index 36e28841d5..c8124dbade 100644 --- a/.github/workflows/golang-test-lint.yml +++ b/.github/workflows/golang-test-lint.yml @@ -21,7 +21,7 @@ jobs: with: go-version: '1.22' - name: Dummy Static Files - run: touch bskyweb/static/js/blah.js && touch bskyweb/static/media/blah.txt + run: touch bskyweb/static/js/blah.js && touch bskyweb/static/css/blah.txt && touch bskyweb/static/media/blah.txt - name: Check run: cd bskyweb/ && make check - name: Build (binary) @@ -38,6 +38,6 @@ jobs: with: go-version: '1.22' - name: Dummy Static Files - run: touch bskyweb/static/js/blah.js && touch bskyweb/static/media/blah.txt + run: touch bskyweb/static/js/blah.js && touch bskyweb/static/css/blah.txt && touch bskyweb/static/media/blah.txt - name: Lint run: cd bskyweb/ && make lint diff --git a/.prettierignore b/.prettierignore index 641a1b8bfc..e107e129a4 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1,4 @@ -# Ignore everything except JS-ey code. +# Ignore everything except JS-ey or CSS code. # Based on https://stackoverflow.com/a/70715829/458193 * !**/*.js @@ -7,9 +7,14 @@ !**/*.tsx !*/ +!**/*.css + # More specific ignores go below. .expo android ios src/locale/locales lib/react-compiler-runtime +bskyweb/static +coverage +web-build diff --git a/Dockerfile.embedr b/Dockerfile.embedr index 9ff04aa5c7..663cbcfc51 100644 --- a/Dockerfile.embedr +++ b/Dockerfile.embedr @@ -40,6 +40,7 @@ RUN find ./bskyweb/embedr-static && find ./bskyweb/embedr-templates && find ./bs # hack around issue with empty directory and go:embed RUN touch bskyweb/static/js/empty.txt +RUN touch bskyweb/static/css/empty.txt RUN touch bskyweb/static/media/empty.txt # diff --git a/__e2e__/flows/curate-lists.yml b/__e2e__/flows/curate-lists.yml index e497898b27..3947204c2b 100644 --- a/__e2e__/flows/curate-lists.yml +++ b/__e2e__/flows/curate-lists.yml @@ -74,8 +74,7 @@ appId: xyz.blueskyweb.app - tapOn: "Delete List" - tapOn: id: "confirmBtn" -- assertVisible: - id: "listsEmpty" +- assertVisible: "This list is empty!" - tapOn: label: "Create a new curatelist" @@ -132,7 +131,8 @@ appId: xyz.blueskyweb.app id: "feedItem-by-bob.test" - tapOn: id: "e2eGotoFeeds" -- tapOn: "Good Ppl" +- tapOn: + id: "saved-feed-Good Ppl" - assertVisible: id: "feedItem-by-bob.test" - tapOn: @@ -160,17 +160,6 @@ appId: xyz.blueskyweb.app - assertNotVisible: id: "userAddRemoveListsModal" -- tapOn: - label: "Shows the curatelist on my profile" - id: "bottomBarProfileBtn" -- swipe: - from: - id: "profilePager-selector" - direction: LEFT -- tapOn: - id: "profilePager-selector-5" -- tapOn: "Good Ppl" - - tapOn: label: "Adds and removes users on curatelists from the profile" id: "bottomBarSearchBtn" diff --git a/__e2e__/flows/feed-reorder.yml b/__e2e__/flows/feed-reorder.yml index 449df065d7..2b892b533e 100644 --- a/__e2e__/flows/feed-reorder.yml +++ b/__e2e__/flows/feed-reorder.yml @@ -10,8 +10,13 @@ appId: xyz.blueskyweb.app id: "e2eSignInAlice" # Pin alice's feed +- extendedWaitUntil: + visible: + id: "viewHeaderDrawerBtn" - tapOn: - id: "bottomBarProfileBtn" + id: "viewHeaderDrawerBtn" +- tapOn: + id: "profileCardButton" - swipe: from: id: "profilePager-selector" diff --git a/__e2e__/flows/home-screen.yml b/__e2e__/flows/home-screen.yml index c8d83fb1fa..799a20214c 100644 --- a/__e2e__/flows/home-screen.yml +++ b/__e2e__/flows/home-screen.yml @@ -9,6 +9,9 @@ appId: xyz.blueskyweb.app - tapOn: id: "e2eSignInAlice" +- extendedWaitUntil: + visible: + text: "Feeds ✨" - tapOn: label: "Can go to feeds page using feeds button in tab bar" text: "Feeds ✨" @@ -34,26 +37,16 @@ appId: xyz.blueskyweb.app - tapOn: label: "Can like posts" id: "likeBtn" -- assertVisible: - id: "likeCount" - text: "1" - tapOn: id: "likeBtn" -- assertNotVisible: - id: "likeCount" - tapOn: label: "Can repost posts" id: "repostBtn" - tapOn: "Repost" -- assertVisible: - id: "repostCount" - text: "1" - tapOn: id: "repostBtn" - tapOn: "Remove repost" -- assertNotVisible: - id: "repostCount" - tapOn: label: "Can delete posts" diff --git a/__e2e__/flows/profile-screen-edit.yml b/__e2e__/flows/profile-screen-edit.yml index 288a5d4f6d..251eca3581 100644 --- a/__e2e__/flows/profile-screen-edit.yml +++ b/__e2e__/flows/profile-screen-edit.yml @@ -11,6 +11,9 @@ appId: xyz.blueskyweb.app # Navigate to my profile +- extendedWaitUntil: + visible: + id: "bottomBarSearchBtn" - tapOn: id: "bottomBarProfileBtn" diff --git a/__e2e__/flows/profile-screen.yml b/__e2e__/flows/profile-screen.yml index 7d2d43deea..b9f95aca26 100644 --- a/__e2e__/flows/profile-screen.yml +++ b/__e2e__/flows/profile-screen.yml @@ -10,6 +10,9 @@ appId: xyz.blueskyweb.app id: "e2eSignInAlice" # Navigate to another user profile +- extendedWaitUntil: + visible: + id: "bottomBarSearchBtn" - tapOn: id: "bottomBarSearchBtn" - tapOn: diff --git a/__e2e__/flows/thread-screen.yml b/__e2e__/flows/thread-screen.yml index fdc732596b..f3671ebcb1 100644 --- a/__e2e__/flows/thread-screen.yml +++ b/__e2e__/flows/thread-screen.yml @@ -11,6 +11,8 @@ appId: xyz.blueskyweb.app # Navigate to thread +- extendedWaitUntil: + visible: "Thread root" - tapOn: "Thread root" - assertVisible: "Thread reply" @@ -19,32 +21,22 @@ appId: xyz.blueskyweb.app id: "likeBtn" childOf: id: "postThreadItem-by-bob.test" -- assertVisible: - id: "likeCount-expanded" +- assertVisible: "1 like" - tapOn: id: "likeBtn" childOf: id: "postThreadItem-by-bob.test" -- assertNotVisible: - id: "likeCount-expanded" +- assertNotVisible: "1 like" # Can like a reply post - tapOn: id: "likeBtn" childOf: id: "postThreadItem-by-carla.test" -- assertVisible: - id: "likeCount" - childOf: - id: "postThreadItem-by-carla.test" - tapOn: id: "likeBtn" childOf: id: "postThreadItem-by-carla.test" -- assertNotVisible: - id: "likeCount" - childOf: - id: "postThreadItem-by-carla.test" # Can repost the root post - tapOn: diff --git a/__tests__/lib/__mocks__/exampleComHtml.ts b/__tests__/lib/__mocks__/exampleComHtml.ts deleted file mode 100644 index 6633e40ca5..0000000000 --- a/__tests__/lib/__mocks__/exampleComHtml.ts +++ /dev/null @@ -1,47 +0,0 @@ -export const exampleComHtml = ` - - - Example Domain - - - - - - - - - -
-

Example Domain

-

This domain is for use in illustrative examples in documents. You may use this - domain in literature without prior coordination or asking for permission.

-

More information...

-
- -` diff --git a/__tests__/lib/__mocks__/tiktokHtml.ts b/__tests__/lib/__mocks__/tiktokHtml.ts deleted file mode 100644 index fa3d112836..0000000000 --- a/__tests__/lib/__mocks__/tiktokHtml.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const tiktokHtml = ` -Coca-Cola and Mentos! Super Reaction! #cocacola #mentos #reaction #bal... | TikTok
Upload

For You

Log in to follow creators, like videos, and view comments.

Suggested accounts

© 2023 TikTok
Coca-Cola and Mentos! Super Reaction! #cocacola #mentos #reaction #balloon #sciencemoment #scienceexperiment #experiment #test #amazing #pvexp
00:00/00:00
Coca-Cola and Mentos! Super Reaction! #cocacola #mentos #reaction #balloon #sciencemoment #scienceexperiment #experiment #test #amazing #pvexp
_powervision_
Power Vision Tests · 2019-10-19

Related videos

Get TikTok App
-` diff --git a/__tests__/lib/__mocks__/youtubeChannelHtml.ts b/__tests__/lib/__mocks__/youtubeChannelHtml.ts deleted file mode 100644 index cc71995c45..0000000000 --- a/__tests__/lib/__mocks__/youtubeChannelHtml.ts +++ /dev/null @@ -1,63 +0,0 @@ -export const youtubeChannelHtml = ` - -
AboutPressCopyrightContact usCreatorsAdvertiseDevelopersTermsPrivacyPolicy & SafetyHow YouTube worksTest new features
penguinz0 - YouTube
- -` diff --git a/__tests__/lib/__mocks__/youtubeHtml.ts b/__tests__/lib/__mocks__/youtubeHtml.ts deleted file mode 100644 index 7fd9f819da..0000000000 --- a/__tests__/lib/__mocks__/youtubeHtml.ts +++ /dev/null @@ -1,19 +0,0 @@ -export const youtubeHTML = ` - -YouTube
- -` diff --git a/__tests__/lib/email.test.ts b/__tests__/lib/email.test.ts new file mode 100644 index 0000000000..4dfda658f7 --- /dev/null +++ b/__tests__/lib/email.test.ts @@ -0,0 +1,82 @@ +import {describe, expect, it} from '@jest/globals' +import tldts from 'tldts' + +import {isEmailMaybeInvalid} from '#/lib/strings/email' + +describe('emailTypoChecker', () => { + const invalidCases = [ + 'gnail.com', + 'gnail.co', + 'gmaill.com', + 'gmaill.co', + 'gmai.com', + 'gmai.co', + 'gmal.com', + 'gmal.co', + 'gmail.co', + 'iclod.com', + 'iclod.co', + 'outllok.com', + 'outllok.co', + 'outlook.co', + 'yaoo.com', + 'yaoo.co', + 'yaho.com', + 'yaho.co', + 'yahooo.com', + 'yahooo.co', + 'yahoo.co', + 'hithere.jul', + 'agpowj.notshop', + 'thisisnot.avalid.tld.nope', + // old tld for czechoslovakia + 'czechoslovakia.cs', + // tlds that cbs was registering in 2024 but cancelled + 'liveon.cbs', + 'its.showtime', + ] + const validCases = [ + 'gmail.com', + // subdomains (tests end of string) + 'gnail.com.test.com', + 'outlook.com', + 'yahoo.com', + 'icloud.com', + 'firefox.com', + 'firefox.co', + 'hello.world.com', + 'buy.me.a.coffee.shop', + 'mayotte.yt', + 'aland.ax', + 'bouvet.bv', + 'uk.gb', + 'chad.td', + 'somalia.so', + 'plane.aero', + 'cute.cat', + 'together.coop', + 'findme.jobs', + 'nightatthe.museum', + 'industrial.mil', + 'czechrepublic.cz', + 'lovakia.sk', + // new gtlds in 2024 + 'whatsinyour.locker', + 'letsmakea.deal', + 'skeet.now', + 'everyone.みんな', + 'bourgeois.lifestyle', + 'california.living', + 'skeet.ing', + 'listeningto.music', + 'createa.meme', + ] + + it.each(invalidCases)(`should be invalid: abcde@%s`, domain => { + expect(isEmailMaybeInvalid(`abcde@${domain}`, tldts)).toEqual(true) + }) + + it.each(validCases)(`should be valid: abcde@%s`, domain => { + expect(isEmailMaybeInvalid(`abcde@${domain}`, tldts)).toEqual(false) + }) +}) diff --git a/__tests__/lib/errors.test.ts b/__tests__/lib/errors.test.ts index 39e8d189e0..e721396845 100644 --- a/__tests__/lib/errors.test.ts +++ b/__tests__/lib/errors.test.ts @@ -9,11 +9,11 @@ describe('isNetworkError', () => { ] const outputs = [true, false, false, true] - it('correctly distinguishes network errors', () => { - for (let i = 0; i < inputs.length; i++) { - const input = inputs[i] - const result = isNetworkError(input) - expect(result).toEqual(outputs[i]) - } - }) + for (let i = 0; i < inputs.length; i++) { + const input = inputs[i] + const output = outputs[i] + it(`correctly distinguishes network errors for ${input}`, () => { + expect(isNetworkError(input)).toEqual(output) + }) + } }) diff --git a/__tests__/lib/extractHtmlMeta.test.ts b/__tests__/lib/extractHtmlMeta.test.ts deleted file mode 100644 index cdd2a33848..0000000000 --- a/__tests__/lib/extractHtmlMeta.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import {extractHtmlMeta} from '../../src/lib/link-meta/html' -import {exampleComHtml} from './__mocks__/exampleComHtml' -import {youtubeHTML} from './__mocks__/youtubeHtml' -import {tiktokHtml} from './__mocks__/tiktokHtml' -import {youtubeChannelHtml} from './__mocks__/youtubeChannelHtml' - -describe('extractHtmlMeta', () => { - const cases = [ - ['', {}], - ['nothing', {}], - ['title', {title: 'title'}], - [' aSd!@#AC ', {title: 'aSd!@#AC'}], - ['\n title\n ', {title: 'title'}], - ['', {title: 'meta title'}], - [ - '', - {description: 'meta description'}, - ], - ['', {title: 'og title'}], - [ - '', - {description: 'og description'}, - ], - [ - '', - {image: 'https://ogimage.com/foo.png'}, - ], - [ - '', - {title: 'twitter title'}, - ], - [ - '', - {description: 'twitter description'}, - ], - [ - '', - {image: 'https://twitterimage.com/foo.png'}, - ], - ['', {title: 'meta title'}], - ] - - it.each(cases)( - 'given the html tag %p, returns %p', - // @ts-ignore not worth fixing -prf - (input, expectedResult) => { - const output = extractHtmlMeta({html: input as string, hostname: ''}) - expect(output).toEqual(expectedResult) - }, - ) - - it('extracts title and description from a generic HTML page', () => { - const input = exampleComHtml - const expectedOutput = { - title: 'Example Domain', - description: 'An example website', - } - const output = extractHtmlMeta({html: input, hostname: 'example.com'}) - expect(output).toEqual(expectedOutput) - }) - - it('extracts title and description from a Tiktok HTML page', () => { - const input = tiktokHtml - const expectedOutput = { - title: - 'Coca-Cola and Mentos! Super Reaction! #cocacola #mentos #reaction #bal... | TikTok', - description: - '5.5M Likes, 20.8K Comments. TikTok video from Power Vision Tests (@_powervision_): "Coca-Cola and Mentos! Super Reaction! #cocacola #mentos #reaction #balloon #sciencemoment #scienceexperiment #experiment #test #amazing #pvexp". оригинальный звук - Power Vision Tests.', - } - const output = extractHtmlMeta({html: input, hostname: 'tiktok.com'}) - expect(output).toEqual(expectedOutput) - }) - - it('extracts title and description from a generic youtube page', () => { - const input = youtubeHTML - const expectedOutput = { - title: 'HD Video (1080p) with Relaxing Music of Native American Shamans', - description: - 'Stunning HD Video ( 1080p ) of Patagonian Nature with Relaxing Native American Shamanic Music. HD footage used from ', - image: 'https://i.ytimg.com/vi/x6UITRjhijI/sddefault.jpg', - } - const output = extractHtmlMeta({html: input, hostname: 'youtube.com'}) - expect(output).toEqual(expectedOutput) - }) - - it('extracts avatar from a youtube channel', () => { - const input = youtubeChannelHtml - const expectedOutput = { - title: 'penguinz0', - description: - 'Clips channel: https://www.youtube.com/channel/UC4EQHfzIbkL_Skit_iKt1aA\n\nTwitter: https://twitter.com/MoistCr1TiKaL\n\nInstagram: https://www.instagram.com/bigmoistcr1tikal/?hl=en\n\nTwitch: https://www.twitch.tv/moistcr1tikal\n\nSnapchat: Hugecharles\n\nTik Tok: Hugecharles\n\nI don't have any other public accounts.', - image: - 'https://yt3.googleusercontent.com/ytc/AL5GRJWOhJOuUC6C2b7gP-5D2q6ypXbcOOckyAE1En4RUQ=s176-c-k-c0x00ffffff-no-rj', - } - const output = extractHtmlMeta({html: input, hostname: 'youtube.com'}) - expect(output).toEqual(expectedOutput) - }) - - it('extracts username from the url a twitter profile page', () => { - const expectedOutput = { - title: '@bluesky on Twitter', - } - const output = extractHtmlMeta({ - html: '', - hostname: 'twitter.com', - pathname: '/bluesky', - }) - expect(output).toEqual(expectedOutput) - }) - - it('extracts username from the url a tweet', () => { - const expectedOutput = { - title: 'Tweet by @bluesky', - } - const output = extractHtmlMeta({ - html: '', - hostname: 'twitter.com', - pathname: '/bluesky/status/1582437529969917953', - }) - expect(output).toEqual(expectedOutput) - }) - - it("does not extract username from the url when it's not a tweet or profile page", () => { - const expectedOutput = { - title: 'Twitter', - } - const output = extractHtmlMeta({ - html: '', - hostname: 'twitter.com', - pathname: '/i/articles/follows/-1675653703?time_window=24', - }) - expect(output).toEqual(expectedOutput) - }) -}) diff --git a/__tests__/lib/images.test.ts b/__tests__/lib/images.test.ts index 595f566c47..a5acad25f6 100644 --- a/__tests__/lib/images.test.ts +++ b/__tests__/lib/images.test.ts @@ -1,26 +1,30 @@ -import ImageResizer from '@bam.tech/react-native-image-resizer' +import {deleteAsync} from 'expo-file-system' +import {manipulateAsync, SaveFormat} from 'expo-image-manipulator' import RNFetchBlob from 'rn-fetch-blob' import { downloadAndResize, DownloadAndResizeOpts, + getResizedDimensions, } from '../../src/lib/media/manip' +const mockResizedImage = { + path: 'file://resized-image.jpg', + size: 100, + width: 100, + height: 100, + mime: 'image/jpeg', +} + describe('downloadAndResize', () => { const errorSpy = jest.spyOn(global.console, 'error') - const mockResizedImage = { - path: jest.fn().mockReturnValue('file://resized-image.jpg'), - size: 100, - width: 50, - height: 50, - mime: 'image/jpeg', - } - beforeEach(() => { - const mockedCreateResizedImage = - ImageResizer.createResizedImage as jest.Mock - mockedCreateResizedImage.mockResolvedValue(mockResizedImage) + const mockedCreateResizedImage = manipulateAsync as jest.Mock + mockedCreateResizedImage.mockResolvedValue({ + uri: 'file://resized-image.jpg', + ...mockResizedImage, + }) }) afterEach(() => { @@ -54,17 +58,17 @@ describe('downloadAndResize', () => { 'GET', 'https://example.com/image.jpg', ) - expect(ImageResizer.createResizedImage).toHaveBeenCalledWith( - 'file://downloaded-image.jpg', - 100, - 100, - 'JPEG', - 100, - undefined, - undefined, - undefined, - {mode: 'cover'}, + + // First time it gets called is to get dimensions + expect(manipulateAsync).toHaveBeenCalledWith(expect.any(String), [], {}) + expect(manipulateAsync).toHaveBeenCalledWith( + expect.any(String), + [{resize: {height: opts.height, width: opts.width}}], + {format: SaveFormat.JPEG, compress: 1.0}, ) + expect(deleteAsync).toHaveBeenCalledWith(expect.any(String), { + idempotent: true, + }) }) it('should return undefined for invalid URI', async () => { @@ -82,46 +86,6 @@ describe('downloadAndResize', () => { expect(result).toBeUndefined() }) - it('should return undefined for unsupported file type', async () => { - const mockedFetch = RNFetchBlob.fetch as jest.Mock - mockedFetch.mockResolvedValueOnce({ - path: jest.fn().mockReturnValue('file://downloaded-image'), - info: jest.fn().mockReturnValue({status: 200}), - flush: jest.fn(), - }) - - const opts: DownloadAndResizeOpts = { - uri: 'https://example.com/image', - width: 100, - height: 100, - maxSize: 500000, - mode: 'cover', - timeout: 10000, - } - - const result = await downloadAndResize(opts) - expect(result).toEqual(mockResizedImage) - expect(RNFetchBlob.config).toHaveBeenCalledWith({ - fileCache: true, - appendExt: 'jpeg', - }) - expect(RNFetchBlob.fetch).toHaveBeenCalledWith( - 'GET', - 'https://example.com/image', - ) - expect(ImageResizer.createResizedImage).toHaveBeenCalledWith( - 'file://downloaded-image', - 100, - 100, - 'JPEG', - 100, - undefined, - undefined, - undefined, - {mode: 'cover'}, - ) - }) - it('should return undefined for non-200 response', async () => { const mockedFetch = RNFetchBlob.fetch as jest.Mock mockedFetch.mockResolvedValueOnce({ @@ -143,4 +107,44 @@ describe('downloadAndResize', () => { expect(errorSpy).not.toHaveBeenCalled() expect(result).toBeUndefined() }) + + it('should not downsize whenever dimensions are below the max dimensions', () => { + const initialDimensionsOne = { + width: 1200, + height: 1000, + } + const resizedDimensionsOne = getResizedDimensions(initialDimensionsOne) + + const initialDimensionsTwo = { + width: 1000, + height: 1200, + } + const resizedDimensionsTwo = getResizedDimensions(initialDimensionsTwo) + + expect(resizedDimensionsOne).toEqual(initialDimensionsOne) + expect(resizedDimensionsTwo).toEqual(initialDimensionsTwo) + }) + + it('should resize dimensions and maintain aspect ratio if they are above the max dimensons', () => { + const initialDimensionsOne = { + width: 3000, + height: 1500, + } + const resizedDimensionsOne = getResizedDimensions(initialDimensionsOne) + + const initialDimensionsTwo = { + width: 2000, + height: 4000, + } + const resizedDimensionsTwo = getResizedDimensions(initialDimensionsTwo) + + expect(resizedDimensionsOne).toEqual({ + width: 2000, + height: 1000, + }) + expect(resizedDimensionsTwo).toEqual({ + width: 1000, + height: 2000, + }) + }) }) diff --git a/__tests__/lib/string.test.ts b/__tests__/lib/string.test.ts index 0da9551e30..f226de992b 100644 --- a/__tests__/lib/string.test.ts +++ b/__tests__/lib/string.test.ts @@ -340,12 +340,14 @@ describe('parseEmbedPlayerFromUrl', () => { 'https://youtube.com/watch?v=videoId', 'https://youtube.com/watch?v=videoId&feature=share', 'https://youtube.com/shorts/videoId', + 'https://youtube.com/live/videoId', 'https://m.youtube.com/watch?v=videoId', 'https://music.youtube.com/watch?v=videoId', 'https://youtube.com/shorts/', 'https://youtube.com/', 'https://youtube.com/random', + 'https://youtube.com/live/', 'https://twitch.tv/channelName', 'https://www.twitch.tv/channelName', @@ -475,10 +477,16 @@ describe('parseEmbedPlayerFromUrl', () => { source: 'youtube', playerUri: 'https://bsky.app/iframe/youtube.html?videoId=videoId&start=0', }, + { + type: 'youtube_video', + source: 'youtube', + playerUri: 'https://bsky.app/iframe/youtube.html?videoId=videoId&start=0', + }, undefined, undefined, undefined, + undefined, { type: 'twitch_video', diff --git a/app.config.js b/app.config.js index cd8a4b034b..6b2bad9385 100644 --- a/app.config.js +++ b/app.config.js @@ -55,6 +55,7 @@ module.exports = function (config) { : undefined const UPDATES_ENABLED = !!UPDATES_CHANNEL + const USE_SENTRY = Boolean(process.env.SENTRY_AUTH_TOKEN) const SENTRY_DIST = `${PLATFORM}.${VERSION}.${IS_TESTFLIGHT ? 'tf' : ''}${ IS_DEV ? 'dev' : '' }` @@ -186,12 +187,20 @@ module.exports = function (config) { }, plugins: [ 'expo-localization', - Boolean(process.env.SENTRY_AUTH_TOKEN) && 'sentry-expo', + USE_SENTRY && [ + '@sentry/react-native/expo', + { + organization: 'blueskyweb', + project: 'react-native', + release: VERSION, + dist: SENTRY_DIST, + }, + ], [ 'expo-build-properties', { ios: { - deploymentTarget: '14.0', + deploymentTarget: '15.1', newArchEnabled: false, }, android: { @@ -211,7 +220,6 @@ module.exports = function (config) { sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'], }, ], - 'expo-video', 'react-native-compressor', './plugins/starterPackAppClipExtension/withStarterPackAppClip.js', './plugins/withAndroidManifestPlugin.js', @@ -222,6 +230,15 @@ module.exports = function (config) { './plugins/shareExtension/withShareExtensions.js', './plugins/notificationsExtension/withNotificationsExtension.js', './plugins/withAppDelegateReferrer.js', + [ + 'expo-font', + { + fonts: [ + './assets/fonts/inter/InterVariable.ttf', + './assets/fonts/inter/InterVariable-Italic.ttf', + ], + }, + ], ].filter(Boolean), extra: { eas: { @@ -264,7 +281,7 @@ module.exports = function (config) { * @see https://docs.expo.dev/guides/using-sentry/#app-configuration */ { - file: 'sentry-expo/upload-sourcemaps', + file: './postHooks/uploadSentrySourcemapsPostHook', config: { organization: 'blueskyweb', project: 'react-native', diff --git a/assets/fonts/inter/Inter-Black.otf b/assets/fonts/inter/Inter-Black.otf new file mode 100644 index 0000000000..44d1779af6 Binary files /dev/null and b/assets/fonts/inter/Inter-Black.otf differ diff --git a/assets/fonts/inter/Inter-BlackItalic.otf b/assets/fonts/inter/Inter-BlackItalic.otf new file mode 100644 index 0000000000..6fc475e415 Binary files /dev/null and b/assets/fonts/inter/Inter-BlackItalic.otf differ diff --git a/assets/fonts/inter/Inter-Bold.otf b/assets/fonts/inter/Inter-Bold.otf new file mode 100644 index 0000000000..58a38073e8 Binary files /dev/null and b/assets/fonts/inter/Inter-Bold.otf differ diff --git a/assets/fonts/inter/Inter-BoldItalic.otf b/assets/fonts/inter/Inter-BoldItalic.otf new file mode 100644 index 0000000000..e67935aa5a Binary files /dev/null and b/assets/fonts/inter/Inter-BoldItalic.otf differ diff --git a/assets/fonts/inter/Inter-ExtraBold.otf b/assets/fonts/inter/Inter-ExtraBold.otf new file mode 100644 index 0000000000..66cd95228c Binary files /dev/null and b/assets/fonts/inter/Inter-ExtraBold.otf differ diff --git a/assets/fonts/inter/Inter-ExtraBoldItalic.otf b/assets/fonts/inter/Inter-ExtraBoldItalic.otf new file mode 100644 index 0000000000..f269814a64 Binary files /dev/null and b/assets/fonts/inter/Inter-ExtraBoldItalic.otf differ diff --git a/assets/fonts/inter/Inter-ExtraLight.otf b/assets/fonts/inter/Inter-ExtraLight.otf new file mode 100644 index 0000000000..b603db3c77 Binary files /dev/null and b/assets/fonts/inter/Inter-ExtraLight.otf differ diff --git a/assets/fonts/inter/Inter-ExtraLightItalic.otf b/assets/fonts/inter/Inter-ExtraLightItalic.otf new file mode 100644 index 0000000000..f65051941c Binary files /dev/null and b/assets/fonts/inter/Inter-ExtraLightItalic.otf differ diff --git a/assets/fonts/inter/Inter-Italic.otf b/assets/fonts/inter/Inter-Italic.otf new file mode 100644 index 0000000000..f78848b987 Binary files /dev/null and b/assets/fonts/inter/Inter-Italic.otf differ diff --git a/assets/fonts/inter/Inter-Light.otf b/assets/fonts/inter/Inter-Light.otf new file mode 100644 index 0000000000..7da794bd3b Binary files /dev/null and b/assets/fonts/inter/Inter-Light.otf differ diff --git a/assets/fonts/inter/Inter-LightItalic.otf b/assets/fonts/inter/Inter-LightItalic.otf new file mode 100644 index 0000000000..32ef937c52 Binary files /dev/null and b/assets/fonts/inter/Inter-LightItalic.otf differ diff --git a/assets/fonts/inter/Inter-Medium.otf b/assets/fonts/inter/Inter-Medium.otf new file mode 100644 index 0000000000..f44f89adac Binary files /dev/null and b/assets/fonts/inter/Inter-Medium.otf differ diff --git a/assets/fonts/inter/Inter-MediumItalic.otf b/assets/fonts/inter/Inter-MediumItalic.otf new file mode 100644 index 0000000000..1970f57214 Binary files /dev/null and b/assets/fonts/inter/Inter-MediumItalic.otf differ diff --git a/assets/fonts/inter/Inter-Regular.otf b/assets/fonts/inter/Inter-Regular.otf new file mode 100644 index 0000000000..2d0bd1d645 Binary files /dev/null and b/assets/fonts/inter/Inter-Regular.otf differ diff --git a/assets/fonts/inter/Inter-SemiBold.otf b/assets/fonts/inter/Inter-SemiBold.otf new file mode 100644 index 0000000000..52c84550ba Binary files /dev/null and b/assets/fonts/inter/Inter-SemiBold.otf differ diff --git a/assets/fonts/inter/Inter-SemiBoldItalic.otf b/assets/fonts/inter/Inter-SemiBoldItalic.otf new file mode 100644 index 0000000000..b725bfc883 Binary files /dev/null and b/assets/fonts/inter/Inter-SemiBoldItalic.otf differ diff --git a/assets/fonts/inter/Inter-Thin.otf b/assets/fonts/inter/Inter-Thin.otf new file mode 100644 index 0000000000..568a18560c Binary files /dev/null and b/assets/fonts/inter/Inter-Thin.otf differ diff --git a/assets/fonts/inter/Inter-ThinItalic.otf b/assets/fonts/inter/Inter-ThinItalic.otf new file mode 100644 index 0000000000..c5ed37c358 Binary files /dev/null and b/assets/fonts/inter/Inter-ThinItalic.otf differ diff --git a/assets/fonts/inter/InterVariable-Italic.ttf b/assets/fonts/inter/InterVariable-Italic.ttf new file mode 100644 index 0000000000..ed674e77cb Binary files /dev/null and b/assets/fonts/inter/InterVariable-Italic.ttf differ diff --git a/assets/fonts/inter/InterVariable.ttf b/assets/fonts/inter/InterVariable.ttf new file mode 100644 index 0000000000..2d4b47093a Binary files /dev/null and b/assets/fonts/inter/InterVariable.ttf differ diff --git a/assets/icons/accessibility_stroke2_corner2_rounded.svg b/assets/icons/accessibility_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..62184bd8d9 --- /dev/null +++ b/assets/icons/accessibility_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/alien_stroke2_corner0_rounded.svg b/assets/icons/alien_stroke2_corner0_rounded.svg index 595308c97b..c4dcc350a5 100644 --- a/assets/icons/alien_stroke2_corner0_rounded.svg +++ b/assets/icons/alien_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/apple_stroke2_corner0_rounded.svg b/assets/icons/apple_stroke2_corner0_rounded.svg index 3c7f051a3c..300b1396af 100644 --- a/assets/icons/apple_stroke2_corner0_rounded.svg +++ b/assets/icons/apple_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/arrowBoxLeft_stroke2_corner2_rounded.svg b/assets/icons/arrowBoxLeft_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..ea9afbc60f --- /dev/null +++ b/assets/icons/arrowBoxLeft_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/arrowTriangleBottom_stroke2_corner1_rounded.svg b/assets/icons/arrowTriangleBottom_stroke2_corner1_rounded.svg index f40546f7cd..e8bd913893 100644 --- a/assets/icons/arrowTriangleBottom_stroke2_corner1_rounded.svg +++ b/assets/icons/arrowTriangleBottom_stroke2_corner1_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/arrowsDiagonalIn_stroke2_corner0_rounded.svg b/assets/icons/arrowsDiagonalIn_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..84e0d1e53c --- /dev/null +++ b/assets/icons/arrowsDiagonalIn_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/arrowsDiagonalIn_stroke2_corner2_rounded.svg b/assets/icons/arrowsDiagonalIn_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..16b7f7db11 --- /dev/null +++ b/assets/icons/arrowsDiagonalIn_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/arrowsDiagonalOut_stroke2_corner0_rounded.svg b/assets/icons/arrowsDiagonalOut_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..ef8268de1f --- /dev/null +++ b/assets/icons/arrowsDiagonalOut_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/arrowsDiagonalOut_stroke2_corner2_rounded.svg b/assets/icons/arrowsDiagonalOut_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..56a6a6165a --- /dev/null +++ b/assets/icons/arrowsDiagonalOut_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/aspectRatio11_stroke2_corner0_rounded.svg b/assets/icons/aspectRatio11_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..949eba697b --- /dev/null +++ b/assets/icons/aspectRatio11_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/aspectRatio34_stroke2_corner0_rounded.svg b/assets/icons/aspectRatio34_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..50761a05c5 --- /dev/null +++ b/assets/icons/aspectRatio34_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/aspectRatio43_stroke2_corner0_rounded.svg b/assets/icons/aspectRatio43_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..4ca8362e94 --- /dev/null +++ b/assets/icons/aspectRatio43_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/at_stroke2_corner0_rounded.svg b/assets/icons/at_stroke2_corner0_rounded.svg index 8d30d7c8c5..e43b7f77c3 100644 --- a/assets/icons/at_stroke2_corner0_rounded.svg +++ b/assets/icons/at_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/at_stroke2_corner2_rounded.svg b/assets/icons/at_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..37ccbda238 --- /dev/null +++ b/assets/icons/at_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/atom_stroke2_corner0_rounded.svg b/assets/icons/atom_stroke2_corner0_rounded.svg index 723fdeab6d..a2965fd3be 100644 --- a/assets/icons/atom_stroke2_corner0_rounded.svg +++ b/assets/icons/atom_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/bell2_filled_corner0_rounded.svg b/assets/icons/bell2_filled_corner0_rounded.svg index 9c66129b11..556854d35a 100644 --- a/assets/icons/bell2_filled_corner0_rounded.svg +++ b/assets/icons/bell2_filled_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/bell2_stroke2_corner0_rounded.svg b/assets/icons/bell2_stroke2_corner0_rounded.svg index 577bc5eaa1..5290906ab8 100644 --- a/assets/icons/bell2_stroke2_corner0_rounded.svg +++ b/assets/icons/bell2_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/bellOff_filled_corner0_rounded.svg b/assets/icons/bellOff_filled_corner0_rounded.svg index 4c6997a709..80b48c4c93 100644 --- a/assets/icons/bellOff_filled_corner0_rounded.svg +++ b/assets/icons/bellOff_filled_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/bellOff_stroke2_corner0_rounded.svg b/assets/icons/bellOff_stroke2_corner0_rounded.svg index 0ed4910d4b..07b30755e9 100644 --- a/assets/icons/bellOff_stroke2_corner0_rounded.svg +++ b/assets/icons/bellOff_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/bell_filled_corner0_rounded.svg b/assets/icons/bell_filled_corner0_rounded.svg index 3f21b7e9bd..249e8bc19b 100644 --- a/assets/icons/bell_filled_corner0_rounded.svg +++ b/assets/icons/bell_filled_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/bell_stroke2_corner0_rounded.svg b/assets/icons/bell_stroke2_corner0_rounded.svg index a31f1bd152..8b50bb2eef 100644 --- a/assets/icons/bell_stroke2_corner0_rounded.svg +++ b/assets/icons/bell_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/birthdayCake_stroke2_corner2_rounded.svg b/assets/icons/birthdayCake_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..542e1552f4 --- /dev/null +++ b/assets/icons/birthdayCake_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/bubbleInfo_stroke2_corner2_rounded.svg b/assets/icons/bubbleInfo_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..2cc08924f8 --- /dev/null +++ b/assets/icons/bubbleInfo_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/bubbleQuestion_stroke2_corner0_rounded.svg b/assets/icons/bubbleQuestion_stroke2_corner0_rounded.svg index 0bfcc48a0e..fa37c14332 100644 --- a/assets/icons/bubbleQuestion_stroke2_corner0_rounded.svg +++ b/assets/icons/bubbleQuestion_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/calendar_stroke2_corner0_rounded.svg b/assets/icons/calendar_stroke2_corner0_rounded.svg index 703f389dba..a8180a22fb 100644 --- a/assets/icons/calendar_stroke2_corner0_rounded.svg +++ b/assets/icons/calendar_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/camera_filled_stroke2_corner0_rounded.svg b/assets/icons/camera_filled_stroke2_corner0_rounded.svg index fa0101cf0d..a6baed32c6 100644 --- a/assets/icons/camera_filled_stroke2_corner0_rounded.svg +++ b/assets/icons/camera_filled_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/camera_stroke2_corner0_rounded.svg b/assets/icons/camera_stroke2_corner0_rounded.svg index ce0c29ae50..789bc14a84 100644 --- a/assets/icons/camera_stroke2_corner0_rounded.svg +++ b/assets/icons/camera_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/cc_filled_stroke2_corner0_rounded.svg b/assets/icons/cc_filled_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..ae8466e67e --- /dev/null +++ b/assets/icons/cc_filled_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/cc_stroke2_corner0_rounded.svg b/assets/icons/cc_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..4d711d85be --- /dev/null +++ b/assets/icons/cc_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/celebrate_stroke2_corner0_rounded.svg b/assets/icons/celebrate_stroke2_corner0_rounded.svg index 3ea7bc8d2a..f2ea3c44d8 100644 --- a/assets/icons/celebrate_stroke2_corner0_rounded.svg +++ b/assets/icons/celebrate_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/chevronBottom_stroke2_corner0_rounded.svg b/assets/icons/chevronBottom_stroke2_corner0_rounded.svg index 705c1c5139..085a28bb8c 100644 --- a/assets/icons/chevronBottom_stroke2_corner0_rounded.svg +++ b/assets/icons/chevronBottom_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/chevronTop_stroke2_corner0_rounded.svg b/assets/icons/chevronTop_stroke2_corner0_rounded.svg index da94ba911f..2012da128f 100644 --- a/assets/icons/chevronTop_stroke2_corner0_rounded.svg +++ b/assets/icons/chevronTop_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/circleBanSign_stroke2_corner0_rounded.svg b/assets/icons/circleBanSign_stroke2_corner0_rounded.svg index 73251477fa..349e9c32de 100644 --- a/assets/icons/circleBanSign_stroke2_corner0_rounded.svg +++ b/assets/icons/circleBanSign_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/circleQuestion_stroke2_corner2_rounded.svg b/assets/icons/circleQuestion_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..a534f98716 --- /dev/null +++ b/assets/icons/circleQuestion_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/clipboard_stroke2_corner2_rounded.svg b/assets/icons/clipboard_stroke2_corner2_rounded.svg index f403cfb929..bcda20dd69 100644 --- a/assets/icons/clipboard_stroke2_corner2_rounded.svg +++ b/assets/icons/clipboard_stroke2_corner2_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/closeQuote_filled_stroke2_corner0_rounded.svg b/assets/icons/closeQuote_filled_stroke2_corner0_rounded.svg index 41e75887c0..3eddcbfacc 100644 --- a/assets/icons/closeQuote_filled_stroke2_corner0_rounded.svg +++ b/assets/icons/closeQuote_filled_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/closeQuote_stroke2_corner0_rounded.svg b/assets/icons/closeQuote_stroke2_corner0_rounded.svg index 3c76c73920..304d3c59db 100644 --- a/assets/icons/closeQuote_stroke2_corner0_rounded.svg +++ b/assets/icons/closeQuote_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/closeQuote_stroke2_corner1_rounded.svg b/assets/icons/closeQuote_stroke2_corner1_rounded.svg index b27eb94f23..357ede850e 100644 --- a/assets/icons/closeQuote_stroke2_corner1_rounded.svg +++ b/assets/icons/closeQuote_stroke2_corner1_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/coffee_stroke2_corner0_rounded.svg b/assets/icons/coffee_stroke2_corner0_rounded.svg index b734ef606a..90bd5b6ae1 100644 --- a/assets/icons/coffee_stroke2_corner0_rounded.svg +++ b/assets/icons/coffee_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/colorPalette_stroke2_corner0_rounded.svg b/assets/icons/colorPalette_stroke2_corner0_rounded.svg index b1056e1a96..db94cb6208 100644 --- a/assets/icons/colorPalette_stroke2_corner0_rounded.svg +++ b/assets/icons/colorPalette_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/crop_stroke2_corner0_rounded.svg b/assets/icons/crop_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..118d148f3c --- /dev/null +++ b/assets/icons/crop_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/download_stroke2_corner0_rounded.svg b/assets/icons/download_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..899fef3e5e --- /dev/null +++ b/assets/icons/download_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/earth_stroke2_corner0_rounded.svg b/assets/icons/earth_stroke2_corner0_rounded.svg index 02d2fe0b0e..e130a2e819 100644 --- a/assets/icons/earth_stroke2_corner0_rounded.svg +++ b/assets/icons/earth_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/emojiArc_stroke2_corner0_rounded.svg b/assets/icons/emojiArc_stroke2_corner0_rounded.svg index 3d09228e71..33a1e208f2 100644 --- a/assets/icons/emojiArc_stroke2_corner0_rounded.svg +++ b/assets/icons/emojiArc_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/emojiHeartEyes_stroke2_corner0_rounded.svg b/assets/icons/emojiHeartEyes_stroke2_corner0_rounded.svg index 4aecb86c54..9cebeb17c5 100644 --- a/assets/icons/emojiHeartEyes_stroke2_corner0_rounded.svg +++ b/assets/icons/emojiHeartEyes_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/emojiSad_stroke2_corner0_rounded.svg b/assets/icons/emojiSad_stroke2_corner0_rounded.svg index 0a5a43cd0b..fbbfd366d9 100644 --- a/assets/icons/emojiSad_stroke2_corner0_rounded.svg +++ b/assets/icons/emojiSad_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/emojiSmile_stroke2_corner0_rounded.svg b/assets/icons/emojiSmile_stroke2_corner0_rounded.svg index fd329b5098..fac12d4c79 100644 --- a/assets/icons/emojiSmile_stroke2_corner0_rounded.svg +++ b/assets/icons/emojiSmile_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/envelope_filled_stroke2_corner0_rounded.svg b/assets/icons/envelope_filled_stroke2_corner0_rounded.svg index 3810bf334f..6c3ea83020 100644 --- a/assets/icons/envelope_filled_stroke2_corner0_rounded.svg +++ b/assets/icons/envelope_filled_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/envelope_stroke2_corner0_rounded.svg b/assets/icons/envelope_stroke2_corner0_rounded.svg index c3ab45980b..4de98cca03 100644 --- a/assets/icons/envelope_stroke2_corner0_rounded.svg +++ b/assets/icons/envelope_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/envelope_stroke2_corner2_rounded.svg b/assets/icons/envelope_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..39331f8a12 --- /dev/null +++ b/assets/icons/envelope_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/explosion_stroke2_corner0_rounded.svg b/assets/icons/explosion_stroke2_corner0_rounded.svg index 11544bf79c..e661ec3511 100644 --- a/assets/icons/explosion_stroke2_corner0_rounded.svg +++ b/assets/icons/explosion_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/eyeSlash_stroke2_corner0_rounded.svg b/assets/icons/eyeSlash_stroke2_corner0_rounded.svg index f11bdd937f..6de34c5f3d 100644 --- a/assets/icons/eyeSlash_stroke2_corner0_rounded.svg +++ b/assets/icons/eyeSlash_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/eye_stroke2_corner0_rounded.svg b/assets/icons/eye_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..604b3ff79e --- /dev/null +++ b/assets/icons/eye_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/eye_stroke2_corner2_rounded.svg b/assets/icons/eye_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..81e31ba032 --- /dev/null +++ b/assets/icons/eye_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/flag_stroke2_corner0_rounded.svg b/assets/icons/flag_stroke2_corner0_rounded.svg index 9f9cc5cdd1..d1de1d0075 100644 --- a/assets/icons/flag_stroke2_corner0_rounded.svg +++ b/assets/icons/flag_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/flipHorizontal_stroke2_corner0_rounded.svg b/assets/icons/flipHorizontal_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..0d4c81d2c1 --- /dev/null +++ b/assets/icons/flipHorizontal_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/flipVertical_stroke2_corner0_rounded.svg b/assets/icons/flipVertical_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..42fca985c5 --- /dev/null +++ b/assets/icons/flipVertical_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/gameController_stroke2_corner0_rounded.svg b/assets/icons/gameController_stroke2_corner0_rounded.svg index e530c802b0..a3584d158c 100644 --- a/assets/icons/gameController_stroke2_corner0_rounded.svg +++ b/assets/icons/gameController_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/gifSquare_stroke2_corner0_rounded.svg b/assets/icons/gifSquare_stroke2_corner0_rounded.svg index 47b9df9846..7d44106dd5 100644 --- a/assets/icons/gifSquare_stroke2_corner0_rounded.svg +++ b/assets/icons/gifSquare_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/gif_stroke2_corner0_rounded.svg b/assets/icons/gif_stroke2_corner0_rounded.svg index 519acfd4d2..019900b593 100644 --- a/assets/icons/gif_stroke2_corner0_rounded.svg +++ b/assets/icons/gif_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/globe_stroke2_corner0_rounded.svg b/assets/icons/globe_stroke2_corner0_rounded.svg index 83cb88d136..fe0142abcb 100644 --- a/assets/icons/globe_stroke2_corner0_rounded.svg +++ b/assets/icons/globe_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/group3_stroke2_corner0_rounded.svg b/assets/icons/group3_stroke2_corner0_rounded.svg index 2a8f43a8a4..4a7cc0c2e4 100644 --- a/assets/icons/group3_stroke2_corner0_rounded.svg +++ b/assets/icons/group3_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/growth_stroke2_corner0_rounded.svg b/assets/icons/growth_stroke2_corner0_rounded.svg index ec9083fb1e..f5d931cf9c 100644 --- a/assets/icons/growth_stroke2_corner0_rounded.svg +++ b/assets/icons/growth_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/lab_stroke2_corner0_rounded.svg b/assets/icons/lab_stroke2_corner0_rounded.svg index 466809194e..cb1e017b9c 100644 --- a/assets/icons/lab_stroke2_corner0_rounded.svg +++ b/assets/icons/lab_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/leaf_stroke2_corner0_rounded.svg b/assets/icons/leaf_stroke2_corner0_rounded.svg index 16b379f98c..f5d931cf9c 100644 --- a/assets/icons/leaf_stroke2_corner0_rounded.svg +++ b/assets/icons/leaf_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/lock_stroke2_corner0_rounded.svg b/assets/icons/lock_stroke2_corner0_rounded.svg index 8b094ba5eb..6a2717fae9 100644 --- a/assets/icons/lock_stroke2_corner0_rounded.svg +++ b/assets/icons/lock_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/lock_stroke2_corner2_rounded.svg b/assets/icons/lock_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..8e34c3b05c --- /dev/null +++ b/assets/icons/lock_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/message_stroke2_corner0_rounded.svg b/assets/icons/message_stroke2_corner0_rounded.svg index 2cbaa3e628..1cfdb51250 100644 --- a/assets/icons/message_stroke2_corner0_rounded.svg +++ b/assets/icons/message_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/message_stroke2_corner0_rounded_filled.svg b/assets/icons/message_stroke2_corner0_rounded_filled.svg index 0de0246727..8e012013a2 100644 --- a/assets/icons/message_stroke2_corner0_rounded_filled.svg +++ b/assets/icons/message_stroke2_corner0_rounded_filled.svg @@ -1 +1 @@ - + diff --git a/assets/icons/moon_stroke2_corner2_rounded.svg b/assets/icons/moon_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..06758ee3ff --- /dev/null +++ b/assets/icons/moon_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/musicNote_stroke2_corner0_rounded.svg b/assets/icons/musicNote_stroke2_corner0_rounded.svg index 2dcc2e3b29..031ba810ef 100644 --- a/assets/icons/musicNote_stroke2_corner0_rounded.svg +++ b/assets/icons/musicNote_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/mute_stroke2_corner0_rounded.svg b/assets/icons/mute_stroke2_corner0_rounded.svg index 8ebecb3920..ab73567563 100644 --- a/assets/icons/mute_stroke2_corner0_rounded.svg +++ b/assets/icons/mute_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/news2_stroke2_corner0_rounded.svg b/assets/icons/news2_stroke2_corner0_rounded.svg index 66e4c373a0..a298ad69a9 100644 --- a/assets/icons/news2_stroke2_corner0_rounded.svg +++ b/assets/icons/news2_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/newskie.svg b/assets/icons/newskie.svg index e3a9d83c80..308049f586 100644 --- a/assets/icons/newskie.svg +++ b/assets/icons/newskie.svg @@ -1 +1 @@ - + diff --git a/assets/icons/openQuote_filled_stroke2_corner0_rounded.svg b/assets/icons/openQuote_filled_stroke2_corner0_rounded.svg index e8141a1128..97db191f3b 100644 --- a/assets/icons/openQuote_filled_stroke2_corner0_rounded.svg +++ b/assets/icons/openQuote_filled_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/openQuote_stroke2_corner0_rounded.svg b/assets/icons/openQuote_stroke2_corner0_rounded.svg index eee6344cee..10cd33d20e 100644 --- a/assets/icons/openQuote_stroke2_corner0_rounded.svg +++ b/assets/icons/openQuote_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/paintRoller_stroke2_corner2_rounded.svg b/assets/icons/paintRoller_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..3ebb36aa82 --- /dev/null +++ b/assets/icons/paintRoller_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/pause_filled_corner0_rounded.svg b/assets/icons/pause_filled_corner0_rounded.svg new file mode 100644 index 0000000000..b901dc7c51 --- /dev/null +++ b/assets/icons/pause_filled_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/pause_filled_corner2_rounded.svg b/assets/icons/pause_filled_corner2_rounded.svg new file mode 100644 index 0000000000..3eebad4453 --- /dev/null +++ b/assets/icons/pause_filled_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/pause_stroke2_corner0_rounded.svg b/assets/icons/pause_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..ee1c978f8e --- /dev/null +++ b/assets/icons/pause_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/pause_stroke2_corner2_rounded.svg b/assets/icons/pause_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..6ce60ecf9a --- /dev/null +++ b/assets/icons/pause_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/pencil_stroke2_corner0_rounded.svg b/assets/icons/pencil_stroke2_corner0_rounded.svg index 7341989894..098b180763 100644 --- a/assets/icons/pencil_stroke2_corner0_rounded.svg +++ b/assets/icons/pencil_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/peopleRemove2_stroke2_corner0_rounded.svg b/assets/icons/peopleRemove2_stroke2_corner0_rounded.svg index daec6f5579..b8d7437caa 100644 --- a/assets/icons/peopleRemove2_stroke2_corner0_rounded.svg +++ b/assets/icons/peopleRemove2_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/personCheck_stroke2_corner0_rounded.svg b/assets/icons/personCheck_stroke2_corner0_rounded.svg index b3231c2780..212b166b15 100644 --- a/assets/icons/personCheck_stroke2_corner0_rounded.svg +++ b/assets/icons/personCheck_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/personPlus_stroke2_corner0_rounded.svg b/assets/icons/personPlus_stroke2_corner0_rounded.svg index 118268bf97..2de70426cb 100644 --- a/assets/icons/personPlus_stroke2_corner0_rounded.svg +++ b/assets/icons/personPlus_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/personX_stroke2_corner0_rounded.svg b/assets/icons/personX_stroke2_corner0_rounded.svg index 073015bc54..248e6ca76a 100644 --- a/assets/icons/personX_stroke2_corner0_rounded.svg +++ b/assets/icons/personX_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/person_stroke2_corner0_rounded.svg b/assets/icons/person_stroke2_corner0_rounded.svg index a23ad76071..01037371d3 100644 --- a/assets/icons/person_stroke2_corner0_rounded.svg +++ b/assets/icons/person_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/person_stroke2_corner2_rounded.svg b/assets/icons/person_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..7088c2880c --- /dev/null +++ b/assets/icons/person_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/phone_stroke2_corner2_rounded.svg b/assets/icons/phone_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..7e555c37a4 --- /dev/null +++ b/assets/icons/phone_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/piggyBank_stroke2_corner0_rounded.svg b/assets/icons/piggyBank_stroke2_corner0_rounded.svg index 36d3060102..0ec432635b 100644 --- a/assets/icons/piggyBank_stroke2_corner0_rounded.svg +++ b/assets/icons/piggyBank_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/pizza_stroke2_corner0_rounded.svg b/assets/icons/pizza_stroke2_corner0_rounded.svg index e63351b897..5f809453b3 100644 --- a/assets/icons/pizza_stroke2_corner0_rounded.svg +++ b/assets/icons/pizza_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/play_filled_corner0_rounded.svg b/assets/icons/play_filled_corner0_rounded.svg new file mode 100644 index 0000000000..2b030208fb --- /dev/null +++ b/assets/icons/play_filled_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/play_filled_corner2_rounded.svg b/assets/icons/play_filled_corner2_rounded.svg new file mode 100644 index 0000000000..e25e8d4628 --- /dev/null +++ b/assets/icons/play_filled_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/play_stroke2_corner0_rounded.svg b/assets/icons/play_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..0aecbfabb7 --- /dev/null +++ b/assets/icons/play_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/play_stroke2_corner2_rounded.svg b/assets/icons/play_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..54bba91fb9 --- /dev/null +++ b/assets/icons/play_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/poop_stroke2_corner0_rounded.svg b/assets/icons/poop_stroke2_corner0_rounded.svg index daa6c11126..d6342739ff 100644 --- a/assets/icons/poop_stroke2_corner0_rounded.svg +++ b/assets/icons/poop_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/qrCode_stroke2_corner0_rounded.svg b/assets/icons/qrCode_stroke2_corner0_rounded.svg index b17db39533..680354f802 100644 --- a/assets/icons/qrCode_stroke2_corner0_rounded.svg +++ b/assets/icons/qrCode_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/raisingHand4Finger_stroke2_corner0_rounded.svg b/assets/icons/raisingHand4Finger_stroke2_corner0_rounded.svg index aed3d9e7ec..e032738579 100644 --- a/assets/icons/raisingHand4Finger_stroke2_corner0_rounded.svg +++ b/assets/icons/raisingHand4Finger_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/rose_stroke2_corner0_rounded.svg b/assets/icons/rose_stroke2_corner0_rounded.svg index 2d269855bd..2b5ed9ab79 100644 --- a/assets/icons/rose_stroke2_corner0_rounded.svg +++ b/assets/icons/rose_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/settingsGear2_filled_corner0_rounded.svg b/assets/icons/settingsGear2_filled_corner0_rounded.svg index dfc89ff507..b01874bde2 100644 --- a/assets/icons/settingsGear2_filled_corner0_rounded.svg +++ b/assets/icons/settingsGear2_filled_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/shaka_stroke2_corner0_rounded.svg b/assets/icons/shaka_stroke2_corner0_rounded.svg index 32af469e4a..63f6c222aa 100644 --- a/assets/icons/shaka_stroke2_corner0_rounded.svg +++ b/assets/icons/shaka_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/shield_stroke2_corner0_rounded.svg b/assets/icons/shield_stroke2_corner0_rounded.svg index c4ef98e5ad..36057176ff 100644 --- a/assets/icons/shield_stroke2_corner0_rounded.svg +++ b/assets/icons/shield_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/speakerVolumeFull_stroke2_corner0_rounded.svg b/assets/icons/speakerVolumeFull_stroke2_corner0_rounded.svg index 81357a12e3..9e0c2cfda3 100644 --- a/assets/icons/speakerVolumeFull_stroke2_corner0_rounded.svg +++ b/assets/icons/speakerVolumeFull_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/starterPack.svg b/assets/icons/starterPack.svg index 7f0df55952..cefbab50cb 100644 --- a/assets/icons/starterPack.svg +++ b/assets/icons/starterPack.svg @@ -1 +1 @@ - + diff --git a/assets/icons/starter_pack_icon.svg b/assets/icons/starter_pack_icon.svg index 47a2f49b64..3d604b76dd 100644 --- a/assets/icons/starter_pack_icon.svg +++ b/assets/icons/starter_pack_icon.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/textSize_stroke2_corner0_rounded.svg b/assets/icons/textSize_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..27665627d4 --- /dev/null +++ b/assets/icons/textSize_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/ticket_stroke2_corner0_rounded.svg b/assets/icons/ticket_stroke2_corner0_rounded.svg index a45a90ae5f..0edb01eedf 100644 --- a/assets/icons/ticket_stroke2_corner0_rounded.svg +++ b/assets/icons/ticket_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/timesLarge_stroke2_corner0_rounded.svg b/assets/icons/timesLarge_stroke2_corner0_rounded.svg index 68403f5984..8b0f526bfb 100644 --- a/assets/icons/timesLarge_stroke2_corner0_rounded.svg +++ b/assets/icons/timesLarge_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/titleCase_stroke2_corner0_rounded.svg b/assets/icons/titleCase_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..facdfc0e16 --- /dev/null +++ b/assets/icons/titleCase_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/trash_stroke2_corner0_rounded.svg b/assets/icons/trash_stroke2_corner0_rounded.svg index d4b32f81fe..e7cbf50be8 100644 --- a/assets/icons/trash_stroke2_corner0_rounded.svg +++ b/assets/icons/trash_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/trash_stroke2_corner2_rounded.svg b/assets/icons/trash_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..e97dfe90c4 --- /dev/null +++ b/assets/icons/trash_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/trending2_stroke2_corner2_rounded.svg b/assets/icons/trending2_stroke2_corner2_rounded.svg index cc806b0eb6..e5c2db79a9 100644 --- a/assets/icons/trending2_stroke2_corner2_rounded.svg +++ b/assets/icons/trending2_stroke2_corner2_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/triangleExclamation_stroke2_corner2_rounded.svg b/assets/icons/triangleExclamation_stroke2_corner2_rounded.svg index aa56404457..ac71eab37a 100644 --- a/assets/icons/triangleExclamation_stroke2_corner2_rounded.svg +++ b/assets/icons/triangleExclamation_stroke2_corner2_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/ufo_stroke2_corner0_rounded.svg b/assets/icons/ufo_stroke2_corner0_rounded.svg index 115c589e0a..ae86aa7bef 100644 --- a/assets/icons/ufo_stroke2_corner0_rounded.svg +++ b/assets/icons/ufo_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/userCircle_filled_corner0_rounded.svg b/assets/icons/userCircle_filled_corner0_rounded.svg index 67bb6eac77..984205b020 100644 --- a/assets/icons/userCircle_filled_corner0_rounded.svg +++ b/assets/icons/userCircle_filled_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/userCircle_stroke2_corner0_rounded.svg b/assets/icons/userCircle_stroke2_corner0_rounded.svg index ffad04f2b7..7a3747e28c 100644 --- a/assets/icons/userCircle_stroke2_corner0_rounded.svg +++ b/assets/icons/userCircle_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/verified_stroke2_corner2_rounded.svg b/assets/icons/verified_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..048b2816e3 --- /dev/null +++ b/assets/icons/verified_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/window_stroke2_corner2_rounded.svg b/assets/icons/window_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..859c00c4a5 --- /dev/null +++ b/assets/icons/window_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/zap_stroke2_corner0_rounded.svg b/assets/icons/zap_stroke2_corner0_rounded.svg index 06a88ad8d2..979649fddb 100644 --- a/assets/icons/zap_stroke2_corner0_rounded.svg +++ b/assets/icons/zap_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/bskyembed/.eslintrc b/bskyembed/.eslintrc index e6e575a11c..2b290d5815 100644 --- a/bskyembed/.eslintrc +++ b/bskyembed/.eslintrc @@ -10,11 +10,12 @@ ], "rules": { "simple-import-sort/imports": "warn", - "simple-import-sort/exports": "warn" + "simple-import-sort/exports": "warn", + 'no-else-return': 'off' }, "parserOptions": { "sourceType": "module", "ecmaVersion": "latest", "project": "./bskyembed/tsconfig.json" } -} \ No newline at end of file +} diff --git a/bskyembed/assets/play_filled_corner2_rounded.svg b/bskyembed/assets/play_filled_corner2_rounded.svg new file mode 100644 index 0000000000..48da4add65 --- /dev/null +++ b/bskyembed/assets/play_filled_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/bskyembed/assets/starterPack.svg b/bskyembed/assets/starterPack.svg new file mode 100644 index 0000000000..eb8dd710fe --- /dev/null +++ b/bskyembed/assets/starterPack.svg @@ -0,0 +1 @@ + diff --git a/bskyembed/package.json b/bskyembed/package.json index f610e8c064..e269b1c953 100644 --- a/bskyembed/package.json +++ b/bskyembed/package.json @@ -9,7 +9,7 @@ "lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src" }, "dependencies": { - "@atproto/api": "^0.12.2", + "@atproto/api": "0.13.6", "@preact/preset-vite": "^2.8.2", "@vitejs/plugin-legacy": "^5.3.2", "preact": "^10.4.8", @@ -22,7 +22,7 @@ "eslint-plugin-simple-import-sort": "^12.0.0", "postcss": "^8.4.38", "tailwindcss": "^3.4.3", - "typescript": "^4.0.5", + "typescript": "^5.5.4", "vite": "^5.2.8", "vite-tsconfig-paths": "^4.3.2" } diff --git a/bskyembed/src/components/embed.tsx b/bskyembed/src/components/embed.tsx index 1dadfee38e..82c3fd60a0 100644 --- a/bskyembed/src/components/embed.tsx +++ b/bskyembed/src/components/embed.tsx @@ -3,15 +3,20 @@ import { AppBskyEmbedImages, AppBskyEmbedRecord, AppBskyEmbedRecordWithMedia, + AppBskyEmbedVideo, AppBskyFeedDefs, AppBskyFeedPost, AppBskyGraphDefs, + AppBskyGraphStarterpack, AppBskyLabelerDefs, + AtUri, } from '@atproto/api' import {ComponentChildren, h} from 'preact' import {useMemo} from 'preact/hooks' import infoIcon from '../../assets/circleInfo_stroke2_corner0_rounded.svg' +import playIcon from '../../assets/play_filled_corner2_rounded.svg' +import starterPackIcon from '../../assets/starterPack.svg' import {CONTENT_LABELS, labelsToInfo} from '../labels' import {getRkey} from '../utils' import {Link} from './link' @@ -105,7 +110,7 @@ export function Embed({ // Case 3.2: List if (AppBskyGraphDefs.isListView(record)) { return ( - - ) + // Embed type does not exist in the app, so show nothing + return null } - // Case 3.5: Post not found + // Case 3.5: Starter pack + if (AppBskyGraphDefs.isStarterPackViewBasic(record)) { + return + } + + // Case 3.6: Post not found if (AppBskyEmbedRecord.isViewNotFound(record)) { return Quoted post not found, it may have been deleted. } - // Case 3.6: Post blocked + // Case 3.7: Post blocked if (AppBskyEmbedRecord.isViewBlocked(record)) { return The quoted post is blocked. } - throw new Error('Unknown embed type') + // Case 3.8: Detached quote post + if (AppBskyEmbedRecord.isViewDetached(record)) { + // Just don't show anything + return null + } + + // Unknown embed type + return null } - // Case 4: Record with media + // Case 4: Video + if (AppBskyEmbedVideo.isView(content)) { + return + } + + // Case 5: Record with media if ( AppBskyEmbedRecordWithMedia.isView(content) && AppBskyEmbedRecord.isViewRecord(content.record.record) @@ -182,7 +197,8 @@ export function Embed({ ) } - throw new Error('Unsupported embed type') + // Unknown embed type + return null } catch (err) { return ( {err instanceof Error ? err.message : 'An error occurred'} @@ -314,7 +330,7 @@ function ExternalEmbed({ ) } -function GenericWithImage({ +function GenericWithImageEmbed({ title, subtitle, href, @@ -350,3 +366,89 @@ function GenericWithImage({ ) } + +// just the thumbnail and a play button +function VideoEmbed({content}: {content: AppBskyEmbedVideo.View}) { + let aspectRatio = 1 + + if (content.aspectRatio) { + const {width, height} = content.aspectRatio + aspectRatio = clamp(width / height, 1 / 1, 3 / 1) + } + + return ( +
+ {content.alt} +
+ +
+
+ ) +} + +function StarterPackEmbed({ + content, +}: { + content: AppBskyGraphDefs.StarterPackViewBasic +}) { + if (!AppBskyGraphStarterpack.isRecord(content.record)) { + return null + } + + const starterPackHref = getStarterPackHref(content) + const imageUri = getStarterPackImage(content) + + return ( + + +
+
+ +
+

+ {content.record.name} +

+

+ Starter pack by{' '} + {content.creator.displayName || `@${content.creator.handle}`} +

+
+
+ {content.record.description && ( +

{content.record.description}

+ )} + {!!content.joinedAllTimeCount && content.joinedAllTimeCount > 50 && ( +

+ {content.joinedAllTimeCount} users have joined! +

+ )} +
+ + ) +} + +// from #/lib/strings/starter-pack.ts +function getStarterPackImage(starterPack: AppBskyGraphDefs.StarterPackView) { + const rkey = new AtUri(starterPack.uri).rkey + return `https://ogcard.cdn.bsky.app/start/${starterPack.creator.did}/${rkey}` +} + +function getStarterPackHref( + starterPack: AppBskyGraphDefs.StarterPackViewBasic, +) { + const rkey = new AtUri(starterPack.uri).rkey + const handleOrDid = starterPack.creator.handle || starterPack.creator.did + return `/starter-pack/${handleOrDid}/${rkey}` +} + +function clamp(num: number, min: number, max: number) { + return Math.max(min, Math.min(num, max)) +} diff --git a/bskyembed/src/components/post.tsx b/bskyembed/src/components/post.tsx index d23c84cbfb..4db5eeb45e 100644 --- a/bskyembed/src/components/post.tsx +++ b/bskyembed/src/components/post.tsx @@ -11,7 +11,7 @@ import likeIcon from '../../assets/heart2_filled_stroke2_corner0_rounded.svg' import logo from '../../assets/logo.svg' import repostIcon from '../../assets/repost_stroke2_corner2_rounded.svg' import {CONTENT_LABELS} from '../labels' -import {getRkey, niceDate} from '../utils' +import {getRkey, niceDate, prettyNumber} from '../utils' import {Container} from './container' import {Embed} from './embed' import {Link} from './link' @@ -78,7 +78,7 @@ export function Post({thread}: Props) {

- {post.likeCount} + {prettyNumber(post.likeCount)}

)} @@ -86,7 +86,7 @@ export function Post({thread}: Props) {

- {post.repostCount} + {prettyNumber(post.repostCount)}

)} @@ -97,7 +97,7 @@ export function Post({thread}: Props) {

{post.replyCount - ? `Read ${post.replyCount} ${ + ? `Read ${prettyNumber(post.replyCount)} ${ post.replyCount > 1 ? 'replies' : 'reply' } on Bluesky` : `View on Bluesky`} @@ -132,7 +132,10 @@ function PostContent({record}: {record: AppBskyFeedPost.Record | null}) { key={counter} href={segment.link.uri} className="text-blue-400 hover:underline" - disableTracking={!segment.link.uri.startsWith('https://bsky.app')}> + disableTracking={ + !segment.link.uri.startsWith('https://bsky.app') && + !segment.link.uri.startsWith('https://go.bsky.app') + }> {segment.text} , ) diff --git a/bskyembed/src/index.css b/bskyembed/src/index.css index 23457ec28d..22b2b8be5c 100644 --- a/bskyembed/src/index.css +++ b/bskyembed/src/index.css @@ -4,4 +4,4 @@ .break-word { word-break: break-word; -} \ No newline at end of file +} diff --git a/bskyembed/src/screens/post.tsx b/bskyembed/src/screens/post.tsx index 337bf01007..6ccf10a791 100644 --- a/bskyembed/src/screens/post.tsx +++ b/bskyembed/src/screens/post.tsx @@ -1,6 +1,6 @@ import '../index.css' -import {AppBskyFeedDefs, BskyAgent} from '@atproto/api' +import {AppBskyFeedDefs, AtpAgent} from '@atproto/api' import {h, render} from 'preact' import logo from '../../assets/logo.svg' @@ -12,7 +12,7 @@ import {getRkey} from '../utils' const root = document.getElementById('app') if (!root) throw new Error('No root element') -const agent = new BskyAgent({ +const agent = new AtpAgent({ service: 'https://public.api.bsky.app', }) diff --git a/bskyembed/src/utils.ts b/bskyembed/src/utils.ts index 1f6fd5061c..cfa4a525bf 100644 --- a/bskyembed/src/utils.ts +++ b/bskyembed/src/utils.ts @@ -16,3 +16,13 @@ export function getRkey({uri}: {uri: string}): string { const at = new AtUri(uri) return at.rkey } + +const formatter = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, + roundingMode: 'trunc', +}) + +export function prettyNumber(number: number) { + return formatter.format(number) +} diff --git a/bskyembed/tsconfig.json b/bskyembed/tsconfig.json index 44c516ed11..b3b6055ccd 100644 --- a/bskyembed/tsconfig.json +++ b/bskyembed/tsconfig.json @@ -20,5 +20,5 @@ "jsxFragmentFactory": "Fragment", "downlevelIteration": true }, - "include": ["src"] + "include": ["src", "vite.config.ts"] } diff --git a/bskyembed/tsconfig.snippet.json b/bskyembed/tsconfig.snippet.json index a6b6071dd6..fee21964a1 100644 --- a/bskyembed/tsconfig.snippet.json +++ b/bskyembed/tsconfig.snippet.json @@ -6,5 +6,5 @@ "strict": true, "outDir": "dist" }, - "include": ["snippet"], + "include": ["snippet"] } diff --git a/bskyembed/yarn.lock b/bskyembed/yarn.lock index 60efe36845..46c8519ba1 100644 --- a/bskyembed/yarn.lock +++ b/bskyembed/yarn.lock @@ -20,15 +20,16 @@ "@jridgewell/gen-mapping" "^0.3.5" "@jridgewell/trace-mapping" "^0.3.24" -"@atproto/api@^0.12.2": - version "0.12.2" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.2.tgz#5df6d4f60dea0395c84fdebd9e81a7e853edf130" - integrity sha512-UVzCiDZH2j0wrr/O8nb1edD5cYLVqB5iujueXUCbHS3rAwIxgmyLtA3Hzm2QYsGPo/+xsIg1fNvpq9rNT6KWUA== +"@atproto/api@0.13.6": + version "0.13.6" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.6.tgz#2500e9d7143e6718089632300c42ce50149f8cd5" + integrity sha512-58emFFZhqY8nVWD3xFWK0yYqAmJ2un+NaTtZxBbRo00mGq1rz9VXTpVmfoHFcuXL1hoDQN3WyJfsub8r6xGOgg== dependencies: "@atproto/common-web" "^0.3.0" - "@atproto/lexicon" "^0.4.0" + "@atproto/lexicon" "^0.4.1" "@atproto/syntax" "^0.3.0" - "@atproto/xrpc" "^0.5.0" + "@atproto/xrpc" "^0.6.1" + await-lock "^2.2.2" multiformats "^9.9.0" tlds "^1.234.0" @@ -42,29 +43,29 @@ uint8arrays "3.0.0" zod "^3.21.4" -"@atproto/lexicon@^0.4.0": - version "0.4.0" - resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.0.tgz#63e8829945d80c25524882caa8ed27b1151cc576" - integrity sha512-RvCBKdSI4M8qWm5uTNz1z3R2yIvIhmOsMuleOj8YR6BwRD+QbtUBy3l+xQ7iXf4M5fdfJFxaUNa6Ty0iRwdKqQ== +"@atproto/lexicon@^0.4.1": + version "0.4.1" + resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.1.tgz#19155210570a2fafbcc7d4f655d9b813948e72a0" + integrity sha512-bzyr+/VHXLQWbumViX5L7h1NKQObfs8Z+XZJl43OUK8nYFUI4e/sW1IZKRNfw7Wvi5YVNK+J+yP3DWIBZhkCYA== dependencies: "@atproto/common-web" "^0.3.0" "@atproto/syntax" "^0.3.0" iso-datestring-validator "^2.2.2" multiformats "^9.9.0" - zod "^3.21.4" + zod "^3.23.8" "@atproto/syntax@^0.3.0": version "0.3.0" resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.3.0.tgz#fafa2dbea9add37253005cb663e7373e05e618b3" integrity sha512-Weq0ZBxffGHDXHl9U7BQc2BFJi/e23AL+k+i5+D9hUq/bzT4yjGsrCejkjq0xt82xXDjmhhvQSZ0LqxyZ5woxA== -"@atproto/xrpc@^0.5.0": - version "0.5.0" - resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.5.0.tgz#dacbfd8f7b13f0ab5bd56f8fdd4b460e132a6032" - integrity sha512-swu+wyOLvYW4l3n+VAuJbHcPcES+tin2Lsrp8Bw5aIXIICiuFn1YMFlwK9JwVUzTH21Py1s1nHEjr4CJeElJog== +"@atproto/xrpc@^0.6.1": + version "0.6.1" + resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.1.tgz#dcd1315c8c60eef5af2db7fa4e35a38ebc6d79d5" + integrity sha512-Zy5ydXEdk6sY7FDUZcEVfCL1jvbL4tXu5CcdPqbEaW6LQtk9GLds/DK1bCX9kswTGaBC88EMuqQMfkxOhp2t4A== dependencies: - "@atproto/lexicon" "^0.4.0" - zod "^3.21.4" + "@atproto/lexicon" "^0.4.1" + zod "^3.23.8" "@babel/code-frame@^7.23.5", "@babel/code-frame@^7.24.1", "@babel/code-frame@^7.24.2": version "7.24.2" @@ -1710,6 +1711,11 @@ available-typed-arrays@^1.0.7: dependencies: possible-typed-array-names "^1.0.0" +await-lock@^2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/await-lock/-/await-lock-2.2.2.tgz#a95a9b269bfd2f69d22b17a321686f551152bcef" + integrity sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw== + babel-plugin-polyfill-corejs2@^0.4.10: version "0.4.10" resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.10.tgz#276f41710b03a64f6467433cab72cbc2653c38b1" @@ -3730,8 +3736,16 @@ stack-trace@^1.0.0-pre2: resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-1.0.0-pre2.tgz#46a83a79f1b287807e9aaafc6a5dd8bcde626f9c" integrity sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A== -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0: - name string-width-cjs +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -3795,7 +3809,14 @@ string.prototype.trimstart@^1.0.8: define-properties "^1.2.1" es-object-atoms "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -4003,10 +4024,10 @@ typed-array-length@^1.0.6: is-typed-array "^1.1.13" possible-typed-array-names "^1.0.0" -typescript@^4.0.5: - version "4.9.5" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a" - integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== +typescript@^5.5.4: + version "5.5.4" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.5.4.tgz#d9852d6c82bad2d2eda4fd74a5762a8f5909e9ba" + integrity sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q== uint8arrays@3.0.0: version "3.0.0" @@ -4192,3 +4213,8 @@ zod@^3.21.4: version "3.22.4" resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.4.tgz#f31c3a9386f61b1f228af56faa9255e845cf3fff" integrity sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg== + +zod@^3.23.8: + version "3.23.8" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d" + integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g== diff --git a/bskyweb/.gitignore b/bskyweb/.gitignore index 05b3ad7ab5..a63a381f94 100644 --- a/bskyweb/.gitignore +++ b/bskyweb/.gitignore @@ -10,6 +10,10 @@ static/js/*.js static/js/*.map static/js/*.js.LICENSE.txt static/js/empty.txt +static/css/*.css +static/css/*.map +static/css/*.css.LICENSE.txt +static/css/empty.txt static/media/*.png static/media/empty.txt templates/scripts.html diff --git a/bskyweb/cmd/bskyweb/main.go b/bskyweb/cmd/bskyweb/main.go index 908486aa7e..5c46af418d 100644 --- a/bskyweb/cmd/bskyweb/main.go +++ b/bskyweb/cmd/bskyweb/main.go @@ -41,10 +41,10 @@ func run(args []string) { EnvVars: []string{"ATP_APPVIEW_HOST", "ATP_PDS_HOST"}, }, &cli.StringFlag{ - Name: "ogcard-host", - Usage: "scheme, hostname, and port of ogcard service", + Name: "ogcard-host", + Usage: "scheme, hostname, and port of ogcard service", Required: false, - EnvVars: []string{"OGCARD_HOST"}, + EnvVars: []string{"OGCARD_HOST"}, }, &cli.StringFlag{ Name: "http-address", @@ -60,6 +60,12 @@ func run(args []string) { Value: "", EnvVars: []string{"LINK_HOST"}, }, + &cli.StringFlag{ + Name: "ipcc-host", + Usage: "scheme, hostname, and port of ipcc service", + Value: "https://localhost:8730", + EnvVars: []string{"IPCC_HOST"}, + }, &cli.BoolFlag{ Name: "debug", Usage: "Enable debug mode", @@ -67,6 +73,27 @@ func run(args []string) { Required: false, EnvVars: []string{"DEBUG"}, }, + &cli.StringFlag{ + Name: "basic-auth-password", + Usage: "optional password to restrict access to web interface", + Required: false, + Value: "", + EnvVars: []string{"BASIC_AUTH_PASSWORD"}, + }, + &cli.StringSliceFlag{ + Name: "cors-allowed-origins", + Usage: "list of allowed origins for CORS requests", + Required: false, + Value: cli.NewStringSlice("https://bsky.app", "https://main.bsky.dev", "https://app.staging.bsky.dev"), + EnvVars: []string{"CORS_ALLOWED_ORIGINS"}, + }, + &cli.StringFlag{ + Name: "static-cdn-host", + Usage: "scheme, hostname, and port of static content CDN, don't end with a slash", + Required: false, + Value: "", + EnvVars: []string{"STATIC_CDN_HOST"}, + }, }, }, } diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index d7e41a4ca8..1089e3c1af 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -1,11 +1,17 @@ package main import ( + "bytes" "context" + "crypto/subtle" + "crypto/tls" + "encoding/base64" + "encoding/json" "errors" "fmt" "io/fs" "net/http" + "net/netip" "net/url" "os" "os/signal" @@ -35,11 +41,13 @@ type Server struct { } type Config struct { - debug bool - httpAddress string - appviewHost string - ogcardHost string - linkHost string + debug bool + httpAddress string + appviewHost string + ogcardHost string + linkHost string + ipccHost string + staticCDNHost string } func serve(cctx *cli.Context) error { @@ -48,6 +56,11 @@ func serve(cctx *cli.Context) error { appviewHost := cctx.String("appview-host") ogcardHost := cctx.String("ogcard-host") linkHost := cctx.String("link-host") + ipccHost := cctx.String("ipcc-host") + basicAuthPassword := cctx.String("basic-auth-password") + corsOrigins := cctx.StringSlice("cors-allowed-origins") + staticCDNHost := cctx.String("static-cdn-host") + staticCDNHost = strings.TrimSuffix(staticCDNHost, "/") // Echo e := echo.New() @@ -84,11 +97,13 @@ func serve(cctx *cli.Context) error { echo: e, xrpcc: xrpcc, cfg: &Config{ - debug: debug, - httpAddress: httpAddress, - appviewHost: appviewHost, - ogcardHost: ogcardHost, - linkHost: linkHost, + debug: debug, + httpAddress: httpAddress, + appviewHost: appviewHost, + ogcardHost: ogcardHost, + linkHost: linkHost, + ipccHost: ipccHost, + staticCDNHost: staticCDNHost, }, } @@ -140,12 +155,30 @@ func serve(cctx *cli.Context) error { }, })) + // optional password gating of entire web interface + if basicAuthPassword != "" { + e.Use(middleware.BasicAuth(func(username, password string, c echo.Context) (bool, error) { + // Be careful to use constant time comparison to prevent timing attacks + if subtle.ConstantTimeCompare([]byte(username), []byte("admin")) == 1 && + subtle.ConstantTimeCompare([]byte(password), []byte(basicAuthPassword)) == 1 { + return true, nil + } + return false, nil + })) + } + // redirect trailing slash to non-trailing slash. // all of our current endpoints have no trailing slash. e.Use(middleware.RemoveTrailingSlashWithConfig(middleware.TrailingSlashConfig{ RedirectCode: http.StatusFound, })) + // CORS middleware + e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ + AllowOrigins: corsOrigins, + AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodOptions}, + })) + // // configure routes // @@ -175,10 +208,9 @@ func serve(cctx *cli.Context) error { path := c.Request().URL.Path maxAge := 1 * (60 * 60) // default is 1 hour - // Cache javascript and images files for 1 week, which works because - // they're always versioned (e.g. /static/js/main.64c14927.js) - if strings.HasPrefix(path, "/static/js/") || strings.HasPrefix(path, "/static/images/") || strings.HasPrefix(path, "/static/media/") { - maxAge = 7 * (60 * 60 * 24) // 1 week + // all assets in /static/js, /static/css, /static/media are content-hashed and can be cached for a long time + if strings.HasPrefix(path, "/static/js/") || strings.HasPrefix(path, "/static/css/") || strings.HasPrefix(path, "/static/media/") { + maxAge = 365 * (60 * 60 * 24) // 1 year } c.Response().Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAge)) @@ -197,6 +229,7 @@ func serve(cctx *cli.Context) error { e.GET("/search", server.WebGeneric) e.GET("/feeds", server.WebGeneric) e.GET("/notifications", server.WebGeneric) + e.GET("/notifications/settings", server.WebGeneric) e.GET("/lists", server.WebGeneric) e.GET("/moderation", server.WebGeneric) e.GET("/moderation/modlists", server.WebGeneric) @@ -210,6 +243,7 @@ func serve(cctx *cli.Context) error { e.GET("/settings/threads", server.WebGeneric) e.GET("/settings/external-embeds", server.WebGeneric) e.GET("/settings/accessibility", server.WebGeneric) + e.GET("/settings/appearance", server.WebGeneric) e.GET("/sys/debug", server.WebGeneric) e.GET("/sys/debug-mod", server.WebGeneric) e.GET("/sys/log", server.WebGeneric) @@ -219,6 +253,7 @@ func serve(cctx *cli.Context) error { e.GET("/support/community-guidelines", server.WebGeneric) e.GET("/support/copyright", server.WebGeneric) e.GET("/intent/compose", server.WebGeneric) + e.GET("/intent/verify-email", server.WebGeneric) e.GET("/messages", server.WebGeneric) e.GET("/messages/:conversation", server.WebGeneric) @@ -239,11 +274,16 @@ func serve(cctx *cli.Context) error { e.GET("/profile/:handleOrDID/post/:rkey", server.WebPost) e.GET("/profile/:handleOrDID/post/:rkey/liked-by", server.WebGeneric) e.GET("/profile/:handleOrDID/post/:rkey/reposted-by", server.WebGeneric) + e.GET("/profile/:handleOrDID/post/:rkey/quotes", server.WebGeneric) // starter packs e.GET("/starter-pack/:handleOrDID/:rkey", server.WebStarterPack) + e.GET("/starter-pack-short/:code", server.WebGeneric) e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack) + // ipcc + e.GET("/ipcc", server.WebIpCC) + if linkHost != "" { linkUrl, err := url.Parse(linkHost) if err != nil { @@ -297,15 +337,21 @@ func (srv *Server) Shutdown() error { return srv.httpd.Shutdown(ctx) } +// NewTemplateContext returns a new pongo2 context with some default values. +func (srv *Server) NewTemplateContext() pongo2.Context { + return pongo2.Context{ + "staticCDNHost": srv.cfg.staticCDNHost, + } +} + func (srv *Server) errorHandler(err error, c echo.Context) { code := http.StatusInternalServerError if he, ok := err.(*echo.HTTPError); ok { code = he.Code } c.Logger().Error(err) - data := pongo2.Context{ - "statusCode": code, - } + data := srv.NewTemplateContext() + data["statusCode"] = code c.Render(code, "error.html", data) } @@ -349,18 +395,18 @@ func (srv *Server) LinkProxyMiddleware(url *url.URL) echo.MiddlewareFunc { // handler for endpoint that have no specific server-side handling func (srv *Server) WebGeneric(c echo.Context) error { - data := pongo2.Context{} + data := srv.NewTemplateContext() return c.Render(http.StatusOK, "base.html", data) } func (srv *Server) WebHome(c echo.Context) error { - data := pongo2.Context{} + data := srv.NewTemplateContext() return c.Render(http.StatusOK, "home.html", data) } func (srv *Server) WebPost(c echo.Context) error { ctx := c.Request().Context() - data := pongo2.Context{} + data := srv.NewTemplateContext() // sanity check arguments. don't 4xx, just let app handle if not expected format rkeyParam := c.Param("rkey") @@ -435,7 +481,7 @@ func (srv *Server) WebPost(c echo.Context) error { func (srv *Server) WebStarterPack(c echo.Context) error { req := c.Request() ctx := req.Context() - data := pongo2.Context{} + data := srv.NewTemplateContext() data["requestURI"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path) // sanity check arguments. don't 4xx, just let app handle if not expected format rkeyParam := c.Param("rkey") @@ -473,7 +519,7 @@ func (srv *Server) WebStarterPack(c echo.Context) error { func (srv *Server) WebProfile(c echo.Context) error { ctx := c.Request().Context() - data := pongo2.Context{} + data := srv.NewTemplateContext() // sanity check arguments. don't 4xx, just let app handle if not expected format handleOrDIDParam := c.Param("handleOrDID") @@ -503,3 +549,61 @@ func (srv *Server) WebProfile(c echo.Context) error { data["requestHost"] = req.Host return c.Render(http.StatusOK, "profile.html", data) } + +type IPCCRequest struct { + IP string `json:"ip"` +} +type IPCCResponse struct { + CC string `json:"countryCode"` +} + +func (srv *Server) WebIpCC(c echo.Context) error { + realIP := c.RealIP() + addr, err := netip.ParseAddr(realIP) + if err != nil { + log.Warnf("could not parse IP %q %s", realIP, err) + return c.JSON(400, IPCCResponse{}) + } + var request []byte + if addr.Is4() { + ip4 := addr.As4() + var dest [8]byte + base64.StdEncoding.Encode(dest[:], ip4[:]) + request, _ = json.Marshal(IPCCRequest{IP: string(dest[:])}) + } else if addr.Is6() { + ip6 := addr.As16() + var dest [24]byte + base64.StdEncoding.Encode(dest[:], ip6[:]) + request, _ = json.Marshal(IPCCRequest{IP: string(dest[:])}) + } + + ipccUrlBuilder, err := url.Parse(srv.cfg.ipccHost) + if err != nil { + log.Errorf("ipcc misconfigured bad url %s", err) + return c.JSON(500, IPCCResponse{}) + } + ipccUrlBuilder.Path = "ipccdata.IpCcService/Lookup" + ipccUrl := ipccUrlBuilder.String() + cl := http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + }, + } + postBodyReader := bytes.NewReader(request) + response, err := cl.Post(ipccUrl, "application/json", postBodyReader) + if err != nil { + log.Warnf("ipcc backend error %s", err) + return c.JSON(500, IPCCResponse{}) + } + defer response.Body.Close() + dec := json.NewDecoder(response.Body) + var outResponse IPCCResponse + err = dec.Decode(&outResponse) + if err != nil { + log.Warnf("ipcc bad response %s", err) + return c.JSON(500, IPCCResponse{}) + } + return c.JSON(200, outResponse) +} diff --git a/bskyweb/static/css/.gitkeep b/bskyweb/static/css/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/bskyweb/static/media/InterVariable-Italic.55d6a3f35e9b605ba6f4.ttf b/bskyweb/static/media/InterVariable-Italic.55d6a3f35e9b605ba6f4.ttf new file mode 100644 index 0000000000..ed674e77cb Binary files /dev/null and b/bskyweb/static/media/InterVariable-Italic.55d6a3f35e9b605ba6f4.ttf differ diff --git a/bskyweb/static/media/InterVariable.c9f788f6e7ebaec75d7c.ttf b/bskyweb/static/media/InterVariable.c9f788f6e7ebaec75d7c.ttf new file mode 100644 index 0000000000..2d4b47093a Binary files /dev/null and b/bskyweb/static/media/InterVariable.c9f788f6e7ebaec75d7c.ttf differ diff --git a/bskyweb/static/media/MaterialIcons.f20305dee9d396fea5c7.ttf b/bskyweb/static/media/MaterialIcons.f20305dee9d396fea5c7.ttf new file mode 100644 index 0000000000..9d09b0feb8 Binary files /dev/null and b/bskyweb/static/media/MaterialIcons.f20305dee9d396fea5c7.ttf differ diff --git a/bskyweb/templates/base.html b/bskyweb/templates/base.html index cb2caed443..9a50fae69e 100644 --- a/bskyweb/templates/base.html +++ b/bskyweb/templates/base.html @@ -13,253 +13,75 @@ + + + - + {% include "scripts.html" %} - - - - + + + + @@ -271,7 +93,7 @@ {%- block body_all %}

-
+
diff --git a/eslint/index.js b/eslint/index.js index cf5d41225d..6f75f1bc34 100644 --- a/eslint/index.js +++ b/eslint/index.js @@ -5,5 +5,6 @@ module.exports = { 'avoid-unwrapped-text': require('./avoid-unwrapped-text'), 'use-exact-imports': require('./use-exact-imports'), 'use-typed-gates': require('./use-typed-gates'), + 'use-prefixed-imports': require('./use-prefixed-imports'), }, } diff --git a/eslint/use-exact-imports.js b/eslint/use-exact-imports.js index 06723043fe..26e688563e 100644 --- a/eslint/use-exact-imports.js +++ b/eslint/use-exact-imports.js @@ -1,4 +1,3 @@ -/* eslint-disable bsky-internal/use-exact-imports */ const BANNED_IMPORTS = [ '@fortawesome/free-regular-svg-icons', '@fortawesome/free-solid-svg-icons', @@ -6,11 +5,12 @@ const BANNED_IMPORTS = [ exports.create = function create(context) { return { - Literal(node) { - if (typeof node.value !== 'string') { + ImportDeclaration(node) { + const source = node.source + if (typeof source.value !== 'string') { return } - if (BANNED_IMPORTS.includes(node.value)) { + if (BANNED_IMPORTS.includes(source.value)) { context.report({ node, message: diff --git a/eslint/use-prefixed-imports.js b/eslint/use-prefixed-imports.js new file mode 100644 index 0000000000..141d536484 --- /dev/null +++ b/eslint/use-prefixed-imports.js @@ -0,0 +1,39 @@ +const BANNED_IMPORT_PREFIXES = [ + 'alf/', + 'components/', + 'lib/', + 'locale/', + 'logger/', + 'platform/', + 'state/', + 'storage/', + 'view/', +] + +module.exports = { + meta: { + type: 'suggestion', + fixable: 'code', + }, + create(context) { + return { + ImportDeclaration(node) { + const source = node.source + if (typeof source.value !== 'string') { + return + } + if ( + BANNED_IMPORT_PREFIXES.some(banned => source.value.startsWith(banned)) + ) { + context.report({ + node: source, + message: `Use '#/${source.value}'`, + fix(fixer) { + return fixer.replaceText(source, `'#/${source.value}'`) + }, + }) + } + }, + } + }, +} diff --git a/index.js b/index.js index 7630d0538a..2f13ce1ea1 100644 --- a/index.js +++ b/index.js @@ -1,14 +1,11 @@ import 'react-native-gesture-handler' // must be first -import {LogBox} from 'react-native' - import '#/platform/polyfills' -import {IS_TEST} from '#/env' + +import {LogBox} from 'react-native' import {registerRootComponent} from 'expo' -import {doPolyfill} from '#/lib/api/api-polyfill' import App from '#/App' - -doPolyfill() +import {IS_TEST} from '#/env' if (IS_TEST) { LogBox.ignoreAllLogs() // suppress all logs in tests diff --git a/index.web.js b/index.web.js index 9623734512..be75bc772e 100644 --- a/index.web.js +++ b/index.web.js @@ -1,9 +1,8 @@ import '#/platform/markBundleStartTime' - import '#/platform/polyfills' + import {registerRootComponent} from 'expo' -import {doPolyfill} from '#/lib/api/api-polyfill' + import App from '#/App' -doPolyfill() registerRootComponent(App) diff --git a/jest/jestSetup.js b/jest/jestSetup.js index a6b7c24f69..5564d81f15 100644 --- a/jest/jestSetup.js +++ b/jest/jestSetup.js @@ -42,23 +42,16 @@ jest.mock('rn-fetch-blob', () => ({ fetch: jest.fn(), })) -jest.mock('@bam.tech/react-native-image-resizer', () => ({ - createResizedImage: jest.fn(), +jest.mock('expo-file-system', () => ({ + getInfoAsync: jest.fn().mockResolvedValue({exists: true, size: 100}), + deleteAsync: jest.fn(), })) -jest.mock('@segment/analytics-react-native', () => ({ - createClient: () => ({ - add: jest.fn(), - }), - useAnalytics: () => ({ - track: jest.fn(), - identify: jest.fn(), - reset: jest.fn(), - group: jest.fn(), - screen: jest.fn(), - alias: jest.fn(), - flush: jest.fn(), +jest.mock('expo-image-manipulator', () => ({ + manipulateAsync: jest.fn().mockResolvedValue({ + uri: 'file://resized-image', }), + SaveFormat: jest.requireActual('expo-image-manipulator').SaveFormat, })) jest.mock('expo-camera', () => ({ @@ -95,3 +88,21 @@ jest.mock('expo-application', () => ({ nativeApplicationVersion: '1.0.0', nativeBuildVersion: '1', })) + +jest.mock('expo-modules-core', () => ({ + requireNativeModule: jest.fn().mockImplementation(moduleName => { + if (moduleName === 'ExpoPlatformInfo') { + return { + getIsReducedMotionEnabled: () => false, + } + } + if (moduleName === 'BottomSheet') { + return { + dismissAll: () => {}, + } + } + }), + requireNativeViewManager: jest.fn().mockImplementation(moduleName => { + return () => null + }), +})) diff --git a/jest/test-pds.ts b/jest/test-pds.ts index 2fe623ca98..962bb7b48e 100644 --- a/jest/test-pds.ts +++ b/jest/test-pds.ts @@ -79,32 +79,33 @@ export async function createServer( plc: {port: port2}, }) + // DISABLED - looks like dev-env added this and now it conflicts // add the test mod authority - const agent = new BskyAgent({service: pdsUrl}) - const res = await agent.api.com.atproto.server.createAccount({ - email: 'mod-authority@test.com', - handle: 'mod-authority.test', - password: 'hunter2', - }) - agent.api.setHeader('Authorization', `Bearer ${res.data.accessJwt}`) - await agent.api.app.bsky.actor.profile.create( - {repo: res.data.did}, - { - displayName: 'Dev-env Moderation', - description: `The pretend version of mod.bsky.app`, - }, - ) + // const agent = new BskyAgent({service: pdsUrl}) + // const res = await agent.api.com.atproto.server.createAccount({ + // email: 'mod-authority@test.com', + // handle: 'mod-authority.test', + // password: 'hunter2', + // }) + // agent.api.setHeader('Authorization', `Bearer ${res.data.accessJwt}`) + // await agent.api.app.bsky.actor.profile.create( + // {repo: res.data.did}, + // { + // displayName: 'Dev-env Moderation', + // description: `The pretend version of mod.bsky.app`, + // }, + // ) - await agent.api.app.bsky.labeler.service.create( - {repo: res.data.did, rkey: 'self'}, - { - policies: { - labelValues: ['!hide', '!warn'], - labelValueDefinitions: [], - }, - createdAt: new Date().toISOString(), - }, - ) + // await agent.api.app.bsky.labeler.service.create( + // {repo: res.data.did, rkey: 'self'}, + // { + // policies: { + // labelValues: ['!hide', '!warn'], + // labelValueDefinitions: [], + // }, + // createdAt: new Date().toISOString(), + // }, + // ) const pic = fs.readFileSync( path.join(__dirname, '..', 'assets', 'default-avatar.png'), @@ -156,7 +157,7 @@ class Mocker { } async createUser(name: string) { - const agent = new BskyAgent({service: this.agent.service}) + const agent = new BskyAgent({service: this.service}) const inviteRes = await agent.api.com.atproto.server.createInviteCode( {useCount: 1}, @@ -332,7 +333,7 @@ class Mocker { } async createInvite(forAccount: string) { - const agent = new BskyAgent({service: this.agent.service}) + const agent = new BskyAgent({service: this.service}) await agent.api.com.atproto.server.createInviteCode( {useCount: 1, forAccount}, { diff --git a/lingui.config.js b/lingui.config.js index 14a94b5ded..796969ab54 100644 --- a/lingui.config.js +++ b/lingui.config.js @@ -14,6 +14,7 @@ module.exports = { 'ja', 'ko', 'pt-BR', + 'ru', 'tr', 'uk', 'zh-CN', diff --git a/modules/BlueskyNSE/NotificationService.swift b/modules/BlueskyNSE/NotificationService.swift index 00e77c8834..481402890f 100644 --- a/modules/BlueskyNSE/NotificationService.swift +++ b/modules/BlueskyNSE/NotificationService.swift @@ -2,104 +2,80 @@ import UserNotifications import UIKit let APP_GROUP = "group.app.bsky" -let INCREMENTED_FOR_KEY = "incremented-for-convos" +typealias ContentHandler = (UNNotificationContent) -> Void -enum NotificationType: String { - case Like = "like" - case Repost = "repost" - case Follow = "follow" - case Reply = "reply" - case Quote = "quote" - case ChatMessage = "chat-message" - case MarkReadGeneric = "mark-read-generic" - case MarkReadMessages = "mark-read-messages" - case StarterPackJoined = "starterpack-joined" -} - -enum BadgeType: String { - case Generic = "badgeCountGeneric" - case Messages = "badgeCountMessages" -} - -enum BadgeOperation { - case Increment - case Decrement -} - -class Defaults { - static let shared = UserDefaults(suiteName: APP_GROUP) -} +// This extension allows us to do some processing of the received notification +// data before displaying the notification to the user. In our use case, there +// are a few particular things that we want to do: +// +// - Determine whether we should play a sound for the notification +// - Download and display any images for the notification +// - Update the badge count accordingly +// +// The extension may or may not create a new process to handle a notification. +// It is also possible that multiple notifications will be processed by the +// same instance of `NotificationService`, though these will happen in +// parallel. +// +// Because multiple instances of `NotificationService` may exist, we should +// be careful in accessing preferences that will be mutated _by the +// extension itself_. For example, we should not worry about `playChatSound` +// changing, since we never mutate that value within the extension itself. +// However, since we mutate `badgeCount` frequently, we should ensure that +// these updates always run sync with each other and that the have access +// to the most recent values. class NotificationService: UNNotificationServiceExtension { + private var contentHandler: ContentHandler? + private var bestAttempt: UNMutableNotificationContent? + override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { - guard let bestAttempt = createCopy(request.content), - let reasonString = request.content.userInfo["reason"] as? String, - let reason = NotificationType(rawValue: reasonString) + self.contentHandler = contentHandler + + guard let bestAttempt = NSEUtil.createCopy(request.content), + let reason = request.content.userInfo["reason"] as? String else { contentHandler(request.content) return } - switch reason { - case .Like, .Repost, .Follow, .Reply, .Quote, .StarterPackJoined: - mutateWithBadge(bestAttempt, badgeType: .Generic, operation: .Increment) - - case .ChatMessage: + self.bestAttempt = bestAttempt + if reason == "chat-message" { mutateWithChatMessage(bestAttempt) - mutateWithBadge(bestAttempt, badgeType: .Messages, operation: .Increment) - - case .MarkReadGeneric: - mutateWithBadge(bestAttempt, badgeType: .Generic, operation: .Decrement) - - case .MarkReadMessages: - mutateWithBadge(bestAttempt, badgeType: .Messages, operation: .Decrement) + } else { + mutateWithBadge(bestAttempt) } + // Any image downloading (or other network tasks) should be handled at the end + // of this block. Otherwise, if there is a timeout and serviceExtensionTimeWillExpire + // gets called, we might not have all the needed mutations completed in time. + contentHandler(bestAttempt) } override func serviceExtensionTimeWillExpire() { - // If for some reason the alloted time expires, we don't actually want to display a notification - } - - func createCopy(_ content: UNNotificationContent) -> UNMutableNotificationContent? { - return content.mutableCopy() as? UNMutableNotificationContent - } - - func getDecrementedBadgeCount(current: Int, decrementBy by: Int) -> Int { - let new = current - by - if new < 0 { - return 0 + guard let contentHandler = self.contentHandler, + let bestAttempt = self.bestAttempt else { + return } - return new + contentHandler(bestAttempt) } - func mutateWithBadge(_ content: UNMutableNotificationContent, badgeType type: BadgeType, operation: BadgeOperation) { - var genericCount = Defaults.shared?.integer(forKey: BadgeType.Generic.rawValue) ?? 0 - var messagesCount = Defaults.shared?.integer(forKey: BadgeType.Messages.rawValue) ?? 0 + // MARK: Mutations - if type == .Generic { - var genericCount = Defaults.shared?.integer(forKey: BadgeType.Generic.rawValue) ?? 0 - if operation == .Decrement { - genericCount = 0 - } else { - genericCount += 1 - } - Defaults.shared?.setValue(genericCount, forKey: BadgeType.Generic.rawValue) - } else if type == .Messages { - if operation == .Decrement, shouldDecrementForConvo(content) { - messagesCount = getDecrementedBadgeCount(current: messagesCount, decrementBy: 1) - } else if shouldIncrementForConvo(content) { - messagesCount += 1 - } - Defaults.shared?.setValue(messagesCount, forKey: BadgeType.Generic.rawValue) + func mutateWithBadge(_ content: UNMutableNotificationContent) { + NSEUtil.shared.prefsQueue.sync { + var count = NSEUtil.shared.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) + NSEUtil.shared.prefs?.setValue(count, forKey: "badgeCount") } - - content.badge = NSNumber(value: genericCount + messagesCount) } func mutateWithChatMessage(_ content: UNMutableNotificationContent) { - if Defaults.shared?.bool(forKey: "playSoundChat") == true { + if NSEUtil.shared.prefs?.bool(forKey: "playSoundChat") == true { mutateWithDmSound(content) } } @@ -111,36 +87,19 @@ class NotificationService: UNNotificationServiceExtension { func mutateWithDmSound(_ content: UNMutableNotificationContent) { content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: "dm.aiff")) } +} - func shouldIncrementForConvo(_ content: UNMutableNotificationContent) -> Bool { - guard let convoId = content.userInfo["convoId"] as? String, - var dict = Defaults.shared?.dictionary(forKey: INCREMENTED_FOR_KEY) as? [String: Bool] - else { - return false - } +// NSEUtil's purpose is to create a shared instance of `UserDefaults` across +// `NotificationService` instances. It also includes a queue so that we can process +// updates to `UserDefaults` in parallel. - if dict["convoId"] == true { - return false - } +private class NSEUtil { + static let shared = NSEUtil() - dict[convoId] = true - Defaults.shared?.set(dict, forKey: INCREMENTED_FOR_KEY) - return true - } + var prefs = UserDefaults(suiteName: APP_GROUP) + var prefsQueue = DispatchQueue(label: "NSEPrefsQueue") - func shouldDecrementForConvo(_ content: UNMutableNotificationContent) -> Bool { - guard let convoId = content.userInfo["convoId"] as? String, - var dict = Defaults.shared?.dictionary(forKey: INCREMENTED_FOR_KEY) as? [String: Bool] - else { - return false - } - - if dict["convoId"] != true { - return false - } - - dict.removeValue(forKey: convoId) - Defaults.shared?.set(dict, forKey: INCREMENTED_FOR_KEY) - return true + static func createCopy(_ content: UNNotificationContent) -> UNMutableNotificationContent? { + return content.mutableCopy() as? UNMutableNotificationContent } } diff --git a/modules/Share-with-Bluesky/Info.plist b/modules/Share-with-Bluesky/Info.plist index 421abb3c41..d73a9093fc 100644 --- a/modules/Share-with-Bluesky/Info.plist +++ b/modules/Share-with-Bluesky/Info.plist @@ -16,6 +16,8 @@ 1 NSExtensionActivationSupportsImageWithMaxCount 10 + NSExtensionActivationSupportsMovieWithMaxCount + 1 NSExtensionPointIdentifier diff --git a/modules/Share-with-Bluesky/ShareViewController.swift b/modules/Share-with-Bluesky/ShareViewController.swift index c045d578fe..2acbb6187b 100644 --- a/modules/Share-with-Bluesky/ShareViewController.swift +++ b/modules/Share-with-Bluesky/ShareViewController.swift @@ -1,11 +1,19 @@ import UIKit +let IMAGE_EXTENSIONS: [String] = ["png", "jpg", "jpeg", "gif", "heic"] +let MOVIE_EXTENSIONS: [String] = ["mov", "mp4", "m4v"] + +enum URLType: String, CaseIterable { + case image + case movie + case other +} + class ShareViewController: UIViewController { // This allows other forks to use this extension while also changing their // scheme. let appScheme = Bundle.main.object(forInfoDictionaryKey: "MainAppScheme") as? String ?? "bluesky" - // override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) @@ -24,6 +32,8 @@ class ShareViewController: UIViewController { await self.handleUrl(item: firstAttachment) } else if firstAttachment.hasItemConformingToTypeIdentifier("public.image") { await self.handleImages(items: attachments) + } else if firstAttachment.hasItemConformingToTypeIdentifier("public.movie") { + await self.handleVideos(items: attachments) } else { self.completeRequest() } @@ -31,31 +41,32 @@ class ShareViewController: UIViewController { } private func handleText(item: NSItemProvider) async { - do { - if let data = try await item.loadItem(forTypeIdentifier: "public.text") as? String { - if let encoded = data.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed), - let url = URL(string: "\(self.appScheme)://intent/compose?text=\(encoded)") { - _ = self.openURL(url) - } + if let data = try? await item.loadItem(forTypeIdentifier: "public.text") as? String { + if let encoded = data.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed), + let url = URL(string: "\(self.appScheme)://intent/compose?text=\(encoded)") { + _ = self.openURL(url) } - self.completeRequest() - } catch { - self.completeRequest() } + self.completeRequest() } private func handleUrl(item: NSItemProvider) async { - do { - if let data = try await item.loadItem(forTypeIdentifier: "public.url") as? URL { + if let data = try? await item.loadItem(forTypeIdentifier: "public.url") as? URL { + switch data.type { + case .image: + await handleImages(items: [item]) + return + case .movie: + await handleVideos(items: [item]) + return + case .other: if let encoded = data.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed), let url = URL(string: "\(self.appScheme)://intent/compose?text=\(encoded)") { _ = self.openURL(url) } } - self.completeRequest() - } catch { - self.completeRequest() } + self.completeRequest() } private func handleImages(items: [NSItemProvider]) async { @@ -105,6 +116,25 @@ class ShareViewController: UIViewController { self.completeRequest() } + private func handleVideos(items: [NSItemProvider]) async { + let firstItem = items.first + + if let dataUri = try? await firstItem?.loadItem(forTypeIdentifier: "public.movie") as? URL { + let ext = String(dataUri.lastPathComponent.split(separator: ".").last ?? "mp4") + if let tempUrl = getTempUrl(ext: ext) { + let data = try? Data(contentsOf: dataUri) + try? data?.write(to: tempUrl) + + if let encoded = tempUrl.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed), + let url = URL(string: "\(self.appScheme)://intent/compose?videoUri=\(encoded)") { + _ = self.openURL(url) + } + } + } + + self.completeRequest() + } + private func saveImageWithInfo(_ image: UIImage?) -> String? { guard let image = image else { return nil @@ -114,35 +144,53 @@ class ShareViewController: UIViewController { // Saving this file to the bundle group's directory lets us access it from // inside of the app. Otherwise, we wouldn't have access even though the // extension does. - if let dir = FileManager() - .containerURL( - forSecurityApplicationGroupIdentifier: "group.app.bsky") { - let filePath = "\(dir.absoluteString)\(ProcessInfo.processInfo.globallyUniqueString).jpeg" - - if let newUri = URL(string: filePath), - let jpegData = image.jpegData(compressionQuality: 1) { - try jpegData.write(to: newUri) - return "\(newUri.absoluteString)|\(image.size.width)|\(image.size.height)" - } + if let tempUrl = getTempUrl(ext: "jpeg"), + let jpegData = image.jpegData(compressionQuality: 1) { + try jpegData.write(to: tempUrl) + return "\(tempUrl.absoluteString)|\(image.size.width)|\(image.size.height)" } - return nil - } catch { - return nil - } + } catch {} + return nil } private func completeRequest() { self.extensionContext?.completeRequest(returningItems: nil) } + private func getTempUrl(ext: String) -> URL? { + if let dir = FileManager().containerURL(forSecurityApplicationGroupIdentifier: "group.app.bsky") { + return URL(string: "\(dir.absoluteString)\(ProcessInfo.processInfo.globallyUniqueString).\(ext)")! + } + return nil + } + @objc func openURL(_ url: URL) -> Bool { var responder: UIResponder? = self while responder != nil { if let application = responder as? UIApplication { - return application.perform(#selector(openURL(_:)), with: url) != nil + application.open(url) + return true } responder = responder?.next } return false } } + +extension URL { + var type: URLType { + get { + guard self.absoluteString.starts(with: "file://"), + let ext = self.pathComponents.last?.split(separator: ".").last?.lowercased() else { + return .other + } + + if IMAGE_EXTENSIONS.contains(ext) { + return .image + } else if MOVIE_EXTENSIONS.contains(ext) { + return .movie + } + return .other + } + } +} diff --git a/modules/bottom-sheet/android/build.gradle b/modules/bottom-sheet/android/build.gradle new file mode 100644 index 0000000000..a1d423044b --- /dev/null +++ b/modules/bottom-sheet/android/build.gradle @@ -0,0 +1,49 @@ +apply plugin: 'com.android.library' + +group = 'expo.modules.bottomsheet' +version = '0.1.0' + +def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") +apply from: expoModulesCorePlugin +applyKotlinExpoModulesCorePlugin() +useCoreDependencies() +useExpoPublishing() + +// If you want to use the managed Android SDK versions from expo-modules-core, set this to true. +// The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code. +// Most of the time, you may like to manage the Android SDK versions yourself. +def useManagedAndroidSdkVersions = false +if (useManagedAndroidSdkVersions) { + useDefaultAndroidSdkVersions() +} else { + buildscript { + // Simple helper that allows the root project to override versions declared by this library. + ext.safeExtGet = { prop, fallback -> + rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback + } + } + project.android { + compileSdkVersion safeExtGet("compileSdkVersion", 34) + defaultConfig { + minSdkVersion safeExtGet("minSdkVersion", 21) + targetSdkVersion safeExtGet("targetSdkVersion", 34) + } + } +} + +android { + namespace "expo.modules.bottomsheet" + defaultConfig { + versionCode 1 + versionName "0.1.0" + } + lintOptions { + abortOnError false + } +} + +dependencies { + implementation project(':expo-modules-core') + implementation 'com.google.android.material:material:1.12.0' + implementation "com.facebook.react:react-native:+" +} diff --git a/modules/bottom-sheet/android/src/main/AndroidManifest.xml b/modules/bottom-sheet/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..bdae66c8f5 --- /dev/null +++ b/modules/bottom-sheet/android/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetModule.kt b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetModule.kt new file mode 100644 index 0000000000..057e6ed2e2 --- /dev/null +++ b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetModule.kt @@ -0,0 +1,53 @@ +package expo.modules.bottomsheet + +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +class BottomSheetModule : Module() { + override fun definition() = + ModuleDefinition { + Name("BottomSheet") + + AsyncFunction("dismissAll") { + SheetManager.dismissAll() + } + + View(BottomSheetView::class) { + Events( + arrayOf( + "onAttemptDismiss", + "onSnapPointChange", + "onStateChange", + ), + ) + + AsyncFunction("dismiss") { view: BottomSheetView -> + view.dismiss() + } + + AsyncFunction("updateLayout") { view: BottomSheetView -> + view.updateLayout() + } + + Prop("disableDrag") { view: BottomSheetView, prop: Boolean -> + view.disableDrag = prop + } + + Prop("minHeight") { view: BottomSheetView, prop: Float -> + view.minHeight = prop + } + + Prop("maxHeight") { view: BottomSheetView, prop: Float -> + view.maxHeight = prop + } + + Prop("preventDismiss") { view: BottomSheetView, prop: Boolean -> + view.preventDismiss = prop + } + + Prop("preventExpansion") { view: BottomSheetView, prop: Boolean -> + view.preventExpansion = prop + } + } + } +} diff --git a/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt new file mode 100644 index 0000000000..a5a84ec3d9 --- /dev/null +++ b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt @@ -0,0 +1,339 @@ +package expo.modules.bottomsheet + +import android.content.Context +import android.view.View +import android.view.ViewGroup +import android.view.ViewStructure +import android.view.accessibility.AccessibilityEvent +import android.widget.FrameLayout +import androidx.core.view.allViews +import com.facebook.react.bridge.LifecycleEventListener +import com.facebook.react.bridge.ReactContext +import com.facebook.react.bridge.UiThreadUtil +import com.facebook.react.uimanager.UIManagerHelper +import com.facebook.react.uimanager.events.EventDispatcher +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import expo.modules.kotlin.AppContext +import expo.modules.kotlin.viewevent.EventDispatcher +import expo.modules.kotlin.views.ExpoView +import java.util.ArrayList + +class BottomSheetView( + context: Context, + appContext: AppContext, +) : ExpoView(context, appContext), + LifecycleEventListener { + private var innerView: View? = null + private var dialog: BottomSheetDialog? = null + + private lateinit var dialogRootViewGroup: DialogRootViewGroup + private var eventDispatcher: EventDispatcher? = null + + private val screenHeight = + context.resources.displayMetrics.heightPixels + .toFloat() + + private val onAttemptDismiss by EventDispatcher() + private val onSnapPointChange by EventDispatcher() + private val onStateChange by EventDispatcher() + + // Props + var disableDrag = false + set (value) { + field = value + this.setDraggable(!value) + } + + var preventDismiss = false + set(value) { + field = value + this.dialog?.setCancelable(!value) + } + var preventExpansion = false + + var minHeight = 0f + set(value) { + field = + if (value < 0) { + 0f + } else { + value + } + } + + var maxHeight = this.screenHeight + set(value) { + field = + if (value > this.screenHeight) { + this.screenHeight.toFloat() + } else { + value + } + } + + private var isOpen: Boolean = false + set(value) { + field = value + onStateChange( + mapOf( + "state" to if (value) "open" else "closed", + ), + ) + } + + private var isOpening: Boolean = false + set(value) { + field = value + if (value) { + onStateChange( + mapOf( + "state" to "opening", + ), + ) + } + } + + private var isClosing: Boolean = false + set(value) { + field = value + if (value) { + onStateChange( + mapOf( + "state" to "closing", + ), + ) + } + } + + private var selectedSnapPoint = 0 + set(value) { + if (field == value) return + + field = value + onSnapPointChange( + mapOf( + "snapPoint" to value, + ), + ) + } + + // Lifecycle + + init { + (appContext.reactContext as? ReactContext)?.let { + it.addLifecycleEventListener(this) + this.eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(it, this.id) + + this.dialogRootViewGroup = DialogRootViewGroup(context) + this.dialogRootViewGroup.eventDispatcher = this.eventDispatcher + } + SheetManager.add(this) + } + + override fun onLayout( + changed: Boolean, + l: Int, + t: Int, + r: Int, + b: Int, + ) { + this.present() + } + + private fun destroy() { + this.isClosing = false + this.isOpen = false + this.dialog = null + this.innerView = null + SheetManager.remove(this) + } + + // Presentation + + private fun present() { + if (this.isOpen || this.isOpening || this.isClosing) return + + val contentHeight = this.getContentHeight() + + val dialog = BottomSheetDialog(context) + dialog.setContentView(dialogRootViewGroup) + dialog.setCancelable(!preventDismiss) + dialog.setOnShowListener { + val bottomSheet = dialog.findViewById(com.google.android.material.R.id.design_bottom_sheet) + bottomSheet?.let { + // Let the outside view handle the background color on its own, the default for this is + // white and we don't want that. + it.setBackgroundColor(0) + + val behavior = BottomSheetBehavior.from(it) + + behavior.isFitToContents = true + behavior.halfExpandedRatio = this.clampRatio(this.getTargetHeight() / this.screenHeight) + if (contentHeight > this.screenHeight) { + behavior.state = BottomSheetBehavior.STATE_EXPANDED + this.selectedSnapPoint = 2 + } else { + behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED + this.selectedSnapPoint = 1 + } + behavior.skipCollapsed = true + behavior.isDraggable = true + behavior.isHideable = true + + behavior.addBottomSheetCallback( + object : BottomSheetBehavior.BottomSheetCallback() { + override fun onStateChanged( + bottomSheet: View, + newState: Int, + ) { + when (newState) { + BottomSheetBehavior.STATE_EXPANDED -> { + selectedSnapPoint = 2 + } + BottomSheetBehavior.STATE_COLLAPSED -> { + selectedSnapPoint = 1 + } + BottomSheetBehavior.STATE_HALF_EXPANDED -> { + selectedSnapPoint = 1 + } + BottomSheetBehavior.STATE_HIDDEN -> { + selectedSnapPoint = 0 + } + } + } + + override fun onSlide( + bottomSheet: View, + slideOffset: Float, + ) { } + }, + ) + } + } + dialog.setOnDismissListener { + this.isClosing = true + this.destroy() + } + + this.isOpening = true + dialog.show() + this.dialog = dialog + } + + fun updateLayout() { + val dialog = this.dialog ?: return + val contentHeight = this.getContentHeight() + + val bottomSheet = dialog.findViewById(com.google.android.material.R.id.design_bottom_sheet) + bottomSheet?.let { + val behavior = BottomSheetBehavior.from(it) + + behavior.halfExpandedRatio = this.clampRatio(this.getTargetHeight() / this.screenHeight) + + if (contentHeight > this.screenHeight && behavior.state != BottomSheetBehavior.STATE_EXPANDED) { + behavior.state = BottomSheetBehavior.STATE_EXPANDED + } else if (contentHeight < this.screenHeight && behavior.state != BottomSheetBehavior.STATE_HALF_EXPANDED) { + behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED + } + } + } + + fun dismiss() { + this.dialog?.dismiss() + } + + // Util + + private fun getContentHeight(): Float { + val innerView = this.innerView ?: return 0f + var index = 0 + innerView.allViews.forEach { + if (index == 1) { + return it.height.toFloat() + } + index++ + } + return 0f + } + + private fun getTargetHeight(): Float { + val contentHeight = this.getContentHeight() + val height = + if (contentHeight > maxHeight) { + maxHeight + } else if (contentHeight < minHeight) { + minHeight + } else { + contentHeight + } + return height + } + + private fun clampRatio(ratio: Float): Float { + if (ratio < 0.01) { + return 0.01f + } else if (ratio > 0.99) { + return 0.99f + } + return ratio + } + + private fun setDraggable(draggable: Boolean) { + val dialog = this.dialog ?: return + val bottomSheet = dialog.findViewById(com.google.android.material.R.id.design_bottom_sheet) + bottomSheet?.let { + val behavior = BottomSheetBehavior.from(it) + behavior.isDraggable = draggable + } + } + + override fun onHostResume() { } + + override fun onHostPause() { } + + override fun onHostDestroy() { + (appContext.reactContext as? ReactContext)?.let { + it.removeLifecycleEventListener(this) + this.destroy() + } + } + + // View overrides to pass to DialogRootViewGroup instead + + override fun dispatchProvideStructure(structure: ViewStructure?) { + dialogRootViewGroup.dispatchProvideStructure(structure) + } + + override fun setId(id: Int) { + super.setId(id) + dialogRootViewGroup.id = id + } + + override fun addView( + child: View?, + index: Int, + ) { + this.innerView = child + (child as ViewGroup).let { + dialogRootViewGroup.addView(child, index) + } + } + + override fun removeView(view: View?) { + UiThreadUtil.assertOnUiThread() + if (view != null) { + dialogRootViewGroup.removeView(view) + } + } + + override fun removeViewAt(index: Int) { + UiThreadUtil.assertOnUiThread() + val child = getChildAt(index) + dialogRootViewGroup.removeView(child) + } + + override fun addChildrenForAccessibility(outChildren: ArrayList?) { } + + override fun dispatchPopulateAccessibilityEvent(event: AccessibilityEvent?): Boolean = false +} diff --git a/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/DialogRootViewGroup.kt b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/DialogRootViewGroup.kt new file mode 100644 index 0000000000..c022924e99 --- /dev/null +++ b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/DialogRootViewGroup.kt @@ -0,0 +1,171 @@ +package expo.modules.bottomsheet + +import android.annotation.SuppressLint +import android.content.Context +import android.view.MotionEvent +import android.view.View +import com.facebook.react.bridge.GuardedRunnable +import com.facebook.react.config.ReactFeatureFlags +import com.facebook.react.uimanager.JSPointerDispatcher +import com.facebook.react.uimanager.JSTouchDispatcher +import com.facebook.react.uimanager.RootView +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.UIManagerModule +import com.facebook.react.uimanager.events.EventDispatcher +import com.facebook.react.views.view.ReactViewGroup + +// SEE https://github.com/facebook/react-native/blob/309cdea337101cfe2212cfb6abebf1e783e43282/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostView.kt#L378 + +/** + * DialogRootViewGroup is the ViewGroup which contains all the children of a Modal. It gets all + * child information forwarded from [ReactModalHostView] and uses that to create children. It is + * also responsible for acting as a RootView and handling touch events. It does this the same way + * as ReactRootView. + * + * To get layout to work properly, we need to layout all the elements within the Modal as if they + * can fill the entire window. To do that, we need to explicitly set the styleWidth and + * styleHeight on the LayoutShadowNode to be the window size. This is done through the + * UIManagerModule, and will then cause the children to layout as if they can fill the window. + */ +class DialogRootViewGroup( + private val context: Context?, +) : ReactViewGroup(context), + RootView { + private var hasAdjustedSize = false + private var viewWidth = 0 + private var viewHeight = 0 + + private val jSTouchDispatcher = JSTouchDispatcher(this) + private var jSPointerDispatcher: JSPointerDispatcher? = null + private var sizeChangeListener: OnSizeChangeListener? = null + + var eventDispatcher: EventDispatcher? = null + + interface OnSizeChangeListener { + fun onSizeChange( + width: Int, + height: Int, + ) + } + + init { + if (ReactFeatureFlags.dispatchPointerEvents) { + jSPointerDispatcher = JSPointerDispatcher(this) + } + } + + override fun onSizeChanged( + w: Int, + h: Int, + oldw: Int, + oldh: Int, + ) { + super.onSizeChanged(w, h, oldw, oldh) + + viewWidth = w + viewHeight = h + updateFirstChildView() + + sizeChangeListener?.onSizeChange(w, h) + } + + fun setOnSizeChangeListener(listener: OnSizeChangeListener) { + sizeChangeListener = listener + } + + private fun updateFirstChildView() { + if (childCount > 0) { + hasAdjustedSize = false + val viewTag = getChildAt(0).id + reactContext.runOnNativeModulesQueueThread( + object : GuardedRunnable(reactContext) { + override fun runGuarded() { + val uiManager: UIManagerModule = + reactContext + .reactApplicationContext + .getNativeModule(UIManagerModule::class.java) ?: return + + uiManager.updateNodeSize(viewTag, viewWidth, viewHeight) + } + }, + ) + } else { + hasAdjustedSize = true + } + } + + override fun addView( + child: View, + index: Int, + params: LayoutParams, + ) { + super.addView(child, index, params) + if (hasAdjustedSize) { + updateFirstChildView() + } + } + + override fun handleException(t: Throwable) { + reactContext.reactApplicationContext.handleException(RuntimeException(t)) + } + + private val reactContext: ThemedReactContext + get() = context as ThemedReactContext + + override fun onInterceptTouchEvent(event: MotionEvent): Boolean { + eventDispatcher?.let { jSTouchDispatcher.handleTouchEvent(event, it) } + jSPointerDispatcher?.handleMotionEvent(event, eventDispatcher, true) + return super.onInterceptTouchEvent(event) + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + eventDispatcher?.let { jSTouchDispatcher.handleTouchEvent(event, it) } + jSPointerDispatcher?.handleMotionEvent(event, eventDispatcher, false) + super.onTouchEvent(event) + + // In case when there is no children interested in handling touch event, we return true from + // the root view in order to receive subsequent events related to that gesture + return true + } + + override fun onInterceptHoverEvent(event: MotionEvent): Boolean { + jSPointerDispatcher?.handleMotionEvent(event, eventDispatcher, true) + return super.onHoverEvent(event) + } + + override fun onHoverEvent(event: MotionEvent): Boolean { + jSPointerDispatcher?.handleMotionEvent(event, eventDispatcher, false) + return super.onHoverEvent(event) + } + + @Deprecated("Deprecated in Java") + override fun onChildStartedNativeGesture(ev: MotionEvent?) { + eventDispatcher?.let { + if (ev != null) { + jSTouchDispatcher.onChildStartedNativeGesture(ev, it) + } + } + } + + override fun onChildStartedNativeGesture( + childView: View, + ev: MotionEvent, + ) { + eventDispatcher?.let { jSTouchDispatcher.onChildStartedNativeGesture(ev, it) } + jSPointerDispatcher?.onChildStartedNativeGesture(childView, ev, eventDispatcher) + } + + override fun onChildEndedNativeGesture( + childView: View, + ev: MotionEvent, + ) { + eventDispatcher?.let { jSTouchDispatcher.onChildEndedNativeGesture(ev, it) } + jSPointerDispatcher?.onChildEndedNativeGesture() + } + + override fun requestDisallowInterceptTouchEvent(disallowIntercept: Boolean) { + // No-op - override in order to still receive events to onInterceptTouchEvent + // even when some other view disallow that + } +} diff --git a/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/SheetManager.kt b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/SheetManager.kt new file mode 100644 index 0000000000..be78849988 --- /dev/null +++ b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/SheetManager.kt @@ -0,0 +1,28 @@ +package expo.modules.bottomsheet + +import java.lang.ref.WeakReference + +class SheetManager { + companion object { + private val sheets = mutableSetOf>() + + fun add(view: BottomSheetView) { + sheets.add(WeakReference(view)) + } + + fun remove(view: BottomSheetView) { + sheets.forEach { + if (it.get() == view) { + sheets.remove(it) + return + } + } + } + + fun dismissAll() { + sheets.forEach { + it.get()?.dismiss() + } + } + } +} diff --git a/modules/bottom-sheet/expo-module.config.json b/modules/bottom-sheet/expo-module.config.json new file mode 100644 index 0000000000..81b5b078ed --- /dev/null +++ b/modules/bottom-sheet/expo-module.config.json @@ -0,0 +1,9 @@ +{ + "platforms": ["ios", "android"], + "ios": { + "modules": ["BottomSheetModule"] + }, + "android": { + "modules": ["expo.modules.bottomsheet.BottomSheetModule"] + } +} diff --git a/modules/bottom-sheet/index.ts b/modules/bottom-sheet/index.ts new file mode 100644 index 0000000000..1fe3dac0eb --- /dev/null +++ b/modules/bottom-sheet/index.ts @@ -0,0 +1,13 @@ +import {BottomSheet} from './src/BottomSheet' +import { + BottomSheetSnapPoint, + BottomSheetState, + BottomSheetViewProps, +} from './src/BottomSheet.types' + +export { + BottomSheet, + BottomSheetSnapPoint, + type BottomSheetState, + type BottomSheetViewProps, +} diff --git a/modules/bottom-sheet/ios/BottomSheet.podspec b/modules/bottom-sheet/ios/BottomSheet.podspec new file mode 100644 index 0000000000..a42356f614 --- /dev/null +++ b/modules/bottom-sheet/ios/BottomSheet.podspec @@ -0,0 +1,21 @@ +Pod::Spec.new do |s| + s.name = 'BottomSheet' + s.version = '1.0.0' + s.summary = 'A bottom sheet for use in Bluesky' + s.description = 'A bottom sheet for use in Bluesky' + s.author = '' + s.homepage = 'https://github.com/bluesky-social/social-app' + s.platforms = { :ios => '15.0', :tvos => '15.0' } + 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,swift}" +end diff --git a/modules/bottom-sheet/ios/BottomSheetModule.swift b/modules/bottom-sheet/ios/BottomSheetModule.swift new file mode 100644 index 0000000000..579608e75b --- /dev/null +++ b/modules/bottom-sheet/ios/BottomSheetModule.swift @@ -0,0 +1,47 @@ +import ExpoModulesCore + +public class BottomSheetModule: Module { + public func definition() -> ModuleDefinition { + Name("BottomSheet") + + AsyncFunction("dismissAll") { + SheetManager.shared.dismissAll() + } + + View(SheetView.self) { + Events([ + "onAttemptDismiss", + "onSnapPointChange", + "onStateChange" + ]) + + AsyncFunction("dismiss") { (view: SheetView) in + view.dismiss() + } + + AsyncFunction("updateLayout") { (view: SheetView) in + view.updateLayout() + } + + Prop("cornerRadius") { (view: SheetView, prop: Float) in + view.cornerRadius = CGFloat(prop) + } + + Prop("minHeight") { (view: SheetView, prop: Double) in + view.minHeight = prop + } + + Prop("maxHeight") { (view: SheetView, prop: Double) in + view.maxHeight = prop + } + + Prop("preventDismiss") { (view: SheetView, prop: Bool) in + view.preventDismiss = prop + } + + Prop("preventExpansion") { (view: SheetView, prop: Bool) in + view.preventExpansion = prop + } + } + } +} diff --git a/modules/bottom-sheet/ios/SheetManager.swift b/modules/bottom-sheet/ios/SheetManager.swift new file mode 100644 index 0000000000..e4e843bea5 --- /dev/null +++ b/modules/bottom-sheet/ios/SheetManager.swift @@ -0,0 +1,28 @@ +// +// SheetManager.swift +// Pods +// +// Created by Hailey on 10/1/24. +// + +import ExpoModulesCore + +class SheetManager { + static let shared = SheetManager() + + private var sheetViews = NSHashTable(options: .weakMemory) + + func add(_ view: SheetView) { + sheetViews.add(view) + } + + func remove(_ view: SheetView) { + sheetViews.remove(view) + } + + func dismissAll() { + sheetViews.allObjects.forEach { sheetView in + sheetView.dismiss() + } + } +} diff --git a/modules/bottom-sheet/ios/SheetView.swift b/modules/bottom-sheet/ios/SheetView.swift new file mode 100644 index 0000000000..cf2019c6a1 --- /dev/null +++ b/modules/bottom-sheet/ios/SheetView.swift @@ -0,0 +1,189 @@ +import ExpoModulesCore +import UIKit + +class SheetView: ExpoView, UISheetPresentationControllerDelegate { + // Views + private var sheetVc: SheetViewController? + private var innerView: UIView? + private var touchHandler: RCTTouchHandler? + + // Events + private let onAttemptDismiss = EventDispatcher() + private let onSnapPointChange = EventDispatcher() + private let onStateChange = EventDispatcher() + + // Open event firing + private var isOpen: Bool = false { + didSet { + onStateChange([ + "state": isOpen ? "open" : "closed" + ]) + } + } + + // React view props + var preventDismiss = false + var preventExpansion = false + var cornerRadius: CGFloat? + var minHeight = 0.0 + var maxHeight: CGFloat! { + didSet { + let screenHeight = Util.getScreenHeight() ?? 0 + if maxHeight > screenHeight { + maxHeight = screenHeight + } + } + } + + private var isOpening = false { + didSet { + if isOpening { + onStateChange([ + "state": "opening" + ]) + } + } + } + private var isClosing = false { + didSet { + if isClosing { + onStateChange([ + "state": "closing" + ]) + } + } + } + private var selectedDetentIdentifier: UISheetPresentationController.Detent.Identifier? { + didSet { + if selectedDetentIdentifier == .large { + onSnapPointChange([ + "snapPoint": 2 + ]) + } else { + onSnapPointChange([ + "snapPoint": 1 + ]) + } + } + } + + // MARK: - Lifecycle + + required init (appContext: AppContext? = nil) { + super.init(appContext: appContext) + self.maxHeight = Util.getScreenHeight() + self.touchHandler = RCTTouchHandler(bridge: appContext?.reactBridge) + SheetManager.shared.add(self) + } + + deinit { + self.destroy() + } + + // We don't want this view to actually get added to the tree, so we'll simply store it for adding + // to the SheetViewController + override func insertReactSubview(_ subview: UIView!, at atIndex: Int) { + self.touchHandler?.attach(to: subview) + self.innerView = subview + } + + // We'll grab the content height from here so we know the initial detent to set + override func layoutSubviews() { + super.layoutSubviews() + + guard let innerView = self.innerView else { + return + } + + if innerView.subviews.count != 1 { + return + } + + self.present() + } + + private func destroy() { + self.isClosing = false + self.isOpen = false + self.sheetVc = nil + self.touchHandler?.detach(from: self.innerView) + self.touchHandler = nil + self.innerView = nil + SheetManager.shared.remove(self) + } + + // MARK: - Presentation + + func present() { + guard !self.isOpen, + !self.isOpening, + !self.isClosing, + let innerView = self.innerView, + let contentHeight = innerView.subviews.first?.frame.height, + let rvc = self.reactViewController() else { + return + } + + let sheetVc = SheetViewController() + sheetVc.setDetents(contentHeight: self.clampHeight(contentHeight), preventExpansion: self.preventExpansion) + if let sheet = sheetVc.sheetPresentationController { + sheet.delegate = self + sheet.preferredCornerRadius = self.cornerRadius + self.selectedDetentIdentifier = sheet.selectedDetentIdentifier + } + sheetVc.view.addSubview(innerView) + + self.sheetVc = sheetVc + self.isOpening = true + + rvc.present(sheetVc, animated: true) { [weak self] in + self?.isOpening = false + self?.isOpen = true + } + } + + func updateLayout() { + if let contentHeight = self.innerView?.subviews.first?.frame.size.height { + self.sheetVc?.updateDetents(contentHeight: self.clampHeight(contentHeight), + preventExpansion: self.preventExpansion) + self.selectedDetentIdentifier = self.sheetVc?.getCurrentDetentIdentifier() + } + } + + func dismiss() { + self.isClosing = true + self.sheetVc?.dismiss(animated: true) { [weak self] in + self?.destroy() + } + } + + // MARK: - Utils + + private func clampHeight(_ height: CGFloat) -> CGFloat { + if height < self.minHeight { + return self.minHeight + } else if height > self.maxHeight { + return self.maxHeight + } + return height + } + + // MARK: - UISheetPresentationControllerDelegate + + func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { + self.onAttemptDismiss() + return !self.preventDismiss + } + + func presentationControllerWillDismiss(_ presentationController: UIPresentationController) { + self.isClosing = true + } + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + self.destroy() + } + + func sheetPresentationControllerDidChangeSelectedDetentIdentifier(_ sheetPresentationController: UISheetPresentationController) { + self.selectedDetentIdentifier = sheetPresentationController.selectedDetentIdentifier + } +} diff --git a/modules/bottom-sheet/ios/SheetViewController.swift b/modules/bottom-sheet/ios/SheetViewController.swift new file mode 100644 index 0000000000..56473b21cd --- /dev/null +++ b/modules/bottom-sheet/ios/SheetViewController.swift @@ -0,0 +1,76 @@ +// +// SheetViewController.swift +// Pods +// +// Created by Hailey on 9/30/24. +// + +import Foundation +import UIKit + +class SheetViewController: UIViewController { + init() { + super.init(nibName: nil, bundle: nil) + + self.modalPresentationStyle = .formSheet + self.isModalInPresentation = false + + if let sheet = self.sheetPresentationController { + sheet.prefersGrabberVisible = false + } + } + + func setDetents(contentHeight: CGFloat, preventExpansion: Bool) { + guard let sheet = self.sheetPresentationController, + let screenHeight = Util.getScreenHeight() + else { + return + } + + if contentHeight > screenHeight - 100 { + sheet.detents = [ + .large() + ] + sheet.selectedDetentIdentifier = .large + } else { + if #available(iOS 16.0, *) { + sheet.detents = [ + .custom { _ in + return contentHeight + } + ] + } else { + sheet.detents = [ + .medium() + ] + } + + if !preventExpansion { + sheet.detents.append(.large()) + } + sheet.selectedDetentIdentifier = .medium + } + } + + func updateDetents(contentHeight: CGFloat, preventExpansion: Bool) { + if let sheet = self.sheetPresentationController { + sheet.animateChanges { + self.setDetents(contentHeight: contentHeight, preventExpansion: preventExpansion) + if #available(iOS 16.0, *) { + sheet.invalidateDetents() + } + } + } + } + + func getCurrentDetentIdentifier() -> UISheetPresentationController.Detent.Identifier? { + guard let sheet = self.sheetPresentationController else { + return nil + } + return sheet.selectedDetentIdentifier + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/modules/bottom-sheet/ios/Util.swift b/modules/bottom-sheet/ios/Util.swift new file mode 100644 index 0000000000..c654596a74 --- /dev/null +++ b/modules/bottom-sheet/ios/Util.swift @@ -0,0 +1,18 @@ +// +// Util.swift +// Pods +// +// Created by Hailey on 10/2/24. +// + +class Util { + static func getScreenHeight() -> CGFloat? { + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first { + let safeAreaInsets = window.safeAreaInsets + let fullScreenHeight = UIScreen.main.bounds.height + return fullScreenHeight - (safeAreaInsets.top + safeAreaInsets.bottom) + } + return nil + } +} diff --git a/modules/bottom-sheet/src/BottomSheet.tsx b/modules/bottom-sheet/src/BottomSheet.tsx new file mode 100644 index 0000000000..9e7d0c2091 --- /dev/null +++ b/modules/bottom-sheet/src/BottomSheet.tsx @@ -0,0 +1,100 @@ +import * as React from 'react' +import { + Dimensions, + NativeSyntheticEvent, + Platform, + StyleProp, + View, + ViewStyle, +} from 'react-native' +import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core' + +import {BottomSheetState, BottomSheetViewProps} from './BottomSheet.types' + +const screenHeight = Dimensions.get('screen').height + +const NativeView: React.ComponentType< + BottomSheetViewProps & { + ref: React.RefObject + style: StyleProp + } +> = requireNativeViewManager('BottomSheet') + +const NativeModule = requireNativeModule('BottomSheet') + +export class BottomSheet extends React.Component< + BottomSheetViewProps, + { + open: boolean + } +> { + ref = React.createRef() + + constructor(props: BottomSheetViewProps) { + super(props) + this.state = { + open: false, + } + } + + present() { + this.setState({open: true}) + } + + dismiss() { + this.ref.current?.dismiss() + } + + private onStateChange = ( + event: NativeSyntheticEvent<{state: BottomSheetState}>, + ) => { + const {state} = event.nativeEvent + const isOpen = state !== 'closed' + this.setState({open: isOpen}) + this.props.onStateChange?.(event) + } + + private updateLayout = () => { + this.ref.current?.updateLayout() + } + + static dismissAll = async () => { + await NativeModule.dismissAll() + } + + render() { + const {children, backgroundColor, ...rest} = this.props + const cornerRadius = rest.cornerRadius ?? 0 + + if (!this.state.open) { + return null + } + + return ( + + + {children} + + + ) + } +} diff --git a/modules/bottom-sheet/src/BottomSheet.types.ts b/modules/bottom-sheet/src/BottomSheet.types.ts new file mode 100644 index 0000000000..150932d423 --- /dev/null +++ b/modules/bottom-sheet/src/BottomSheet.types.ts @@ -0,0 +1,35 @@ +import React from 'react' +import {ColorValue, NativeSyntheticEvent} from 'react-native' + +export type BottomSheetState = 'closed' | 'closing' | 'open' | 'opening' + +export enum BottomSheetSnapPoint { + Hidden, + Partial, + Full, +} + +export type BottomSheetAttemptDismissEvent = NativeSyntheticEvent +export type BottomSheetSnapPointChangeEvent = NativeSyntheticEvent<{ + snapPoint: BottomSheetSnapPoint +}> +export type BottomSheetStateChangeEvent = NativeSyntheticEvent<{ + state: BottomSheetState +}> + +export interface BottomSheetViewProps { + children: React.ReactNode + cornerRadius?: number + preventDismiss?: boolean + preventExpansion?: boolean + backgroundColor?: ColorValue + containerBackgroundColor?: ColorValue + disableDrag?: boolean + + minHeight?: number + maxHeight?: number + + onAttemptDismiss?: (event: BottomSheetAttemptDismissEvent) => void + onSnapPointChange?: (event: BottomSheetSnapPointChangeEvent) => void + onStateChange?: (event: BottomSheetStateChangeEvent) => void +} diff --git a/modules/bottom-sheet/src/BottomSheet.web.tsx b/modules/bottom-sheet/src/BottomSheet.web.tsx new file mode 100644 index 0000000000..4573604eb2 --- /dev/null +++ b/modules/bottom-sheet/src/BottomSheet.web.tsx @@ -0,0 +1,5 @@ +import {BottomSheetViewProps} from './BottomSheet.types' + +export function BottomSheet(_: BottomSheetViewProps) { + throw new Error('BottomSheetView is not available on web') +} diff --git a/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/platforminfo/ExpoPlatformInfoModule.kt b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/platforminfo/ExpoPlatformInfoModule.kt new file mode 100644 index 0000000000..189796f817 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/platforminfo/ExpoPlatformInfoModule.kt @@ -0,0 +1,24 @@ +package expo.modules.blueskyswissarmy.platforminfo + +import android.provider.Settings +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +class ExpoPlatformInfoModule : Module() { + override fun definition() = + ModuleDefinition { + Name("ExpoPlatformInfo") + + // See https://github.com/software-mansion/react-native-reanimated/blob/7df5fd57d608fe25724608835461cd925ff5151d/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/nativeProxy/NativeProxyCommon.java#L242 + Function("getIsReducedMotionEnabled") { + val resolver = appContext.reactContext?.contentResolver ?: return@Function false + val scale = Settings.Global.getString(resolver, Settings.Global.TRANSITION_ANIMATION_SCALE) ?: return@Function false + + try { + return@Function scale.toFloat() == 0f + } catch (_: Error) { + return@Function false + } + } + } +} diff --git a/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/ExpoBlueskyVisibilityViewModule.kt b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/ExpoBlueskyVisibilityViewModule.kt new file mode 100644 index 0000000000..ddbb05cde3 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/ExpoBlueskyVisibilityViewModule.kt @@ -0,0 +1,23 @@ +package expo.modules.blueskyswissarmy.visibilityview + +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +class ExpoBlueskyVisibilityViewModule : Module() { + override fun definition() = + ModuleDefinition { + Name("ExpoBlueskyVisibilityView") + + AsyncFunction("updateActiveViewAsync") { + VisibilityViewManager.updateActiveView() + } + + View(VisibilityView::class) { + Events(arrayOf("onChangeStatus")) + + Prop("enabled") { view: VisibilityView, prop: Boolean -> + view.isViewEnabled = prop + } + } + } +} diff --git a/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/VisibilityView.kt b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/VisibilityView.kt new file mode 100644 index 0000000000..a55ab80d5a --- /dev/null +++ b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/VisibilityView.kt @@ -0,0 +1,63 @@ +package expo.modules.blueskyswissarmy.visibilityview + +import android.content.Context +import android.graphics.Rect +import expo.modules.kotlin.AppContext +import expo.modules.kotlin.viewevent.EventDispatcher +import expo.modules.kotlin.views.ExpoView + +class VisibilityView( + context: Context, + appContext: AppContext, +) : ExpoView(context, appContext) { + var isViewEnabled: Boolean = false + + private val onChangeStatus by EventDispatcher() + + private var isCurrentlyActive = false + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + VisibilityViewManager.addView(this) + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + VisibilityViewManager.removeView(this) + } + + fun setIsCurrentlyActive(isActive: Boolean) { + if (isCurrentlyActive == isActive) { + return + } + + this.isCurrentlyActive = isActive + this.onChangeStatus( + mapOf( + "isActive" to isActive, + ), + ) + } + + fun getPositionOnScreen(): Rect? { + if (!this.isShown) { + return null + } + + val screenPosition = intArrayOf(0, 0) + this.getLocationInWindow(screenPosition) + return Rect( + screenPosition[0], + screenPosition[1], + screenPosition[0] + this.width, + screenPosition[1] + this.height, + ) + } + + fun isViewableEnough(): Boolean { + val positionOnScreen = this.getPositionOnScreen() ?: return false + val visibleArea = positionOnScreen.width() * positionOnScreen.height() + val totalArea = this.width * this.height + return visibleArea >= 0.5 * totalArea + } +} diff --git a/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/VisibilityViewManager.kt b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/VisibilityViewManager.kt new file mode 100644 index 0000000000..ec1e49816a --- /dev/null +++ b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/VisibilityViewManager.kt @@ -0,0 +1,82 @@ +package expo.modules.blueskyswissarmy.visibilityview + +import android.graphics.Rect + +class VisibilityViewManager { + companion object { + private val views = HashMap() + private var currentlyActiveView: VisibilityView? = null + private var prevCount = 0 + + fun addView(view: VisibilityView) { + this.views[view.id] = view + + if (this.prevCount == 0) { + this.updateActiveView() + } + this.prevCount = this.views.count() + } + + fun removeView(view: VisibilityView) { + this.views.remove(view.id) + this.prevCount = this.views.count() + } + + fun updateActiveView() { + var activeView: VisibilityView? = null + val count = this.views.count() + + if (count == 1) { + val view = this.views.values.first() + if (view.isViewableEnough()) { + activeView = view + } + } else if (count > 1) { + val views = this.views.values + var mostVisibleView: VisibilityView? = null + var mostVisiblePosition: Rect? = null + + views.forEach { view -> + if (!view.isViewableEnough()) { + return + } + + val position = view.getPositionOnScreen() ?: return@forEach + val topY = position.centerY() - (position.height() / 2) + + if (topY >= 150) { + if (mostVisiblePosition == null) { + mostVisiblePosition = position + } + + if (position.centerY() <= mostVisiblePosition!!.centerY()) { + mostVisibleView = view + mostVisiblePosition = position + } + } + } + + activeView = mostVisibleView + } + + if (activeView == this.currentlyActiveView) { + return + } + + this.clearActiveView() + if (activeView != null) { + this.setActiveView(activeView) + } + } + + private fun clearActiveView() { + this.currentlyActiveView?.setIsCurrentlyActive(false) + this.currentlyActiveView = null + } + + private fun setActiveView(view: VisibilityView) { + view.setIsCurrentlyActive(true) + this.currentlyActiveView = view + } + } +} diff --git a/modules/expo-bluesky-swiss-army/expo-module.config.json b/modules/expo-bluesky-swiss-army/expo-module.config.json index 1111f8a0be..4cdc11e993 100644 --- a/modules/expo-bluesky-swiss-army/expo-module.config.json +++ b/modules/expo-bluesky-swiss-army/expo-module.config.json @@ -1,12 +1,19 @@ { "platforms": ["ios", "tvos", "android", "web"], "ios": { - "modules": ["ExpoBlueskySharedPrefsModule", "ExpoBlueskyReferrerModule"] + "modules": [ + "ExpoBlueskySharedPrefsModule", + "ExpoBlueskyReferrerModule", + "ExpoBlueskyVisibilityViewModule", + "ExpoPlatformInfoModule" + ] }, "android": { "modules": [ "expo.modules.blueskyswissarmy.sharedprefs.ExpoBlueskySharedPrefsModule", - "expo.modules.blueskyswissarmy.referrer.ExpoBlueskyReferrerModule" + "expo.modules.blueskyswissarmy.referrer.ExpoBlueskyReferrerModule", + "expo.modules.blueskyswissarmy.visibilityview.ExpoBlueskyVisibilityViewModule", + "expo.modules.blueskyswissarmy.platforminfo.ExpoPlatformInfoModule" ] } } diff --git a/modules/expo-bluesky-swiss-army/index.ts b/modules/expo-bluesky-swiss-army/index.ts index 89cea00a28..2cf4f36c52 100644 --- a/modules/expo-bluesky-swiss-army/index.ts +++ b/modules/expo-bluesky-swiss-army/index.ts @@ -1,4 +1,7 @@ +import * as PlatformInfo from './src/PlatformInfo' +import {AudioCategory} from './src/PlatformInfo/types' import * as Referrer from './src/Referrer' import * as SharedPrefs from './src/SharedPrefs' +import VisibilityView from './src/VisibilityView' -export {Referrer, SharedPrefs} +export {AudioCategory, PlatformInfo, Referrer, SharedPrefs, VisibilityView} diff --git a/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift b/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift new file mode 100644 index 0000000000..02bf5c6628 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift @@ -0,0 +1,46 @@ +import ExpoModulesCore + +public class ExpoPlatformInfoModule: Module { + private var prevAudioActive: Bool? + private var prevAudioCategory: AVAudioSession.Category? + + public func definition() -> ModuleDefinition { + Name("ExpoPlatformInfo") + + Function("getIsReducedMotionEnabled") { + return UIAccessibility.isReduceMotionEnabled + } + + Function("setAudioCategory") { (audioCategoryString: String) in + let audioCategory = AVAudioSession.Category(rawValue: audioCategoryString) + if audioCategory == self.prevAudioCategory { + return + } + self.prevAudioCategory = audioCategory + DispatchQueue.global(qos: .background).async { + try? AVAudioSession.sharedInstance().setCategory(audioCategory) + } + } + + Function("setAudioActive") { (active: Bool) in + if active == self.prevAudioActive { + return + } + self.prevAudioActive = active + if active { + DispatchQueue.global(qos: .background).async { + try? AVAudioSession.sharedInstance().setActive(true) + } + } else { + DispatchQueue.global(qos: .background).async { + try? AVAudioSession + .sharedInstance() + .setActive( + false, + options: [.notifyOthersOnDeactivation] + ) + } + } + } + } +} diff --git a/modules/expo-bluesky-swiss-army/ios/Visibility/ExpoBlueskyVisibilityViewModule.swift b/modules/expo-bluesky-swiss-army/ios/Visibility/ExpoBlueskyVisibilityViewModule.swift new file mode 100644 index 0000000000..ec12a84af7 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/ios/Visibility/ExpoBlueskyVisibilityViewModule.swift @@ -0,0 +1,21 @@ +import ExpoModulesCore + +public class ExpoBlueskyVisibilityViewModule: Module { + public func definition() -> ModuleDefinition { + Name("ExpoBlueskyVisibilityView") + + AsyncFunction("updateActiveViewAsync") { + VisibilityViewManager.shared.updateActiveView() + } + + View(VisibilityView.self) { + Events([ + "onChangeStatus" + ]) + + Prop("enabled") { (view: VisibilityView, prop: Bool) in + view.enabled = prop + } + } + } +} diff --git a/modules/expo-bluesky-swiss-army/ios/Visibility/VisibilityViewManager.swift b/modules/expo-bluesky-swiss-army/ios/Visibility/VisibilityViewManager.swift new file mode 100644 index 0000000000..ae8e168681 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/ios/Visibility/VisibilityViewManager.swift @@ -0,0 +1,86 @@ +import Foundation + +class VisibilityViewManager { + static let shared = VisibilityViewManager() + + private let views = NSHashTable(options: .weakMemory) + private var currentlyActiveView: VisibilityView? + private var screenHeight: CGFloat = UIScreen.main.bounds.height + private var prevCount = 0 + + func addView(_ view: VisibilityView) { + self.views.add(view) + + if self.prevCount == 0 { + self.updateActiveView() + } + self.prevCount = self.views.count + } + + func removeView(_ view: VisibilityView) { + self.views.remove(view) + self.prevCount = self.views.count + } + + func updateActiveView() { + DispatchQueue.main.async { + var activeView: VisibilityView? + + if self.views.count == 1 { + let view = self.views.allObjects[0] + if view.isViewableEnough() { + activeView = view + } + } else if self.views.count > 1 { + let views = self.views.allObjects + var mostVisibleView: VisibilityView? + var mostVisiblePosition: CGRect? + + views.forEach { view in + if !view.isViewableEnough() { + return + } + + guard let position = view.getPositionOnScreen() else { + return + } + + if position.minY >= 150 { + if mostVisiblePosition == nil { + mostVisiblePosition = position + } + + if let unwrapped = mostVisiblePosition, + position.minY <= unwrapped.minY { + mostVisibleView = view + mostVisiblePosition = position + } + } + } + + activeView = mostVisibleView + } + + if activeView == self.currentlyActiveView { + return + } + + self.clearActiveView() + if let view = activeView { + self.setActiveView(view) + } + } + } + + private func clearActiveView() { + if let currentlyActiveView = self.currentlyActiveView { + currentlyActiveView.setIsCurrentlyActive(isActive: false) + self.currentlyActiveView = nil + } + } + + private func setActiveView(_ view: VisibilityView) { + view.setIsCurrentlyActive(isActive: true) + self.currentlyActiveView = view + } +} diff --git a/modules/expo-bluesky-swiss-army/ios/Visibility/VisiblityView.swift b/modules/expo-bluesky-swiss-army/ios/Visibility/VisiblityView.swift new file mode 100644 index 0000000000..fd99ee4938 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/ios/Visibility/VisiblityView.swift @@ -0,0 +1,69 @@ +import ExpoModulesCore + +class VisibilityView: ExpoView { + var enabled = false { + didSet { + if enabled { + VisibilityViewManager.shared.removeView(self) + } + } + } + + private let onChangeStatus = EventDispatcher() + private var isCurrentlyActiveView = false + + required init(appContext: AppContext? = nil) { + super.init(appContext: appContext) + } + + public override func willMove(toWindow newWindow: UIWindow?) { + super.willMove(toWindow: newWindow) + + if !self.enabled { + return + } + + if newWindow == nil { + VisibilityViewManager.shared.removeView(self) + } else { + VisibilityViewManager.shared.addView(self) + } + } + + func setIsCurrentlyActive(isActive: Bool) { + if isCurrentlyActiveView == isActive { + return + } + self.isCurrentlyActiveView = isActive + self.onChangeStatus([ + "isActive": isActive + ]) + } +} + +// 🚨 DANGER 🚨 +// These functions need to be called from the main thread. Xcode will warn you if you call one of them +// off the main thread, so pay attention! +extension UIView { + func getPositionOnScreen() -> CGRect? { + if let window = self.window { + return self.convert(self.bounds, to: window) + } + return nil + } + + func isViewableEnough() -> Bool { + guard let window = self.window else { + return false + } + + let viewFrameOnScreen = self.convert(self.bounds, to: window) + let screenBounds = window.bounds + let intersection = viewFrameOnScreen.intersection(screenBounds) + + let viewHeight = viewFrameOnScreen.height + let intersectionHeight = intersection.height + + return intersectionHeight >= 0.5 * viewHeight + } +} diff --git a/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.native.ts b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.native.ts new file mode 100644 index 0000000000..b515206d9f --- /dev/null +++ b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.native.ts @@ -0,0 +1,20 @@ +import {Platform} from 'react-native' +import {requireNativeModule} from 'expo-modules-core' + +import {AudioCategory} from './types' + +const NativeModule = requireNativeModule('ExpoPlatformInfo') + +export function getIsReducedMotionEnabled(): boolean { + return NativeModule.getIsReducedMotionEnabled() +} + +export function setAudioActive(active: boolean): void { + if (Platform.OS !== 'ios') return + NativeModule.setAudioActive(active) +} + +export function setAudioCategory(audioCategory: AudioCategory): void { + if (Platform.OS !== 'ios') return + NativeModule.setAudioCategory(audioCategory) +} diff --git a/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.ts b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.ts new file mode 100644 index 0000000000..81f8c45f4d --- /dev/null +++ b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.ts @@ -0,0 +1,25 @@ +import {NotImplementedError} from '../NotImplemented' +import {AudioCategory} from './types' + +export function getIsReducedMotionEnabled(): boolean { + throw new NotImplementedError() +} + +/** + * Set whether the app's audio should mix with other apps' audio. Will also resume background music playback when `false` + * if it was previously playing. + * @param mixWithOthers + * @see https://developer.apple.com/documentation/avfaudio/avaudiosession/setactiveoptions/1616603-notifyothersondeactivation + */ +export function setAudioActive(active: boolean): void { + throw new NotImplementedError({active}) +} + +/** + * Set the audio category for the app. + * @param audioCategory + * @platform ios + */ +export function setAudioCategory(audioCategory: AudioCategory): void { + throw new NotImplementedError({audioCategory}) +} diff --git a/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.web.ts b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.web.ts new file mode 100644 index 0000000000..61412753c9 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.web.ts @@ -0,0 +1,17 @@ +import {NotImplementedError} from '../NotImplemented' +import {AudioCategory} from './types' + +export function getIsReducedMotionEnabled(): boolean { + if (typeof window === 'undefined') { + return false + } + return window.matchMedia('(prefers-reduced-motion: reduce)').matches +} + +export function setAudioActive(active: boolean): void { + throw new NotImplementedError({active}) +} + +export function setAudioCategory(audioCategory: AudioCategory): void { + throw new NotImplementedError({audioCategory}) +} diff --git a/modules/expo-bluesky-swiss-army/src/PlatformInfo/types.ts b/modules/expo-bluesky-swiss-army/src/PlatformInfo/types.ts new file mode 100644 index 0000000000..374f343183 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/src/PlatformInfo/types.ts @@ -0,0 +1,15 @@ +/** + * Sets the audio session category on iOS. In general, we should only need to use this for the `playback` and `ambient` + * categories. This enum however includes other categories that are available in the native API for clarity and + * potential future use. + * @see https://developer.apple.com/documentation/avfoundation/avaudiosession/category + * @platform ios + */ +export enum AudioCategory { + Ambient = 'AVAudioSessionCategoryAmbient', + Playback = 'AVAudioSessionCategoryPlayback', + _SoloAmbient = 'AVAudioSessionCategorySoloAmbient', + _Record = 'AVAudioSessionCategoryRecord', + _PlayAndRecord = 'AVAudioSessionCategoryPlayAndRecord', + _MultiRoute = 'AVAudioSessionCategoryMultiRoute', +} diff --git a/modules/expo-bluesky-swiss-army/src/VisibilityView/index.native.tsx b/modules/expo-bluesky-swiss-army/src/VisibilityView/index.native.tsx new file mode 100644 index 0000000000..9d0e8cf220 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/src/VisibilityView/index.native.tsx @@ -0,0 +1,39 @@ +import React from 'react' +import {StyleProp, ViewStyle} from 'react-native' +import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core' + +import {VisibilityViewProps} from './types' +const NativeView: React.ComponentType<{ + onChangeStatus: (e: {nativeEvent: {isActive: boolean}}) => void + children: React.ReactNode + enabled: Boolean + style: StyleProp +}> = requireNativeViewManager('ExpoBlueskyVisibilityView') + +const NativeModule = requireNativeModule('ExpoBlueskyVisibilityView') + +export async function updateActiveViewAsync() { + await NativeModule.updateActiveViewAsync() +} + +export default function VisibilityView({ + children, + onChangeStatus: onChangeStatusOuter, + enabled, +}: VisibilityViewProps) { + const onChangeStatus = React.useCallback( + (e: {nativeEvent: {isActive: boolean}}) => { + onChangeStatusOuter(e.nativeEvent.isActive) + }, + [onChangeStatusOuter], + ) + + return ( + + {children} + + ) +} diff --git a/modules/expo-bluesky-swiss-army/src/VisibilityView/index.tsx b/modules/expo-bluesky-swiss-army/src/VisibilityView/index.tsx new file mode 100644 index 0000000000..8b4f1928cb --- /dev/null +++ b/modules/expo-bluesky-swiss-army/src/VisibilityView/index.tsx @@ -0,0 +1,10 @@ +import {NotImplementedError} from '../NotImplemented' +import {VisibilityViewProps} from './types' + +export async function updateActiveViewAsync() { + throw new NotImplementedError() +} + +export default function VisibilityView({children}: VisibilityViewProps) { + return children +} diff --git a/modules/expo-bluesky-swiss-army/src/VisibilityView/types.ts b/modules/expo-bluesky-swiss-army/src/VisibilityView/types.ts new file mode 100644 index 0000000000..312acf2d29 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/src/VisibilityView/types.ts @@ -0,0 +1,6 @@ +import React from 'react' +export interface VisibilityViewProps { + children: React.ReactNode + onChangeStatus: (isActive: boolean) => void + enabled: boolean +} diff --git a/modules/expo-receive-android-intents/android/src/main/java/xyz/blueskyweb/app/exporeceiveandroidintents/ExpoReceiveAndroidIntentsModule.kt b/modules/expo-receive-android-intents/android/src/main/java/xyz/blueskyweb/app/exporeceiveandroidintents/ExpoReceiveAndroidIntentsModule.kt index 7ecea16314..c88442057c 100644 --- a/modules/expo-receive-android-intents/android/src/main/java/xyz/blueskyweb/app/exporeceiveandroidintents/ExpoReceiveAndroidIntentsModule.kt +++ b/modules/expo-receive-android-intents/android/src/main/java/xyz/blueskyweb/app/exporeceiveandroidintents/ExpoReceiveAndroidIntentsModule.kt @@ -12,6 +12,11 @@ import java.io.File import java.io.FileOutputStream import java.net.URLEncoder +enum class AttachmentType { + IMAGE, + VIDEO, +} + class ExpoReceiveAndroidIntentsModule : Module() { override fun definition() = ModuleDefinition { @@ -23,17 +28,26 @@ class ExpoReceiveAndroidIntentsModule : Module() { } private fun handleIntent(intent: Intent?) { - if (appContext.currentActivity == null || intent == null) return - - if (intent.action == Intent.ACTION_SEND) { - if (intent.type == "text/plain") { - handleTextIntent(intent) - } else if (intent.type.toString().startsWith("image/")) { - handleImageIntent(intent) + if (appContext.currentActivity == null) return + intent?.let { + if (it.action == Intent.ACTION_SEND && it.type == "text/plain") { + handleTextIntent(it) + return } - } else if (intent.action == Intent.ACTION_SEND_MULTIPLE) { - if (intent.type.toString().startsWith("image/")) { - handleImagesIntent(intent) + + val type = + if (it.type.toString().startsWith("image/")) { + AttachmentType.IMAGE + } else if (it.type.toString().startsWith("video/")) { + AttachmentType.VIDEO + } else { + return + } + + if (it.action == Intent.ACTION_SEND) { + handleAttachmentIntent(it, type) + } else if (it.action == Intent.ACTION_SEND_MULTIPLE) { + handleAttachmentsIntent(it, type) } } } @@ -48,26 +62,46 @@ class ExpoReceiveAndroidIntentsModule : Module() { } } - private fun handleImageIntent(intent: Intent) { + private fun handleAttachmentIntent( + intent: Intent, + type: AttachmentType, + ) { val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java) } else { intent.getParcelableExtra(Intent.EXTRA_STREAM) } - if (uri == null) return - handleImageIntents(listOf(uri)) + uri?.let { + when (type) { + AttachmentType.IMAGE -> handleImageIntents(listOf(it)) + AttachmentType.VIDEO -> handleVideoIntents(listOf(it)) + } + } } - private fun handleImagesIntent(intent: Intent) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)?.let { - handleImageIntents(it.filterIsInstance().take(4)) + private fun handleAttachmentsIntent( + intent: Intent, + type: AttachmentType, + ) { + val uris = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent + .getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java) + ?.filterIsInstance() + ?.take(4) + } else { + intent + .getParcelableArrayListExtra(Intent.EXTRA_STREAM) + ?.filterIsInstance() + ?.take(4) } - } else { - intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)?.let { - handleImageIntents(it.filterIsInstance().take(4)) + + uris?.let { + when (type) { + AttachmentType.IMAGE -> handleImageIntents(it) + else -> return } } } @@ -93,11 +127,33 @@ class ExpoReceiveAndroidIntentsModule : Module() { } } + private fun handleVideoIntents(uris: List) { + val uri = uris[0] + // If there is no extension for the file, substringAfterLast returns the original string - not + // null, so we check for that below + // It doesn't actually matter what the extension is, so defaulting to mp4 is fine, even if the + // video isn't actually an mp4 + var extension = uri.path?.substringAfterLast(".") + if (extension == null || extension == uri.path) { + extension = "mp4" + } + val file = createFile(extension) + + val out = FileOutputStream(file) + appContext.currentActivity?.contentResolver?.openInputStream(uri)?.use { + it.copyTo(out) + } + "bluesky://intent/compose?videoUri=${URLEncoder.encode(file.path, "UTF-8")}".toUri().let { + val newIntent = Intent(Intent.ACTION_VIEW, it) + appContext.currentActivity?.startActivity(newIntent) + } + } + private fun getImageInfo(uri: Uri): Map { val bitmap = MediaStore.Images.Media.getBitmap(appContext.currentActivity?.contentResolver, uri) // We have to save this so that we can access it later when uploading the image. // createTempFile will automatically place a unique string between "img" and "temp.jpeg" - val file = File.createTempFile("img", "temp.jpeg", appContext.currentActivity?.cacheDir) + val file = createFile("jpeg") val out = FileOutputStream(file) bitmap.compress(Bitmap.CompressFormat.JPEG, 100, out) out.flush() @@ -110,6 +166,8 @@ class ExpoReceiveAndroidIntentsModule : Module() { ) } + private fun createFile(extension: String): File = File.createTempFile(extension, "temp.$extension", appContext.currentActivity?.cacheDir) + // We will pas the width and height to the app here, since getting measurements // on the RN side is a bit more involved, and we already have them here anyway. private fun buildUriData(info: Map): String { diff --git a/package.json b/package.json index b56dd9e39b..828f11cb76 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bsky.app", - "version": "1.89.0", + "version": "1.91.2", "private": true, "engines": { "node": ">=18" @@ -15,7 +15,7 @@ "web": "expo start --web", "use-build-number": "./scripts/useBuildNumberEnv.sh", "use-build-number-with-bump": "./scripts/useBuildNumberEnvWithBump.sh", - "build-web": "expo export:web && node ./scripts/post-web-build.js && cp -v ./web-build/static/js/*.* ./bskyweb/static/js/ && cp -v ./web-build/static/media/*.png ./bskyweb/static/media/", + "build-web": "expo export:web && node ./scripts/post-web-build.js", "build-all": "yarn intl:build && yarn use-build-number-with-bump eas build --platform all", "build-ios": "yarn use-build-number-with-bump eas build -p ios", "build-android": "yarn use-build-number-with-bump eas build -p android", @@ -32,7 +32,7 @@ "lint-native": "swiftlint ./modules && ktlint ./modules", "lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules", "typecheck": "tsc --project ./tsconfig.check.json", - "e2e:mock-server": "./jest/dev-infra/with-test-redis-and-db.sh ts-node --project tsconfig.e2e.json __e2e__/mock-server.ts", + "e2e:mock-server": "NODE_ENV=development ./jest/dev-infra/with-test-redis-and-db.sh ts-node --project tsconfig.e2e.json __e2e__/mock-server.ts", "e2e:metro": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios", "e2e:metro-android": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:android", "e2e:run": "maestro test __e2e__", @@ -49,11 +49,11 @@ "export": "npx expo export", "make-deploy-bundle": "bash scripts/bundleUpdate.sh", "generate-webpack-stats-file": "EXPO_PUBLIC_GENERATE_STATS=1 yarn build-web", - "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web" + "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web", + "icons:optimize": "svgo -f ./assets/icons" }, "dependencies": { - "@atproto/api": "^0.12.23", - "@bam.tech/react-native-image-resizer": "^3.0.4", + "@atproto/api": "^0.13.11", "@braintree/sanitize-url": "^6.0.2", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", "@emoji-mart/react": "^1.1.1", @@ -68,6 +68,7 @@ "@fortawesome/free-regular-svg-icons": "^6.1.1", "@fortawesome/free-solid-svg-icons": "^6.1.1", "@fortawesome/react-native-fontawesome": "^0.3.2", + "@haileyok/bluesky-video": "0.1.10", "@lingui/react": "^4.5.0", "@mattermost/react-native-paste-input": "^0.7.1", "@miblanchard/react-native-slider": "^2.3.1", @@ -80,11 +81,7 @@ "@react-navigation/drawer": "^6.6.15", "@react-navigation/native": "^6.1.17", "@react-navigation/native-stack": "^6.9.26", - "@segment/analytics-next": "^1.51.3", - "@segment/analytics-react": "^1.0.0-rc1", - "@segment/analytics-react-native": "^2.10.1", - "@segment/sovran-react-native": "^0.4.5", - "@sentry/react-native": "5.5.0", + "@sentry/react-native": "5.24.3", "@tamagui/focus-scope": "^1.84.1", "@tanstack/query-async-storage-persister": "^5.25.0", "@tanstack/react-query": "^5.8.1", @@ -109,20 +106,24 @@ "await-lock": "^2.2.2", "babel-plugin-transform-remove-console": "^6.9.4", "base64-js": "^1.5.1", + "bcp-47": "^2.1.0", "bcp-47-match": "^2.0.3", "date-fns": "^2.30.0", "deprecated-react-native-prop-types": "^5.0.0", "email-validator": "^2.0.4", "emoji-mart": "^5.5.2", + "emoji-regex": "^10.4.0", "eventemitter3": "^5.0.1", "expo": "^51.0.8", "expo-application": "^5.9.1", + "expo-blur": "^13.0.2", "expo-build-properties": "^0.12.1", "expo-camera": "~15.0.9", "expo-clipboard": "^6.0.3", "expo-dev-client": "^4.0.14", "expo-device": "~6.0.2", "expo-file-system": "^17.0.1", + "expo-font": "~12.0.10", "expo-haptics": "^13.0.1", "expo-image": "~1.12.9", "expo-image-manipulator": "^12.0.5", @@ -136,13 +137,12 @@ "expo-sharing": "^12.0.1", "expo-splash-screen": "~0.27.4", "expo-status-bar": "~1.12.1", - "expo-system-ui": "~3.0.4", "expo-task-manager": "~11.8.1", "expo-updates": "~0.25.14", - "expo-video": "^1.1.10", "expo-web-browser": "~13.0.3", "fast-text-encoding": "^1.0.6", "history": "^5.3.0", + "hls.js": "^1.5.11", "js-sha256": "^0.9.0", "jwt-decode": "^4.0.0", "lande": "^1.0.10", @@ -156,29 +156,26 @@ "lodash.set": "^4.3.2", "lodash.shuffle": "^4.2.0", "lodash.throttle": "^4.1.1", - "mobx": "^6.6.1", - "mobx-react-lite": "^3.4.0", - "mobx-utils": "^6.0.6", "nanoid": "^5.0.5", "normalize-url": "^8.0.0", "patch-package": "^6.5.1", "postinstall-postinstall": "^2.1.0", "psl": "^1.9.0", "react": "18.2.0", - "react-avatar-editor": "^13.0.0", "react-compiler-runtime": "file:./lib/react-compiler-runtime", "react-dom": "^18.2.0", + "react-image-crop": "^11.0.7", "react-keyed-flatten-children": "^3.0.0", "react-native": "0.74.1", "react-native-compressor": "^1.8.24", "react-native-date-picker": "^4.4.2", "react-native-drawer-layout": "^4.0.0-alpha.3", - "react-native-fs": "^2.20.0", - "react-native-gesture-handler": "~2.16.2", + "react-native-gesture-handler": "2.20.0", "react-native-get-random-values": "~1.11.0", - "react-native-image-crop-picker": "0.40.3", + "react-native-image-crop-picker": "0.41.2", "react-native-ios-context-menu": "^1.15.3", - "react-native-keyboard-controller": "^1.12.1", + "react-native-keyboard-controller": "^1.14.0", + "react-native-mmkv": "^2.12.2", "react-native-pager-view": "6.2.3", "react-native-picker-select": "^9.1.3", "react-native-progress": "bluesky-social/react-native-progress", @@ -188,7 +185,7 @@ "react-native-safe-area-context": "4.10.1", "react-native-screens": "~3.31.1", "react-native-svg": "^15.3.0", - "react-native-uitextview": "^1.1.7", + "react-native-uitextview": "^1.3.0", "react-native-url-polyfill": "^1.3.0", "react-native-uuid": "^2.0.2", "react-native-view-shot": "^3.8.0", @@ -198,16 +195,15 @@ "react-responsive": "^9.0.2", "react-textarea-autosize": "^8.5.3", "rn-fetch-blob": "^0.12.0", - "rn-tourguide": "bluesky-social/rn-tourguide", - "sentry-expo": "~7.0.1", "statsig-react-native-expo": "^4.6.1", "tippy.js": "^6.3.7", "tlds": "^1.234.0", + "tldts": "^6.1.46", "zeego": "^1.6.2", "zod": "^3.20.2" }, "devDependencies": { - "@atproto/dev-env": "^0.3.5", + "@atproto/dev-env": "^0.3.39", "@babel/core": "^7.23.2", "@babel/preset-env": "^7.20.0", "@babel/runtime": "^7.20.0", @@ -234,7 +230,6 @@ "@types/lodash.set": "^4.3.7", "@types/lodash.shuffle": "^4.2.7", "@types/psl": "^1.1.1", - "@types/react-avatar-editor": "^13.0.0", "@types/react-dom": "^18.2.18", "@types/react-responsive": "^8.0.5", "@types/react-test-renderer": "^17.0.1", @@ -268,8 +263,9 @@ "react-refresh": "^0.14.0", "react-scripts": "^5.0.1", "react-test-renderer": "18.2.0", + "svgo": "^3.3.2", "ts-node": "^10.9.1", - "typescript": "^5.3.3", + "typescript": "^5.5.4", "url-loader": "^4.1.1", "webpack": "^5.75.0", "webpack-bundle-analyzer": "^4.10.1", @@ -335,8 +331,13 @@ }, "lint-staged": { "*{.js,.jsx,.ts,.tsx}": [ - "eslint --cache --fix", + "eslint --cache --fix" + ], + "*{.js,.jsx,.ts,.tsx,.css}": [ "prettier --cache --write --ignore-unknown" + ], + "assets/icons/*.svg": [ + "svgo" ] } } diff --git a/patches/@atproto+lexicon+0.4.0.patch b/patches/@atproto+lexicon+0.4.0.patch deleted file mode 100644 index 4643db32af..0000000000 --- a/patches/@atproto+lexicon+0.4.0.patch +++ /dev/null @@ -1,28 +0,0 @@ -diff --git a/node_modules/@atproto/lexicon/dist/validators/complex.js b/node_modules/@atproto/lexicon/dist/validators/complex.js -index 32d7798..9d688b7 100644 ---- a/node_modules/@atproto/lexicon/dist/validators/complex.js -+++ b/node_modules/@atproto/lexicon/dist/validators/complex.js -@@ -113,7 +113,22 @@ function object(lexicons, path, def, value) { - if (value[key] === null && nullableProps.has(key)) { - continue; - } -- const propDef = def.properties[key]; -+ const propDef = def.properties[key] -+ if (typeof value[key] === 'undefined' && !requiredProps.has(key)) { -+ // Fast path for non-required undefined props. -+ if ( -+ propDef.type === 'integer' || -+ propDef.type === 'boolean' || -+ propDef.type === 'string' -+ ) { -+ if (typeof propDef.default === 'undefined') { -+ continue -+ } -+ } else { -+ // Other types have no defaults. -+ continue -+ } -+ } - const propPath = `${path}/${key}`; - const validated = (0, util_1.validateOneOf)(lexicons, propPath, propDef, value[key]); - const propValue = validated.success ? validated.value : value[key]; diff --git a/patches/@sentry+react-native+5.32.0.patch b/patches/@sentry+react-native+5.32.0.patch new file mode 100644 index 0000000000..a5ccecc21a --- /dev/null +++ b/patches/@sentry+react-native+5.32.0.patch @@ -0,0 +1,36 @@ +diff --git a/node_modules/@sentry/react-native/dist/js/utils/ignorerequirecyclelogs.js b/node_modules/@sentry/react-native/dist/js/utils/ignorerequirecyclelogs.js +index 7e0b4cd..177454c 100644 +--- a/node_modules/@sentry/react-native/dist/js/utils/ignorerequirecyclelogs.js ++++ b/node_modules/@sentry/react-native/dist/js/utils/ignorerequirecyclelogs.js +@@ -3,6 +3,8 @@ import { LogBox } from 'react-native'; + * This is a workaround for using fetch on RN, this is a known issue in react-native and only generates a warning. + */ + export function ignoreRequireCycleLogs() { +- LogBox.ignoreLogs(['Require cycle:']); ++ try { ++ LogBox.ignoreLogs(['Require cycle:']); ++ } catch (e) {} + } + //# sourceMappingURL=ignorerequirecyclelogs.js.map +\ No newline at end of file +diff --git a/node_modules/@sentry/react-native/scripts/expo-upload-sourcemaps.js b/node_modules/@sentry/react-native/scripts/expo-upload-sourcemaps.js +index 0f244f2..ae7dfb3 100755 +--- a/node_modules/@sentry/react-native/scripts/expo-upload-sourcemaps.js ++++ b/node_modules/@sentry/react-native/scripts/expo-upload-sourcemaps.js +@@ -174,6 +174,7 @@ if (!outputDir) { + process.exit(1); + } + ++const otherArgs = process.argv.slice(3); + const files = getAssetPathsSync(outputDir); + const groupedAssets = groupAssets(files); + +@@ -195,7 +196,7 @@ for (const [assetGroupName, assets] of Object.entries(groupedAssets)) { + + const isHermes = assets.find(asset => asset.endsWith('.hbc')); + const windowsCallback = process.platform === "win32" ? 'node ' : ''; +- execSync(`${windowsCallback}${sentryCliBin} sourcemaps upload ${isHermes ? '--debug-id-reference' : ''} ${assets.join(' ')}`, { ++ execSync(`${windowsCallback}${sentryCliBin} sourcemaps upload ${isHermes ? '--debug-id-reference' : ''} ${assets.join(' ')} ${otherArgs.join(' ')}`, { + env: { + ...process.env, + [SENTRY_PROJECT]: sentryProject, diff --git a/patches/@sentry+react-native+5.5.0.patch b/patches/@sentry+react-native+5.5.0.patch deleted file mode 100644 index 2962aa44c2..0000000000 --- a/patches/@sentry+react-native+5.5.0.patch +++ /dev/null @@ -1,15 +0,0 @@ -diff --git a/node_modules/@sentry/react-native/dist/js/utils/ignorerequirecyclelogs.js b/node_modules/@sentry/react-native/dist/js/utils/ignorerequirecyclelogs.js -index 7e0b4cd..177454c 100644 ---- a/node_modules/@sentry/react-native/dist/js/utils/ignorerequirecyclelogs.js -+++ b/node_modules/@sentry/react-native/dist/js/utils/ignorerequirecyclelogs.js -@@ -3,6 +3,8 @@ import { LogBox } from 'react-native'; - * This is a workaround for using fetch on RN, this is a known issue in react-native and only generates a warning. - */ - export function ignoreRequireCycleLogs() { -- LogBox.ignoreLogs(['Require cycle:']); -+ try { -+ LogBox.ignoreLogs(['Require cycle:']); -+ } catch (e) {} - } - //# sourceMappingURL=ignorerequirecyclelogs.js.map -\ No newline at end of file diff --git a/patches/expo-modules-core+1.12.11.patch b/patches/expo-modules-core+1.12.11.patch index 4878bb9f7e..ea26b821da 100644 --- a/patches/expo-modules-core+1.12.11.patch +++ b/patches/expo-modules-core+1.12.11.patch @@ -4,11 +4,23 @@ index bb74e80..0aa0202 100644 +++ 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 constants = new HashMap<>(3); constants.put(MODULES_CONSTANTS_KEY, new HashMap<>()); +diff --git a/node_modules/expo-modules-core/build/uuid/uuid.js b/node_modules/expo-modules-core/build/uuid/uuid.js +index 109d3fe..c7fce9e 100644 +--- a/node_modules/expo-modules-core/build/uuid/uuid.js ++++ b/node_modules/expo-modules-core/build/uuid/uuid.js +@@ -1,5 +1,7 @@ + import bytesToUuid from './lib/bytesToUuid'; + import { Uuidv5Namespace } from './uuid.types'; ++import { ensureNativeModulesAreInstalled } from '../ensureNativeModulesAreInstalled'; ++ensureNativeModulesAreInstalled(); + const nativeUuidv4 = globalThis?.expo?.uuidv4; + const nativeUuidv5 = globalThis?.expo?.uuidv5; + function uuidv4() { diff --git a/patches/react-native+0.74.1.patch b/patches/react-native+0.74.1.patch index 789ba84ace..ea6161e2ff 100644 --- a/patches/react-native+0.74.1.patch +++ b/patches/react-native+0.74.1.patch @@ -1,5 +1,18 @@ +diff --git a/node_modules/react-native/Libraries/Blob/RCTFileReaderModule.mm b/node_modules/react-native/Libraries/Blob/RCTFileReaderModule.mm +index caa5540..c5d4e67 100644 +--- a/node_modules/react-native/Libraries/Blob/RCTFileReaderModule.mm ++++ b/node_modules/react-native/Libraries/Blob/RCTFileReaderModule.mm +@@ -73,7 +73,7 @@ @implementation RCTFileReaderModule + } else { + NSString *type = [RCTConvert NSString:blob[@"type"]]; + NSString *text = [NSString stringWithFormat:@"data:%@;base64,%@", +- type != nil && [type length] > 0 ? type : @"application/octet-stream", ++ ![type isEqual:[NSNull null]] && [type length] > 0 ? type : @"application/octet-stream", + [data base64EncodedStringWithOptions:0]]; + + resolve(text); 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 +index b0d71dc..41b9a0e 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 @@ -25,37 +38,65 @@ index b0d71dc..9974932 100644 - (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 +index e9b330f..ec5f58c 100644 --- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h +++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h -@@ -16,4 +16,6 @@ +@@ -15,5 +15,8 @@ + @property (nonatomic, copy) NSString *title; @property (nonatomic, copy) RCTDirectEventBlock onRefresh; @property (nonatomic, weak) UIScrollView *scrollView; - -+- (void)forwarderBeginRefreshing; ++@property (nonatomic, copy) UIColor *customTintColor; + ++- (void)forwarderBeginRefreshing; + @end diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m -index b09e653..4c32b31 100644 +index b09e653..288e60c 100644 --- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m +++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m -@@ -198,9 +198,53 @@ - (void)refreshControlValueChanged - [self setCurrentRefreshingState:super.refreshing]; - _refreshingProgrammatically = NO; +@@ -22,6 +22,7 @@ @implementation RCTRefreshControl { + NSString *_title; + UIColor *_titleColor; + CGFloat _progressViewOffset; ++ UIColor *_customTintColor; + } -+ if (@available(iOS 17.4, *)) { -+ if (_currentRefreshingState) { -+ UIImpactFeedbackGenerator *feedbackGenerator = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight]; -+ [feedbackGenerator prepare]; -+ [feedbackGenerator impactOccurred]; -+ } -+ } + - (instancetype)init +@@ -56,6 +57,12 @@ - (void)layoutSubviews + _isInitialRender = false; + } + ++- (void)didMoveToSuperview ++{ ++ [super didMoveToSuperview]; ++ [self setTintColor:_customTintColor]; ++} + - if (_onRefresh) { - _onRefresh(nil); + - (void)beginRefreshingProgrammatically + { + UInt64 beginRefreshingTimestamp = _currentRefreshingStateTimestamp; +@@ -203,4 +210,58 @@ - (void)refreshControlValueChanged } } ++- (void)setCustomTintColor:(UIColor *)customTintColor ++{ ++ _customTintColor = customTintColor; ++ [self setTintColor:customTintColor]; ++} ++ ++// Fix for https://github.com/facebook/react-native/issues/43388 ++// A bug in iOS 17.4 causes the haptic to not play when refreshing if the tintColor ++// is set before the refresh control gets added to the scrollview. We'll call this ++// function whenever the superview changes. We'll also call it if the value of customTintColor ++// changes. ++- (void)setTintColor:(UIColor *)tintColor ++{ ++ if ([self.superview isKindOfClass:[UIScrollView class]] && self.tintColor != tintColor) { ++ [super setTintColor:tintColor]; ++ } ++} ++ +/* + 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 @@ -93,6 +134,24 @@ index b09e653..4c32b31 100644 +} + @end +diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControlManager.m b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControlManager.m +index 40aaf9c..1c60164 100644 +--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControlManager.m ++++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControlManager.m +@@ -22,11 +22,12 @@ - (UIView *)view + + RCT_EXPORT_VIEW_PROPERTY(onRefresh, RCTDirectEventBlock) + RCT_EXPORT_VIEW_PROPERTY(refreshing, BOOL) +-RCT_EXPORT_VIEW_PROPERTY(tintColor, UIColor) + RCT_EXPORT_VIEW_PROPERTY(title, NSString) + RCT_EXPORT_VIEW_PROPERTY(titleColor, UIColor) + RCT_EXPORT_VIEW_PROPERTY(progressViewOffset, CGFloat) + ++RCT_REMAP_VIEW_PROPERTY(tintColor, customTintColor, UIColor) ++ + RCT_EXPORT_METHOD(setNativeRefreshing : (nonnull NSNumber *)viewTag toRefreshing : (BOOL)refreshing) + { + [self.bridge.uiManager addUIBlock:^(RCTUIManager *uiManager, NSDictionary *viewRegistry) { 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 diff --git a/patches/react-native-reanimated+3.11.0.patch b/patches/react-native-reanimated+3.11.0.patch index 9147cf08ef..a79a0ac085 100644 --- a/patches/react-native-reanimated+3.11.0.patch +++ b/patches/react-native-reanimated+3.11.0.patch @@ -207,31 +207,3 @@ index 88b3fdf..2488ebc 100644 const { layout, entering, exiting, sharedTransitionTag } = this.props; if ( -diff --git a/node_modules/react-native-reanimated/lib/module/reanimated2/index.js b/node_modules/react-native-reanimated/lib/module/reanimated2/index.js -index ac9be5d..86d4605 100644 ---- a/node_modules/react-native-reanimated/lib/module/reanimated2/index.js -+++ b/node_modules/react-native-reanimated/lib/module/reanimated2/index.js -@@ -47,4 +47,5 @@ export { LayoutAnimationConfig } from './component/LayoutAnimationConfig'; - export { PerformanceMonitor } from './component/PerformanceMonitor'; - export { startMapper, stopMapper } from './mappers'; - export { startScreenTransition, finishScreenTransition, ScreenTransition } from './screenTransition'; -+export { isReducedMotion } from './PlatformChecker'; - //# sourceMappingURL=index.js.map -diff --git a/node_modules/react-native-reanimated/lib/typescript/reanimated2/index.d.ts b/node_modules/react-native-reanimated/lib/typescript/reanimated2/index.d.ts -index f01dc57..161ef22 100644 ---- a/node_modules/react-native-reanimated/lib/typescript/reanimated2/index.d.ts -+++ b/node_modules/react-native-reanimated/lib/typescript/reanimated2/index.d.ts -@@ -36,3 +36,4 @@ export type { FlatListPropsWithLayout } from './component/FlatList'; - export { startMapper, stopMapper } from './mappers'; - export { startScreenTransition, finishScreenTransition, ScreenTransition, } from './screenTransition'; - export type { AnimatedScreenTransition, GoBackGesture, ScreenTransitionConfig, } from './screenTransition'; -+export { isReducedMotion } from './PlatformChecker'; -diff --git a/node_modules/react-native-reanimated/src/reanimated2/index.ts b/node_modules/react-native-reanimated/src/reanimated2/index.ts -index 5885fa1..a3c693f 100644 ---- a/node_modules/react-native-reanimated/src/reanimated2/index.ts -+++ b/node_modules/react-native-reanimated/src/reanimated2/index.ts -@@ -284,3 +284,4 @@ export type { - GoBackGesture, - ScreenTransitionConfig, - } from './screenTransition'; -+export { isReducedMotion } from './PlatformChecker'; diff --git a/plugins/shareExtension/withIntentFilters.js b/plugins/shareExtension/withIntentFilters.js index 605fcfd052..16494893bb 100644 --- a/plugins/shareExtension/withIntentFilters.js +++ b/plugins/shareExtension/withIntentFilters.js @@ -27,6 +27,29 @@ const withIntentFilters = config => { }, ], }, + { + action: [ + { + $: { + 'android:name': 'android.intent.action.SEND', + }, + }, + ], + category: [ + { + $: { + 'android:name': 'android.intent.category.DEFAULT', + }, + }, + ], + data: [ + { + $: { + 'android:mimeType': 'video/*', + }, + }, + ], + }, { action: [ { diff --git a/plugins/starterPackAppClipExtension/withXcodeTarget.js b/plugins/starterPackAppClipExtension/withXcodeTarget.js index 61d5f81b07..c14d27291a 100644 --- a/plugins/starterPackAppClipExtension/withXcodeTarget.js +++ b/plugins/starterPackAppClipExtension/withXcodeTarget.js @@ -57,7 +57,7 @@ const withXcodeTarget = (config, {targetName}) => { buildSettingsObj.SWIFT_VERSION = '5.0' buildSettingsObj.TARGETED_DEVICE_FAMILY = `"1"` buildSettingsObj.DEVELOPMENT_TEAM = 'B3LX46C5HS' - buildSettingsObj.IPHONEOS_DEPLOYMENT_TARGET = '14.0' + buildSettingsObj.IPHONEOS_DEPLOYMENT_TARGET = '15.1' buildSettingsObj.ASSETCATALOG_COMPILER_APPICON_NAME = 'AppIcon' } } diff --git a/postHooks/uploadSentrySourcemapsPostHook.js b/postHooks/uploadSentrySourcemapsPostHook.js new file mode 100644 index 0000000000..756e64253c --- /dev/null +++ b/postHooks/uploadSentrySourcemapsPostHook.js @@ -0,0 +1,34 @@ +const exec = require('child_process').execSync + +const SENTRY_AUTH_TOKEN = process.env.SENTRY_AUTH_TOKEN + +module.exports = ({config}) => { + if (!SENTRY_AUTH_TOKEN) { + console.log( + 'SENTRY_AUTH_TOKEN environment variable must be set to upload sourcemaps. Skipping.', + ) + return + } + + const org = config.organization + const project = config.project + const release = config.release + const dist = config.dist + + if (!org || !project || !release || !dist) { + console.log( + '"organization", "project", "release", and "dist" must be set in the hook config to upload sourcemaps. Skipping.', + ) + return + } + + try { + console.log('Uploading sourcemaps to Sentry...') + exec( + `node node_modules/@sentry/react-native/scripts/expo-upload-sourcemaps dist --url https://sentry.io/ -o ${org} -p ${project} -r ${release} -d ${dist}`, + ) + console.log('Sourcemaps uploaded to Sentry.') + } catch (e) { + console.error('Error uploading sourcemaps to Sentry:', e) + } +} diff --git a/scripts/post-web-build.js b/scripts/post-web-build.js index 5db3788545..3b1b62dfd8 100644 --- a/scripts/post-web-build.js +++ b/scripts/post-web-build.js @@ -2,7 +2,6 @@ const path = require('path') const fs = require('fs') const projectRoot = path.join(__dirname, '..') -const webBuildJs = path.join(projectRoot, 'web-build', 'static', 'js') const templateFile = path.join( projectRoot, 'bskyweb', @@ -10,18 +9,41 @@ const templateFile = path.join( 'scripts.html', ) -const jsFiles = fs.readdirSync(webBuildJs).filter(name => name.endsWith('.js')) -jsFiles.sort((a, b) => { - // make sure main is written last - if (a.startsWith('main')) return 1 - if (b.startsWith('main')) return -1 - return a.localeCompare(b) -}) +const {entrypoints} = require(path.join( + projectRoot, + 'web-build/asset-manifest.json', +)) -console.log(`Found ${jsFiles.length} js files in web-build`) +console.log(`Found ${entrypoints.length} entrypoints`) console.log(`Writing ${templateFile}`) -const outputFile = jsFiles - .map(name => ``) +const outputFile = entrypoints + .map(name => { + const file = path.basename(name) + const ext = path.extname(file) + + if (ext === '.js') { + return `` + } + if (ext === '.css') { + return `` + } + + return '' + }) .join('\n') fs.writeFileSync(templateFile, outputFile) + +function copyFiles(sourceDir, targetDir) { + const files = fs.readdirSync(path.join(projectRoot, sourceDir)) + files.forEach(file => { + const sourcePath = path.join(projectRoot, sourceDir, file) + const targetPath = path.join(projectRoot, targetDir, file) + fs.copyFileSync(sourcePath, targetPath) + console.log(`Copied ${sourcePath} to ${targetPath}`) + }) +} + +copyFiles('web-build/static/js', 'bskyweb/static/js') +copyFiles('web-build/static/css', 'bskyweb/static/css') +copyFiles('web-build/static/media', 'bskyweb/static/media') diff --git a/src/App.native.tsx b/src/App.native.tsx index f0dde6ee1a..c6334379f7 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -1,6 +1,6 @@ import 'react-native-url-polyfill/auto' -import 'lib/sentry' // must be near top -import 'view/icons' +import '#/lib/sentry' // must be near top +import '#/view/icons' import React, {useEffect, useState} from 'react' import {GestureHandlerRootView} from 'react-native-gesture-handler' @@ -23,10 +23,17 @@ import { } from '#/lib/statsig/statsig' import {s} from '#/lib/styles' import {ThemeProvider} from '#/lib/ThemeContext' +import I18nProvider from '#/locale/i18nProvider' import {logger} from '#/logger' import {Provider as A11yProvider} from '#/state/a11y' import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes' import {Provider as DialogStateProvider} from '#/state/dialogs' +import {listenSessionDropped} from '#/state/events' +import { + beginResolveGeolocation, + ensureGeolocationResolved, + Provider as GeolocationProvider, +} from '#/state/geolocation' import {Provider as InvitesStateProvider} from '#/state/invites' import {Provider as LightboxStateProvider} from '#/state/lightbox' import {MessagesProvider} from '#/state/messages' @@ -48,21 +55,27 @@ import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out' import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide' import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' +import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies' import {TestCtrls} from '#/view/com/testing/TestCtrls' +import {Provider as VideoVolumeProvider} from '#/view/com/util/post-embeds/VideoVolumeContext' import * as Toast from '#/view/com/util/Toast' import {Shell} from '#/view/shell' import {ThemeProvider as Alf} from '#/alf' import {useColorModeTheme} from '#/alf/util/useColorModeTheme' +import {NuxDialogs} from '#/components/dialogs/nuxs' import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry' +import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs' import {Provider as PortalProvider} from '#/components/Portal' import {Splash} from '#/Splash' -import {Provider as TourProvider} from '#/tours' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' -import I18nProvider from './locale/i18nProvider' -import {listenSessionDropped} from './state/events' SplashScreen.preventAutoHideAsync() +/** + * Begin geolocation ASAP + */ +beginResolveGeolocation() + function InnerApp() { const [isReady, setIsReady] = React.useState(false) const {currentAccount} = useSession() @@ -95,16 +108,19 @@ function InnerApp() { useEffect(() => { return listenSessionDropped(() => { - Toast.show(_(msg`Sorry! Your session expired. Please log in again.`)) + Toast.show( + _(msg`Sorry! Your session expired. Please log in again.`), + 'info', + ) }) }, [_]) return ( - - - - - + + + + + @@ -116,21 +132,22 @@ function InnerApp() { - - - - + + + + + - - - - + + + + @@ -139,11 +156,11 @@ function InnerApp() { - - - - - + + + + + ) } @@ -151,7 +168,9 @@ function App() { const [isReady, setReady] = useState(false) React.useEffect(() => { - initPersistedState().then(() => setReady(true)) + Promise.all([initPersistedState(), ensureGeolocationResolved()]).then(() => + setReady(true), + ) }, []) if (!isReady) { @@ -163,31 +182,38 @@ function App() { * that is set up in the InnerApp component above. */ return ( - - - - + + + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + - - - - + + + + ) } diff --git a/src/App.web.tsx b/src/App.web.tsx index eb4a925d07..1664812d08 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -1,5 +1,6 @@ -import 'lib/sentry' // must be near top -import 'view/icons' +import '#/lib/sentry' // must be near top +import '#/view/icons' +import './style.css' import React, {useEffect, useState} from 'react' import {KeyboardProvider} from 'react-native-keyboard-controller' @@ -12,10 +13,17 @@ import {useIntentHandler} from '#/lib/hooks/useIntentHandler' import {QueryProvider} from '#/lib/react-query' import {Provider as StatsigProvider} from '#/lib/statsig/statsig' import {ThemeProvider} from '#/lib/ThemeContext' +import I18nProvider from '#/locale/i18nProvider' import {logger} from '#/logger' import {Provider as A11yProvider} from '#/state/a11y' import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes' import {Provider as DialogStateProvider} from '#/state/dialogs' +import {listenSessionDropped} from '#/state/events' +import { + beginResolveGeolocation, + ensureGeolocationResolved, + Provider as GeolocationProvider, +} from '#/state/geolocation' import {Provider as InvitesStateProvider} from '#/state/invites' import {Provider as LightboxStateProvider} from '#/state/lightbox' import {MessagesProvider} from '#/state/messages' @@ -33,21 +41,29 @@ import { } from '#/state/session' import {readLastActiveAccount} from '#/state/session/util' import {Provider as ShellStateProvider} from '#/state/shell' +import {useComposerKeyboardShortcut} from '#/state/shell/composer/useComposerKeyboardShortcut' import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out' import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide' import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' +import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies' +import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoWebContext' +import {Provider as VideoVolumeProvider} from '#/view/com/util/post-embeds/VideoVolumeContext' import * as Toast from '#/view/com/util/Toast' import {ToastContainer} from '#/view/com/util/Toast.web' import {Shell} from '#/view/shell/index' import {ThemeProvider as Alf} from '#/alf' import {useColorModeTheme} from '#/alf/util/useColorModeTheme' +import {NuxDialogs} from '#/components/dialogs/nuxs' import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry' +import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs' import {Provider as PortalProvider} from '#/components/Portal' -import {Provider as TourProvider} from '#/tours' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' -import I18nProvider from './locale/i18nProvider' -import {listenSessionDropped} from './state/events' + +/** + * Begin geolocation ASAP + */ +beginResolveGeolocation() function InnerApp() { const [isReady, setIsReady] = React.useState(false) @@ -58,6 +74,8 @@ function InnerApp() { useIntentHandler() const hasCheckedReferrer = useStarterPackEntry() + useComposerKeyboardShortcut() + // init useEffect(() => { async function onLaunch(account?: SessionAccount) { @@ -77,7 +95,10 @@ function InnerApp() { useEffect(() => { return listenSessionDropped(() => { - Toast.show(_(msg`Sorry! Your session expired. Please log in again.`)) + Toast.show( + _(msg`Sorry! Your session expired. Please log in again.`), + 'info', + ) }) }, [_]) @@ -89,39 +110,44 @@ function InnerApp() { - - - - - {/* LabelDefsProvider MUST come before ModerationOptsProvider */} - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + {/* LabelDefsProvider MUST come before ModerationOptsProvider */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -133,7 +159,9 @@ function App() { const [isReady, setReady] = useState(false) React.useEffect(() => { - initPersistedState().then(() => setReady(true)) + Promise.all([initPersistedState(), ensureGeolocationResolved()]).then(() => + setReady(true), + ) }, []) if (!isReady) { @@ -145,29 +173,33 @@ function App() { * that is set up in the InnerApp component above. */ return ( - - - + + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - - - + + + ) } diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 9e9b49443a..323f668b79 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -15,10 +15,11 @@ import { StackActions, } from '@react-navigation/native' -import {timeout} from 'lib/async/timeout' -import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' -import {usePalette} from 'lib/hooks/usePalette' -import {buildStateObject} from 'lib/routes/helpers' +import {timeout} from '#/lib/async/timeout' +import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle' +import {usePalette} from '#/lib/hooks/usePalette' +import {useWebScrollRestoration} from '#/lib/hooks/useWebScrollRestoration' +import {buildStateObject} from '#/lib/routes/helpers' import { AllNavigatorParams, BottomTabNavigatorParams, @@ -28,71 +29,72 @@ import { MyProfileTabNavigatorParams, NotificationsTabNavigatorParams, SearchTabNavigatorParams, -} from 'lib/routes/types' -import {RouteParams, State} from 'lib/routes/types' -import {bskyTitle} from 'lib/strings/headings' -import {isAndroid, isNative, isWeb} from 'platform/detection' +} from '#/lib/routes/types' +import {RouteParams, State} from '#/lib/routes/types' +import {attachRouteToLogEvents, logEvent} from '#/lib/statsig/statsig' +import {bskyTitle} from '#/lib/strings/headings' +import {isAndroid, isNative, isWeb} from '#/platform/detection' +import {useModalControls} from '#/state/modals' +import {useUnreadNotifications} from '#/state/queries/notifications/unread' +import {useSession} from '#/state/session' +import { + shouldRequestEmailConfirmation, + snoozeEmailConfirmationPrompt, +} from '#/state/shell/reminders' +import {AccessibilitySettingsScreen} from '#/view/screens/AccessibilitySettings' +import {AppPasswords} from '#/view/screens/AppPasswords' +import {CommunityGuidelinesScreen} from '#/view/screens/CommunityGuidelines' +import {CopyrightPolicyScreen} from '#/view/screens/CopyrightPolicy' +import {DebugModScreen} from '#/view/screens/DebugMod' +import {FeedsScreen} from '#/view/screens/Feeds' +import {HomeScreen} from '#/view/screens/Home' +import {LanguageSettingsScreen} from '#/view/screens/LanguageSettings' +import {ListsScreen} from '#/view/screens/Lists' +import {LogScreen} from '#/view/screens/Log' +import {ModerationBlockedAccounts} from '#/view/screens/ModerationBlockedAccounts' +import {ModerationModlistsScreen} from '#/view/screens/ModerationModlists' +import {ModerationMutedAccounts} from '#/view/screens/ModerationMutedAccounts' +import {NotFoundScreen} from '#/view/screens/NotFound' +import {NotificationsScreen} from '#/view/screens/Notifications' +import {NotificationsSettingsScreen} from '#/view/screens/NotificationsSettings' +import {PostThreadScreen} from '#/view/screens/PostThread' import {PreferencesExternalEmbeds} from '#/view/screens/PreferencesExternalEmbeds' -import {AppPasswords} from 'view/screens/AppPasswords' -import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts' -import {ModerationMutedAccounts} from 'view/screens/ModerationMutedAccounts' -import {PreferencesFollowingFeed} from 'view/screens/PreferencesFollowingFeed' -import {PreferencesThreads} from 'view/screens/PreferencesThreads' -import {SavedFeeds} from 'view/screens/SavedFeeds' +import {PreferencesFollowingFeed} from '#/view/screens/PreferencesFollowingFeed' +import {PreferencesThreads} from '#/view/screens/PreferencesThreads' +import {PrivacyPolicyScreen} from '#/view/screens/PrivacyPolicy' +import {ProfileScreen} from '#/view/screens/Profile' +import {ProfileFeedScreen} from '#/view/screens/ProfileFeed' +import {ProfileFeedLikedByScreen} from '#/view/screens/ProfileFeedLikedBy' +import {ProfileFollowersScreen} from '#/view/screens/ProfileFollowers' +import {ProfileFollowsScreen} from '#/view/screens/ProfileFollows' +import {ProfileListScreen} from '#/view/screens/ProfileList' +import {SavedFeeds} from '#/view/screens/SavedFeeds' +import {SearchScreen} from '#/view/screens/Search' +import {SettingsScreen} from '#/view/screens/Settings' +import {Storybook} from '#/view/screens/Storybook' +import {SupportScreen} from '#/view/screens/Support' +import {TermsOfServiceScreen} from '#/view/screens/TermsOfService' +import {BottomBar} from '#/view/shell/bottom-bar/BottomBar' +import {createNativeStackNavigatorWithAuth} from '#/view/shell/createNativeStackNavigatorWithAuth' import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTesterScreen' import HashtagScreen from '#/screens/Hashtag' +import {MessagesScreen} from '#/screens/Messages/ChatList' +import {MessagesConversationScreen} from '#/screens/Messages/Conversation' +import {MessagesSettingsScreen} from '#/screens/Messages/Settings' import {ModerationScreen} from '#/screens/Moderation' +import {PostLikedByScreen} from '#/screens/Post/PostLikedBy' +import {PostQuotesScreen} from '#/screens/Post/PostQuotes' +import {PostRepostedByScreen} from '#/screens/Post/PostRepostedBy' import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers' import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy' +import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings' import { StarterPackScreen, StarterPackScreenShort, } from '#/screens/StarterPack/StarterPackScreen' import {Wizard} from '#/screens/StarterPack/Wizard' +import {router} from '#/routes' import {Referrer} from '../modules/expo-bluesky-swiss-army' -import {init as initAnalytics} from './lib/analytics/analytics' -import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration' -import {attachRouteToLogEvents, logEvent} from './lib/statsig/statsig' -import {router} from './routes' -import {MessagesConversationScreen} from './screens/Messages/Conversation' -import {MessagesScreen} from './screens/Messages/List' -import {MessagesSettingsScreen} from './screens/Messages/Settings' -import {useModalControls} from './state/modals' -import {useUnreadNotifications} from './state/queries/notifications/unread' -import {useSession} from './state/session' -import { - shouldRequestEmailConfirmation, - snoozeEmailConfirmationPrompt, -} from './state/shell/reminders' -import {AccessibilitySettingsScreen} from './view/screens/AccessibilitySettings' -import {CommunityGuidelinesScreen} from './view/screens/CommunityGuidelines' -import {CopyrightPolicyScreen} from './view/screens/CopyrightPolicy' -import {DebugModScreen} from './view/screens/DebugMod' -import {FeedsScreen} from './view/screens/Feeds' -import {HomeScreen} from './view/screens/Home' -import {LanguageSettingsScreen} from './view/screens/LanguageSettings' -import {ListsScreen} from './view/screens/Lists' -import {LogScreen} from './view/screens/Log' -import {ModerationModlistsScreen} from './view/screens/ModerationModlists' -import {NotFoundScreen} from './view/screens/NotFound' -import {NotificationsScreen} from './view/screens/Notifications' -import {PostLikedByScreen} from './view/screens/PostLikedBy' -import {PostRepostedByScreen} from './view/screens/PostRepostedBy' -import {PostThreadScreen} from './view/screens/PostThread' -import {PrivacyPolicyScreen} from './view/screens/PrivacyPolicy' -import {ProfileScreen} from './view/screens/Profile' -import {ProfileFeedScreen} from './view/screens/ProfileFeed' -import {ProfileFeedLikedByScreen} from './view/screens/ProfileFeedLikedBy' -import {ProfileFollowersScreen} from './view/screens/ProfileFollowers' -import {ProfileFollowsScreen} from './view/screens/ProfileFollows' -import {ProfileListScreen} from './view/screens/ProfileList' -import {SearchScreen} from './view/screens/Search' -import {SettingsScreen} from './view/screens/Settings' -import {Storybook} from './view/screens/Storybook' -import {SupportScreen} from './view/screens/Support' -import {TermsOfServiceScreen} from './view/screens/TermsOfService' -import {BottomBar} from './view/shell/bottom-bar/BottomBar' -import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStackNavigatorWithAuth' const navigationRef = createNavigationContainerRef() @@ -210,6 +212,13 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { title: title(msg`Post by @${route.params.name}`), })} /> + PostQuotesScreen} + options={({route}) => ({ + title: title(msg`Post by @${route.params.name}`), + })} + /> ProfileFeedScreen} @@ -309,6 +318,14 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { requireAuth: true, }} /> + AppearanceSettingsScreen} + options={{ + title: title(msg`Appearance Settings`), + requireAuth: true, + }} + /> HashtagScreen} @@ -324,6 +341,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { getComponent={() => MessagesSettingsScreen} options={{title: title(msg`Chat settings`), requireAuth: true}} /> + NotificationsSettingsScreen} + options={{title: title(msg`Notification settings`), requireAuth: true}} + /> FeedsScreen} @@ -467,6 +489,7 @@ function MyProfileTabNavigator() { getComponent={() => ProfileScreen} initialParams={{ name: 'me', + hideBackButton: true, }} /> {commonScreens(MyProfileTab as typeof HomeTab)} @@ -623,8 +646,6 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) { function onReady() { prevLoggedRouteName.current = getCurrentRouteName() - initAnalytics(currentAccount) - if (currentAccount && shouldRequestEmailConfirmation(currentAccount)) { openModal({name: 'verify-email', showReminder: true}) snoozeEmailConfirmationPrompt() @@ -637,16 +658,15 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) { linking={LINKING} theme={theme} onStateChange={() => { - logEvent('router:navigate:sampled', { - from: prevLoggedRouteName.current, - }) - prevLoggedRouteName.current = getCurrentRouteName() + const routeName = getCurrentRouteName() + if (routeName === 'Notifications') { + logEvent('router:navigate:notifications:sampled', {}) + } }} onReady={() => { attachRouteToLogEvents(getCurrentRouteName) logModuleInitTime() onReady() - logEvent('router:navigate:sampled', {}) }}> {children} diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts index 1dc2dfa7b0..ca4c14dc6d 100644 --- a/src/alf/atoms.ts +++ b/src/alf/atoms.ts @@ -1,10 +1,14 @@ -import {Platform, StyleSheet} from 'react-native' +import {Platform, StyleSheet, ViewStyle} from 'react-native' import * as tokens from '#/alf/tokens' import {native, web} from '#/alf/util/platform' -import hairlineWidth = StyleSheet.hairlineWidth export const atoms = { + debug: { + borderColor: 'red', + borderWidth: 1, + }, + /* * Positioning */ @@ -56,6 +60,19 @@ export const atoms = { height: '100vh', }), + /** + * Used for the outermost components on screens, to ensure that they can fill + * the screen and extend beyond. + */ + util_screen_outer: [ + web({ + minHeight: '100vh', + }), + native({ + height: '100%', + }), + ] as ViewStyle, + /* * Theme-independent bg colors */ @@ -145,6 +162,9 @@ export const atoms = { flex_shrink: { flexShrink: 1, }, + flex_shrink_0: { + flexShrink: 0, + }, justify_start: { justifyContent: 'flex-start', }, @@ -205,43 +225,43 @@ export const atoms = { }, text_2xs: { fontSize: tokens.fontSize._2xs, - letterSpacing: 0.25, + letterSpacing: tokens.TRACKING, }, text_xs: { fontSize: tokens.fontSize.xs, - letterSpacing: 0.25, + letterSpacing: tokens.TRACKING, }, text_sm: { fontSize: tokens.fontSize.sm, - letterSpacing: 0.25, + letterSpacing: tokens.TRACKING, }, text_md: { fontSize: tokens.fontSize.md, - letterSpacing: 0.25, + letterSpacing: tokens.TRACKING, }, text_lg: { fontSize: tokens.fontSize.lg, - letterSpacing: 0.25, + letterSpacing: tokens.TRACKING, }, text_xl: { fontSize: tokens.fontSize.xl, - letterSpacing: 0.25, + letterSpacing: tokens.TRACKING, }, text_2xl: { fontSize: tokens.fontSize._2xl, - letterSpacing: 0.25, + letterSpacing: tokens.TRACKING, }, text_3xl: { fontSize: tokens.fontSize._3xl, - letterSpacing: 0.25, + letterSpacing: tokens.TRACKING, }, text_4xl: { fontSize: tokens.fontSize._4xl, - letterSpacing: 0.25, + letterSpacing: tokens.TRACKING, }, text_5xl: { fontSize: tokens.fontSize._5xl, - letterSpacing: 0.25, + letterSpacing: tokens.TRACKING, }, leading_tight: { lineHeight: 1.15, @@ -253,17 +273,11 @@ export const atoms = { lineHeight: 1.5, }, tracking_normal: { - letterSpacing: 0, - }, - tracking_wide: { - letterSpacing: 0.25, + letterSpacing: tokens.TRACKING, }, font_normal: { fontWeight: tokens.fontWeight.normal, }, - font_semibold: { - fontWeight: tokens.fontWeight.semibold, - }, font_bold: { fontWeight: tokens.fontWeight.bold, }, @@ -281,19 +295,19 @@ export const atoms = { borderWidth: 0, }, border: { - borderWidth: hairlineWidth, + borderWidth: StyleSheet.hairlineWidth, }, border_t: { - borderTopWidth: hairlineWidth, + borderTopWidth: StyleSheet.hairlineWidth, }, border_b: { - borderBottomWidth: hairlineWidth, + borderBottomWidth: StyleSheet.hairlineWidth, }, border_l: { - borderLeftWidth: hairlineWidth, + borderLeftWidth: StyleSheet.hairlineWidth, }, border_r: { - borderRightWidth: hairlineWidth, + borderRightWidth: StyleSheet.hairlineWidth, }, /* @@ -851,6 +865,7 @@ export const atoms = { mr_auto: { marginRight: 'auto', }, + /* * Pointer events & user select */ @@ -869,6 +884,7 @@ export const atoms = { user_select_all: { userSelect: 'all', }, + /* * Text decoration */ @@ -878,4 +894,39 @@ export const atoms = { strike_through: { textDecorationLine: 'line-through', }, + + /* + * Display + */ + hidden: { + display: 'none', + }, + + /* + * Transition + */ + transition_none: web({ + transitionProperty: 'none', + }), + transition_all: web({ + transitionProperty: 'all', + transitionTimingFunction: 'cubic-bezier(0.17, 0.73, 0.14, 1)', + transitionDuration: '100ms', + }), + transition_color: web({ + transitionProperty: + 'color, background-color, border-color, text-decoration-color, fill, stroke', + transitionTimingFunction: 'cubic-bezier(0.17, 0.73, 0.14, 1)', + transitionDuration: '100ms', + }), + transition_opacity: web({ + transitionProperty: 'opacity', + transitionTimingFunction: 'cubic-bezier(0.17, 0.73, 0.14, 1)', + transitionDuration: '100ms', + }), + transition_transform: web({ + transitionProperty: 'transform', + transitionTimingFunction: 'cubic-bezier(0.17, 0.73, 0.14, 1)', + transitionDuration: '100ms', + }), } as const diff --git a/src/alf/fonts.ts b/src/alf/fonts.ts new file mode 100644 index 0000000000..b46faed1cc --- /dev/null +++ b/src/alf/fonts.ts @@ -0,0 +1,81 @@ +import {useFonts} from 'expo-font' + +import {isWeb} from '#/platform/detection' +import {Device, device} from '#/storage' + +const FAMILIES = `-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Liberation Sans", Helvetica, Arial, sans-serif` + +const factor = 0.0625 // 1 - (15/16) +const fontScaleMultipliers: Record = { + '-2': 1 - factor * 3, + '-1': 1 - factor * 2, + '0': 1 - factor * 1, // default + '1': 1, + '2': 1 + factor * 1, +} + +export function computeFontScaleMultiplier(scale: Device['fontScale']) { + return fontScaleMultipliers[scale] +} + +export function getFontScale() { + return device.get(['fontScale']) ?? '0' +} + +export function setFontScale(fontScale: Device['fontScale']) { + device.set(['fontScale'], fontScale) +} + +export function getFontFamily() { + return device.get(['fontFamily']) || 'theme' +} + +export function setFontFamily(fontFamily: Device['fontFamily']) { + device.set(['fontFamily'], fontFamily) +} + +/* + * Unused fonts are commented out, but the files are there if we need them. + */ +export function applyFonts( + style: Record, + fontFamily: 'system' | 'theme', +) { + if (fontFamily === 'theme') { + style.fontFamily = 'InterVariable' + + if (style.fontStyle === 'italic') { + style.fontFamily += 'Italic' + } + + // fallback families only supported on web + if (isWeb) { + style.fontFamily += `, ${FAMILIES}` + } + } else { + // fallback families only supported on web + if (isWeb) { + style.fontFamily = style.fontFamily || FAMILIES + } + } + + /** + * Disable contextual ligatures + * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/font-variant} + */ + style.fontVariant = ['no-contextual'] +} + +/* + * IMPORTANT: This is unused. Expo statically extracts these fonts. + * + * All used fonts MUST be configured here. Unused fonts can be commented out. + * + * This is used for both web fonts and native fonts. + */ +export function DO_NOT_USE() { + return useFonts({ + InterVariable: require('../../assets/fonts/inter/InterVariable.ttf'), + 'InterVariable-Italic': require('../../assets/fonts/inter/InterVariable-Italic.ttf'), + }) +} diff --git a/src/alf/index.tsx b/src/alf/index.tsx index ade2ce1451..f9d93d4ca8 100644 --- a/src/alf/index.tsx +++ b/src/alf/index.tsx @@ -1,69 +1,98 @@ import React from 'react' -import {Dimensions} from 'react-native' +import {useMediaQuery} from 'react-responsive' +import { + computeFontScaleMultiplier, + getFontFamily, + getFontScale, + setFontFamily as persistFontFamily, + setFontScale as persistFontScale, +} from '#/alf/fonts' import {createThemes, defaultTheme} from '#/alf/themes' import {Theme, ThemeName} from '#/alf/types' import {BLUE_HUE, GREEN_HUE, RED_HUE} from '#/alf/util/colorGeneration' +import {Device} from '#/storage' export {atoms} from '#/alf/atoms' +export * from '#/alf/fonts' export * as tokens from '#/alf/tokens' export * from '#/alf/types' export * from '#/alf/util/flatten' export * from '#/alf/util/platform' export * from '#/alf/util/themeSelector' -type BreakpointName = keyof typeof breakpoints - -/* - * Breakpoints - */ -const breakpoints: { - [key: string]: number -} = { - gtPhone: 500, - gtMobile: 800, - gtTablet: 1300, -} -function getActiveBreakpoints({width}: {width: number}) { - const active: (keyof typeof breakpoints)[] = Object.keys(breakpoints).filter( - breakpoint => width >= breakpoints[breakpoint], - ) - - return { - active: active[active.length - 1], - gtPhone: active.includes('gtPhone'), - gtMobile: active.includes('gtMobile'), - gtTablet: active.includes('gtTablet'), +export type Alf = { + themeName: ThemeName + theme: Theme + themes: ReturnType + fonts: { + scale: Exclude + scaleMultiplier: number + family: Device['fontFamily'] + setFontScale: (fontScale: Exclude) => void + setFontFamily: (fontFamily: Device['fontFamily']) => void } + /** + * Feature flags or other gated options + */ + flags: {} } /* * Context */ -export const Context = React.createContext<{ - themeName: ThemeName - theme: Theme - breakpoints: { - active: BreakpointName | undefined - gtPhone: boolean - gtMobile: boolean - gtTablet: boolean - } -}>({ +export const Context = React.createContext({ themeName: 'light', theme: defaultTheme, - breakpoints: { - active: undefined, - gtPhone: false, - gtMobile: false, - gtTablet: false, + themes: createThemes({ + hues: { + primary: BLUE_HUE, + negative: RED_HUE, + positive: GREEN_HUE, + }, + }), + fonts: { + scale: getFontScale(), + scaleMultiplier: computeFontScaleMultiplier(getFontScale()), + family: getFontFamily(), + setFontScale: () => {}, + setFontFamily: () => {}, }, + flags: {}, }) export function ThemeProvider({ children, theme: themeName, }: React.PropsWithChildren<{theme: ThemeName}>) { + const [fontScale, setFontScale] = React.useState(() => + getFontScale(), + ) + const [fontScaleMultiplier, setFontScaleMultiplier] = React.useState(() => + computeFontScaleMultiplier(fontScale), + ) + const setFontScaleAndPersist = React.useCallback< + Alf['fonts']['setFontScale'] + >( + fontScale => { + setFontScale(fontScale) + persistFontScale(fontScale) + setFontScaleMultiplier(computeFontScaleMultiplier(fontScale)) + }, + [setFontScale], + ) + const [fontFamily, setFontFamily] = React.useState( + () => getFontFamily(), + ) + const setFontFamilyAndPersist = React.useCallback< + Alf['fonts']['setFontFamily'] + >( + fontFamily => { + setFontFamily(fontFamily) + persistFontFamily(fontFamily) + }, + [setFontFamily], + ) const themes = React.useMemo(() => { return createThemes({ hues: { @@ -73,39 +102,56 @@ export function ThemeProvider({ }, }) }, []) - const theme = themes[themeName] - const [breakpoints, setBreakpoints] = React.useState(() => - getActiveBreakpoints({width: Dimensions.get('window').width}), - ) - - React.useEffect(() => { - const listener = Dimensions.addEventListener('change', ({window}) => { - const bp = getActiveBreakpoints({width: window.width}) - if (bp.active !== breakpoints.active) setBreakpoints(bp) - }) - - return listener.remove - }, [breakpoints, setBreakpoints]) return ( ( () => ({ + themes, themeName: themeName, - theme: theme, - breakpoints, + theme: themes[themeName], + fonts: { + scale: fontScale, + scaleMultiplier: fontScaleMultiplier, + family: fontFamily, + setFontScale: setFontScaleAndPersist, + setFontFamily: setFontFamilyAndPersist, + }, + flags: {}, }), - [theme, themeName, breakpoints], + [ + themeName, + themes, + fontScale, + setFontScaleAndPersist, + fontFamily, + setFontFamilyAndPersist, + fontScaleMultiplier, + ], )}> {children} ) } -export function useTheme() { - return React.useContext(Context).theme +export function useAlf() { + return React.useContext(Context) +} + +export function useTheme(theme?: ThemeName) { + const alf = useAlf() + return React.useMemo(() => { + return theme ? alf.themes[theme] : alf.theme + }, [theme, alf]) } export function useBreakpoints() { - return React.useContext(Context).breakpoints + const gtPhone = useMediaQuery({minWidth: 500}) + const gtMobile = useMediaQuery({minWidth: 800}) + const gtTablet = useMediaQuery({minWidth: 1300}) + return { + gtPhone, + gtMobile, + gtTablet, + } } diff --git a/src/alf/themes.ts b/src/alf/themes.ts index ba18ee0072..0cfe09aadc 100644 --- a/src/alf/themes.ts +++ b/src/alf/themes.ts @@ -183,22 +183,22 @@ export function createThemes({ } as const const darkPalette: Palette = { - white: color.gray_0, + white: color.gray_25, black: color.trueBlack, - contrast_25: color.gray_1000, - contrast_50: color.gray_975, - contrast_100: color.gray_950, - contrast_200: color.gray_900, - contrast_300: color.gray_800, - contrast_400: color.gray_700, - contrast_500: color.gray_600, - contrast_600: color.gray_500, - contrast_700: color.gray_400, - contrast_800: color.gray_300, - contrast_900: color.gray_200, - contrast_950: color.gray_100, - contrast_975: color.gray_50, + contrast_25: color.gray_975, + contrast_50: color.gray_950, + contrast_100: color.gray_900, + contrast_200: color.gray_800, + contrast_300: color.gray_700, + contrast_400: color.gray_600, + contrast_500: color.gray_500, + contrast_600: color.gray_400, + contrast_700: color.gray_300, + contrast_800: color.gray_200, + contrast_900: color.gray_100, + contrast_950: color.gray_50, + contrast_975: color.gray_25, primary_25: color.primary_975, primary_50: color.primary_950, @@ -305,6 +305,7 @@ export function createThemes({ } as const const light: Theme = { + scheme: 'light', name: 'light', palette: lightPalette, atoms: { @@ -390,6 +391,7 @@ export function createThemes({ } const dark: Theme = { + scheme: 'dark', name: 'dark', palette: darkPalette, atoms: { @@ -400,7 +402,7 @@ export function createThemes({ color: darkPalette.contrast_400, }, text_contrast_medium: { - color: darkPalette.contrast_700, + color: darkPalette.contrast_600, }, text_contrast_high: { color: darkPalette.contrast_900, @@ -479,6 +481,7 @@ export function createThemes({ const dim: Theme = { ...dark, + scheme: 'dark', name: 'dim', palette: dimPalette, atoms: { diff --git a/src/alf/tokens.ts b/src/alf/tokens.ts index 0208945eeb..9ea585ee7c 100644 --- a/src/alf/tokens.ts +++ b/src/alf/tokens.ts @@ -1,3 +1,7 @@ +import {isAndroid} from '#/platform/detection' + +export const TRACKING = isAndroid ? 0.1 : 0 + export const color = { temp_purple: 'rgb(105 0 255)', temp_purple_dark: 'rgb(83 0 202)', @@ -43,14 +47,25 @@ export const borderRadius = { full: 999, } as const +/** + * These correspond to Inter font files we actually load. + */ export const fontWeight = { normal: '400', - semibold: '500', - bold: '600', - heavy: '700', + bold: isAndroid ? '700' : '600', + heavy: isAndroid ? '900' : '800', } as const export const gradients = { + primary: { + values: [ + [0, '#054CFF'], + [0.4, '#1085FE'], + [0.6, '#1085FE'], + [1, '#59B9FF'], + ], + hover_value: '#1085FE', + }, sky: { values: [ [0, '#0A7AFF'], diff --git a/src/alf/types.ts b/src/alf/types.ts index 41822b8dd5..08ec593927 100644 --- a/src/alf/types.ts +++ b/src/alf/types.ts @@ -156,6 +156,7 @@ export type ThemedAtoms = { } } export type Theme = { + scheme: 'light' | 'dark' // for library support name: ThemeName palette: Palette atoms: ThemedAtoms diff --git a/src/alf/util/useColorModeTheme.ts b/src/alf/util/useColorModeTheme.ts index 12840c7062..561a504b2f 100644 --- a/src/alf/util/useColorModeTheme.ts +++ b/src/alf/util/useColorModeTheme.ts @@ -1,9 +1,8 @@ import React from 'react' import {ColorSchemeName, useColorScheme} from 'react-native' -import * as SystemUI from 'expo-system-ui' -import {isWeb} from 'platform/detection' -import {useThemePrefs} from 'state/shell' +import {isWeb} from '#/platform/detection' +import {useThemePrefs} from '#/state/shell' import {dark, dim, light} from '#/alf/themes' import {ThemeName} from '#/alf/types' @@ -12,7 +11,6 @@ export function useColorModeTheme(): ThemeName { React.useLayoutEffect(() => { updateDocument(theme) - SystemUI.setBackgroundColorAsync(getBackgroundColor(theme)) }, [theme]) return theme diff --git a/src/components/AccountList.tsx b/src/components/AccountList.tsx index 883c06c144..68bb482af2 100644 --- a/src/components/AccountList.tsx +++ b/src/components/AccountList.tsx @@ -126,10 +126,12 @@ function AccountItem({ - + {profile?.displayName || account.handle}{' '} - {account.handle} + + {account.handle} + {isCurrentAccount ? ( & AccessibilityProps & VariantProps & { testID?: string + /** + * For a11y, try to make this descriptive and clear + */ label: string style?: StyleProp hoverStyle?: StyleProp children: NonTextElements | ((context: ButtonContext) => NonTextElements) + PressableComponent?: React.ComponentType } export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean} @@ -101,6 +115,7 @@ export const Button = React.forwardRef( disabled = false, style, hoverStyle: hoverStyleProp, + PressableComponent = Pressable, ...rest }, ref, @@ -112,30 +127,50 @@ export const Button = React.forwardRef( focused: false, }) - const onPressIn = React.useCallback(() => { - setState(s => ({ - ...s, - pressed: true, - })) - }, [setState]) - const onPressOut = React.useCallback(() => { - setState(s => ({ - ...s, - pressed: false, - })) - }, [setState]) - const onHoverIn = React.useCallback(() => { - setState(s => ({ - ...s, - hovered: true, - })) - }, [setState]) - const onHoverOut = React.useCallback(() => { - setState(s => ({ - ...s, - hovered: false, - })) - }, [setState]) + const onPressInOuter = rest.onPressIn + const onPressIn = React.useCallback( + (e: GestureResponderEvent) => { + setState(s => ({ + ...s, + pressed: true, + })) + onPressInOuter?.(e) + }, + [setState, onPressInOuter], + ) + const onPressOutOuter = rest.onPressOut + const onPressOut = React.useCallback( + (e: GestureResponderEvent) => { + setState(s => ({ + ...s, + pressed: false, + })) + onPressOutOuter?.(e) + }, + [setState, onPressOutOuter], + ) + const onHoverInOuter = rest.onHoverIn + const onHoverIn = React.useCallback( + (e: MouseEvent) => { + setState(s => ({ + ...s, + hovered: true, + })) + onHoverInOuter?.(e) + }, + [setState, onHoverInOuter], + ) + const onHoverOutOuter = rest.onHoverOut + const onHoverOut = React.useCallback( + (e: MouseEvent) => { + setState(s => ({ + ...s, + hovered: false, + })) + onHoverOutOuter?.(e) + }, + [setState, onHoverOutOuter], + ) const onFocus = React.useCallback(() => { setState(s => ({ ...s, @@ -199,28 +234,10 @@ export const Button = React.forwardRef( } else if (color === 'secondary') { if (variant === 'solid') { if (!disabled) { - baseStyles.push({ - backgroundColor: select(t.name, { - light: t.palette.contrast_25, - dim: t.palette.contrast_100, - dark: t.palette.contrast_100, - }), - }) - hoverStyles.push({ - backgroundColor: select(t.name, { - light: t.palette.contrast_50, - dim: t.palette.contrast_200, - dark: t.palette.contrast_200, - }), - }) + baseStyles.push(t.atoms.bg_contrast_25) + hoverStyles.push(t.atoms.bg_contrast_50) } else { - baseStyles.push({ - backgroundColor: select(t.name, { - light: t.palette.contrast_100, - dim: t.palette.contrast_25, - dark: t.palette.contrast_25, - }), - }) + baseStyles.push(t.atoms.bg_contrast_100) } } else if (variant === 'outline') { baseStyles.push(a.border, t.atoms.bg, { @@ -329,39 +346,46 @@ export const Button = React.forwardRef( if (shape === 'default') { if (size === 'large') { - baseStyles.push( - {paddingVertical: 15}, - a.px_2xl, - a.rounded_sm, - a.gap_md, - ) - } else if (size === 'medium') { - baseStyles.push( - {paddingVertical: 12}, - a.px_2xl, - a.rounded_sm, - a.gap_md, - ) + baseStyles.push({ + paddingVertical: 13, + paddingHorizontal: 20, + borderRadius: 8, + gap: 8, + }) } else if (size === 'small') { - baseStyles.push({paddingVertical: 9}, a.px_lg, a.rounded_sm, a.gap_sm) - } else if (size === 'xsmall') { - baseStyles.push({paddingVertical: 6}, a.px_sm, a.rounded_sm, a.gap_sm) + baseStyles.push({ + paddingVertical: 9, + paddingHorizontal: 12, + borderRadius: 6, + gap: 6, + }) } else if (size === 'tiny') { - baseStyles.push({paddingVertical: 4}, a.px_sm, a.rounded_xs, a.gap_xs) + baseStyles.push({ + paddingVertical: 4, + paddingHorizontal: 8, + borderRadius: 4, + gap: 4, + }) } } else if (shape === 'round' || shape === 'square') { if (size === 'large') { if (shape === 'round') { - baseStyles.push({height: 54, width: 54}) + baseStyles.push({height: 46, width: 46}) } else { - baseStyles.push({height: 50, width: 50}) + baseStyles.push({height: 44, width: 44}) } } else if (size === 'small') { - baseStyles.push({height: 34, width: 34}) - } else if (size === 'xsmall') { - baseStyles.push({height: 28, width: 28}) + if (shape === 'round') { + baseStyles.push({height: 34, width: 34}) + } else { + baseStyles.push({height: 34, width: 34}) + } } else if (size === 'tiny') { - baseStyles.push({height: 20, width: 20}) + if (shape === 'round') { + baseStyles.push({height: 22, width: 22}) + } else { + baseStyles.push({height: 21, width: 21}) + } } if (shape === 'round') { @@ -391,6 +415,7 @@ export const Button = React.forwardRef( secondary: tokens.gradients.sky, secondary_inverted: tokens.gradients.sky, negative: tokens.gradients.sky, + gradient_primary: tokens.gradients.primary, gradient_sky: tokens.gradients.sky, gradient_midnight: tokens.gradients.midnight, gradient_sunrise: tokens.gradients.sunrise, @@ -423,13 +448,14 @@ export const Button = React.forwardRef( [state, variant, color, size, disabled], ) - const flattenedBaseStyles = flatten(baseStyles) + const flattenedBaseStyles = flatten([baseStyles, style]) return ( - ( a.align_center, a.justify_center, flattenedBaseStyles, - flatten(style), ...(state.hovered || state.pressed ? [hoverStyles, flatten(hoverStyleProp)] : []), @@ -478,7 +503,7 @@ export const Button = React.forwardRef( {typeof children === 'function' ? children(context) : children} - + ) }, ) @@ -605,11 +630,11 @@ export function useSharedButtonTextStyles() { } if (size === 'large') { - baseStyles.push(a.text_md, android({paddingBottom: 1})) + baseStyles.push(a.text_md, a.leading_tight) + } else if (size === 'small') { + baseStyles.push(a.text_sm, a.leading_tight) } else if (size === 'tiny') { - baseStyles.push(a.text_xs, android({paddingBottom: 1})) - } else { - baseStyles.push(a.text_sm, android({paddingBottom: 1})) + baseStyles.push(a.text_xs, a.leading_tight) } return StyleSheet.flatten(baseStyles) @@ -620,14 +645,7 @@ export function ButtonText({children, style, ...rest}: ButtonTextProps) { const textStyles = useSharedButtonTextStyles() return ( - + {children} ) @@ -636,31 +654,98 @@ export function ButtonText({children, style, ...rest}: ButtonTextProps) { export function ButtonIcon({ icon: Comp, position, - size: iconSize, + size, }: { icon: React.ComponentType position?: 'left' | 'right' size?: SVGIconProps['size'] }) { - const {size, disabled} = useButtonContext() + const {size: buttonSize, disabled} = useButtonContext() const textStyles = useSharedButtonTextStyles() + const {iconSize, iconContainerSize} = React.useMemo(() => { + /** + * Pre-set icon sizes for different button sizes + */ + const iconSizeShorthand = + size ?? + (({ + large: 'sm', + small: 'xs', + tiny: 'xs', + }[buttonSize || 'small'] || 'sm') as Exclude< + SVGIconProps['size'], + undefined + >) + + /* + * Copied here from icons/common.tsx so we can tweak if we need to, but + * also so that we can calculate transforms. + */ + const iconSize = { + xs: 12, + sm: 16, + md: 20, + lg: 24, + xl: 28, + '2xl': 32, + }[iconSizeShorthand] + + /* + * Goal here is to match rendered text size so that different size icons + * don't increase button size + */ + const iconContainerSize = { + large: 18, + small: 16, + tiny: 13, + }[buttonSize || 'small'] + + return { + iconSize, + iconContainerSize, + } + }, [buttonSize, size]) return ( - + + + ) } diff --git a/src/components/Dialog/context.ts b/src/components/Dialog/context.ts index 859f8edd77..b479bc7f06 100644 --- a/src/components/Dialog/context.ts +++ b/src/components/Dialog/context.ts @@ -6,9 +6,14 @@ import { DialogControlRefProps, DialogOuterProps, } from '#/components/Dialog/types' +import {BottomSheetSnapPoint} from '../../../modules/bottom-sheet/src/BottomSheet.types' export const Context = React.createContext({ close: () => {}, + isNativeDialog: false, + nativeSnapPoint: BottomSheetSnapPoint.Hidden, + disableDrag: false, + setDisableDrag: () => {}, }) export function useDialogContext() { diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx index 158244c8e9..49b5e10b23 100644 --- a/src/components/Dialog/index.tsx +++ b/src/components/Dialog/index.tsx @@ -1,85 +1,48 @@ import React, {useImperativeHandle} from 'react' import { - Dimensions, - Keyboard, + NativeScrollEvent, + NativeSyntheticEvent, Pressable, + ScrollView, StyleProp, + TextInput, View, ViewStyle, } from 'react-native' -import Animated, {useAnimatedStyle} from 'react-native-reanimated' +import { + KeyboardAwareScrollView, + useKeyboardHandler, +} from 'react-native-keyboard-controller' +import {runOnJS} from 'react-native-reanimated' import {useSafeAreaInsets} from 'react-native-safe-area-context' -import BottomSheet, { - BottomSheetBackdropProps, - BottomSheetFlatList, - BottomSheetFlatListMethods, - BottomSheetScrollView, - BottomSheetScrollViewMethods, - BottomSheetTextInput, - BottomSheetView, - useBottomSheet, - WINDOW_HEIGHT, -} from '@discord/bottom-sheet/src' -import {BottomSheetFlatListProps} from '@discord/bottom-sheet/src/components/bottomSheetScrollable/types' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' import {logger} from '#/logger' +import {isAndroid, isIOS} from '#/platform/detection' +import {useA11y} from '#/state/a11y' import {useDialogStateControlContext} from '#/state/dialogs' -import {atoms as a, flatten, useTheme} from '#/alf' -import {Context} from '#/components/Dialog/context' +import {List, ListMethods, ListProps} from '#/view/com/util/List' +import {atoms as a, useTheme} from '#/alf' +import {Context, useDialogContext} from '#/components/Dialog/context' import { DialogControlProps, DialogInnerProps, DialogOuterProps, } from '#/components/Dialog/types' import {createInput} from '#/components/forms/TextField' -import {FullWindowOverlay} from '#/components/FullWindowOverlay' -import {Portal} from '#/components/Portal' +import {Portal as DefaultPortal} from '#/components/Portal' +import {BottomSheet, BottomSheetSnapPoint} from '../../../modules/bottom-sheet' +import { + BottomSheetSnapPointChangeEvent, + BottomSheetStateChangeEvent, +} from '../../../modules/bottom-sheet/src/BottomSheet.types' export {useDialogContext, useDialogControl} from '#/components/Dialog/context' export * from '#/components/Dialog/types' +export * from '#/components/Dialog/utils' // @ts-ignore -export const Input = createInput(BottomSheetTextInput) - -function Backdrop(props: BottomSheetBackdropProps) { - const t = useTheme() - const bottomSheet = useBottomSheet() - - const animatedStyle = useAnimatedStyle(() => { - const opacity = - (Math.abs(WINDOW_HEIGHT - props.animatedPosition.value) - 50) / 1000 - - return { - opacity: Math.min(Math.max(opacity, 0), 0.55), - } - }) - - const onPress = React.useCallback(() => { - bottomSheet.close() - }, [bottomSheet]) - - return ( - - - - ) -} +export const Input = createInput(TextInput) export function Outer({ children, @@ -87,24 +50,22 @@ export function Outer({ onClose, nativeOptions, testID, + Portal = DefaultPortal, }: React.PropsWithChildren) { const t = useTheme() - const sheet = React.useRef(null) - const sheetOptions = nativeOptions?.sheet || {} - const hasSnapPoints = !!sheetOptions.snapPoints - const insets = useSafeAreaInsets() + const ref = React.useRef(null) const closeCallbacks = React.useRef<(() => void)[]>([]) - const {setDialogIsOpen} = useDialogStateControlContext() + const {setDialogIsOpen, setFullyExpandedCount} = + useDialogStateControlContext() - /* - * Used to manage open/closed, but index is otherwise handled internally by `BottomSheet` - */ - const [openIndex, setOpenIndex] = React.useState(-1) + const prevSnapPoint = React.useRef( + BottomSheetSnapPoint.Hidden, + ) - /* - * `openIndex` is the index of the snap point to open the bottom sheet to. If >0, the bottom sheet is open. - */ - const isOpen = openIndex > -1 + const [disableDrag, setDisableDrag] = React.useState(false) + const [snapPoint, setSnapPoint] = React.useState( + BottomSheetSnapPoint.Partial, + ) const callQueuedCallbacks = React.useCallback(() => { for (const cb of closeCallbacks.current) { @@ -118,25 +79,19 @@ export function Outer({ closeCallbacks.current = [] }, []) - const open = React.useCallback( - ({index} = {}) => { - // Run any leftover callbacks that might have been queued up before calling `.open()` - callQueuedCallbacks() - - setDialogIsOpen(control.id, true) - // can be set to any index of `snapPoints`, but `0` is the first i.e. "open" - setOpenIndex(index || 0) - sheet.current?.snapToIndex(index || 0) - }, - [setDialogIsOpen, control.id, callQueuedCallbacks], - ) + const open = React.useCallback(() => { + // Run any leftover callbacks that might have been queued up before calling `.open()` + callQueuedCallbacks() + setDialogIsOpen(control.id, true) + ref.current?.present() + }, [setDialogIsOpen, control.id, callQueuedCallbacks]) // This is the function that we call when we want to dismiss the dialog. const close = React.useCallback(cb => { if (typeof cb === 'function') { closeCallbacks.current.push(cb) } - sheet.current?.close() + ref.current?.dismiss() }, []) // This is the actual thing we are doing once we "confirm" the dialog. We want the dialog's close animation to @@ -145,12 +100,39 @@ export function Outer({ // This removes the dialog from our list of stored dialogs. Not super necessary on iOS, but on Android this // tells us that we need to toggle the accessibility overlay setting setDialogIsOpen(control.id, false) - setOpenIndex(-1) - callQueuedCallbacks() onClose?.() }, [callQueuedCallbacks, control.id, onClose, setDialogIsOpen]) + const onSnapPointChange = (e: BottomSheetSnapPointChangeEvent) => { + const {snapPoint} = e.nativeEvent + setSnapPoint(snapPoint) + + if ( + snapPoint === BottomSheetSnapPoint.Full && + prevSnapPoint.current !== BottomSheetSnapPoint.Full + ) { + setFullyExpandedCount(c => c + 1) + } else if ( + snapPoint !== BottomSheetSnapPoint.Full && + prevSnapPoint.current === BottomSheetSnapPoint.Full + ) { + setFullyExpandedCount(c => c - 1) + } + prevSnapPoint.current = snapPoint + } + + const onStateChange = (e: BottomSheetStateChangeEvent) => { + if (e.nativeEvent.state === 'closed') { + onCloseAnimationComplete() + + if (prevSnapPoint.current === BottomSheetSnapPoint.Full) { + setFullyExpandedCount(c => c - 1) + } + prevSnapPoint.current = BottomSheetSnapPoint.Hidden + } + } + useImperativeHandle( control.ref, () => ({ @@ -160,159 +142,144 @@ export function Outer({ [open, close], ) - React.useEffect(() => { - return () => { - setDialogIsOpen(control.id, false) - } - }, [control.id, setDialogIsOpen]) - - const context = React.useMemo(() => ({close}), [close]) + const context = React.useMemo( + () => ({ + close, + isNativeDialog: true, + nativeSnapPoint: snapPoint, + disableDrag, + setDisableDrag, + }), + [close, snapPoint, disableDrag, setDisableDrag], + ) return ( - isOpen && ( - - - Keyboard.dismiss()}> - - - - {children} - - - - - - ) + + + + {children} + + + ) } export function Inner({children, style}: DialogInnerProps) { const insets = useSafeAreaInsets() return ( - {children} - + ) } -export const ScrollableInner = React.forwardRef< - BottomSheetScrollViewMethods, - DialogInnerProps ->(function ScrollableInner({children, style}, ref) { - const insets = useSafeAreaInsets() - return ( - - {children} - - - ) -}) +export const ScrollableInner = React.forwardRef( + function ScrollableInner({children, style, ...props}, ref) { + const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext() + const insets = useSafeAreaInsets() + const [keyboardHeight, setKeyboardHeight] = React.useState(0) + useKeyboardHandler({ + onEnd: e => { + 'worklet' + runOnJS(setKeyboardHeight)(e.height) + }, + }) + + const basePading = + (isIOS ? 30 : 50) + (isIOS ? keyboardHeight / 4 : keyboardHeight) + const fullPaddingBase = insets.bottom + insets.top + basePading + const fullPadding = isIOS ? fullPaddingBase : fullPaddingBase + 50 + + const paddingBottom = + nativeSnapPoint === BottomSheetSnapPoint.Full ? fullPadding : basePading + + const onScroll = (e: NativeSyntheticEvent) => { + const {contentOffset} = e.nativeEvent + if (contentOffset.y > 0 && !disableDrag) { + setDisableDrag(true) + } else if (contentOffset.y <= 1 && disableDrag) { + setDisableDrag(false) + } + } + + return ( + + {children} + + ) + }, +) export const InnerFlatList = React.forwardRef< - BottomSheetFlatListMethods, - BottomSheetFlatListProps & {webInnerStyle?: StyleProp} ->(function InnerFlatList({style, contentContainerStyle, ...props}, ref) { + ListMethods, + ListProps & {webInnerStyle?: StyleProp} +>(function InnerFlatList({style, ...props}, ref) { const insets = useSafeAreaInsets() - + const {nativeSnapPoint} = useDialogContext() return ( - } ref={ref} {...props} - style={[ - a.flex_1, - a.p_xl, - a.pt_0, - a.h_full, - { - marginTop: 40, - }, - flatten(style), - ]} + style={[style]} /> ) }) export function Handle() { const t = useTheme() + const {_} = useLingui() + const {screenReaderEnabled} = useA11y() + const {close} = useDialogContext() return ( - - + + close()} + accessibilityLabel={_(msg`Dismiss`)} + accessibilityHint={_(msg`Double tap to close the dialog`)}> + + ) } diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx index aff1842f77..7b9cfb6931 100644 --- a/src/components/Dialog/index.web.tsx +++ b/src/components/Dialog/index.web.tsx @@ -27,6 +27,7 @@ import {Portal} from '#/components/Portal' export {useDialogContext, useDialogControl} from '#/components/Dialog/context' export * from '#/components/Dialog/types' +export * from '#/components/Dialog/utils' export {Input} from '#/components/forms/TextField' const stopPropagation = (e: any) => e.stopPropagation() @@ -102,6 +103,10 @@ export function Outer({ const context = React.useMemo( () => ({ close, + isNativeDialog: false, + nativeSnapPoint: 0, + disableDrag: false, + setDisableDrag: () => {}, }), [close], ) @@ -228,10 +233,6 @@ export const InnerFlatList = React.forwardRef< ) }) -export function Handle() { - return null -} - export function Close() { const {_} = useLingui() const {close} = React.useContext(Context) @@ -257,3 +258,7 @@ export function Close() { ) } + +export function Handle() { + return null +} diff --git a/src/components/Dialog/sheet-wrapper.ts b/src/components/Dialog/sheet-wrapper.ts new file mode 100644 index 0000000000..37c6633837 --- /dev/null +++ b/src/components/Dialog/sheet-wrapper.ts @@ -0,0 +1,20 @@ +import {useCallback} from 'react' + +import {useDialogStateControlContext} from '#/state/dialogs' + +/** + * If we're calling a system API like the image picker that opens a sheet + * wrap it in this function to make sure the status bar is the correct color. + */ +export function useSheetWrapper() { + const {setFullyExpandedCount} = useDialogStateControlContext() + return useCallback( + async (promise: Promise): Promise => { + setFullyExpandedCount(c => c + 1) + const res = await promise + setFullyExpandedCount(c => c - 1) + return res + }, + [setFullyExpandedCount], + ) +} diff --git a/src/components/Dialog/types.ts b/src/components/Dialog/types.ts index 1ddab02eea..caa787535b 100644 --- a/src/components/Dialog/types.ts +++ b/src/components/Dialog/types.ts @@ -4,9 +4,11 @@ import type { GestureResponderEvent, ScrollViewProps, } from 'react-native' -import {BottomSheetProps} from '@discord/bottom-sheet/src' import {ViewStyleProp} from '#/alf' +import {PortalComponent} from '#/components/Portal' +import {BottomSheetViewProps} from '../../../modules/bottom-sheet' +import {BottomSheetSnapPoint} from '../../../modules/bottom-sheet/src/BottomSheet.types' type A11yProps = Required @@ -37,6 +39,10 @@ export type DialogControlProps = DialogControlRefProps & { export type DialogContextProps = { close: DialogControlProps['close'] + isNativeDialog: boolean + nativeSnapPoint: BottomSheetSnapPoint + disableDrag: boolean + setDisableDrag: React.Dispatch> } export type DialogControlOpenOptions = { @@ -52,11 +58,10 @@ export type DialogControlOpenOptions = { export type DialogOuterProps = { control: DialogControlProps onClose?: () => void - nativeOptions?: { - sheet?: Omit - } + nativeOptions?: Omit webOptions?: {} testID?: string + Portal?: PortalComponent } type DialogInnerPropsBase = React.PropsWithChildren & T diff --git a/src/components/Dialog/utils.ts b/src/components/Dialog/utils.ts new file mode 100644 index 0000000000..058d6e8041 --- /dev/null +++ b/src/components/Dialog/utils.ts @@ -0,0 +1,18 @@ +import React from 'react' + +import {DialogControlProps} from '#/components/Dialog/types' + +export function useAutoOpen(control: DialogControlProps, showTimeout?: number) { + React.useEffect(() => { + if (showTimeout) { + const timeout = setTimeout(() => { + control.open() + }, showTimeout) + return () => { + clearTimeout(timeout) + } + } else { + control.open() + } + }, [control, showTimeout]) +} diff --git a/src/components/Error.tsx b/src/components/Error.tsx index 481532434a..59d219831c 100644 --- a/src/components/Error.tsx +++ b/src/components/Error.tsx @@ -2,21 +2,18 @@ import React from 'react' import {View} from 'react-native' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useNavigation} from '@react-navigation/core' -import {StackActions} from '@react-navigation/native' -import {NavigationProp} from 'lib/routes/types' +import {useGoBack} from 'lib/hooks/useGoBack' import {CenteredView} from 'view/com/util/Views' import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' import {Text} from '#/components/Typography' -import {router} from '#/routes' export function Error({ title, message, onRetry, - onGoBack: onGoBackProp, + onGoBack, hideBackButton, sideBorders = true, }: { @@ -27,31 +24,10 @@ export function Error({ hideBackButton?: boolean sideBorders?: boolean }) { - const navigation = useNavigation() const {_} = useLingui() const t = useTheme() const {gtMobile} = useBreakpoints() - - const canGoBack = navigation.canGoBack() - const onGoBack = React.useCallback(() => { - if (onGoBackProp) { - onGoBackProp() - return - } - if (canGoBack) { - navigation.goBack() - } else { - navigation.navigate('HomeTab') - - // Checking the state for routes ensures that web doesn't encounter errors while going back - if (navigation.getState()?.routes) { - navigation.dispatch(StackActions.push(...router.matchPath('/'))) - } else { - navigation.navigate('HomeTab') - navigation.dispatch(StackActions.popToTop()) - } - } - }, [navigation, canGoBack, onGoBackProp]) + const goBack = useGoBack(onGoBack) return ( diff --git a/src/components/FeedCard.tsx b/src/components/FeedCard.tsx index 5e50f3c483..b28f66f839 100644 --- a/src/components/FeedCard.tsx +++ b/src/components/FeedCard.tsx @@ -11,17 +11,17 @@ import {msg, plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' +import {sanitizeHandle} from '#/lib/strings/handles' import {logger} from '#/logger' +import {precacheFeedFromGeneratorView} from '#/state/queries/feed' import { useAddSavedFeedsMutation, usePreferencesQuery, useRemoveFeedMutation, } from '#/state/queries/preferences' -import {sanitizeHandle} from 'lib/strings/handles' -import {precacheFeedFromGeneratorView} from 'state/queries/feed' -import {useSession} from 'state/session' +import {useSession} from '#/state/session' +import * as Toast from '#/view/com/util/Toast' 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' @@ -40,7 +40,7 @@ type Props = { export function Default(props: Props) { const {view} = props return ( - +
@@ -58,7 +58,7 @@ export function Link({ view, children, ...props -}: Props & Omit) { +}: Props & Omit) { const queryClient = useQueryClient() const href = React.useMemo(() => { @@ -70,7 +70,11 @@ export function Link({ }, [view, queryClient]) return ( - + {children} ) @@ -117,7 +121,10 @@ export function TitleAndByline({ return ( - + {title} {creator && ( @@ -264,9 +271,9 @@ function SaveButtonInner({ ]) } 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`)) + } catch (err: any) { + logger.error(err, {context: `FeedCard: failed to update feeds`, pin}) + Toast.show(_(msg`Failed to update feeds`), 'xmark') } }, [_, pin, saveFeeds, removeFeed, uri, savedFeedConfig, type], diff --git a/src/components/FeedInterstitials.tsx b/src/components/FeedInterstitials.tsx index 53c1fac0b9..286ded13e2 100644 --- a/src/components/FeedInterstitials.tsx +++ b/src/components/FeedInterstitials.tsx @@ -1,7 +1,7 @@ import React from 'react' import {View} from 'react-native' import {ScrollView} from 'react-native-gesture-handler' -import {AppBskyFeedDefs, AtUri} from '@atproto/api' +import {AppBskyActorDefs, AppBskyFeedDefs, AtUri} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' @@ -12,7 +12,9 @@ import {logEvent} from '#/lib/statsig/statsig' import {logger} from '#/logger' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useGetPopularFeedsQuery} from '#/state/queries/feed' +import {FeedDescriptor} from '#/state/queries/post-feed' import {useProfilesQuery} from '#/state/queries/profile' +import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows' import {useSession} from '#/state/session' import {useProgressGuide} from '#/state/shell/progress-guide' import * as userActionHistory from '#/state/userActionHistory' @@ -58,16 +60,13 @@ function CardOuter({ export function SuggestedFollowPlaceholder() { const t = useTheme() return ( - + + - - - - - + ) } @@ -92,14 +91,16 @@ function getRank(seenPost: SeenPost): string { tier = 'a' } else if (seenPost.feedContext?.startsWith('cluster')) { tier = 'b' - } else if (seenPost.feedContext?.startsWith('ntpc')) { + } else if (seenPost.feedContext === 'popcluster') { tier = 'c' - } else if (seenPost.feedContext?.startsWith('t-')) { + } else if (seenPost.feedContext?.startsWith('ntpc')) { tier = 'd' - } else if (seenPost.feedContext === 'nettop') { + } else if (seenPost.feedContext?.startsWith('t-')) { tier = 'e' - } else { + } else if (seenPost.feedContext === 'nettop') { tier = 'f' + } else { + tier = 'g' } let score = Math.round( Math.log( @@ -131,16 +132,26 @@ function useExperimentalSuggestedUsersQuery() { const {currentAccount} = useSession() const userActionSnapshot = userActionHistory.useActionHistorySnapshot() const dids = React.useMemo(() => { - const {likes, follows, seen} = userActionSnapshot + const {likes, follows, followSuggestions, seen} = userActionSnapshot const likeDids = likes .map(l => new AtUri(l)) .map(uri => uri.host) .filter(did => !follows.includes(did)) + let suggestedDids: string[] = [] + if (followSuggestions.length > 0) { + suggestedDids = [ + // It's ok if these will pick the same item (weighed by its frequency) + followSuggestions[Math.floor(Math.random() * followSuggestions.length)], + followSuggestions[Math.floor(Math.random() * followSuggestions.length)], + followSuggestions[Math.floor(Math.random() * followSuggestions.length)], + followSuggestions[Math.floor(Math.random() * followSuggestions.length)], + ] + } const seenDids = seen .sort(sortSeenPosts) .map(l => new AtUri(l.uri)) .map(uri => uri.host) - return [...new Set([...likeDids, ...seenDids])].filter( + return [...new Set([...suggestedDids, ...likeDids, ...seenDids])].filter( did => did !== currentAccount?.did, ) }, [userActionSnapshot, currentAccount]) @@ -161,14 +172,67 @@ function useExperimentalSuggestedUsersQuery() { } } -export function SuggestedFollows() { - const t = useTheme() - const {_} = useLingui() +export function SuggestedFollows({feed}: {feed: FeedDescriptor}) { + const {currentAccount} = useSession() + const [feedType, feedUriOrDid] = feed.split('|') + if (feedType === 'author') { + if (currentAccount?.did === feedUriOrDid) { + return null + } else { + return + } + } else { + return + } +} + +export function SuggestedFollowsProfile({did}: {did: string}) { + const { + isLoading: isSuggestionsLoading, + data, + error, + } = useSuggestedFollowsByActorQuery({ + did, + }) + return ( + + ) +} + +export function SuggestedFollowsHome() { const { isLoading: isSuggestionsLoading, profiles, error, } = useExperimentalSuggestedUsersQuery() + return ( + + ) +} + +export function ProfileGrid({ + isSuggestionsLoading, + error, + profiles, + viewContext = 'feed', +}: { + isSuggestionsLoading: boolean + profiles: AppBskyActorDefs.ProfileViewDetailed[] + error: Error | null + viewContext: 'profile' | 'feed' +}) { + const t = useTheme() + const {_} = useLingui() const moderationOpts = useModerationOpts() const navigation = useNavigation() const {gtMobile} = useBreakpoints() @@ -190,7 +254,7 @@ export function SuggestedFollows() { {profiles.slice(0, maxLength).map(profile => ( { logEvent('feed:interstitial:profileCard:press', {}) }} @@ -222,7 +286,7 @@ export function SuggestedFollows() { shape="round" /> - + )} @@ -239,34 +303,35 @@ export function SuggestedFollows() { return ( - - - Suggested for you + + + {viewContext === 'profile' ? ( + Similar accounts + ) : ( + Suggested for you + )} - + {gtMobile ? ( - - + + {content} - - + + Browse more suggestions @@ -278,7 +343,7 @@ export function SuggestedFollows() { showsHorizontalScrollIndicator={false} snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap} decelerationRate="fast"> - + {content} + {/* Maybe fix this later -h */} + {isAndroid ? : null} ) } @@ -269,6 +259,7 @@ function LabelerToggle({title}: {title: string}) { a.z_10, ]}> - ) @@ -40,10 +38,7 @@ function ReportDialogInner(props: ReportDialogProps) { } = useMyLabelersQuery() const isLoading = useDelayedLoading(500, isLabelerLoading) - const ref = React.useRef(null) - useOnKeyboardDidShow(() => { - ref.current?.scrollToEnd({animated: true}) - }) + const ref = React.useRef(null) return ( diff --git a/src/components/RichText.tsx b/src/components/RichText.tsx index 7511775978..1c65a87acc 100644 --- a/src/components/RichText.tsx +++ b/src/components/RichText.tsx @@ -66,6 +66,7 @@ export function RichText({ (flattenedStyle.fontSize ?? a.text_sm.fontSize) * emojiMultiplier return ( , ) } else { - els.push(segment.text) + els.push( + + {segment.text} + , + ) } key++ } @@ -213,6 +219,7 @@ function RichTextTag({ ( function PostsListImpl({listUri, headerHeight, scrollElRef}, ref) { - const feed: FeedDescriptor = `list|${listUri}|as_following` + const feed: FeedDescriptor = `list|${listUri}` const {_} = useLingui() const onScrollToTop = useCallback(() => { diff --git a/src/components/StarterPack/Main/ProfilesList.tsx b/src/components/StarterPack/Main/ProfilesList.tsx index 3249f1b32e..a5c7cd1b73 100644 --- a/src/components/StarterPack/Main/ProfilesList.tsx +++ b/src/components/StarterPack/Main/ProfilesList.tsx @@ -9,14 +9,15 @@ import { import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query' import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset' +import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' import {isBlockedOrBlocking} from 'lib/moderation/blocked-and-muted' import {isNative, isWeb} from 'platform/detection' -import {useListMembersQuery} from 'state/queries/list-members' +import {useAllListMembersQuery} from 'state/queries/list-members' import {useSession} from 'state/session' import {List, ListRef} from 'view/com/util/List' import {SectionRef} from '#/screens/Profile/Sections/types' import {atoms as a, useTheme} from '#/alf' -import {ListMaybePlaceholder} from '#/components/Lists' +import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' import {Default as ProfileCard} from '#/components/ProfileCard' function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic, index: number) { @@ -39,17 +40,20 @@ export const ProfilesList = React.forwardRef( ref, ) { const t = useTheme() - const [initialHeaderHeight] = React.useState(headerHeight) - const bottomBarOffset = useBottomBarOffset(20) + const bottomBarOffset = useBottomBarOffset(300) + const initialNumToRender = useInitialNumToRender() const {currentAccount} = useSession() - const {data, refetch, isError} = useListMembersQuery(listUri, 50) + const {data, refetch, isError} = useAllListMembersQuery(listUri) const [isPTRing, setIsPTRing] = React.useState(false) // The server returns these sorted by descending creation date, so we want to invert - const profiles = data?.pages - .flatMap(p => p.items.map(i => i.subject)) - .filter(p => !isBlockedOrBlocking(p) && !p.associated?.labeler) + + const profiles = data + ?.filter( + p => !isBlockedOrBlocking(p.subject) && !p.subject.associated?.labeler, + ) + .map(p => p.subject) .reverse() const isOwn = new AtUri(listUri).host === currentAccount?.did @@ -99,7 +103,11 @@ export const ProfilesList = React.forwardRef( if (!data) { return ( - + ( ref={scrollElRef} headerOffset={headerHeight} ListFooterComponent={ - + } showsVerticalScrollIndicator={false} desktopFixedHeight + initialNumToRender={initialNumToRender} refreshing={isPTRing} onRefresh={async () => { setIsPTRing(true) diff --git a/src/components/StarterPack/ProfileStarterPacks.tsx b/src/components/StarterPack/ProfileStarterPacks.tsx index 7fb0545a21..00afbdcfe9 100644 --- a/src/components/StarterPack/ProfileStarterPacks.tsx +++ b/src/components/StarterPack/ProfileStarterPacks.tsx @@ -12,15 +12,15 @@ import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query' +import {useGenerateStarterPackMutation} from '#/lib/generate-starterpack' +import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset' +import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' +import {NavigationProp} from '#/lib/routes/types' +import {parseStarterPackUri} from '#/lib/strings/starter-pack' import {logger} from '#/logger' -import {useGenerateStarterPackMutation} from 'lib/generate-starterpack' -import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {NavigationProp} from 'lib/routes/types' -import {parseStarterPackUri} from 'lib/strings/starter-pack' -import {List, ListRef} from 'view/com/util/List' -import {Text} from 'view/com/util/text/Text' -import {atoms as a, useTheme} from '#/alf' +import {List, ListRef} from '#/view/com/util/List' +import {Text} from '#/view/com/util/text/Text' +import {atoms as a, ios, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {useDialogControl} from '#/components/Dialog' import {LinearGradientBackground} from '#/components/LinearGradientBackground' @@ -132,6 +132,7 @@ export const ProfileStarterPacks = React.forwardRef< keyExtractor={keyExtractor} refreshing={isPTRing} headerOffset={headerOffset} + progressViewOffset={ios(0)} contentContainerStyle={{paddingBottom: headerOffset + bottomBarOffset}} indicatorStyle={t.name === 'light' ? 'black' : 'white'} removeClippedSubviews={true} diff --git a/src/components/StarterPack/QrCode.tsx b/src/components/StarterPack/QrCode.tsx index a8a2e34917..c6408109bb 100644 --- a/src/components/StarterPack/QrCode.tsx +++ b/src/components/StarterPack/QrCode.tsx @@ -1,18 +1,23 @@ import React from 'react' import {View} from 'react-native' import QRCode from 'react-native-qrcode-styled' -import ViewShot from 'react-native-view-shot' +import type ViewShot from 'react-native-view-shot' import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api' import {Trans} from '@lingui/macro' -import {isWeb} from 'platform/detection' -import {Logo} from 'view/icons/Logo' -import {Logotype} from 'view/icons/Logotype' +import {isWeb} from '#/platform/detection' +import {Logo} from '#/view/icons/Logo' +import {Logotype} from '#/view/icons/Logotype' import {useTheme} from '#/alf' import {atoms as a} from '#/alf' import {LinearGradientBackground} from '#/components/LinearGradientBackground' import {Text} from '#/components/Typography' +const LazyViewShot = React.lazy( + // @ts-expect-error dynamic import + () => import('react-native-view-shot/src/index'), +) + interface Props { starterPack: AppBskyGraphDefs.StarterPackView link: string @@ -29,7 +34,7 @@ export const QrCode = React.forwardRef(function QrCode( } return ( - + (function QrCode( - - - on - - - - - - + + + on + + + + + + + + - + ) }) diff --git a/src/components/StarterPack/QrCodeDialog.tsx b/src/components/StarterPack/QrCodeDialog.tsx index 39eb3076d8..2feea0973a 100644 --- a/src/components/StarterPack/QrCodeDialog.tsx +++ b/src/components/StarterPack/QrCodeDialog.tsx @@ -1,6 +1,6 @@ import React from 'react' import {View} from 'react-native' -import ViewShot from 'react-native-view-shot' +import type ViewShot from 'react-native-view-shot' import {requestMediaLibraryPermissionsAsync} from 'expo-image-picker' import {createAssetAsync} from 'expo-media-library' import * as Sharing from 'expo-sharing' @@ -8,9 +8,9 @@ import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {logEvent} from '#/lib/statsig/statsig' import {logger} from '#/logger' -import {logEvent} from 'lib/statsig/statsig' -import {isNative, isWeb} from 'platform/detection' +import {isNative, isWeb} from '#/platform/detection' import * as Toast from '#/view/com/util/Toast' import {atoms as a} from '#/alf' import {Button, ButtonText} from '#/components/Button' @@ -67,7 +67,10 @@ export function QrCodeDialog({ try { await createAssetAsync(`file://${uri}`) } catch (e: unknown) { - Toast.show(_(msg`An error occurred while saving the QR code!`)) + Toast.show( + _(msg`An error occurred while saving the QR code!`), + 'xmark', + ) logger.error('Failed to save QR code', {error: e}) return } @@ -146,50 +149,57 @@ export function QrCodeDialog({ return ( - - {!link ? ( - - - - ) : ( - <> - - {isProcessing ? ( - - - - ) : ( - - - - - )} - - )} + }> + {!link ? ( + + ) : ( + <> + + {isProcessing ? ( + + + + ) : ( + + + + + )} + + )} + ) } + +function Loading() { + return ( + + + + ) +} diff --git a/src/components/StarterPack/ShareDialog.tsx b/src/components/StarterPack/ShareDialog.tsx index 61e238081d..997c6479c5 100644 --- a/src/components/StarterPack/ShareDialog.tsx +++ b/src/components/StarterPack/ShareDialog.tsx @@ -6,14 +6,14 @@ import {AppBskyGraphDefs} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' +import {saveImageToMediaLibrary} from '#/lib/media/manip' +import {shareUrl} from '#/lib/sharing' +import {logEvent} from '#/lib/statsig/statsig' +import {getStarterPackOgCard} from '#/lib/strings/starter-pack' import {logger} from '#/logger' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {saveImageToMediaLibrary} from 'lib/media/manip' -import {shareUrl} from 'lib/sharing' -import {logEvent} from 'lib/statsig/statsig' -import {getStarterPackOgCard} from 'lib/strings/starter-pack' -import {isNative, isWeb} from 'platform/detection' -import * as Toast from 'view/com/util/Toast' +import {isNative, isWeb} from '#/platform/detection' +import * as Toast from '#/view/com/util/Toast' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' import {DialogControlProps} from '#/components/Dialog' @@ -32,6 +32,7 @@ interface Props { export function ShareDialog(props: Props) { return ( + ) @@ -66,6 +67,7 @@ function ShareDialogInner({ if (!res) { Toast.show( _(msg`You must grant access to your photo library to save the image.`), + 'xmark', ) return } @@ -75,7 +77,7 @@ function ShareDialogInner({ Toast.show(_(msg`Image saved to your camera roll!`)) control.close() } catch (e: unknown) { - Toast.show(_(msg`An error occurred while saving the QR code!`)) + Toast.show(_(msg`An error occurred while saving the QR code!`), 'xmark') logger.error('Failed to save QR code', {error: e}) return } @@ -83,7 +85,6 @@ function ShareDialogInner({ return ( <> - {!imageLoaded || !link ? ( diff --git a/src/components/StarterPack/StarterPackCard.tsx b/src/components/StarterPack/StarterPackCard.tsx index dc9e4b70da..ead9c92480 100644 --- a/src/components/StarterPack/StarterPackCard.tsx +++ b/src/components/StarterPack/StarterPackCard.tsx @@ -1,23 +1,26 @@ import React from 'react' import {View} from 'react-native' import {Image} from 'expo-image' -import {AppBskyGraphStarterpack, AtUri} from '@atproto/api' -import {StarterPackViewBasic} from '@atproto/api/dist/client/types/app/bsky/graph/defs' -import {msg, Trans} from '@lingui/macro' +import {AppBskyGraphDefs, AppBskyGraphStarterpack, AtUri} from '@atproto/api' +import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' -import {sanitizeHandle} from 'lib/strings/handles' -import {getStarterPackOgCard} from 'lib/strings/starter-pack' -import {precacheResolvedUri} from 'state/queries/resolve-uri' -import {precacheStarterPack} from 'state/queries/starter-packs' -import {useSession} from 'state/session' +import {sanitizeHandle} from '#/lib/strings/handles' +import {getStarterPackOgCard} from '#/lib/strings/starter-pack' +import {precacheResolvedUri} from '#/state/queries/resolve-uri' +import {precacheStarterPack} from '#/state/queries/starter-packs' +import {useSession} from '#/state/session' import {atoms as a, useTheme} from '#/alf' import {StarterPack} from '#/components/icons/StarterPack' import {BaseLink} from '#/components/Link' import {Text} from '#/components/Typography' -export function Default({starterPack}: {starterPack?: StarterPackViewBasic}) { +export function Default({ + starterPack, +}: { + starterPack?: AppBskyGraphDefs.StarterPackViewBasic +}) { if (!starterPack) return null return ( @@ -29,7 +32,7 @@ export function Default({starterPack}: {starterPack?: StarterPackViewBasic}) { export function Notification({ starterPack, }: { - starterPack?: StarterPackViewBasic + starterPack?: AppBskyGraphDefs.StarterPackViewBasic }) { if (!starterPack) return null return ( @@ -44,7 +47,7 @@ export function Card({ noIcon, noDescription, }: { - starterPack: StarterPackViewBasic + starterPack: AppBskyGraphDefs.StarterPackViewBasic noIcon?: boolean noDescription?: boolean }) { @@ -63,21 +66,18 @@ export function Card({ {!noIcon ? : null} - + {record.name} - - - Starter pack by{' '} - {creator?.did === currentAccount?.did - ? _(msg`you`) - : `@${sanitizeHandle(creator.handle)}`} - + + {creator?.did === currentAccount?.did + ? _(msg`Starter pack by you`) + : _(msg`Starter pack by ${sanitizeHandle(creator.handle, '@')}`)} {!noDescription && record.description ? ( - + {record.description} ) : null} @@ -94,7 +94,7 @@ export function Link({ starterPack, children, }: { - starterPack: StarterPackViewBasic + starterPack: AppBskyGraphDefs.StarterPackViewBasic onPress?: () => void children: React.ReactNode }) { @@ -129,7 +129,11 @@ export function Link({ ) } -export function Embed({starterPack}: {starterPack: StarterPackViewBasic}) { +export function Embed({ + starterPack, +}: { + starterPack: AppBskyGraphDefs.StarterPackViewBasic +}) { const t = useTheme() const imageUri = getStarterPackOgCard(starterPack) diff --git a/src/components/StarterPack/Wizard/WizardEditListDialog.tsx b/src/components/StarterPack/Wizard/WizardEditListDialog.tsx index cf755e1bcf..1e9f1c52d4 100644 --- a/src/components/StarterPack/Wizard/WizardEditListDialog.tsx +++ b/src/components/StarterPack/Wizard/WizardEditListDialog.tsx @@ -3,12 +3,13 @@ import type {ListRenderItemInfo} from 'react-native' import {View} from 'react-native' import {AppBskyActorDefs, ModerationOpts} from '@atproto/api' import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs' -import {BottomSheetFlatListMethods} from '@discord/bottom-sheet' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {isWeb} from 'platform/detection' -import {useSession} from 'state/session' +import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' +import {isWeb} from '#/platform/detection' +import {useSession} from '#/state/session' +import {ListMethods} from '#/view/com/util/List' import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State' import {atoms as a, native, useTheme, web} from '#/alf' import {Button, ButtonText} from '#/components/Button' @@ -42,8 +43,9 @@ export function WizardEditListDialog({ const {_} = useLingui() const t = useTheme() const {currentAccount} = useSession() + const initialNumToRender = useInitialNumToRender() - const listRef = useRef(null) + const listRef = useRef(null) const getData = () => { if (state.currentStep === 'Feeds') return state.feeds @@ -74,10 +76,7 @@ export function WizardEditListDialog({ ) return ( - + @@ -123,7 +117,7 @@ export function WizardEditListDialog({ label={_(msg`Close`)} variant="ghost" color="primary" - size="xsmall" + size="small" onPress={() => control.close()}> Close @@ -141,13 +135,12 @@ export function WizardEditListDialog({ paddingHorizontal: 0, marginTop: 0, paddingTop: 0, - borderTopLeftRadius: 40, - borderTopRightRadius: 40, }), ]} webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]} keyboardDismissMode="on-drag" removeClippedSubviews={true} + initialNumToRender={initialNumToRender} /> ) diff --git a/src/components/StarterPack/Wizard/WizardListCard.tsx b/src/components/StarterPack/Wizard/WizardListCard.tsx index 55cf0f02b3..44f01a1545 100644 --- a/src/components/StarterPack/Wizard/WizardListCard.tsx +++ b/src/components/StarterPack/Wizard/WizardListCard.tsx @@ -12,11 +12,11 @@ import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {DISCOVER_FEED_URI} from 'lib/constants' -import {sanitizeDisplayName} from 'lib/strings/display-names' -import {sanitizeHandle} from 'lib/strings/handles' -import {useSession} from 'state/session' -import {UserAvatar} from 'view/com/util/UserAvatar' +import {DISCOVER_FEED_URI, STARTER_PACK_MAX_SIZE} from '#/lib/constants' +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {sanitizeHandle} from '#/lib/strings/handles' +import {useSession} from '#/state/session' +import {UserAvatar} from '#/view/com/util/UserAvatar' import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' @@ -78,6 +78,7 @@ function WizardListCard({ /> @@ -130,7 +131,8 @@ export function WizardProfileCard({ const isMe = profile.did === currentAccount?.did const included = isMe || state.profiles.some(p => p.did === profile.did) - const disabled = isMe || (!included && state.profiles.length >= 49) + const disabled = + isMe || (!included && state.profiles.length >= STARTER_PACK_MAX_SIZE - 1) const moderationUi = moderateProfile(profile, moderationOpts).ui('avatar') const displayName = profile.displayName ? sanitizeDisplayName(profile.displayName) diff --git a/src/components/TagMenu/index.tsx b/src/components/TagMenu/index.tsx index 0ed7036671..917624a036 100644 --- a/src/components/TagMenu/index.tsx +++ b/src/components/TagMenu/index.tsx @@ -1,27 +1,27 @@ import React from 'react' import {View} from 'react-native' -import {useNavigation} from '@react-navigation/native' -import {useLingui} from '@lingui/react' import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' -import {atoms as a, native, useTheme} from '#/alf' -import * as Dialog from '#/components/Dialog' -import {Text} from '#/components/Typography' -import {Button, ButtonText} from '#/components/Button' -import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2' -import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person' -import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' -import {Divider} from '#/components/Divider' -import {Link} from '#/components/Link' import {makeSearchLink} from '#/lib/routes/links' import {NavigationProp} from '#/lib/routes/types' +import {isInvalidHandle} from '#/lib/strings/handles' import { usePreferencesQuery, + useRemoveMutedWordsMutation, useUpsertMutedWordsMutation, - useRemoveMutedWordMutation, } from '#/state/queries/preferences' +import {atoms as a, native, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {Divider} from '#/components/Divider' +import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2' +import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' +import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person' +import {Link} from '#/components/Link' import {Loader} from '#/components/Loader' -import {isInvalidHandle} from '#/lib/strings/handles' +import {Text} from '#/components/Typography' export function useTagMenuControl() { return Dialog.useDialogControl() @@ -52,10 +52,10 @@ export function TagMenu({ reset: resetUpsert, } = useUpsertMutedWordsMutation() const { - mutateAsync: removeMutedWord, + mutateAsync: removeMutedWords, variables: optimisticRemove, reset: resetRemove, - } = useRemoveMutedWordMutation() + } = useRemoveMutedWordsMutation() const displayTag = '#' + tag const isMuted = Boolean( @@ -65,16 +65,26 @@ export function TagMenu({ optimisticUpsert?.find( m => m.value === tag && m.targets.includes('tag'), )) && - !(optimisticRemove?.value === tag), + !optimisticRemove?.find(m => m?.value === tag), ) + /* + * Mute word records that exactly match the tag in question. + */ + const removeableMuteWords = React.useMemo(() => { + return ( + preferences?.moderationPrefs.mutedWords?.filter(word => { + return word.value === tag + }) || [] + ) + }, [tag, preferences?.moderationPrefs?.mutedWords]) + return ( <> {children} - {isPreferencesLoading ? ( @@ -212,13 +222,16 @@ export function TagMenu({ control.close(() => { if (isMuted) { resetUpsert() - removeMutedWord({ - value: tag, - targets: ['tag'], - }) + removeMutedWords(removeableMuteWords) } else { resetRemove() - upsertMutedWord([{value: tag, targets: ['tag']}]) + upsertMutedWord([ + { + value: tag, + targets: ['tag'], + actorTarget: 'all', + }, + ]) } }) }}> diff --git a/src/components/TagMenu/index.web.tsx b/src/components/TagMenu/index.web.tsx index 4336223861..b6c306439a 100644 --- a/src/components/TagMenu/index.web.tsx +++ b/src/components/TagMenu/index.web.tsx @@ -3,16 +3,16 @@ import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' -import {isInvalidHandle} from '#/lib/strings/handles' -import {EventStopper} from '#/view/com/util/EventStopper' -import {NativeDropdown} from '#/view/com/util/forms/NativeDropdown' import {NavigationProp} from '#/lib/routes/types' +import {isInvalidHandle} from '#/lib/strings/handles' +import {enforceLen} from '#/lib/strings/helpers' import { usePreferencesQuery, + useRemoveMutedWordsMutation, useUpsertMutedWordsMutation, - useRemoveMutedWordMutation, } from '#/state/queries/preferences' -import {enforceLen} from '#/lib/strings/helpers' +import {EventStopper} from '#/view/com/util/EventStopper' +import {NativeDropdown} from '#/view/com/util/forms/NativeDropdown' import {web} from '#/alf' import * as Dialog from '#/components/Dialog' @@ -47,8 +47,8 @@ export function TagMenu({ const {data: preferences} = usePreferencesQuery() const {mutateAsync: upsertMutedWord, variables: optimisticUpsert} = useUpsertMutedWordsMutation() - const {mutateAsync: removeMutedWord, variables: optimisticRemove} = - useRemoveMutedWordMutation() + const {mutateAsync: removeMutedWords, variables: optimisticRemove} = + useRemoveMutedWordsMutation() const isMuted = Boolean( (preferences?.moderationPrefs.mutedWords?.find( m => m.value === tag && m.targets.includes('tag'), @@ -56,10 +56,21 @@ export function TagMenu({ optimisticUpsert?.find( m => m.value === tag && m.targets.includes('tag'), )) && - !(optimisticRemove?.value === tag), + !optimisticRemove?.find(m => m?.value === tag), ) const truncatedTag = '#' + enforceLen(tag, 15, true, 'middle') + /* + * Mute word records that exactly match the tag in question. + */ + const removeableMuteWords = React.useMemo(() => { + return ( + preferences?.moderationPrefs.mutedWords?.filter(word => { + return word.value === tag + }) || [] + ) + }, [tag, preferences?.moderationPrefs?.mutedWords]) + const dropdownItems = React.useMemo(() => { return [ { @@ -105,9 +116,11 @@ export function TagMenu({ : _(msg`Mute ${truncatedTag}`), onPress() { if (isMuted) { - removeMutedWord({value: tag, targets: ['tag']}) + removeMutedWords(removeableMuteWords) } else { - upsertMutedWord([{value: tag, targets: ['tag']}]) + upsertMutedWord([ + {value: tag, targets: ['tag'], actorTarget: 'all'}, + ]) } }, testID: 'tagMenuMute', @@ -129,7 +142,8 @@ export function TagMenu({ tag, truncatedTag, upsertMutedWord, - removeMutedWord, + removeMutedWords, + removeableMuteWords, ]) return ( diff --git a/src/components/Typography.tsx b/src/components/Typography.tsx index 31dd931c6a..19eba35fbe 100644 --- a/src/components/Typography.tsx +++ b/src/components/Typography.tsx @@ -1,15 +1,90 @@ import React from 'react' import {StyleProp, TextProps as RNTextProps, TextStyle} from 'react-native' import {UITextView} from 'react-native-uitextview' +import createEmojiRegex from 'emoji-regex' -import {isNative} from '#/platform/detection' -import {atoms, flatten, useTheme, web} from '#/alf' +import {logger} from '#/logger' +import {isIOS, isNative} from '#/platform/detection' +import {Alf, applyFonts, atoms, flatten, useAlf, useTheme, web} from '#/alf' +import {IS_DEV} from '#/env' -export type TextProps = RNTextProps & { +export type StringChild = string | (string | null)[] + +export type TextProps = Omit & { /** * Lets the user select text, to use the native copy and paste functionality. */ selectable?: boolean + /** + * Provides `data-*` attributes to the underlying `UITextView` component on + * web only. + */ + dataSet?: Record + /** + * Appears as a small tooltip on web hover. + */ + title?: string +} & ( + | { + emoji: true + children: StringChild + } + | { + emoji?: false + children: RNTextProps['children'] + } + ) + +const EMOJI = createEmojiRegex() + +export function childHasEmoji(children: React.ReactNode) { + return (Array.isArray(children) ? children : [children]).some( + child => typeof child === 'string' && createEmojiRegex().test(child), + ) +} + +export function childIsString( + children: React.ReactNode, +): children is StringChild { + return ( + typeof children === 'string' || + (Array.isArray(children) && + children.every(child => typeof child === 'string' || child === null)) + ) +} + +export function renderChildrenWithEmoji( + children: StringChild, + props: Omit = {}, +) { + const normalized = Array.isArray(children) ? children : [children] + + return ( + + {normalized.map(child => { + if (typeof child !== 'string') return child + + const emojis = child.match(EMOJI) + + if (emojis === null) { + return child + } + + return child.split(EMOJI).map((stringPart, index) => ( + + {stringPart} + {emojis[index] ? ( + + {emojis[index]} + + ) : null} + + )) + })} + + ) } /** @@ -34,30 +109,78 @@ export function leading< * If the `lineHeight` value is > 2, we assume it's an absolute value and * returns it as-is. */ -export function normalizeTextStyles(styles: StyleProp) { +export function normalizeTextStyles( + styles: StyleProp, + { + fontScale, + fontFamily, + }: { + fontScale: number + fontFamily: Alf['fonts']['family'] + } & Pick, +) { const s = flatten(styles) // should always be defined on these components - const fontSize = s.fontSize || atoms.text_md.fontSize + s.fontSize = (s.fontSize || atoms.text_md.fontSize) * fontScale if (s?.lineHeight) { if (s.lineHeight !== 0 && s.lineHeight <= 2) { - s.lineHeight = Math.round(fontSize * s.lineHeight) + s.lineHeight = Math.round(s.fontSize * s.lineHeight) } } else if (!isNative) { s.lineHeight = s.fontSize } + applyFonts(s, fontFamily) + return s } /** * Our main text component. Use this most of the time. */ -export function Text({style, selectable, ...rest}: TextProps) { +export function Text({ + children, + emoji, + style, + selectable, + title, + dataSet, + ...rest +}: TextProps) { + const {fonts, flags} = useAlf() const t = useTheme() - const s = normalizeTextStyles([atoms.text_sm, t.atoms.text, flatten(style)]) + const s = normalizeTextStyles([atoms.text_sm, t.atoms.text, flatten(style)], { + fontScale: fonts.scaleMultiplier, + fontFamily: fonts.family, + flags, + }) - return + if (IS_DEV) { + if (!emoji && childHasEmoji(children)) { + logger.warn( + `Text: emoji detected but emoji not enabled: "${children}"\n\nPlease add '`, + ) + } + + if (emoji && !childIsString(children)) { + logger.error('Text: when , children can only be strings.') + } + } + + const shared = { + uiTextView: true, + selectable, + style: s, + dataSet: Object.assign({tooltip: title}, dataSet || {}), + ...rest, + } + + return ( + + {isIOS && emoji ? renderChildrenWithEmoji(children, shared) : children} + + ) } export function createHeadingElement({level}: {level: number}) { diff --git a/src/components/WhoCanReply.tsx b/src/components/WhoCanReply.tsx index a73aae850b..ab6ef8293c 100644 --- a/src/components/WhoCanReply.tsx +++ b/src/components/WhoCanReply.tsx @@ -1,39 +1,34 @@ import React from 'react' -import {Keyboard, StyleProp, View, ViewStyle} from 'react-native' +import {Keyboard, Platform, StyleProp, View, ViewStyle} from 'react-native' import { AppBskyFeedDefs, - AppBskyFeedGetPostThread, + AppBskyFeedPost, AppBskyGraphDefs, AtUri, - BskyAgent, } from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useQueryClient} from '@tanstack/react-query' -import {createThreadgate} from '#/lib/api' -import {until} from '#/lib/async/until' import {HITSLOP_10} from '#/lib/constants' import {makeListLink, makeProfileLink} from '#/lib/routes/links' -import {logger} from '#/logger' import {isNative} from '#/platform/detection' -import {RQKEY_ROOT as POST_THREAD_RQKEY_ROOT} from '#/state/queries/post-thread' import { - ThreadgateSetting, - threadgateViewToSettings, + ThreadgateAllowUISetting, + threadgateViewToAllowUISetting, } from '#/state/queries/threadgate' -import {useAgent} from '#/state/session' -import * as Toast from 'view/com/util/Toast' import {atoms as a, useTheme} from '#/alf' import {Button} from '#/components/Button' import * as Dialog from '#/components/Dialog' import {useDialogControl} from '#/components/Dialog' +import { + PostInteractionSettingsDialog, + usePrefetchPostInteractionSettings, +} from '#/components/dialogs/PostInteractionSettingsDialog' import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign' import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe' import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group' +import {InlineLinkText} from '#/components/Link' import {Text} from '#/components/Typography' -import {TextLink} from '../view/com/util/Link' -import {ThreadgateEditorDialog} from './dialogs/ThreadgateEditor' import {PencilLine_Stroke2_Corner0_Rounded as PencilLine} from './icons/Pencil' interface WhoCanReplyProps { @@ -47,31 +42,34 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) { const t = useTheme() const infoDialogControl = useDialogControl() const editDialogControl = useDialogControl() - const agent = useAgent() - const queryClient = useQueryClient() - const settings = React.useMemo( - () => threadgateViewToSettings(post.threadgate), - [post], - ) - const isRootPost = !('reply' in post.record) + /* + * `WhoCanReply` is only used for root posts atm, in case this changes + * unexpectedly, we should check to make sure it's for sure the root URI. + */ + const rootUri = + AppBskyFeedPost.isRecord(post.record) && post.record.reply?.root + ? post.record.reply.root.uri + : post.uri + const settings = React.useMemo(() => { + return threadgateViewToAllowUISetting(post.threadgate) + }, [post.threadgate]) - if (!isRootPost) { - return null - } - if (!settings.length && !isThreadAuthor) { - return null - } + const prefetchPostInteractionSettings = usePrefetchPostInteractionSettings({ + postUri: post.uri, + rootPostUri: rootUri, + }) - const isEverybody = settings.length === 0 - const isNobody = !!settings.find(gate => gate.type === 'nobody') - const description = isEverybody + const anyoneCanReply = + settings.length === 1 && settings[0].type === 'everybody' + const noOneCanReply = settings.length === 1 && settings[0].type === 'nobody' + const description = anyoneCanReply ? _(msg`Everybody can reply`) - : isNobody + : noOneCanReply ? _(msg`Replies disabled`) : _(msg`Some people can reply`) - const onPressEdit = () => { + const onPressOpen = () => { if (isNative && Keyboard.isVisible()) { Keyboard.dismiss() } @@ -82,51 +80,23 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) { } } - const onEditConfirm = async (newSettings: ThreadgateSetting[]) => { - if (JSON.stringify(settings) === JSON.stringify(newSettings)) { - return - } - try { - if (newSettings.length) { - await createThreadgate(agent, post.uri, newSettings) - } else { - await agent.api.com.atproto.repo.deleteRecord({ - repo: agent.session!.did, - collection: 'app.bsky.feed.threadgate', - rkey: new AtUri(post.uri).rkey, - }) - } - await whenAppViewReady(agent, post.uri, res => { - const thread = res.data.thread - if (AppBskyFeedDefs.isThreadViewPost(thread)) { - const fetchedSettings = threadgateViewToSettings( - thread.post.threadgate, - ) - return JSON.stringify(fetchedSettings) === JSON.stringify(newSettings) - } - return false - }) - Toast.show(_(msg`Thread settings updated`)) - queryClient.invalidateQueries({ - queryKey: [POST_THREAD_RQKEY_ROOT], - }) - } catch (err) { - Toast.show( - _( - msg`There was an issue. Please check your internet connection and try again.`, - ), - ) - logger.error('Failed to edit threadgate', {message: err}) - } - } - return ( <> - - {isThreadAuthor && ( - + ) : ( + )} @@ -173,7 +148,7 @@ function Icon({ }: { color: string width?: number - settings: ThreadgateSetting[] + settings: ThreadgateAllowUISetting[] }) { const isEverybody = settings.length === 0 const isNobody = !!settings.find(gate => gate.type === 'nobody') @@ -185,79 +160,84 @@ function WhoCanReplyDialog({ control, post, settings, + embeddingDisabled, }: { control: Dialog.DialogControlProps post: AppBskyFeedDefs.PostView - settings: ThreadgateSetting[] -}) { - return ( - - - - - ) -} - -function WhoCanReplyDialogInner({ - post, - settings, -}: { - post: AppBskyFeedDefs.PostView - settings: ThreadgateSetting[] + settings: ThreadgateAllowUISetting[] + embeddingDisabled: boolean }) { const {_} = useLingui() return ( - - - - Who can reply? - - - - + + + + + + Who can interact with this post? + + + + + ) } function Rules({ post, settings, + embeddingDisabled, }: { post: AppBskyFeedDefs.PostView - settings: ThreadgateSetting[] + settings: ThreadgateAllowUISetting[] + embeddingDisabled: boolean }) { const t = useTheme() + return ( - - {!settings.length ? ( - Everybody can reply - ) : settings[0].type === 'nobody' ? ( - Replies to this thread are disabled - ) : ( - - Only{' '} - {settings.map((rule, i) => ( - <> - - - - ))}{' '} - can reply - + <> + + {settings[0].type === 'everybody' ? ( + Everybody can reply to this post. + ) : settings[0].type === 'nobody' ? ( + Replies to this post are disabled. + ) : ( + + Only{' '} + {settings.map((rule, i) => ( + + + + + ))}{' '} + can reply. + + )}{' '} + + {embeddingDisabled && ( + + No one but the author can quote this post. + )} - + ) } @@ -266,11 +246,10 @@ function Rule({ post, lists, }: { - rule: ThreadgateSetting + rule: ThreadgateAllowUISetting post: AppBskyFeedDefs.PostView lists: AppBskyGraphDefs.ListViewBasic[] | undefined }) { - const t = useTheme() if (rule.type === 'mention') { return mentioned users } @@ -278,12 +257,12 @@ function Rule({ return ( users followed by{' '} - + + @{post.author.handle} + ) } @@ -293,12 +272,12 @@ function Rule({ const listUrip = new AtUri(list.uri) return ( - {' '} + + {list.name} + {' '} members ) @@ -319,20 +298,3 @@ function Separator({i, length}: {i: number; length: number}) { } return <>, } - -async function whenAppViewReady( - agent: BskyAgent, - uri: string, - fn: (res: AppBskyFeedGetPostThread.Response) => boolean, -) { - await until( - 5, // 5 tries - 1e3, // 1s delay between tries - fn, - () => - agent.app.bsky.feed.getPostThread({ - uri, - depth: 0, - }), - ) -} diff --git a/src/components/dialogs/BirthDateSettings.tsx b/src/components/dialogs/BirthDateSettings.tsx index d831c6002a..81d0c6740e 100644 --- a/src/components/dialogs/BirthDateSettings.tsx +++ b/src/components/dialogs/BirthDateSettings.tsx @@ -31,7 +31,6 @@ export function BirthDateSettingsDialog({ return ( - @@ -117,7 +116,7 @@ function BirthdayInner({ - - ) -} diff --git a/src/components/dialogs/GifSelect.shared.tsx b/src/components/dialogs/GifSelect.shared.tsx deleted file mode 100644 index 90b2abaa83..0000000000 --- a/src/components/dialogs/GifSelect.shared.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import React, {useCallback} from 'react' -import {Image} from 'expo-image' -import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {logEvent} from '#/lib/statsig/statsig' -import {Gif} from '#/state/queries/tenor' -import {atoms as a, useBreakpoints, useTheme} from '#/alf' -import {Button} from '../Button' - -export function GifPreview({ - gif, - onSelectGif, -}: { - gif: Gif - onSelectGif: (gif: Gif) => void -}) { - const {gtTablet} = useBreakpoints() - const {_} = useLingui() - const t = useTheme() - - const onPress = useCallback(() => { - logEvent('composer:gif:select', {}) - onSelectGif(gif) - }, [onSelectGif, gif]) - - return ( - - ) -} diff --git a/src/components/dialogs/GifSelect.tsx b/src/components/dialogs/GifSelect.tsx index a64edcd6f0..6023b5808a 100644 --- a/src/components/dialogs/GifSelect.tsx +++ b/src/components/dialogs/GifSelect.tsx @@ -6,10 +6,12 @@ import React, { useState, } from 'react' import {TextInput, View} from 'react-native' -import {BottomSheetFlatListMethods} from '@discord/bottom-sheet' +import {useWindowDimensions} from 'react-native' +import {Image} from 'expo-image' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {logEvent} from '#/lib/statsig/statsig' import {cleanError} from '#/lib/strings/errors' import {isWeb} from '#/platform/detection' import { @@ -19,7 +21,8 @@ import { } from '#/state/queries/tenor' import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' -import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {ListMethods} from '#/view/com/util/List' +import {atoms as a, ios, native, useBreakpoints, useTheme} from '#/alf' import * as Dialog from '#/components/Dialog' import * as TextField from '#/components/forms/TextField' import {useThrottledValue} from '#/components/hooks/useThrottledValue' @@ -27,16 +30,18 @@ import {ArrowLeft_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arr import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2' import {Button, ButtonIcon, ButtonText} from '../Button' import {ListFooter, ListMaybePlaceholder} from '../Lists' -import {GifPreview} from './GifSelect.shared' +import {PortalComponent} from '../Portal' export function GifSelectDialog({ controlRef, onClose, onSelectGif: onSelectGifProp, + Portal, }: { controlRef: React.RefObject<{open: () => void}> onClose: () => void onSelectGif: (gif: Gif) => void + Portal?: PortalComponent }) { const control = Dialog.useDialogControl() @@ -59,8 +64,13 @@ export function GifSelectDialog({ return ( + onClose={onClose} + Portal={Portal} + nativeOptions={{ + bottomInset: 0, + // use system corner radius on iOS + ...ios({cornerRadius: undefined}), + }}> @@ -80,9 +90,10 @@ function GifList({ const t = useTheme() const {gtMobile} = useBreakpoints() const textInputRef = useRef(null) - const listRef = useRef(null) + const listRef = useRef(null) const [undeferredSearch, setSearch] = useState('') const search = useThrottledValue(undeferredSearch, 500) + const {height} = useWindowDimensions() const isSearching = search.length > 0 @@ -95,7 +106,7 @@ function GifList({ isFetchingNextPage, hasNextPage, error, - isLoading, + isPending, isError, refetch, } = isSearching ? searchQuery : trendingQuery @@ -132,6 +143,7 @@ function GifList({ return ( {listHeader} {!hasData && ( + control.close()} color="primary" - size="medium" + size="large" variant="solid"> Close @@ -271,3 +286,47 @@ function DialogError({details}: {details?: string}) { ) } + +export function GifPreview({ + gif, + onSelectGif, +}: { + gif: Gif + onSelectGif: (gif: Gif) => void +}) { + const {gtTablet} = useBreakpoints() + const {_} = useLingui() + const t = useTheme() + + const onPress = useCallback(() => { + logEvent('composer:gif:select', {}) + onSelectGif(gif) + }, [onSelectGif, gif]) + + return ( + + ) +} diff --git a/src/components/dialogs/MutedWords.tsx b/src/components/dialogs/MutedWords.tsx index 526652be95..c3aae8f0df 100644 --- a/src/components/dialogs/MutedWords.tsx +++ b/src/components/dialogs/MutedWords.tsx @@ -1,5 +1,5 @@ import React from 'react' -import {Keyboard, View} from 'react-native' +import {View} from 'react-native' import {AppBskyActorDefs, sanitizeMutedWordValue} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -24,14 +24,20 @@ import * as Dialog from '#/components/Dialog' import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' import {Divider} from '#/components/Divider' import * as Toggle from '#/components/forms/Toggle' +import {useFormatDistance} from '#/components/hooks/dates' import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag' 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 {Loader} from '#/components/Loader' +import {createPortalGroup} from '#/components/Portal' import * as Prompt from '#/components/Prompt' import {Text} from '#/components/Typography' +const ONE_DAY = 24 * 60 * 60 * 1000 + +const Portal = createPortalGroup() + export function MutedWordsDialog() { const {mutedWordsDialogControl: control} = useGlobalDialogsControlContext() return ( @@ -53,16 +59,32 @@ function MutedWordsInner() { } = usePreferencesQuery() const {isPending, mutateAsync: addMutedWord} = useUpsertMutedWordsMutation() const [field, setField] = React.useState('') - const [options, setOptions] = React.useState(['content']) + const [targets, setTargets] = React.useState(['content']) const [error, setError] = React.useState('') + const [durations, setDurations] = React.useState(['forever']) + const [excludeFollowing, setExcludeFollowing] = React.useState(false) const submit = React.useCallback(async () => { const sanitizedValue = sanitizeMutedWordValue(field) - const targets = ['tag', options.includes('content') && 'content'].filter( + const surfaces = ['tag', targets.includes('content') && 'content'].filter( Boolean, ) as AppBskyActorDefs.MutedWord['targets'] + const actorTarget = excludeFollowing ? 'exclude-following' : 'all' - if (!sanitizedValue || !targets.length) { + const now = Date.now() + const rawDuration = durations.at(0) + // undefined evaluates to 'forever' + let duration: string | undefined + + if (rawDuration === '24_hours') { + duration = new Date(now + ONE_DAY).toISOString() + } else if (rawDuration === '7_days') { + duration = new Date(now + 7 * ONE_DAY).toISOString() + } else if (rawDuration === '30_days') { + duration = new Date(now + 30 * ONE_DAY).toISOString() + } + + if (!sanitizedValue || !surfaces.length) { setField('') setError(_(msg`Please enter a valid word, tag, or phrase to mute`)) return @@ -70,193 +92,365 @@ function MutedWordsInner() { try { // send raw value and rely on SDK as sanitization source of truth - await addMutedWord([{value: field, targets}]) + await addMutedWord([ + { + value: field, + targets: surfaces, + actorTarget, + expiresAt: duration, + }, + ]) setField('') } catch (e: any) { logger.error(`Failed to save muted word`, {message: e.message}) setError(e.message) } - }, [_, field, options, addMutedWord, setField]) + }, [_, field, targets, addMutedWord, setField, durations, excludeFollowing]) return ( - - - - Add muted words and tags - - - - Posts can be muted based on their text, their tags, or both. - - - - - { - if (error) { - setError('') - } - setField(value) - }} - onSubmitEditing={submit} - /> - - - - - - - - - Mute in text & tags - - - - - - - - - - - - Mute in tags only - - - - - - - - - - - {error && ( - - - {error} - - - )} - - - - We recommend avoiding common words that appear in many posts, - since it can result in no posts being shown. - - - - - - - + + + - Your muted words + Add muted words and tags + + + + Posts can be muted based on their text, their tags, or both. We + recommend avoiding common words that appear in many posts, since + it can result in no posts being shown. + - {isPreferencesLoading ? ( - - ) : preferencesError || !preferences ? ( - - - - We're sorry, but we weren't able to load your muted words at - this time. Please try again. - + + { + if (error) { + setError('') + } + setField(value) + }} + onSubmitEditing={submit} + /> + + + + + + Duration: - - ) : preferences.moderationPrefs.mutedWords.length ? ( - [...preferences.moderationPrefs.mutedWords] - .reverse() - .map((word, i) => ( - - )) - ) : ( - - - You haven't muted any words or tags yet + + + + + + + + + Forever + + + + + + + + + + + 24 hours + + + + + + + + + + + + + 7 days + + + + + + + + + + + 30 days + + + + + + + + + + + Mute in: + + + + + + + + Text & tags + + + + + + + + + + + + Tags only + + + + + + + + + + + Options: + + + + + + + Exclude users you follow + + + + - )} + + + + + + {error && ( + + + {error} + + + )} + + + + + + + Your muted words + + + {isPreferencesLoading ? ( + + ) : preferencesError || !preferences ? ( + + + + We're sorry, but we weren't able to load your muted words at + this time. Please try again. + + + + ) : preferences.moderationPrefs.mutedWords.length ? ( + [...preferences.moderationPrefs.mutedWords] + .reverse() + .map((word, i) => ( + + )) + ) : ( + + + You haven't muted any words or tags yet + + + )} + + + {isNative && } - {isNative && } - + + - - + + ) } @@ -268,6 +462,9 @@ function MutedWordRow({ const {_} = useLingui() const {isPending, mutateAsync: removeMutedWord} = useRemoveMutedWordMutation() const control = Prompt.usePromptControl() + const expiryDate = word.expiresAt ? new Date(word.expiresAt) : undefined + const isExpired = expiryDate && expiryDate < new Date() + const formatDistance = useFormatDistance() const remove = React.useCallback(async () => { control.close() @@ -280,62 +477,104 @@ function MutedWordRow({ control={control} title={_(msg`Are you sure?`)} description={_( - msg`This will delete ${word.value} from your muted words. You can always add it back later.`, + msg`This will delete "${word.value}" from your muted words. You can always add it back later.`, )} onConfirm={remove} confirmButtonCta={_(msg`Remove`)} confirmButtonColor="negative" + Portal={Portal.Portal} /> - - {word.value} - + + + + {word.targets.find(t => t === 'content') ? ( + + {word.value}{' '} + + in{' '} + + text & tags + + + + ) : ( + + {word.value}{' '} + + in{' '} + + tags + + + + )} + + - - {word.targets.map(target => ( - + {(expiryDate || word.actorTarget === 'exclude-following') && ( + - {target === 'content' ? _(msg`text`) : _(msg`tag`)} + style={[ + a.flex_1, + a.text_xs, + a.leading_snug, + t.atoms.text_contrast_medium, + ]}> + {expiryDate && ( + <> + {isExpired ? ( + Expired + ) : ( + + Expires{' '} + {formatDistance(expiryDate, new Date(), { + addSuffix: true, + })} + + )} + + )} + {word.actorTarget === 'exclude-following' && ( + <> + {' • '} + Excludes users you follow + + )} - ))} - - + )} + + ) diff --git a/src/components/dialogs/PostInteractionSettingsDialog.tsx b/src/components/dialogs/PostInteractionSettingsDialog.tsx new file mode 100644 index 0000000000..bddc499682 --- /dev/null +++ b/src/components/dialogs/PostInteractionSettingsDialog.tsx @@ -0,0 +1,537 @@ +import React from 'react' +import {StyleProp, View, ViewStyle} from 'react-native' +import {AppBskyFeedDefs, AppBskyFeedPostgate, AtUri} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useQueryClient} from '@tanstack/react-query' +import isEqual from 'lodash.isequal' + +import {logger} from '#/logger' +import {STALE} from '#/state/queries' +import {useMyListsQuery} from '#/state/queries/my-lists' +import { + createPostgateQueryKey, + getPostgateRecord, + usePostgateQuery, + useWritePostgateMutation, +} from '#/state/queries/postgate' +import { + createPostgateRecord, + embeddingRules, +} from '#/state/queries/postgate/util' +import { + createThreadgateViewQueryKey, + getThreadgateView, + ThreadgateAllowUISetting, + threadgateViewToAllowUISetting, + useSetThreadgateAllowMutation, + useThreadgateViewQuery, +} from '#/state/queries/threadgate' +import {useAgent, useSession} from '#/state/session' +import * as Toast from '#/view/com/util/Toast' +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {Divider} from '#/components/Divider' +import * as Toggle from '#/components/forms/Toggle' +import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' +import {Loader} from '#/components/Loader' +import {PortalComponent} from '#/components/Portal' +import {Text} from '#/components/Typography' + +export type PostInteractionSettingsFormProps = { + onSave: () => void + isSaving?: boolean + + postgate: AppBskyFeedPostgate.Record + onChangePostgate: (v: AppBskyFeedPostgate.Record) => void + + threadgateAllowUISettings: ThreadgateAllowUISetting[] + onChangeThreadgateAllowUISettings: (v: ThreadgateAllowUISetting[]) => void + + replySettingsDisabled?: boolean +} + +export function PostInteractionSettingsControlledDialog({ + control, + Portal, + ...rest +}: PostInteractionSettingsFormProps & { + control: Dialog.DialogControlProps + Portal?: PortalComponent +}) { + const {_} = useLingui() + return ( + + + + + + + + ) +} + +export type PostInteractionSettingsDialogProps = { + control: Dialog.DialogControlProps + /** + * URI of the post to edit the interaction settings for. Could be a root post + * or could be a reply. + */ + postUri: string + /** + * The URI of the root post in the thread. Used to determine if the viewer + * owns the threadgate record and can therefore edit it. + */ + rootPostUri: string + /** + * Optional initial {@link AppBskyFeedDefs.ThreadgateView} to use if we + * happen to have one before opening the settings dialog. + */ + initialThreadgateView?: AppBskyFeedDefs.ThreadgateView +} + +export function PostInteractionSettingsDialog( + props: PostInteractionSettingsDialogProps, +) { + return ( + + + + + ) +} + +export function PostInteractionSettingsDialogControlledInner( + props: PostInteractionSettingsDialogProps, +) { + const {_} = useLingui() + const {currentAccount} = useSession() + const [isSaving, setIsSaving] = React.useState(false) + + const {data: threadgateViewLoaded, isLoading: isLoadingThreadgate} = + useThreadgateViewQuery({postUri: props.rootPostUri}) + const {data: postgate, isLoading: isLoadingPostgate} = usePostgateQuery({ + postUri: props.postUri, + }) + + const {mutateAsync: writePostgateRecord} = useWritePostgateMutation() + const {mutateAsync: setThreadgateAllow} = useSetThreadgateAllowMutation() + + const [editedPostgate, setEditedPostgate] = + React.useState() + const [editedAllowUISettings, setEditedAllowUISettings] = + React.useState() + + const isLoading = isLoadingThreadgate || isLoadingPostgate + const threadgateView = threadgateViewLoaded || props.initialThreadgateView + const isThreadgateOwnedByViewer = React.useMemo(() => { + return currentAccount?.did === new AtUri(props.rootPostUri).host + }, [props.rootPostUri, currentAccount?.did]) + + const postgateValue = React.useMemo(() => { + return ( + editedPostgate || postgate || createPostgateRecord({post: props.postUri}) + ) + }, [postgate, editedPostgate, props.postUri]) + const allowUIValue = React.useMemo(() => { + return ( + editedAllowUISettings || threadgateViewToAllowUISetting(threadgateView) + ) + }, [threadgateView, editedAllowUISettings]) + + const onSave = React.useCallback(async () => { + if (!editedPostgate && !editedAllowUISettings) { + props.control.close() + return + } + + setIsSaving(true) + + try { + const requests = [] + + if (editedPostgate) { + requests.push( + writePostgateRecord({ + postUri: props.postUri, + postgate: editedPostgate, + }), + ) + } + + if (editedAllowUISettings && isThreadgateOwnedByViewer) { + requests.push( + setThreadgateAllow({ + postUri: props.rootPostUri, + allow: editedAllowUISettings, + }), + ) + } + + await Promise.all(requests) + + props.control.close() + } catch (e: any) { + logger.error(`Failed to save post interaction settings`, { + context: 'PostInteractionSettingsDialogControlledInner', + safeMessage: e.message, + }) + Toast.show( + _( + msg`There was an issue. Please check your internet connection and try again.`, + ), + 'xmark', + ) + } finally { + setIsSaving(false) + } + }, [ + _, + props.postUri, + props.rootPostUri, + props.control, + editedPostgate, + editedAllowUISettings, + setIsSaving, + writePostgateRecord, + setThreadgateAllow, + isThreadgateOwnedByViewer, + ]) + + return ( + + {isLoading ? ( + + ) : ( + + )} + + ) +} + +export function PostInteractionSettingsForm({ + onSave, + isSaving, + postgate, + onChangePostgate, + threadgateAllowUISettings, + onChangeThreadgateAllowUISettings, + replySettingsDisabled, +}: PostInteractionSettingsFormProps) { + const t = useTheme() + const {_} = useLingui() + const {data: lists} = useMyListsQuery('curate') + const [quotesEnabled, setQuotesEnabled] = React.useState( + !( + postgate.embeddingRules && + postgate.embeddingRules.find( + v => v.$type === embeddingRules.disableRule.$type, + ) + ), + ) + + const onPressAudience = (setting: ThreadgateAllowUISetting) => { + // remove boolean values + let newSelected: ThreadgateAllowUISetting[] = + threadgateAllowUISettings.filter( + v => v.type !== 'nobody' && v.type !== 'everybody', + ) + // toggle + const i = newSelected.findIndex(v => isEqual(v, setting)) + if (i === -1) { + newSelected.push(setting) + } else { + newSelected.splice(i, 1) + } + + onChangeThreadgateAllowUISettings(newSelected) + } + + const onChangeQuotesEnabled = React.useCallback( + (enabled: boolean) => { + setQuotesEnabled(enabled) + onChangePostgate( + createPostgateRecord({ + ...postgate, + embeddingRules: enabled ? [] : [embeddingRules.disableRule], + }), + ) + }, + [setQuotesEnabled, postgate, onChangePostgate], + ) + + const noOneCanReply = !!threadgateAllowUISettings.find( + v => v.type === 'nobody', + ) + + return ( + + + + Post interaction settings + + + + + Customize who can interact with this post. + + + + + + + Quote settings + + + + + {quotesEnabled ? ( + Quote posts enabled + ) : ( + Quote posts disabled + )} + + + + + + + + {replySettingsDisabled && ( + + + + + Reply settings are chosen by the author of the thread + + + + )} + + + + Reply settings + + + + Allow replies from: + + + + v.type === 'everybody') + } + onPress={() => + onChangeThreadgateAllowUISettings([{type: 'everybody'}]) + } + style={{flex: 1}} + disabled={replySettingsDisabled} + /> + + onChangeThreadgateAllowUISettings([{type: 'nobody'}]) + } + style={{flex: 1}} + disabled={replySettingsDisabled} + /> + + + {!noOneCanReply && ( + <> + + Or combine these options: + + + + v.type === 'mention', + ) + } + onPress={() => onPressAudience({type: 'mention'})} + disabled={replySettingsDisabled} + /> + v.type === 'following', + ) + } + onPress={() => onPressAudience({type: 'following'})} + disabled={replySettingsDisabled} + /> + {lists && lists.length > 0 + ? lists.map(list => ( + v.type === 'list' && v.list === list.uri, + ) + } + onPress={() => + onPressAudience({type: 'list', list: list.uri}) + } + disabled={replySettingsDisabled} + /> + )) + : // No loading states to avoid jumps for the common case (no lists) + null} + + + )} + + + + + + + ) +} + +function Selectable({ + label, + isSelected, + onPress, + style, + disabled, +}: { + label: string + isSelected: boolean + onPress: () => void + style?: StyleProp + disabled?: boolean +}) { + const t = useTheme() + return ( + + ) +} + +export function usePrefetchPostInteractionSettings({ + postUri, + rootPostUri, +}: { + postUri: string + rootPostUri: string +}) { + const queryClient = useQueryClient() + const agent = useAgent() + + return React.useCallback(async () => { + try { + await Promise.all([ + queryClient.prefetchQuery({ + queryKey: createPostgateQueryKey(postUri), + queryFn: () => getPostgateRecord({agent, postUri}), + staleTime: STALE.SECONDS.THIRTY, + }), + queryClient.prefetchQuery({ + queryKey: createThreadgateViewQueryKey(rootPostUri), + queryFn: () => getThreadgateView({agent, postUri: rootPostUri}), + staleTime: STALE.SECONDS.THIRTY, + }), + ]) + } catch (e: any) { + logger.error(`Failed to prefetch post interaction settings`, { + safeMessage: e.message, + }) + } + }, [queryClient, agent, postUri, rootPostUri]) +} diff --git a/src/components/dialogs/SwitchAccount.tsx b/src/components/dialogs/SwitchAccount.tsx index 0bd4bcb8cb..ea870e2da9 100644 --- a/src/components/dialogs/SwitchAccount.tsx +++ b/src/components/dialogs/SwitchAccount.tsx @@ -43,7 +43,6 @@ export function SwitchAccountDialog({ return ( - diff --git a/src/components/dialogs/ThreadgateEditor.tsx b/src/components/dialogs/ThreadgateEditor.tsx deleted file mode 100644 index 92dd157b22..0000000000 --- a/src/components/dialogs/ThreadgateEditor.tsx +++ /dev/null @@ -1,215 +0,0 @@ -import React from 'react' -import {StyleProp, View, ViewStyle} from 'react-native' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import isEqual from 'lodash.isequal' - -import {useMyListsQuery} from '#/state/queries/my-lists' -import {ThreadgateSetting} from '#/state/queries/threadgate' -import {atoms as a, useTheme} from '#/alf' -import {Button, ButtonText} from '#/components/Button' -import * as Dialog from '#/components/Dialog' -import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' -import {Text} from '#/components/Typography' - -interface ThreadgateEditorDialogProps { - control: Dialog.DialogControlProps - threadgate: ThreadgateSetting[] - onChange?: (v: ThreadgateSetting[]) => void - onConfirm?: (v: ThreadgateSetting[]) => void -} - -export function ThreadgateEditorDialog({ - control, - threadgate, - onChange, - onConfirm, -}: ThreadgateEditorDialogProps) { - return ( - - - - - ) -} - -function DialogContent({ - seedThreadgate, - onChange, - onConfirm, -}: { - seedThreadgate: ThreadgateSetting[] - onChange?: (v: ThreadgateSetting[]) => void - onConfirm?: (v: ThreadgateSetting[]) => void -}) { - const {_} = useLingui() - const control = Dialog.useDialogContext() - const {data: lists} = useMyListsQuery('curate') - const [draft, setDraft] = React.useState(seedThreadgate) - - const [prevSeedThreadgate, setPrevSeedThreadgate] = - React.useState(seedThreadgate) - if (seedThreadgate !== prevSeedThreadgate) { - // New data flowed from above (e.g. due to update coming through). - setPrevSeedThreadgate(seedThreadgate) - setDraft(seedThreadgate) // Reset draft. - } - - function updateThreadgate(nextThreadgate: ThreadgateSetting[]) { - setDraft(nextThreadgate) - onChange?.(nextThreadgate) - } - - const onPressEverybody = () => { - updateThreadgate([]) - } - - const onPressNobody = () => { - updateThreadgate([{type: 'nobody'}]) - } - - const onPressAudience = (setting: ThreadgateSetting) => { - // remove nobody - let newSelected = draft.filter(v => v.type !== 'nobody') - // toggle - const i = newSelected.findIndex(v => isEqual(v, setting)) - if (i === -1) { - newSelected.push(setting) - } else { - newSelected.splice(i, 1) - } - updateThreadgate(newSelected) - } - - const doneLabel = onConfirm ? _(msg`Save`) : _(msg`Done`) - return ( - - - - Choose who can reply - - - Either choose "Everybody" or "Nobody" - - - - v.type === 'nobody')} - onPress={onPressNobody} - style={{flex: 1}} - /> - - - Or combine these options: - - - v.type === 'mention')} - onPress={() => onPressAudience({type: 'mention'})} - /> - v.type === 'following')} - onPress={() => onPressAudience({type: 'following'})} - /> - {lists && lists.length > 0 - ? lists.map(list => ( - v.type === 'list' && v.list === list.uri) - } - onPress={() => - onPressAudience({type: 'list', list: list.uri}) - } - /> - )) - : // No loading states to avoid jumps for the common case (no lists) - null} - - - - - - ) -} - -function Selectable({ - label, - isSelected, - onPress, - style, -}: { - label: string - isSelected: boolean - onPress: () => void - style?: StyleProp -}) { - const t = useTheme() - return ( - - ) -} diff --git a/src/components/dialogs/nuxs/NeueTypography.tsx b/src/components/dialogs/nuxs/NeueTypography.tsx new file mode 100644 index 0000000000..f29dc356dd --- /dev/null +++ b/src/components/dialogs/nuxs/NeueTypography.tsx @@ -0,0 +1,117 @@ +import React from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {AppearanceToggleButtonGroup} from '#/screens/Settings/AppearanceSettings' +import {atoms as a, useAlf, useTheme} from '#/alf' +import * as Dialog from '#/components/Dialog' +import {useNuxDialogContext} from '#/components/dialogs/nuxs' +import {Divider} from '#/components/Divider' +import {TextSize_Stroke2_Corner0_Rounded as TextSize} from '#/components/icons/TextSize' +import {TitleCase_Stroke2_Corner0_Rounded as Aa} from '#/components/icons/TitleCase' +import {Text} from '#/components/Typography' + +export function NeueTypography() { + const t = useTheme() + const {_} = useLingui() + const nuxDialogs = useNuxDialogContext() + const control = Dialog.useDialogControl() + const {fonts} = useAlf() + + Dialog.useAutoOpen(control, 3e3) + + const onClose = React.useCallback(() => { + nuxDialogs.dismissActiveNux() + }, [nuxDialogs]) + + const onChangeFontFamily = React.useCallback( + (values: string[]) => { + const next = values[0] === 'system' ? 'system' : 'theme' + fonts.setFontFamily(next) + }, + [fonts], + ) + + const onChangeFontScale = React.useCallback( + (values: string[]) => { + const next = values[0] || ('0' as any) + fonts.setFontScale(next) + }, + [fonts], + ) + + return ( + + + + + + + New font settings ✨ + + + + We're introducing a new theme font, along with adjustable font + sizing. + + + + + You can adjust these in your Appearance Settings later. + + + + + + + + + + + + + + + + + ) +} diff --git a/src/components/dialogs/nuxs/index.tsx b/src/components/dialogs/nuxs/index.tsx new file mode 100644 index 0000000000..e7476972ad --- /dev/null +++ b/src/components/dialogs/nuxs/index.tsx @@ -0,0 +1,180 @@ +import React from 'react' +import {AppBskyActorDefs} from '@atproto/api' + +import {useGate} from '#/lib/statsig/statsig' +import {logger} from '#/logger' +import { + Nux, + useNuxs, + useRemoveNuxsMutation, + useUpsertNuxMutation, +} from '#/state/queries/nuxs' +import { + usePreferencesQuery, + UsePreferencesQueryResponse, +} from '#/state/queries/preferences' +import {useProfileQuery} from '#/state/queries/profile' +import {SessionAccount, useSession} from '#/state/session' +import {useOnboardingState} from '#/state/shell' +/* + * NUXs + */ +import {NeueTypography} from '#/components/dialogs/nuxs/NeueTypography' +import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing' +import {IS_DEV} from '#/env' + +type Context = { + activeNux: Nux | undefined + dismissActiveNux: () => void +} + +const queuedNuxs: { + id: Nux + enabled?: (props: { + gate: ReturnType + currentAccount: SessionAccount + currentProfile: AppBskyActorDefs.ProfileViewDetailed + preferences: UsePreferencesQueryResponse + }) => boolean +}[] = [ + { + id: Nux.NeueTypography, + enabled(props) { + if (props.currentProfile.createdAt) { + if (new Date(props.currentProfile.createdAt) < new Date('2024-10-01')) { + return true + } + } + return false + }, + }, +] + +const Context = React.createContext({ + activeNux: undefined, + dismissActiveNux: () => {}, +}) + +export function useNuxDialogContext() { + return React.useContext(Context) +} + +export function NuxDialogs() { + const {currentAccount} = useSession() + const {data: preferences} = usePreferencesQuery() + const {data: profile} = useProfileQuery({did: currentAccount?.did}) + const onboardingActive = useOnboardingState().isActive + + const isLoading = + !currentAccount || !preferences || !profile || onboardingActive + return !isLoading ? ( + + ) : null +} + +function Inner({ + currentAccount, + currentProfile, + preferences, +}: { + currentAccount: SessionAccount + currentProfile: AppBskyActorDefs.ProfileViewDetailed + preferences: UsePreferencesQueryResponse +}) { + const gate = useGate() + const {nuxs} = useNuxs() + const [snoozed, setSnoozed] = React.useState(() => { + return isSnoozed() + }) + const [activeNux, setActiveNux] = React.useState() + const {mutateAsync: upsertNux} = useUpsertNuxMutation() + const {mutate: removeNuxs} = useRemoveNuxsMutation() + + const snoozeNuxDialog = React.useCallback(() => { + snooze() + setSnoozed(true) + }, [setSnoozed]) + + const dismissActiveNux = React.useCallback(() => { + if (!activeNux) return + setActiveNux(undefined) + }, [activeNux, setActiveNux]) + + if (IS_DEV && typeof window !== 'undefined') { + // @ts-ignore + window.clearNuxDialog = (id: Nux) => { + if (!IS_DEV || !id) return + removeNuxs([id]) + unsnooze() + } + } + + React.useEffect(() => { + if (snoozed) return + if (!nuxs) return + + for (const {id, enabled} of queuedNuxs) { + const nux = nuxs.find(nux => nux.id === id) + + // check if completed first + if (nux && nux.completed) { + continue + } + + // then check gate (track exposure) + if ( + enabled && + !enabled({gate, currentAccount, currentProfile, preferences}) + ) { + continue + } + + logger.debug(`NUX dialogs: activating '${id}' NUX`) + + // we have a winner + setActiveNux(id) + + // immediately snooze for a day + snoozeNuxDialog() + + // immediately update remote data (affects next reload) + upsertNux({ + id, + completed: true, + data: undefined, + }).catch(e => { + logger.error(`NUX dialogs: failed to upsert '${id}' NUX`, { + safeMessage: e.message, + }) + }) + + break + } + }, [ + nuxs, + snoozed, + snoozeNuxDialog, + upsertNux, + gate, + currentAccount, + currentProfile, + preferences, + ]) + + const ctx = React.useMemo(() => { + return { + activeNux, + dismissActiveNux, + } + }, [activeNux, dismissActiveNux]) + + return ( + + {activeNux === Nux.NeueTypography && } + + ) +} diff --git a/src/components/dialogs/nuxs/snoozing.ts b/src/components/dialogs/nuxs/snoozing.ts new file mode 100644 index 0000000000..91effd0501 --- /dev/null +++ b/src/components/dialogs/nuxs/snoozing.ts @@ -0,0 +1,22 @@ +import {simpleAreDatesEqual} from '#/lib/strings/time' +import {device} from '#/storage' + +export function snooze() { + device.set(['lastNuxDialog'], new Date().toISOString()) +} + +export function unsnooze() { + device.set(['lastNuxDialog'], undefined) +} + +export function isSnoozed() { + const lastNuxDialog = device.get(['lastNuxDialog']) + if (!lastNuxDialog) return false + const last = new Date(lastNuxDialog) + const now = new Date() + // already snoozed today + if (simpleAreDatesEqual(last, now)) { + return true + } + return false +} diff --git a/src/components/dms/BlockedByListDialog.tsx b/src/components/dms/BlockedByListDialog.tsx index a277016053..b786e36815 100644 --- a/src/components/dms/BlockedByListDialog.tsx +++ b/src/components/dms/BlockedByListDialog.tsx @@ -42,6 +42,7 @@ export function BlockedByListDialog({ {i === 0 ? null : ', '} {block.source.list.name} diff --git a/src/components/dms/ConvoMenu.tsx b/src/components/dms/ConvoMenu.tsx index 3f680120b8..affc292c18 100644 --- a/src/components/dms/ConvoMenu.tsx +++ b/src/components/dms/ConvoMenu.tsx @@ -85,7 +85,7 @@ let ConvoMenu = ({ } }, onError: () => { - Toast.show(_(msg`Could not mute chat`)) + Toast.show(_(msg`Could not mute chat`), 'xmark') }, }) @@ -136,7 +136,7 @@ let ConvoMenu = ({ + onPress={() => leaveConvoControl.open()}> Leave conversation @@ -195,7 +195,7 @@ let ConvoMenu = ({ + onPress={() => reportControl.open()}> Report conversation @@ -206,7 +206,7 @@ let ConvoMenu = ({ + onPress={() => leaveConvoControl.open()}> Leave conversation diff --git a/src/components/dms/DateDivider.tsx b/src/components/dms/DateDivider.tsx new file mode 100644 index 0000000000..a9c82e8ea2 --- /dev/null +++ b/src/components/dms/DateDivider.tsx @@ -0,0 +1,80 @@ +import React from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {subDays} from 'date-fns' + +import {atoms as a, useTheme} from '#/alf' +import {Text} from '../Typography' +import {localDateString} from './util' + +const timeFormatter = new Intl.DateTimeFormat(undefined, { + hour: 'numeric', + minute: 'numeric', +}) +const weekdayFormatter = new Intl.DateTimeFormat(undefined, { + weekday: 'long', +}) +const longDateFormatter = new Intl.DateTimeFormat(undefined, { + weekday: 'short', + month: 'long', + day: 'numeric', +}) +const longDateFormatterWithYear = new Intl.DateTimeFormat(undefined, { + weekday: 'short', + month: 'long', + day: 'numeric', + year: 'numeric', +}) + +let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => { + const {_} = useLingui() + const t = useTheme() + + let date: string + const time = timeFormatter.format(new Date(dateStr)) + + const timestamp = new Date(dateStr) + + const today = new Date() + const yesterday = subDays(today, 1) + const oneWeekAgo = subDays(today, 7) + + if (localDateString(today) === localDateString(timestamp)) { + date = _(msg`Today`) + } else if (localDateString(yesterday) === localDateString(timestamp)) { + date = _(msg`Yesterday`) + } else { + if (timestamp < oneWeekAgo) { + if (timestamp.getFullYear() === today.getFullYear()) { + date = longDateFormatter.format(timestamp) + } else { + date = longDateFormatterWithYear.format(timestamp) + } + } else { + date = weekdayFormatter.format(timestamp) + } + } + + return ( + + + + + {date} + {' '} + at {time} + + + + ) +} +DateDivider = React.memo(DateDivider) +export {DateDivider} diff --git a/src/components/dms/LeaveConvoPrompt.tsx b/src/components/dms/LeaveConvoPrompt.tsx index 7abc76f34c..b0dd83b294 100644 --- a/src/components/dms/LeaveConvoPrompt.tsx +++ b/src/components/dms/LeaveConvoPrompt.tsx @@ -36,7 +36,7 @@ export function LeaveConvoPrompt({ } }, onError: () => { - Toast.show(_(msg`Could not leave chat`)) + Toast.show(_(msg`Could not leave chat`), 'xmark') }, }) diff --git a/src/components/dms/MessageContext.tsx b/src/components/dms/MessageContext.tsx new file mode 100644 index 0000000000..84056fb306 --- /dev/null +++ b/src/components/dms/MessageContext.tsx @@ -0,0 +1,17 @@ +import React from 'react' + +const MessageContext = React.createContext(false) + +export function MessageContextProvider({ + children, +}: { + children: React.ReactNode +}) { + return ( + {children} + ) +} + +export function useIsWithinMessage() { + return React.useContext(MessageContext) +} diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx index 573c24f77d..52220e2cac 100644 --- a/src/components/dms/MessageItem.tsx +++ b/src/components/dms/MessageItem.tsx @@ -11,18 +11,21 @@ import { ChatBskyConvoDefs, RichText as RichTextAPI, } from '@atproto/api' +import {I18n} from '@lingui/core' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {ConvoItem} from '#/state/messages/convo/types' import {useSession} from '#/state/session' -import {TimeElapsed} from 'view/com/util/TimeElapsed' +import {TimeElapsed} from '#/view/com/util/TimeElapsed' import {atoms as a, useTheme} from '#/alf' import {ActionsWrapper} from '#/components/dms/ActionsWrapper' import {InlineLinkText} from '#/components/Link' import {Text} from '#/components/Typography' import {isOnlyEmoji, RichText} from '../RichText' +import {DateDivider} from './DateDivider' import {MessageItemEmbed} from './MessageItemEmbed' +import {localDateString} from './util' let MessageItem = ({ item, @@ -32,14 +35,37 @@ let MessageItem = ({ const t = useTheme() const {currentAccount} = useSession() - const {message, nextMessage} = item + const {message, nextMessage, prevMessage} = item const isPending = item.type === 'pending-message' const isFromSelf = message.sender?.did === currentAccount?.did + const nextIsMessage = ChatBskyConvoDefs.isMessageView(nextMessage) + const isNextFromSelf = - ChatBskyConvoDefs.isMessageView(nextMessage) && - nextMessage.sender?.did === currentAccount?.did + nextIsMessage && nextMessage.sender?.did === currentAccount?.did + + const isNextFromSameSender = isNextFromSelf === isFromSelf + + const isNewDay = useMemo(() => { + if (!prevMessage) return true + + const thisDate = new Date(message.sentAt) + const prevDate = new Date(prevMessage.sentAt) + + return localDateString(thisDate) !== localDateString(prevDate) + }, [message, prevMessage]) + + const isLastMessageOfDay = useMemo(() => { + if (!nextMessage || !nextIsMessage) return true + + const thisDate = new Date(message.sentAt) + const prevDate = new Date(nextMessage.sentAt) + + return localDateString(thisDate) !== localDateString(prevDate) + }, [message.sentAt, nextIsMessage, nextMessage]) + + const needsTail = isLastMessageOfDay || !isNextFromSameSender const isLastInGroup = useMemo(() => { // if this message is pending, it means the next message is pending too @@ -47,24 +73,19 @@ let MessageItem = ({ return false } - // if the next message is from a different sender, then it's the last in the group - if (isFromSelf ? !isNextFromSelf : isNextFromSelf) { - return true - } - - // or, if there's a 3 minute gap between this message and the next + // or, if there's a 5 minute gap between this message and the next if (ChatBskyConvoDefs.isMessageView(nextMessage)) { const thisDate = new Date(message.sentAt) const nextDate = new Date(nextMessage.sentAt) const diff = nextDate.getTime() - thisDate.getTime() - // 3 minutes - return diff > 3 * 60 * 1000 + // 5 minutes + return diff > 5 * 60 * 1000 } return true - }, [message, nextMessage, isFromSelf, isNextFromSelf, isPending]) + }, [message, nextMessage, isPending]) const lastInGroupRef = useRef(isLastInGroup) if (lastInGroupRef.current !== isLastInGroup) { @@ -79,52 +100,59 @@ let MessageItem = ({ }, [message.text, message.facets]) return ( - - - {AppBskyEmbedRecord.isView(message.embed) && ( - - )} - {rt.text.length > 0 && ( - - - - )} - + <> + {isNewDay && } + + + {AppBskyEmbedRecord.isView(message.embed) && ( + + )} + {rt.text.length > 0 && ( + + + + )} + - {isLastInGroup && ( - - )} - + {isLastInGroup && ( + + )} + + ) } MessageItem = React.memo(MessageItem) @@ -153,42 +181,23 @@ let MessageItemMetadata = ({ ) const relativeTimestamp = useCallback( - (timestamp: string) => { + (i18n: I18n, timestamp: string) => { const date = new Date(timestamp) const now = new Date() - const time = new Intl.DateTimeFormat(undefined, { + const time = i18n.date(date, { hour: 'numeric', minute: 'numeric', - }).format(date) + }) const diff = now.getTime() - date.getTime() - // if under 1 minute - if (diff < 1000 * 60) { + // if under 30 seconds + if (diff < 1000 * 30) { return _(msg`Now`) } - // if in the last day - if (localDateString(now) === localDateString(date)) { - return time - } - - // if yesterday - const yesterday = new Date(now) - yesterday.setDate(yesterday.getDate() - 1) - - if (localDateString(yesterday) === localDateString(date)) { - return _(msg`Yesterday, ${time}`) - } - - return new Intl.DateTimeFormat(undefined, { - hour: 'numeric', - minute: 'numeric', - day: 'numeric', - month: 'numeric', - year: 'numeric', - }).format(date) + return time }, [_], ) @@ -241,15 +250,5 @@ let MessageItemMetadata = ({ ) } - MessageItemMetadata = React.memo(MessageItemMetadata) export {MessageItemMetadata} - -function localDateString(date: Date) { - // can't use toISOString because it should be in local time - const mm = date.getMonth() - const dd = date.getDate() - const yyyy = date.getFullYear() - // not padding with 0s because it's not necessary, it's just used for comparison - return `${yyyy}-${mm}-${dd}` -} diff --git a/src/components/dms/MessageItemEmbed.tsx b/src/components/dms/MessageItemEmbed.tsx index aefd62b9ac..f9eb4d3af7 100644 --- a/src/components/dms/MessageItemEmbed.tsx +++ b/src/components/dms/MessageItemEmbed.tsx @@ -2,8 +2,9 @@ import React from 'react' import {View} from 'react-native' import {AppBskyEmbedRecord} from '@atproto/api' -import {PostEmbeds} from '#/view/com/util/post-embeds' +import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds' import {atoms as a, native, useTheme} from '#/alf' +import {MessageContextProvider} from './MessageContext' let MessageItemEmbed = ({ embed, @@ -13,9 +14,15 @@ let MessageItemEmbed = ({ const t = useTheme() return ( - - - + + + + + ) } MessageItemEmbed = React.memo(MessageItemEmbed) diff --git a/src/components/dms/MessageMenu.tsx b/src/components/dms/MessageMenu.tsx index 92913d1cb2..8680a68bfb 100644 --- a/src/components/dms/MessageMenu.tsx +++ b/src/components/dms/MessageMenu.tsx @@ -7,11 +7,11 @@ import {useLingui} from '@lingui/react' import {richTextToString} from '#/lib/strings/rich-text-helpers' import {getTranslatorLink} from '#/locale/helpers' +import {isWeb} from '#/platform/detection' +import {useConvoActive} from '#/state/messages/convo' import {useLanguagePrefs} from '#/state/preferences' import {useOpenLink} from '#/state/preferences/in-app-browser' -import {isWeb} from 'platform/detection' -import {useConvoActive} from 'state/messages/convo' -import {useSession} from 'state/session' +import {useSession} from '#/state/session' import * as Toast from '#/view/com/util/Toast' import {atoms as a, useTheme} from '#/alf' import {ReportDialog} from '#/components/dms/ReportDialog' @@ -54,7 +54,7 @@ export let MessageMenu = ({ ) Clipboard.setStringAsync(str) - Toast.show(_(msg`Copied to clipboard`)) + Toast.show(_(msg`Copied to clipboard`), 'clipboard-check') }, [_, message.text, message.facets]) const onPressTranslateMessage = React.useCallback(() => { @@ -120,7 +120,7 @@ export let MessageMenu = ({ + onPress={() => deleteControl.open()}> {_(msg`Delete for me`)} @@ -128,7 +128,7 @@ export let MessageMenu = ({ + onPress={() => reportControl.open()}> {_(msg`Report`)} diff --git a/src/components/dms/MessageProfileButton.tsx b/src/components/dms/MessageProfileButton.tsx index 7f440d621f..932982d05e 100644 --- a/src/components/dms/MessageProfileButton.tsx +++ b/src/components/dms/MessageProfileButton.tsx @@ -4,12 +4,13 @@ import {AppBskyActorDefs} from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {logEvent} from '#/lib/statsig/statsig' import {useMaybeConvoForUser} from '#/state/queries/messages/get-convo-for-members' -import {logEvent} from 'lib/statsig/statsig' import {atoms as a, useTheme} from '#/alf' -import {Message_Stroke2_Corner0_Rounded as Message} from '../icons/Message' -import {Link} from '../Link' -import {canBeMessaged} from './util' +import {ButtonIcon} from '#/components/Button' +import {canBeMessaged} from '#/components/dms/util' +import {Message_Stroke2_Corner0_Rounded as Message} from '#/components/icons/Message' +import {Link} from '#/components/Link' export function MessageProfileButton({ profile, @@ -40,15 +41,9 @@ export function MessageProfileButton({ a.align_center, t.atoms.bg_contrast_25, a.rounded_full, - {width: 36, height: 36}, + {width: 34, height: 34}, ]}> - + ) } else { @@ -66,12 +61,9 @@ export function MessageProfileButton({ shape="round" label={_(msg`Message ${profile.handle}`)} to={`/messages/${convo.id}`} - style={[a.justify_center, {width: 36, height: 36}]} + style={[a.justify_center]} onPress={onPress}> - + ) } else { diff --git a/src/components/dms/MessagesListHeader.tsx b/src/components/dms/MessagesListHeader.tsx index 7b9f1a3a02..ab9ec16e4d 100644 --- a/src/components/dms/MessagesListHeader.tsx +++ b/src/components/dms/MessagesListHeader.tsx @@ -10,14 +10,14 @@ import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' -import {BACK_HITSLOP} from 'lib/constants' -import {makeProfileLink} from 'lib/routes/links' -import {NavigationProp} from 'lib/routes/types' -import {sanitizeDisplayName} from 'lib/strings/display-names' -import {isWeb} from 'platform/detection' -import {useProfileShadow} from 'state/cache/profile-shadow' -import {isConvoActive, useConvo} from 'state/messages/convo' -import {PreviewableUserAvatar} from 'view/com/util/UserAvatar' +import {BACK_HITSLOP} from '#/lib/constants' +import {makeProfileLink} from '#/lib/routes/links' +import {NavigationProp} from '#/lib/routes/types' +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {isWeb} from '#/platform/detection' +import {useProfileShadow} from '#/state/cache/profile-shadow' +import {isConvoActive, useConvo} from '#/state/messages/convo' +import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' import {ConvoMenu} from '#/components/dms/ConvoMenu' import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2' @@ -140,6 +140,7 @@ function HeaderReady({ userBlock?: ModerationCause } }) { + const {_} = useLingui() const t = useTheme() const convoState = useConvo() const profile = useProfileShadow(profileUnshadowed) @@ -156,6 +157,7 @@ function HeaderReady({ @@ -168,6 +170,7 @@ function HeaderReady({ { - if (profile && typeof profile.associated?.chat === 'undefined') { - const timeout = setTimeout(() => { - control.open() - }, 1000) - - return () => { - clearTimeout(timeout) - } - } - }, [profile, control]) - - if (!profile) return null - - return ( - - - - - ) -} - -function DialogInner({ - chatDeclation, -}: { - chatDeclation?: ChatBskyActorDeclaration.Record -}) { - const control = Dialog.useDialogContext() - const {_} = useLingui() - const t = useTheme() - - const [initialized, setInitialzed] = React.useState(false) - const {mutate: updateDeclaration} = useUpdateActorDeclaration({ - onError: () => { - Toast.show(_(msg`Failed to update settings`)) - }, - }) - - const onSelectItem = useCallback( - (keys: string[]) => { - const key = keys[0] - if (!key) return - updateDeclaration(key as 'all' | 'none' | 'following') - }, - [updateDeclaration], - ) - - useEffect(() => { - if (!chatDeclation && !initialized) { - updateDeclaration('following') - setInitialzed(true) - } - }, [chatDeclation, updateDeclaration, initialized]) - - return ( - - - - - - Direct messages are here! - - - Privately chat with other users. - - - - - - Who can message you? - - - You can change this at any time. - - - - - - - - Everyone - - - - - - Users I follow - - - - - - No one - - - - - - - - - - - - ) -} diff --git a/src/components/dms/ReportDialog.tsx b/src/components/dms/ReportDialog.tsx index 5493a1c87f..06d69ff4be 100644 --- a/src/components/dms/ReportDialog.tsx +++ b/src/components/dms/ReportDialog.tsx @@ -10,13 +10,11 @@ import {useLingui} from '@lingui/react' import {useMutation} from '@tanstack/react-query' import {ReportOption} from '#/lib/moderation/useReportOptions' -import {isAndroid} from '#/platform/detection' import {useAgent} from '#/state/session' import {CharProgress} from '#/view/com/composer/char-progress/CharProgress' import * as Toast from '#/view/com/util/Toast' import {atoms as a, 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' @@ -41,14 +39,11 @@ let ReportDialog = ({ }): React.ReactNode => { const {_} = useLingui() return ( - + - ) @@ -277,6 +272,7 @@ function PreviewMessage({message}: {message: ChatBskyConvoDefs.MessageView}) { message, key: '', nextMessage: null, + prevMessage: null, }} style={[a.text_left, a.mb_0]} /> diff --git a/src/components/dms/dialogs/NewChatDialog.tsx b/src/components/dms/dialogs/NewChatDialog.tsx index 2b90fb02b2..e80fef2d7e 100644 --- a/src/components/dms/dialogs/NewChatDialog.tsx +++ b/src/components/dms/dialogs/NewChatDialog.tsx @@ -2,8 +2,9 @@ import React, {useCallback} from 'react' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {logEvent} from '#/lib/statsig/statsig' +import {logger} from '#/logger' import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' -import {logEvent} from 'lib/statsig/statsig' import {FAB} from '#/view/com/util/fab/FAB' import * as Toast from '#/view/com/util/Toast' import {useTheme} from '#/alf' @@ -31,7 +32,8 @@ export function NewChat({ logEvent('chat:open', {logContext: 'NewChatDialog'}) }, onError: error => { - Toast.show(error.message) + logger.error('Failed to create chat', {safeMessage: error}) + Toast.show(_(msg`An issue occurred starting the chat`), 'xmark') }, }) @@ -53,10 +55,8 @@ export function NewChat({ accessibilityHint="" /> - + + (null) + const listRef = useRef(null) const {currentAccount} = useSession() - const inputRef = useRef(null) + const inputRef = useRef(null) const [searchText, setSearchText] = useState('') @@ -101,15 +98,15 @@ export function SearchablePeopleList({ }) } - _items = _items.sort(a => { + _items = _items.sort(item => { // @ts-ignore - return a.enabled ? -1 : 1 + return item.enabled ? -1 : 1 }) } } else { const placeholders: Item[] = Array(10) .fill(0) - .map((_, i) => ({ + .map((__, i) => ({ type: 'placeholder', key: i + '', })) @@ -155,9 +152,9 @@ export function SearchablePeopleList({ } // only sort follows - followsItems = followsItems.sort(a => { + followsItems = followsItems.sort(item => { // @ts-ignore - return a.enabled ? -1 : 1 + return item.enabled ? -1 : 1 }) // then append @@ -177,9 +174,9 @@ export function SearchablePeopleList({ } } - _items = _items.sort(a => { + _items = _items.sort(item => { // @ts-ignore - return a.enabled ? -1 : 1 + return item.enabled ? -1 : 1 }) } else { _items.push(...placeholders) @@ -242,57 +239,46 @@ export function SearchablePeopleList({ - - + {title} + {isWeb ? ( + + ) : null} - + item.key} style={[ web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]), - native({ - height: '100%', - paddingHorizontal: 0, - marginTop: 0, - paddingTop: 0, - borderTopLeftRadius: 40, - borderTopRightRadius: 40, - }), + native({height: '100%'}), ]} webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]} keyboardDismissMode="on-drag" @@ -396,7 +374,8 @@ function ProfileCard({ + numberOfLines={1} + emoji> {displayName} void onEscape: () => void - inputRef: React.RefObject + inputRef: React.RefObject }) { const t = useTheme() const {_} = useLingui() diff --git a/src/components/dms/dialogs/ShareViaChatDialog.tsx b/src/components/dms/dialogs/ShareViaChatDialog.tsx index d2fefd33bf..38b5583432 100644 --- a/src/components/dms/dialogs/ShareViaChatDialog.tsx +++ b/src/components/dms/dialogs/ShareViaChatDialog.tsx @@ -2,8 +2,9 @@ import React, {useCallback} from 'react' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {logEvent} from '#/lib/statsig/statsig' +import {logger} from '#/logger' import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' -import {logEvent} from 'lib/statsig/statsig' import * as Toast from '#/view/com/util/Toast' import * as Dialog from '#/components/Dialog' import {SearchablePeopleList} from './SearchablePeopleList' @@ -16,10 +17,8 @@ export function SendViaChatDialog({ onSelectChat: (chatId: string) => void }) { return ( - + + ) @@ -43,7 +42,11 @@ function SendViaChatDialogInner({ logEvent('chat:open', {logContext: 'SendViaChatDialog'}) }, onError: error => { - Toast.show(error.message) + logger.error('Failed to share post to chat', {message: error}) + Toast.show( + _(msg`An issue occurred while trying to open the chat`), + 'xmark', + ) }, }) diff --git a/src/components/dms/util.ts b/src/components/dms/util.ts index 5952b9acf4..003532d0c6 100644 --- a/src/components/dms/util.ts +++ b/src/components/dms/util.ts @@ -16,3 +16,12 @@ export function canBeMessaged(profile: AppBskyActorDefs.ProfileView) { return false } } + +export function localDateString(date: Date) { + // can't use toISOString because it should be in local time + const mm = date.getMonth() + const dd = date.getDate() + const yyyy = date.getFullYear() + // not padding with 0s because it's not necessary, it's just used for comparison + return `${yyyy}-${mm}-${dd}` +} diff --git a/src/components/forms/DateField/index.shared.tsx b/src/components/forms/DateField/index.shared.tsx index 1f54bdc8be..814bbed7cc 100644 --- a/src/components/forms/DateField/index.shared.tsx +++ b/src/components/forms/DateField/index.shared.tsx @@ -1,12 +1,12 @@ import React from 'react' import {Pressable, View} from 'react-native' +import {useLingui} from '@lingui/react' import {android, atoms as a, useTheme, web} from '#/alf' import * as TextField from '#/components/forms/TextField' import {useInteractionState} from '#/components/hooks/useInteractionState' import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays' import {Text} from '#/components/Typography' -import {localizeDate} from './utils' // looks like a TextField.Input, but is just a button. It'll do something different on each platform on press // iOS: open a dialog with an inline date picker @@ -25,6 +25,7 @@ export function DateFieldButton({ isInvalid?: boolean accessibilityHint?: string }) { + const {i18n} = useLingui() const t = useTheme() const { @@ -91,7 +92,7 @@ export function DateFieldButton({ t.atoms.text, {lineHeight: a.text_md.fontSize * 1.1875}, ]}> - {localizeDate(value)} + {i18n.date(value, {timeZone: 'UTC'})} diff --git a/src/components/forms/DateField/index.tsx b/src/components/forms/DateField/index.tsx index c916f4efce..1c78d2abbb 100644 --- a/src/components/forms/DateField/index.tsx +++ b/src/components/forms/DateField/index.tsx @@ -76,7 +76,7 @@ export function DateField({ + + )} + + ) + }, +) diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx index a5ecfdcb83..96d3481cd4 100644 --- a/src/components/forms/TextField.tsx +++ b/src/components/forms/TextField.tsx @@ -9,8 +9,8 @@ import { ViewStyle, } from 'react-native' +import {HITSLOP_20} from '#/lib/constants' import {mergeRefs} from '#/lib/merge-refs' -import {HITSLOP_20} from 'lib/constants' import {android, atoms as a, useTheme, web} from '#/alf' import {useInteractionState} from '#/components/hooks/useInteractionState' import {Props as SVGIconProps} from '#/components/icons/common' @@ -73,7 +73,7 @@ export function Root({children, isInvalid = false}: RootProps) { return ( inputRef.current?.focus(), onMouseOver: onHoverIn, @@ -126,7 +126,7 @@ export type InputProps = Omit & { value?: string onChangeText?: (value: string) => void isInvalid?: boolean - inputRef?: React.RefObject + inputRef?: React.RefObject | React.ForwardedRef } export function createInput(Component: typeof TextInput) { @@ -135,6 +135,8 @@ export function createInput(Component: typeof TextInput) { placeholder, value, onChangeText, + onFocus, + onBlur, isInvalid, inputRef, style, @@ -173,8 +175,14 @@ export function createInput(Component: typeof TextInput) { ref={refs} value={value} onChangeText={onChangeText} - onFocus={ctx.onFocus} - onBlur={ctx.onBlur} + onFocus={e => { + ctx.onFocus() + onFocus?.(e) + }} + onBlur={e => { + ctx.onBlur() + onBlur?.(e) + }} placeholder={placeholder || label} placeholderTextColor={t.palette.contrast_500} keyboardAppearance={t.name === 'light' ? 'light' : 'dark'} @@ -188,21 +196,23 @@ export function createInput(Component: typeof TextInput) { a.px_xs, { // paddingVertical doesn't work w/multiline - esb - paddingTop: 14, - paddingBottom: 14, + paddingTop: 12, + paddingBottom: 13, lineHeight: a.text_md.fontSize * 1.1875, textAlignVertical: rest.multiline ? 'top' : undefined, minHeight: rest.multiline ? 80 : undefined, + minWidth: 0, }, // fix for autofill styles covering border web({ - paddingTop: 12, - paddingBottom: 12, + paddingTop: 10, + paddingBottom: 11, marginTop: 2, marginBottom: 2, }), android({ - paddingBottom: 16, + paddingTop: 8, + paddingBottom: 8, }), style, ]} diff --git a/src/components/forms/Toggle.tsx b/src/components/forms/Toggle.tsx index 8036223f2b..4e3695bbf2 100644 --- a/src/components/forms/Toggle.tsx +++ b/src/components/forms/Toggle.tsx @@ -1,7 +1,9 @@ import React from 'react' import {Pressable, View, ViewStyle} from 'react-native' +import Animated, {LinearTransition} from 'react-native-reanimated' -import {HITSLOP_10} from 'lib/constants' +import {HITSLOP_10} from '#/lib/constants' +import {isNative} from '#/platform/detection' import { atoms as a, flatten, @@ -244,6 +246,7 @@ export function LabelText({ @@ -344,12 +347,12 @@ export function Checkbox() { style={[ a.justify_center, a.align_center, - a.border, a.rounded_xs, t.atoms.border_contrast_high, { - height: 20, - width: 20, + borderWidth: 1, + height: 24, + width: 24, }, baseStyles, hovered ? baseHoverStyles : {}, @@ -375,34 +378,35 @@ export function Switch() { - @@ -428,12 +432,12 @@ export function Radio() { style={[ a.justify_center, a.align_center, - a.border, a.rounded_full, t.atoms.border_contrast_high, { - height: 20, - width: 20, + borderWidth: 1, + height: 24, + width: 24, }, baseStyles, hovered ? baseHoverStyles : {}, @@ -443,7 +447,7 @@ export function Radio() { style={[ a.absolute, a.rounded_full, - {height: 12, width: 12}, + {height: 16, width: 16}, selected ? { backgroundColor: t.palette.primary_500, @@ -456,3 +460,5 @@ export function Radio() { ) } + +export const Platform = isNative ? Switch : Checkbox diff --git a/src/components/forms/ToggleButton.tsx b/src/components/forms/ToggleButton.tsx index 7528426380..f47a272b18 100644 --- a/src/components/forms/ToggleButton.tsx +++ b/src/components/forms/ToggleButton.tsx @@ -23,10 +23,10 @@ export function Group({children, multiple, ...props}: GroupProps) { style={[ a.w_full, a.flex_row, - a.border, a.rounded_sm, a.overflow_hidden, t.atoms.border_contrast_low, + {borderWidth: 1}, ]}> {children} diff --git a/src/components/hooks/dates.ts b/src/components/hooks/dates.ts new file mode 100644 index 0000000000..00b70f762e --- /dev/null +++ b/src/components/hooks/dates.ts @@ -0,0 +1,71 @@ +/** + * Hooks for date-fns localized formatters. + * + * Our app supports some languages that are not included in date-fns by + * default, in which case it will fall back to English. + * + * {@link https://github.com/date-fns/date-fns/blob/main/docs/i18n.md} + */ + +import React from 'react' +import {formatDistance, Locale} from 'date-fns' +import { + ca, + de, + es, + fi, + fr, + hi, + id, + it, + ja, + ko, + ptBR, + ru, + tr, + uk, + zhCN, + zhTW, +} from 'date-fns/locale' + +import {AppLanguage} from '#/locale/languages' +import {useLanguagePrefs} from '#/state/preferences' + +/** + * {@link AppLanguage} + */ +const locales: Record = { + en: undefined, + ca, + de, + es, + fi, + fr, + ga: undefined, + hi, + id, + it, + ja, + ko, + ['pt-BR']: ptBR, + ru, + tr, + uk, + ['zh-CN']: zhCN, + ['zh-TW']: zhTW, +} + +/** + * Returns a localized `formatDistance` function. + * {@link formatDistance} + */ +export function useFormatDistance() { + const {appLanguage} = useLanguagePrefs() + return React.useCallback( + (date, baseDate, options) => { + const locale = locales[appLanguage as AppLanguage] + return formatDistance(date, baseDate, {...options, locale: locale}) + }, + [appLanguage], + ) +} diff --git a/src/components/hooks/useFollowMethods.ts b/src/components/hooks/useFollowMethods.ts index 1e91a1f38a..31a1e43daa 100644 --- a/src/components/hooks/useFollowMethods.ts +++ b/src/components/hooks/useFollowMethods.ts @@ -15,8 +15,8 @@ export function useFollowMethods({ logContext, }: { profile: Shadow - logContext: LogEvents['profile:follow']['logContext'] & - LogEvents['profile:unfollow']['logContext'] + logContext: LogEvents['profile:follow:sampled']['logContext'] & + LogEvents['profile:unfollow:sampled']['logContext'] }) { const {_} = useLingui() const requireAuth = useRequireAuth() @@ -32,7 +32,7 @@ export function useFollowMethods({ } catch (e: any) { logger.error(`useFollowMethods: failed to follow`, {message: String(e)}) if (e?.name !== 'AbortError') { - Toast.show(_(msg`An issue occurred, please try again.`)) + Toast.show(_(msg`An issue occurred, please try again.`), 'xmark') } } }) @@ -47,7 +47,7 @@ export function useFollowMethods({ message: String(e), }) if (e?.name !== 'AbortError') { - Toast.show(_(msg`An issue occurred, please try again.`)) + Toast.show(_(msg`An issue occurred, please try again.`), 'xmark') } } }) diff --git a/src/components/hooks/useFullscreen.ts b/src/components/hooks/useFullscreen.ts new file mode 100644 index 0000000000..498f22223c --- /dev/null +++ b/src/components/hooks/useFullscreen.ts @@ -0,0 +1,53 @@ +import { + useCallback, + useEffect, + useRef, + useState, + useSyncExternalStore, +} from 'react' + +import {isFirefox, isSafari} from '#/lib/browser' +import {isWeb} from '#/platform/detection' + +function fullscreenSubscribe(onChange: () => void) { + document.addEventListener('fullscreenchange', onChange) + return () => document.removeEventListener('fullscreenchange', onChange) +} + +export function useFullscreen(ref?: React.RefObject) { + if (!isWeb) throw new Error("'useFullscreen' is a web-only hook") + const isFullscreen = useSyncExternalStore(fullscreenSubscribe, () => + Boolean(document.fullscreenElement), + ) + const scrollYRef = useRef(null) + const [prevIsFullscreen, setPrevIsFullscreen] = useState(isFullscreen) + + const toggleFullscreen = useCallback(() => { + if (isFullscreen) { + document.exitFullscreen() + } else { + if (!ref) throw new Error('No ref provided') + if (!ref.current) return + scrollYRef.current = window.scrollY + ref.current.requestFullscreen() + } + }, [isFullscreen, ref]) + + useEffect(() => { + if (prevIsFullscreen === isFullscreen) return + setPrevIsFullscreen(isFullscreen) + + // Chrome has an issue where it doesn't scroll back to the top after exiting fullscreen + // Let's play it safe and do it if not FF or Safari, since anything else will probably be chromium + if (prevIsFullscreen && !isFirefox && !isSafari) { + setTimeout(() => { + if (scrollYRef.current !== null) { + window.scrollTo(0, scrollYRef.current) + scrollYRef.current = null + } + }, 100) + } + }, [isFullscreen, prevIsFullscreen]) + + return [isFullscreen, toggleFullscreen] as const +} diff --git a/src/components/hooks/useHeaderOffset.ts b/src/components/hooks/useHeaderOffset.ts index e2290c04fb..5c80e18fe0 100644 --- a/src/components/hooks/useHeaderOffset.ts +++ b/src/components/hooks/useHeaderOffset.ts @@ -9,8 +9,8 @@ export function useHeaderOffset() { return 0 } const navBarHeight = 42 - const tabBarPad = 10 + 10 + 3 // padding + border - const normalLineHeight = 1.2 - const tabBarText = 16 * normalLineHeight * fontScale + const tabBarPad = 10 + 10 + 6 // padding + arbitrary + const normalLineHeight = 20 // matches tab bar + const tabBarText = normalLineHeight * fontScale return navBarHeight + tabBarPad + tabBarText } diff --git a/src/components/hooks/useInteractionState.ts b/src/components/hooks/useInteractionState.ts index 653b1c10e6..67042d4a8c 100644 --- a/src/components/hooks/useInteractionState.ts +++ b/src/components/hooks/useInteractionState.ts @@ -5,10 +5,10 @@ export function useInteractionState() { const onIn = React.useCallback(() => { setState(true) - }, [setState]) + }, []) const onOut = React.useCallback(() => { setState(false) - }, [setState]) + }, []) return React.useMemo( () => ({ diff --git a/src/components/hooks/useThrottledValue.ts b/src/components/hooks/useThrottledValue.ts index 5764c547e4..29a11b9358 100644 --- a/src/components/hooks/useThrottledValue.ts +++ b/src/components/hooks/useThrottledValue.ts @@ -2,7 +2,7 @@ import {useEffect, useRef, useState} from 'react' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' -export function useThrottledValue(value: T, time?: number) { +export function useThrottledValue(value: T, time: number) { const pendingValueRef = useRef(value) const [throttledValue, setThrottledValue] = useState(value) diff --git a/src/components/icons/Accessibility.tsx b/src/components/icons/Accessibility.tsx new file mode 100644 index 0000000000..1e5ec0c090 --- /dev/null +++ b/src/components/icons/Accessibility.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Accessibility_Stroke2_Corner2_Rounded = createSinglePathSVG({ + path: 'M4 12a8 8 0 1 1 16 0 8 8 0 0 1-16 0Zm8-10C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2Zm0 7.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Zm-2.86.26.014.002c.944.125 1.893.238 2.846.238.95 0 1.904-.113 2.846-.238l.014-.002h.003a1 1 0 0 1 .273 1.98l-.006.002-.017.002c-.67.089-1.341.162-2.014.21.195 1.32.65 2.33 1.626 3.357a1 1 0 0 1-1.45 1.378 8.3 8.3 0 0 1-1.234-1.647 8.2 8.2 0 0 1-1.342 1.673 1 1 0 0 1-1.398-1.43c.673-.658 1.088-1.274 1.342-1.922.163-.42.269-.878.32-1.404a33 33 0 0 1-2.075-.215l-.017-.002-.006-.001a1 1 0 0 1 .271-1.982l.004.001Z', +}) diff --git a/src/components/icons/ArrowBoxLeft.tsx b/src/components/icons/ArrowBoxLeft.tsx index 011bf6afa3..82e0d6e7f6 100644 --- a/src/components/icons/ArrowBoxLeft.tsx +++ b/src/components/icons/ArrowBoxLeft.tsx @@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE' export const ArrowBoxLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({ path: 'M3.293 3.293A1 1 0 0 1 4 3h7.25a1 1 0 1 1 0 2H5v14h6.25a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1V4a1 1 0 0 1 .293-.707Zm11.5 3.5a1 1 0 0 1 1.414 0l4.5 4.5a1 1 0 0 1 0 1.414l-4.5 4.5a1 1 0 0 1-1.414-1.414L17.586 13H8.75a1 1 0 1 1 0-2h8.836l-2.793-2.793a1 1 0 0 1 0-1.414Z', }) + +export const ArrowBoxLeft_Stroke2_Corner2_Rounded = createSinglePathSVG({ + path: 'M6 5a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h5.25a1 1 0 1 1 0 2H6a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h5.25a1 1 0 1 1 0 2H6Zm8.793 1.793a1 1 0 0 1 1.414 0l4.5 4.5a1 1 0 0 1 0 1.414l-4.5 4.5a1 1 0 0 1-1.414-1.414L17.586 13H8.75a1 1 0 1 1 0-2h8.836l-2.793-2.793a1 1 0 0 1 0-1.414Z', +}) diff --git a/src/components/icons/ArrowsDiagonal.tsx b/src/components/icons/ArrowsDiagonal.tsx new file mode 100644 index 0000000000..3f9ae40e0f --- /dev/null +++ b/src/components/icons/ArrowsDiagonal.tsx @@ -0,0 +1,17 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const ArrowsDiagonalOut_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M14 5a1 1 0 1 1 0-2h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0V6.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L17.586 5H14ZM4 13a1 1 0 0 1 1 1v3.586l4.293-4.293a1 1 0 0 1 1.414 1.414L6.414 19H10a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1Z', +}) + +export const ArrowsDiagonalIn_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M20.957 3.043a1 1 0 0 1 0 1.414L16.414 9H20a1 1 0 1 1 0 2h-6a1 1 0 0 1-1-1V4a1 1 0 1 1 2 0v3.586l4.543-4.543a1 1 0 0 1 1.414 0ZM3 14a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0v-3.586l-4.543 4.543a1 1 0 0 1-1.414-1.414L7.586 15H4a1 1 0 0 1-1-1Z', +}) + +export const ArrowsDiagonalOut_Stroke2_Corner2_Rounded = createSinglePathSVG({ + path: 'M13 4a1 1 0 0 1 1-1h5a2 2 0 0 1 2 2v5a1 1 0 1 1-2 0V6.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L17.586 5H14a1 1 0 0 1-1-1Zm-9 9a1 1 0 0 1 1 1v3.586l4.293-4.293a1 1 0 0 1 1.414 1.414L6.414 19H10a1 1 0 1 1 0 2H5a2 2 0 0 1-2-2v-5a1 1 0 0 1 1-1Z', +}) + +export const ArrowsDiagonalIn_Stroke2_Corner2_Rounded = createSinglePathSVG({ + path: 'M20.957 3.043a1 1 0 0 1 0 1.414L16.414 9H20a1 1 0 1 1 0 2h-5a2 2 0 0 1-2-2V4a1 1 0 1 1 2 0v3.586l4.543-4.543a1 1 0 0 1 1.414 0ZM3 14a1 1 0 0 1 1-1h5a2 2 0 0 1 2 2v5a1 1 0 1 1-2 0v-3.586l-4.543 4.543a1 1 0 0 1-1.414-1.414L7.586 15H4a1 1 0 0 1-1-1Z', +}) diff --git a/src/components/icons/AspectRatio.tsx b/src/components/icons/AspectRatio.tsx new file mode 100644 index 0000000000..b59c1680ef --- /dev/null +++ b/src/components/icons/AspectRatio.tsx @@ -0,0 +1,13 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const AspectRatio11_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v14h14V5H5Z', +}) + +export const AspectRatio43_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M2 20.5c-.552 0-1-.41-1-.917V4.917C1 4.41 1.448 4 2 4h20c.552 0 1 .41 1 .917v14.666c0 .507-.448.917-1 .917H2Zm1-1.833h18V5.833H3v12.834Z', +}) + +export const AspectRatio34_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M4 2c0-.552.41-1 .917-1h14.666c.507 0 .917.448.917 1v20c0 .552-.41 1-.917 1H4.917C4.41 23 4 22.552 4 22V2Zm1.833 1v18h12.834V3H5.833Z', +}) diff --git a/src/components/icons/At.tsx b/src/components/icons/At.tsx index 2487250545..ef0d1003f1 100644 --- a/src/components/icons/At.tsx +++ b/src/components/icons/At.tsx @@ -1,5 +1,9 @@ import {createSinglePathSVG} from './TEMPLATE' export const At_Stroke2_Corner0_Rounded = createSinglePathSVG({ - path: 'M12 4a8 8 0 1 0 4.21 14.804 1 1 0 0 1 1.054 1.7A9.958 9.958 0 0 1 12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10c0 1.104-.27 2.31-.949 3.243-.716.984-1.849 1.6-3.331 1.465a4.207 4.207 0 0 1-2.93-1.585c-.94 1.21-2.388 1.94-3.985 1.715-2.53-.356-4.04-2.91-3.682-5.458.358-2.547 2.514-4.586 5.044-4.23.905.127 1.68.536 2.286 1.126a1 1 0 0 1 1.964.368l-.515 3.545v.002a2.222 2.222 0 0 0 1.999 2.526c.75.068 1.212-.21 1.533-.65.358-.493.566-1.245.566-2.067a8 8 0 0 0-8-8Zm-.112 5.13c-1.195-.168-2.544.819-2.784 2.529-.24 1.71.784 3.03 1.98 3.198 1.195.168 2.543-.819 2.784-2.529.24-1.71-.784-3.03-1.98-3.198Z', + path: 'M12 4a8 8 0 1 0 4.21 14.804 1 1 0 0 1 1.054 1.7A9.96 9.96 0 0 1 12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10c0 1.104-.27 2.31-.949 3.243-.716.984-1.849 1.6-3.331 1.465a4.2 4.2 0 0 1-2.93-1.585c-.94 1.21-2.388 1.94-3.985 1.715-2.53-.356-4.04-2.91-3.682-5.458s2.514-4.586 5.044-4.23c.905.127 1.68.536 2.286 1.126a1 1 0 0 1 1.964.368l-.515 3.545v.002a2.22 2.22 0 0 0 1.999 2.526c.75.068 1.212-.21 1.533-.65.358-.493.566-1.245.566-2.067a8 8 0 0 0-8-8Zm-.112 5.13c-1.195-.168-2.544.819-2.784 2.529s.784 3.03 1.98 3.198 2.543-.819 2.784-2.529-.784-3.03-1.98-3.198Z', +}) + +export const At_Stroke2_Corner2_Rounded = createSinglePathSVG({ + path: 'M12 4a8 8 0 1 0 4.21 14.804 1 1 0 0 1 1.054 1.7A9.96 9.96 0 0 1 12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10c0 1.104-.27 2.31-.949 3.243-.716.984-1.849 1.6-3.331 1.465a4.2 4.2 0 0 1-2.93-1.585c-.94 1.21-2.388 1.94-3.985 1.715-2.53-.356-4.04-2.91-3.682-5.458s2.514-4.586 5.044-4.23c.905.127 1.68.536 2.286 1.126a1 1 0 0 1 1.964.368l-.515 3.545v.002a2.22 2.22 0 0 0 1.999 2.526c.75.068 1.212-.21 1.533-.65.358-.493.566-1.245.566-2.067a8 8 0 0 0-8-8Zm-.112 5.13c-1.195-.168-2.544.819-2.784 2.529s.784 3.03 1.98 3.198 2.544-.819 2.784-2.529-.784-3.03-1.98-3.198Z', }) diff --git a/src/components/icons/BirthdayCake.tsx b/src/components/icons/BirthdayCake.tsx new file mode 100644 index 0000000000..8e41cbac11 --- /dev/null +++ b/src/components/icons/BirthdayCake.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const BirthdayCake_Stroke2_Corner2_Rounded = createSinglePathSVG({ + path: 'm12 .757 2.122 2.122A3 3 0 0 1 13 7.829V9h4.5a3 3 0 0 1 3 3v1.646c0 .603-.18 1.177-.5 1.658V19a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-3.696a3 3 0 0 1-.5-1.658V12a3 3 0 0 1 3-3H11V7.829a3 3 0 0 1-1.121-4.95L12 .757ZM6.5 11a1 1 0 0 0-1 1v1.646a1 1 0 0 0 .629.928l.5.2a1 1 0 0 0 .742 0l1.015-.405a3 3 0 0 1 2.228 0l1.015.405a1 1 0 0 0 .742 0l1.015-.405a3 3 0 0 1 2.228 0l1.015.405a1 1 0 0 0 .742 0l.5-.2a1 1 0 0 0 .629-.928V12a1 1 0 0 0-1-1h-11ZM6 16.674V19a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-2.326a3 3 0 0 1-2.114-.043l-1.015-.405a1 1 0 0 0-.742 0l-1.015.405a3 3 0 0 1-2.228 0l-1.015-.405a1 1 0 0 0-.742 0l-1.015.405A3 3 0 0 1 6 16.674ZM12.002 6a1 1 0 0 0 .706-1.707L12 3.586l-.707.707A1 1 0 0 0 12.002 6Z', +}) diff --git a/src/components/icons/BubbleInfo.tsx b/src/components/icons/BubbleInfo.tsx new file mode 100644 index 0000000000..2865713743 --- /dev/null +++ b/src/components/icons/BubbleInfo.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const BubbleInfo_Stroke2_Corner2_Rounded = createSinglePathSVG({ + path: 'M6.002 5h12a1 1 0 0 1 1 1v10.036a1 1 0 0 1-1 1h-2.626a2 2 0 0 0-1.276.46l-2.098 1.738-2.065-1.731a2 2 0 0 0-1.285-.467h-2.65a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Zm12-2h-12a3 3 0 0 0-3 3v10.036a3 3 0 0 0 3 3h2.65l2.704 2.266a1 1 0 0 0 1.28.004l2.74-2.27h2.626a3 3 0 0 0 3-3V6a3 3 0 0 0-3-3ZM13 11.75a1 1 0 1 0-2 0v2a1 1 0 1 0 2 0v-2ZM12 10a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Z', +}) diff --git a/src/components/icons/CC.tsx b/src/components/icons/CC.tsx new file mode 100644 index 0000000000..da2e7c5dba --- /dev/null +++ b/src/components/icons/CC.tsx @@ -0,0 +1,9 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const CC_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v14h14V5H5Zm10.957 6.293a1 1 0 1 0 0 1.414 1 1 0 0 1 1.414 1.414 3 3 0 1 1 0-4.242 1 1 0 0 1-1.414 1.414Zm-6.331-.22a1 1 0 1 0 .331 1.634 1 1 0 0 1 1.414 1.414 3 3 0 1 1 0-4.242 1 1 0 0 1-1.414 1.414.994.994 0 0 0-.331-.22Z', +}) + +export const CC_Filled_Corner0_Rounded = createSinglePathSVG({ + path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm11.543 7.293a1 1 0 0 1 1.414 0 1 1 0 0 0 1.414-1.414 3 3 0 1 0 0 4.242 1 1 0 0 0-1.414-1.414 1 1 0 0 1-1.414-1.414Zm-6 0a1 1 0 0 1 1.414 0 1 1 0 0 0 1.414-1.414 3 3 0 1 0 0 4.243 1 1 0 0 0-1.414-1.415 1 1 0 0 1-1.414-1.414Z', +}) diff --git a/src/components/icons/CircleQuestion.tsx b/src/components/icons/CircleQuestion.tsx new file mode 100644 index 0000000000..4eb369379b --- /dev/null +++ b/src/components/icons/CircleQuestion.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const CircleQuestion_Stroke2_Corner2_Rounded = createSinglePathSVG({ + path: 'M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Z" clip-rule="evenodd"/> + + ) : status === 'success' ? ( + + + Email Verified + + + + Thanks, you have successfully verified your email address. You + can close this dialog. + + + + ) : status === 'failure' ? ( + + + Invalid Verification Code + + + + The verification code you have provided is invalid. Please make + sure that you have used the correct verification link or request + a new one. + + + + ) : ( + + + Email Resent + + + + We have sent another verification email to{' '} + + {currentAccount?.email} + + . + + + + )} + + {status === 'failure' && ( + <> + + + + )} + + + + + ) +} diff --git a/src/components/moderation/ContentHider.tsx b/src/components/moderation/ContentHider.tsx index 45122a4efe..bf9bae5171 100644 --- a/src/components/moderation/ContentHider.tsx +++ b/src/components/moderation/ContentHider.tsx @@ -1,5 +1,5 @@ import React from 'react' -import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' +import {StyleProp, View, ViewStyle} from 'react-native' import {ModerationUI} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -40,7 +40,7 @@ export function ContentHider({ if (!blur || (ignoreMute && isJustAMute(modui))) { return ( - + {children} ) @@ -94,7 +94,7 @@ export function ContentHider({ a.text_left, a.font_bold, a.leading_snug, - gtMobile && [a.font_semibold], + gtMobile && [a.font_bold], t.atoms.text_contrast_medium, web({ marginBottom: 1, @@ -107,7 +107,7 @@ export function ContentHider({ style={[ a.font_bold, a.leading_snug, - gtMobile && [a.font_semibold], + gtMobile && [a.font_bold], t.atoms.text_contrast_high, web({ marginBottom: 1, @@ -163,21 +163,3 @@ export function ContentHider({ ) } - -const styles = StyleSheet.create({ - outer: {}, - cover: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - borderRadius: 8, - marginTop: 4, - paddingVertical: 14, - paddingLeft: 14, - paddingRight: 18, - }, - showBtn: { - marginLeft: 'auto', - alignSelf: 'center', - }, -}) diff --git a/src/components/moderation/Hider.tsx b/src/components/moderation/Hider.tsx new file mode 100644 index 0000000000..39f28b4034 --- /dev/null +++ b/src/components/moderation/Hider.tsx @@ -0,0 +1,89 @@ +import React from 'react' +import {ModerationUI} from '@atproto/api' + +import { + ModerationCauseDescription, + useModerationCauseDescription, +} from '#/lib/moderation/useModerationCauseDescription' +import { + ModerationDetailsDialog, + useModerationDetailsDialogControl, +} from '#/components/moderation/ModerationDetailsDialog' + +type Context = { + isContentVisible: boolean + setIsContentVisible: (show: boolean) => void + info: ModerationCauseDescription + showInfoDialog: () => void + meta: { + isNoPwi: boolean + allowOverride: boolean + } +} + +const Context = React.createContext({} as Context) + +export const useHider = () => React.useContext(Context) + +export function Outer({ + modui, + isContentVisibleInitialState, + allowOverride, + children, +}: React.PropsWithChildren<{ + isContentVisibleInitialState?: boolean + allowOverride?: boolean + modui: ModerationUI | undefined +}>) { + const control = useModerationDetailsDialogControl() + const blur = modui?.blurs[0] + const [isContentVisible, setIsContentVisible] = React.useState( + isContentVisibleInitialState || !blur, + ) + const info = useModerationCauseDescription(blur) + + const meta = { + isNoPwi: Boolean( + modui?.blurs.find( + cause => + cause.type === 'label' && + cause.labelDef.identifier === '!no-unauthenticated', + ), + ), + allowOverride: allowOverride ?? !modui?.noOverride, + } + + const showInfoDialog = () => { + control.open() + } + + const onSetContentVisible = (show: boolean) => { + if (!meta.allowOverride) return + setIsContentVisible(show) + } + + const ctx = { + isContentVisible, + setIsContentVisible: onSetContentVisible, + showInfoDialog, + info, + meta, + } + + return ( + + {children} + + + ) +} + +export function Content({children}: {children: React.ReactNode}) { + const ctx = useHider() + return ctx.isContentVisible ? children : null +} + +export function Mask({children}: {children: React.ReactNode}) { + const ctx = useHider() + return ctx.isContentVisible ? null : children +} diff --git a/src/components/moderation/LabelPreference.tsx b/src/components/moderation/LabelPreference.tsx index 6191643038..d6dc45d1a5 100644 --- a/src/components/moderation/LabelPreference.tsx +++ b/src/components/moderation/LabelPreference.tsx @@ -174,7 +174,7 @@ export function LabelerLabelPreference({ disabled?: boolean labelerDid?: string }) { - const {i18n} = useLingui() + const {_, i18n} = useLingui() const t = useTheme() const {gtPhone} = useBreakpoints() @@ -236,14 +236,16 @@ export function LabelerLabelPreference({ - + {adultDisabled ? ( Adult content is disabled. ) : isGlobalLabel ? ( Configured in{' '} - + moderation settings . diff --git a/src/components/moderation/LabelsOnMe.tsx b/src/components/moderation/LabelsOnMe.tsx index 95b5853cf8..33ede3ed23 100644 --- a/src/components/moderation/LabelsOnMe.tsx +++ b/src/components/moderation/LabelsOnMe.tsx @@ -14,19 +14,18 @@ import { } from '#/components/moderation/LabelsOnMeDialog' export function LabelsOnMe({ - details, + type, labels, size, style, }: { - details: {did: string} | {uri: string; cid: string} + type: 'account' | 'content' labels: ComAtprotoLabelDefs.Label[] | undefined size?: ButtonSize style?: StyleProp }) { const {_} = useLingui() const {currentAccount} = useSession() - const isAccount = 'did' in details const control = useLabelsOnMeDialogControl() if (!labels || !currentAccount) { @@ -39,7 +38,7 @@ export function LabelsOnMe({ return ( - + + ) : null} + {isOwner ? ( + + ) : list.viewer?.muted || list.viewer?.blocked ? ( + + ) : null} + + + + + ) +} diff --git a/src/screens/Login/ChooseAccountForm.tsx b/src/screens/Login/ChooseAccountForm.tsx index 8c002b1600..9765786ecf 100644 --- a/src/screens/Login/ChooseAccountForm.tsx +++ b/src/screens/Login/ChooseAccountForm.tsx @@ -3,7 +3,6 @@ import {View} from 'react-native' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useAnalytics} from '#/lib/analytics/analytics' import {logEvent} from '#/lib/statsig/statsig' import {logger} from '#/logger' import {SessionAccount, useSession, useSessionApi} from '#/state/session' @@ -23,16 +22,11 @@ export const ChooseAccountForm = ({ onPressBack: () => void }) => { const [pendingDid, setPendingDid] = React.useState(null) - const {track, screen} = useAnalytics() const {_} = useLingui() const {currentAccount} = useSession() const {resumeSession} = useSessionApi() const {setShowLoggedOut} = useLoggedOutViewControls() - React.useEffect(() => { - screen('Choose Account') - }, [screen]) - const onSelect = React.useCallback( async (account: SessionAccount) => { if (pendingDid) { @@ -56,7 +50,6 @@ export const ChooseAccountForm = ({ logContext: 'ChooseAccountForm', withPassword: false, }) - track('Sign In', {resumedSession: true}) Toast.show(_(msg`Signed in as @${account.handle}`)) } catch (e: any) { logger.error('choose account: initSession failed', { @@ -70,7 +63,6 @@ export const ChooseAccountForm = ({ }, [ currentAccount, - track, resumeSession, pendingDid, onSelectAccount, @@ -98,7 +90,7 @@ export const ChooseAccountForm = ({ label={_(msg`Back`)} variant="solid" color="secondary" - size="medium" + size="large" onPress={onPressBack}> {_(msg`Back`)} diff --git a/src/screens/Login/ForgotPasswordForm.tsx b/src/screens/Login/ForgotPasswordForm.tsx index ec30bab4a8..e8582f46f5 100644 --- a/src/screens/Login/ForgotPasswordForm.tsx +++ b/src/screens/Login/ForgotPasswordForm.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useState} from 'react' +import React, {useState} from 'react' import {ActivityIndicator, Keyboard, View} from 'react-native' import {ComAtprotoServerDescribeServer} from '@atproto/api' import {BskyAgent} from '@atproto/api' @@ -6,7 +6,6 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import * as EmailValidator from 'email-validator' -import {useAnalytics} from '#/lib/analytics/analytics' import {isNetworkError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors' import {logger} from '#/logger' @@ -41,13 +40,8 @@ export const ForgotPasswordForm = ({ const t = useTheme() const [isProcessing, setIsProcessing] = useState(false) const [email, setEmail] = useState('') - const {screen} = useAnalytics() const {_} = useLingui() - useEffect(() => { - screen('Signin:ForgotPassword') - }, [screen]) - const onPressSelectService = React.useCallback(() => { Keyboard.dismiss() }, []) @@ -129,7 +123,7 @@ export const ForgotPasswordForm = ({ label={_(msg`Back`)} variant="solid" color="secondary" - size="medium" + size="large" onPress={onPressBack}> Back @@ -143,9 +137,8 @@ export const ForgotPasswordForm = ({ label={_(msg`Next`)} variant="solid" color={'primary'} - size="medium" - onPress={onPressNext} - disabled={!email}> + size="large" + onPress={onPressNext}> Next @@ -171,7 +164,7 @@ export const ForgotPasswordForm = ({ onPress={onEmailSent} label={_(msg`Go to next`)} accessibilityHint={_(msg`Navigates to the next screen`)} - size="medium" + size="large" variant="ghost" color="secondary"> diff --git a/src/screens/Login/LoginForm.tsx b/src/screens/Login/LoginForm.tsx index 35b124b611..f3661ac92c 100644 --- a/src/screens/Login/LoginForm.tsx +++ b/src/screens/Login/LoginForm.tsx @@ -13,15 +13,14 @@ import { import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useAnalytics} from '#/lib/analytics/analytics' +import {useRequestNotificationsPermission} from '#/lib/notifications/notifications' import {isNetworkError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors' import {createFullHandle} from '#/lib/strings/handles' import {logger} from '#/logger' +import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs' import {useSessionApi} from '#/state/session' import {useLoggedOutViewControls} from '#/state/shell/logged-out' -import {useRequestNotificationsPermission} from 'lib/notifications/notifications' -import {useSetHasCheckedForStarterPack} from 'state/preferences/used-starter-packs' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {FormError} from '#/components/forms/FormError' @@ -57,10 +56,8 @@ export const LoginForm = ({ onPressBack: () => void onPressForgotPassword: () => void }) => { - const {track} = useAnalytics() const t = useTheme() const [isProcessing, setIsProcessing] = useState(false) - const [isReady, setIsReady] = useState(false) const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] = useState(false) const identifierValueRef = useRef(initialHandle || '') @@ -75,20 +72,25 @@ export const LoginForm = ({ const onPressSelectService = React.useCallback(() => { Keyboard.dismiss() - track('Signin:PressedSelectService') - }, [track]) + }, []) const onPressNext = async () => { if (isProcessing) return Keyboard.dismiss() LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) setError('') - setIsProcessing(true) const identifier = identifierValueRef.current.toLowerCase().trim() const password = passwordValueRef.current const authFactorToken = authFactorTokenValueRef.current + if (!identifier || !password) { + setError(_(msg`Invalid username or password`)) + return + } + + setIsProcessing(true) + try { // try to guess the handle if the user just gave their own username let fullIdent = identifier @@ -157,22 +159,6 @@ export const LoginForm = ({ } } - const checkIsReady = () => { - if ( - !!serviceDescription && - !!identifierValueRef.current && - !!passwordValueRef.current - ) { - if (!isReady) { - setIsReady(true) - } - } else { - if (isReady) { - setIsReady(false) - } - } - } - return ( Sign in}> @@ -204,7 +190,6 @@ export const LoginForm = ({ defaultValue={initialHandle || ''} onChangeText={v => { identifierValueRef.current = v - checkIsReady() }} onSubmitEditing={() => { passwordRef.current?.focus() @@ -233,7 +218,6 @@ export const LoginForm = ({ clearButtonMode="while-editing" onChangeText={v => { passwordValueRef.current = v - checkIsReady() }} onSubmitEditing={onPressNext} blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing @@ -298,7 +282,7 @@ export const LoginForm = ({ label={_(msg`Back`)} variant="solid" color="secondary" - size="medium" + size="large" onPress={onPressBack}> Back @@ -312,7 +296,7 @@ export const LoginForm = ({ accessibilityHint={_(msg`Retries login`)} variant="solid" color="secondary" - size="medium" + size="large" onPress={onPressRetryConnect}> Retry @@ -325,21 +309,21 @@ export const LoginForm = ({ Connecting... - ) : isReady ? ( + ) : ( - ) : undefined} + )} ) diff --git a/src/screens/Login/PasswordUpdatedForm.tsx b/src/screens/Login/PasswordUpdatedForm.tsx index 5407f3f1e3..9c12a47e3f 100644 --- a/src/screens/Login/PasswordUpdatedForm.tsx +++ b/src/screens/Login/PasswordUpdatedForm.tsx @@ -1,9 +1,8 @@ -import React, {useEffect} from 'react' +import React from 'react' import {View} from 'react-native' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useAnalytics} from '#/lib/analytics/analytics' import {atoms as a, useBreakpoints} from '#/alf' import {Button, ButtonText} from '#/components/Button' import {Text} from '#/components/Typography' @@ -14,14 +13,9 @@ export const PasswordUpdatedForm = ({ }: { onPressNext: () => void }) => { - const {screen} = useAnalytics() const {_} = useLingui() const {gtMobile} = useBreakpoints() - useEffect(() => { - screen('Signin:PasswordUpdatedForm') - }, [screen]) - return ( + size="large"> Okay diff --git a/src/screens/Login/SetNewPasswordForm.tsx b/src/screens/Login/SetNewPasswordForm.tsx index 88f7ec5416..9efbb96ce4 100644 --- a/src/screens/Login/SetNewPasswordForm.tsx +++ b/src/screens/Login/SetNewPasswordForm.tsx @@ -1,10 +1,9 @@ -import React, {useEffect, useState} from 'react' +import React, {useState} from 'react' import {ActivityIndicator, View} from 'react-native' import {BskyAgent} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useAnalytics} from '#/lib/analytics/analytics' import {isNetworkError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors' import {checkAndFormatResetCode} from '#/lib/strings/password' @@ -31,14 +30,9 @@ export const SetNewPasswordForm = ({ onPressBack: () => void onPasswordSet: () => void }) => { - const {screen} = useAnalytics() const {_} = useLingui() const t = useTheme() - useEffect(() => { - screen('Signin:SetNewPasswordForm') - }, [screen]) - const [isProcessing, setIsProcessing] = useState(false) const [resetCode, setResetCode] = useState('') const [password, setPassword] = useState('') @@ -160,7 +154,7 @@ export const SetNewPasswordForm = ({ label={_(msg`Back`)} variant="solid" color="secondary" - size="medium" + size="large" onPress={onPressBack}> Back @@ -174,7 +168,7 @@ export const SetNewPasswordForm = ({ label={_(msg`Next`)} variant="solid" color="primary" - size="medium" + size="large" onPress={onPressNext}> Next diff --git a/src/screens/Login/index.tsx b/src/screens/Login/index.tsx index 1fce63d298..b46f8d26bf 100644 --- a/src/screens/Login/index.tsx +++ b/src/screens/Login/index.tsx @@ -4,7 +4,6 @@ import {LayoutAnimationConfig} from 'react-native-reanimated' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useAnalytics} from '#/lib/analytics/analytics' import {DEFAULT_SERVICE} from '#/lib/constants' import {logger} from '#/logger' import {useServiceQuery} from '#/state/queries/service' @@ -31,7 +30,6 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => { const {_} = useLingui() const {accounts} = useSession() - const {track} = useAnalytics() const {requestedAccountSwitchTo} = useLoggedOutView() const requestedAccount = accounts.find( acc => acc.did === requestedAccountSwitchTo, @@ -87,7 +85,6 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => { }, [serviceError, serviceUrl, _]) const onPressForgotPassword = () => { - track('Signin:PressedForgotPassword') setCurrentForm(Forms.ForgotPassword) } diff --git a/src/screens/Messages/List/index.tsx b/src/screens/Messages/ChatList.tsx similarity index 97% rename from src/screens/Messages/List/index.tsx rename to src/screens/Messages/ChatList.tsx index 0b1fe2a958..9912456e13 100644 --- a/src/screens/Messages/List/index.tsx +++ b/src/screens/Messages/ChatList.tsx @@ -22,7 +22,6 @@ import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {DialogControlProps, useDialogControl} from '#/components/Dialog' import {NewChat} from '#/components/dms/dialogs/NewChatDialog' -import {MessagesNUX} from '#/components/dms/MessagesNUX' import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus' import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Retry} from '#/components/icons/ArrowRotateCounterClockwise' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' @@ -33,7 +32,7 @@ import {Link} from '#/components/Link' import {ListFooter} from '#/components/Lists' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' -import {ChatListItem} from './ChatListItem' +import {ChatListItem} from './components/ChatListItem' type Props = NativeStackScreenProps @@ -96,7 +95,7 @@ export function MessagesScreen({navigation, route}: Props) { ) }, [_, t]) - const initialNumToRender = useInitialNumToRender(80) + const initialNumToRender = useInitialNumToRender({minItemHeight: 80}) const [isPTRing, setIsPTRing] = useState(false) const { @@ -151,8 +150,6 @@ export function MessagesScreen({navigation, route}: Props) { if (conversations.length < 1) { return ( - - {gtMobile ? ( refetch()}> @@ -240,7 +237,6 @@ export function MessagesScreen({navigation, route}: Props) { return ( - {!gtMobile && ( diff --git a/src/screens/Messages/Conversation/index.tsx b/src/screens/Messages/Conversation.tsx similarity index 93% rename from src/screens/Messages/Conversation/index.tsx rename to src/screens/Messages/Conversation.tsx index a99ef8d4d9..21fdfe0ea9 100644 --- a/src/screens/Messages/Conversation/index.tsx +++ b/src/screens/Messages/Conversation.tsx @@ -8,16 +8,16 @@ import {useFocusEffect} from '@react-navigation/native' import {NativeStackScreenProps} from '@react-navigation/native-stack' import {CommonNavigatorParams} from '#/lib/routes/types' +import {isWeb} from '#/platform/detection' +import {useProfileShadow} from '#/state/cache/profile-shadow' +import {ConvoProvider, isConvoActive, useConvo} from '#/state/messages/convo' +import {ConvoStatus} from '#/state/messages/convo/types' import {useCurrentConvoId} from '#/state/messages/current-convo-id' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useProfileQuery} from '#/state/queries/profile' -import {isWeb} from 'platform/detection' -import {useProfileShadow} from 'state/cache/profile-shadow' -import {ConvoProvider, isConvoActive, useConvo} from 'state/messages/convo' -import {ConvoStatus} from 'state/messages/convo/types' -import {useSetMinimalShellMode} from 'state/shell' -import {CenteredView} from 'view/com/util/Views' -import {MessagesList} from '#/screens/Messages/Conversation/MessagesList' +import {useSetMinimalShellMode} from '#/state/shell' +import {CenteredView} from '#/view/com/util/Views' +import {MessagesList} from '#/screens/Messages/components/MessagesList' import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {MessagesListBlockedFooter} from '#/components/dms/MessagesListBlockedFooter' import {MessagesListHeader} from '#/components/dms/MessagesListHeader' @@ -106,6 +106,7 @@ function Inner() { title={_(msg`Something went wrong`)} message={_(msg`We couldn't load this conversation`)} onRetry={() => convoState.error.retry()} + sideBorders={false} /> ) diff --git a/src/screens/Messages/Settings.tsx b/src/screens/Messages/Settings.tsx index 0ca87ce987..b1c52582f0 100644 --- a/src/screens/Messages/Settings.tsx +++ b/src/screens/Messages/Settings.tsx @@ -11,7 +11,7 @@ import {useProfileQuery} from '#/state/queries/profile' import {useSession} from '#/state/session' import * as Toast from '#/view/com/util/Toast' import {ViewHeader} from '#/view/com/util/ViewHeader' -import {CenteredView} from '#/view/com/util/Views' +import {ScrollView} from '#/view/com/util/Views' import {atoms as a, useTheme} from '#/alf' import {Divider} from '#/components/Divider' import * as Toggle from '#/components/forms/Toggle' @@ -32,7 +32,7 @@ export function MessagesSettingsScreen({}: Props) { const {mutate: updateDeclaration} = useUpdateActorDeclaration({ onError: () => { - Toast.show(_(msg`Failed to update settings`)) + Toast.show(_(msg`Failed to update settings`), 'xmark') }, }) @@ -55,7 +55,7 @@ export function MessagesSettingsScreen({}: Props) { ) return ( - + @@ -107,7 +107,7 @@ export function MessagesSettingsScreen({}: Props) { a.rounded_md, t.atoms.bg_contrast_25, ]}> - + You can continue ongoing conversations regardless of which setting you choose. @@ -149,6 +149,6 @@ export function MessagesSettingsScreen({}: Props) { )} - + ) } diff --git a/src/screens/Messages/Conversation/ChatDisabled.tsx b/src/screens/Messages/components/ChatDisabled.tsx similarity index 98% rename from src/screens/Messages/Conversation/ChatDisabled.tsx rename to src/screens/Messages/components/ChatDisabled.tsx index 5c6e615863..c768d2504b 100644 --- a/src/screens/Messages/Conversation/ChatDisabled.tsx +++ b/src/screens/Messages/components/ChatDisabled.tsx @@ -84,7 +84,7 @@ function DialogInner() { }, onError: err => { logger.error('Failed to submit chat appeal', {message: err}) - Toast.show(_(msg`Failed to submit appeal, please try again.`)) + Toast.show(_(msg`Failed to submit appeal, please try again.`), 'xmark') }, onSuccess: () => { control.close() @@ -128,7 +128,7 @@ function DialogInner() { testID="backBtn" variant="solid" color="secondary" - size="medium" + size="large" onPress={onBack} label={_(msg`Back`)}> {_(msg`Back`)} @@ -137,7 +137,7 @@ function DialogInner() { testID="submitBtn" variant="solid" color="primary" - size="medium" + size="large" onPress={onSubmit} label={_(msg`Submit`)}> {_(msg`Submit`)} diff --git a/src/screens/Messages/List/ChatListItem.tsx b/src/screens/Messages/components/ChatListItem.tsx similarity index 93% rename from src/screens/Messages/List/ChatListItem.tsx rename to src/screens/Messages/components/ChatListItem.tsx index 57587cce58..d51f7b4e43 100644 --- a/src/screens/Messages/List/ChatListItem.tsx +++ b/src/screens/Messages/components/ChatListItem.tsx @@ -10,6 +10,9 @@ import { import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useHaptics} from '#/lib/haptics' +import {logEvent} from '#/lib/statsig/statsig' +import {sanitizeDisplayName} from '#/lib/strings/display-names' import { postUriToRelativePath, toBskyAppUrl, @@ -19,12 +22,10 @@ import {isNative} from '#/platform/detection' import {useProfileShadow} from '#/state/cache/profile-shadow' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useSession} from '#/state/session' -import {useHaptics} from 'lib/haptics' -import {logEvent} from 'lib/statsig/statsig' -import {sanitizeDisplayName} from 'lib/strings/display-names' import {TimeElapsed} from '#/view/com/util/TimeElapsed' -import {UserAvatar} from '#/view/com/util/UserAvatar' +import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' +import * as tokens from '#/alf/tokens' import {ConvoMenu} from '#/components/dms/ConvoMenu' import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2' import {Link} from '#/components/Link' @@ -203,6 +204,19 @@ function ChatListItemReady({ onFocus={onFocus} onBlur={onMouseLeave} style={[a.relative]}> + + + + - + {/* Avatar goes here */} + @@ -248,6 +259,7 @@ function ChatListItemReady({ numberOfLines={1} style={[{maxWidth: '85%'}, web([a.leading_normal])]}> void children?: React.ReactNode + openEmojiPicker?: (pos: EmojiPickerPosition) => void }) { const {_} = useLingui() const t = useTheme() @@ -67,7 +69,7 @@ export function MessageInput({ return } if (new Graphemer().countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) { - Toast.show(_(msg`Message is too long`)) + Toast.show(_(msg`Message is too long`), 'xmark') return } clearDraft() diff --git a/src/screens/Messages/Conversation/MessageInput.web.tsx b/src/screens/Messages/components/MessageInput.web.tsx similarity index 72% rename from src/screens/Messages/Conversation/MessageInput.web.tsx rename to src/screens/Messages/components/MessageInput.web.tsx index b9181774e5..b15cd24921 100644 --- a/src/screens/Messages/Conversation/MessageInput.web.tsx +++ b/src/screens/Messages/components/MessageInput.web.tsx @@ -5,16 +5,23 @@ import {useLingui} from '@lingui/react' import Graphemer from 'graphemer' import TextareaAutosize from 'react-textarea-autosize' +import {isSafari, isTouchDevice} from '#/lib/browser' import {MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants' +import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import { useMessageDraft, useSaveMessageDraft, } from '#/state/messages/message-drafts' -import {isSafari, isTouchDevice} from 'lib/browser' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter' +import { + Emoji, + EmojiPickerPosition, +} from '#/view/com/composer/text-input/web/EmojiPicker.web' import * as Toast from '#/view/com/util/Toast' import {atoms as a, useTheme} from '#/alf' +import {Button} from '#/components/Button' import {useSharedInputStyles} from '#/components/forms/TextField' +import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji' import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane' import {useExtractEmbedFromFacets} from './MessageInputEmbed' @@ -23,11 +30,13 @@ export function MessageInput({ hasEmbed, setEmbed, children, + openEmojiPicker, }: { onSendMessage: (message: string) => void hasEmbed: boolean setEmbed: (embedUrl: string | undefined) => void children?: React.ReactNode + openEmojiPicker?: (pos: EmojiPickerPosition) => void }) { const {isTabletOrDesktop} = useWebMediaQueries() const {_} = useLingui() @@ -40,13 +49,14 @@ export function MessageInput({ const [isFocused, setIsFocused] = React.useState(false) const [isHovered, setIsHovered] = React.useState(false) const [textAreaHeight, setTextAreaHeight] = React.useState(38) + const textAreaRef = React.useRef(null) const onSubmit = React.useCallback(() => { if (!hasEmbed && message.trim() === '') { return } if (new Graphemer().countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) { - Toast.show(_(msg`Message is too long`)) + Toast.show(_(msg`Message is too long`), 'xmark') return } clearDraft() @@ -94,6 +104,23 @@ export function MessageInput({ [], ) + const onEmojiInserted = React.useCallback( + (emoji: Emoji) => { + const position = textAreaRef.current?.selectionStart ?? 0 + setMessage( + message => + message.slice(0, position) + emoji.native + message.slice(position), + ) + }, + [setMessage], + ) + React.useEffect(() => { + textInputWebEmitter.addListener('emoji-inserted', onEmojiInserted) + return () => { + textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted) + } + }, [onEmojiInserted]) + useSaveMessageDraft(message) useExtractEmbedFromFacets(message, setEmbed) @@ -106,7 +133,7 @@ export function MessageInput({ t.atoms.bg_contrast_25, { paddingRight: a.p_sm.padding - 2, - paddingLeft: a.p_md.padding - 2, + paddingLeft: a.p_sm.padding - 2, borderWidth: 1, borderRadius: 23, borderColor: 'transparent', @@ -118,7 +145,44 @@ export function MessageInput({ // @ts-expect-error web only onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)}> + )} - {images && images?.length > 0 && ( - - )} + ) diff --git a/src/screens/Messages/Conversation/MessageListError.tsx b/src/screens/Messages/components/MessageListError.tsx similarity index 100% rename from src/screens/Messages/Conversation/MessageListError.tsx rename to src/screens/Messages/components/MessageListError.tsx diff --git a/src/screens/Messages/Conversation/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx similarity index 93% rename from src/screens/Messages/Conversation/MessagesList.tsx rename to src/screens/Messages/components/MessagesList.tsx index 11b951e99d..b659e98d6e 100644 --- a/src/screens/Messages/Conversation/MessagesList.tsx +++ b/src/screens/Messages/components/MessagesList.tsx @@ -15,6 +15,8 @@ import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/rean import {useSafeAreaInsets} from 'react-native-safe-area-context' import {AppBskyEmbedRecord, AppBskyRichtextFacet, RichText} from '@atproto/api' +import {clamp} from '#/lib/numbers' +import {ScrollProvider} from '#/lib/ScrollContext' import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip' import { convertBskyAppUrlIfNeeded, @@ -22,17 +24,19 @@ import { } from '#/lib/strings/url-helpers' import {logger} from '#/logger' import {isNative} from '#/platform/detection' +import {isWeb} from '#/platform/detection' import {isConvoActive, useConvoActive} from '#/state/messages/convo' import {ConvoItem, ConvoStatus} from '#/state/messages/convo/types' import {useGetPost} from '#/state/queries/post' import {useAgent} from '#/state/session' -import {clamp} from 'lib/numbers' -import {ScrollProvider} from 'lib/ScrollContext' -import {isWeb} from 'platform/detection' -import {List} from 'view/com/util/List' -import {ChatDisabled} from '#/screens/Messages/Conversation/ChatDisabled' -import {MessageInput} from '#/screens/Messages/Conversation/MessageInput' -import {MessageListError} from '#/screens/Messages/Conversation/MessageListError' +import { + EmojiPicker, + EmojiPickerState, +} from '#/view/com/composer/text-input/web/EmojiPicker.web' +import {List} from '#/view/com/util/List' +import {ChatDisabled} from '#/screens/Messages/components/ChatDisabled' +import {MessageInput} from '#/screens/Messages/components/MessageInput' +import {MessageListError} from '#/screens/Messages/components/MessageListError' import {ChatEmptyPill} from '#/components/dms/ChatEmptyPill' import {MessageItem} from '#/components/dms/MessageItem' import {NewMessagesPill} from '#/components/dms/NewMessagesPill' @@ -97,6 +101,12 @@ export function MessagesList({ startContentOffset: 0, }) + const [emojiPickerState, setEmojiPickerState] = + React.useState({ + isOpen: false, + pos: {top: 0, left: 0, right: 0, bottom: 0}, + }) + // We need to keep track of when the scroll offset is at the bottom of the list to know when to scroll as new items // are added to the list. For example, if the user is scrolled up to 1iew older messages, we don't want to scroll to // the bottom. @@ -387,9 +397,6 @@ export function MessagesList({ renderItem={renderItem} keyExtractor={keyExtractor} disableFullWindowScroll={true} - // Prevents wrong position in Firefox when sending a message - // as well as scroll getting stuck on Chome when scrolling upwards. - disableContainStyle={true} disableVirtualization={true} style={animatedListStyle} // The extra two items account for the header and the footer components @@ -425,13 +432,22 @@ export function MessagesList({ + setEmbed={setEmbed} + openEmojiPicker={pos => setEmojiPickerState({isOpen: true, pos})}> )} + {isWeb && ( + setEmojiPickerState(prev => ({...prev, isOpen: false}))} + /> + )} + {newMessagesPill.show && } ) diff --git a/src/screens/Moderation/index.tsx b/src/screens/Moderation/index.tsx index 9342a805ef..070b879508 100644 --- a/src/screens/Moderation/index.tsx +++ b/src/screens/Moderation/index.tsx @@ -22,8 +22,8 @@ import { useProfileUpdateMutation, } from '#/state/queries/profile' import {useSession} from '#/state/session' +import {isNonConfigurableModerationAuthority} from '#/state/session/additional-moderation-authorities' import {useSetMinimalShellMode} from '#/state/shell' -import {useAnalytics} from 'lib/analytics/analytics' import {ViewHeader} from '#/view/com/util/ViewHeader' import {CenteredView} from '#/view/com/util/Views' import {ScrollView} from '#/view/com/util/Views' @@ -162,7 +162,6 @@ export function ModerationScreenInner({ const {_} = useLingui() const t = useTheme() const setMinimalShellMode = useSetMinimalShellMode() - const {screen} = useAnalytics() const {gtMobile} = useBreakpoints() const {mutedWordsDialogControl} = useGlobalDialogsControlContext() const birthdateDialogControl = Dialog.useDialogControl() @@ -174,9 +173,8 @@ export function ModerationScreenInner({ useFocusEffect( React.useCallback(() => { - screen('Moderation') setMinimalShellMode(false) - }, [screen, setMinimalShellMode]), + }, [setMinimalShellMode]), ) const {mutateAsync: setAdultContentPref, variables: optimisticAdultContent} = @@ -240,7 +238,10 @@ export function ModerationScreenInner({ )} - + {state => ( - + {state => ( - + {state => ( - + Enable adult content Adult content can only be enabled via the Web at{' '} { evt.preventDefault() @@ -445,6 +453,9 @@ export function ModerationScreenInner({ value={labeler.creator.description} handle={labeler.creator.handle} /> + {isNonConfigurableModerationAuthority( + labeler.creator.did, + ) && } )} @@ -569,7 +580,9 @@ function PwiOptOut() { - + Learn more about what is public on Bluesky. diff --git a/src/screens/Onboarding/StepFinished.tsx b/src/screens/Onboarding/StepFinished.tsx index 825a0e723d..fdc0a3eb72 100644 --- a/src/screens/Onboarding/StepFinished.tsx +++ b/src/screens/Onboarding/StepFinished.tsx @@ -7,26 +7,26 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' -import {useAnalytics} from '#/lib/analytics/analytics' +import {uploadBlob} from '#/lib/api' import { BSKY_APP_ACCOUNT_DID, DISCOVER_SAVED_FEED, TIMELINE_SAVED_FEED, } from '#/lib/constants' +import {useRequestNotificationsPermission} from '#/lib/notifications/notifications' import {logEvent} from '#/lib/statsig/statsig' import {logger} from '#/logger' +import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs' +import {getAllListMembers} from '#/state/queries/list-members' import {preferencesQueryKey} from '#/state/queries/preferences' import {RQKEY as profileRQKey} from '#/state/queries/profile' import {useAgent} from '#/state/session' import {useOnboardingDispatch} from '#/state/shell' import {useProgressGuideControls} from '#/state/shell/progress-guide' -import {uploadBlob} from 'lib/api' -import {useRequestNotificationsPermission} from 'lib/notifications/notifications' -import {useSetHasCheckedForStarterPack} from 'state/preferences/used-starter-packs' import { useActiveStarterPack, useSetActiveStarterPack, -} from 'state/shell/starter-pack' +} from '#/state/shell/starter-pack' import { DescriptionText, OnboardingControls, @@ -43,12 +43,10 @@ import {News2_Stroke2_Corner0_Rounded as News} from '#/components/icons/News2' import {Trending2_Stroke2_Corner2_Rounded as Trending} from '#/components/icons/Trending2' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' -import {TOURS, useSetQueuedTour} from '#/tours' export function StepFinished() { const {_} = useLingui() const t = useTheme() - const {track} = useAnalytics() const {state, dispatch} = React.useContext(Context) const onboardDispatch = useOnboardingDispatch() const [saving, setSaving] = React.useState(false) @@ -58,7 +56,6 @@ export function StepFinished() { const activeStarterPack = useActiveStarterPack() const setActiveStarterPack = useSetActiveStarterPack() const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack() - const setQueuedTour = useSetQueuedTour() const {startProgressGuide} = useProgressGuideControls() const finishOnboarding = React.useCallback(async () => { @@ -73,18 +70,20 @@ export function StepFinished() { starterPack: activeStarterPack.uri, }) starterPack = spRes.data.starterPack - - if (starterPack.list) { - const listRes = await agent.app.bsky.graph.getList({ - list: starterPack.list.uri, - limit: 50, - }) - listItems = listRes.data.items - } } catch (e) { logger.error('Failed to fetch starter pack', {safeMessage: e}) // don't tell the user, just get them through onboarding. } + try { + if (starterPack?.list) { + listItems = await getAllListMembers(agent, starterPack.list.uri) + } + } catch (e) { + logger.error('Failed to fetch starter pack list items', { + safeMessage: e, + }) + // don't tell the user, just get them through onboarding. + } } try { @@ -186,12 +185,9 @@ export function StepFinished() { setSaving(false) setActiveStarterPack(undefined) setHasCheckedForStarterPack(true) - setQueuedTour(TOURS.HOME) startProgressGuide('like-10-and-follow-7') dispatch({type: 'finish'}) onboardDispatch({type: 'finish'}) - track('OnboardingV2:StepFinished:End') - track('OnboardingV2:Complete') logEvent('onboarding:finished:nextPressed', { usedStarterPack: Boolean(starterPack), starterPackName: AppBskyGraphStarterpack.isRecord(starterPack?.record) @@ -214,20 +210,14 @@ export function StepFinished() { agent, dispatch, onboardDispatch, - track, activeStarterPack, state, requestNotificationsPermission, setActiveStarterPack, setHasCheckedForStarterPack, - setQueuedTour, startProgressGuide, ]) - React.useEffect(() => { - track('OnboardingV2:StepFinished:Start') - }, [track]) - return ( diff --git a/src/screens/Onboarding/StepInterests/index.tsx b/src/screens/Onboarding/StepInterests/index.tsx index ca29b5db9b..2f41433aa5 100644 --- a/src/screens/Onboarding/StepInterests/index.tsx +++ b/src/screens/Onboarding/StepInterests/index.tsx @@ -4,12 +4,9 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQuery} from '@tanstack/react-query' -import {useAnalytics} from '#/lib/analytics/analytics' import {logEvent} from '#/lib/statsig/statsig' -import {useGate} from '#/lib/statsig/statsig' import {capitalize} from '#/lib/strings/capitalize' import {logger} from '#/logger' -import {isWeb} from '#/platform/detection' import {useAgent} from '#/state/session' import {useOnboardingDispatch} from '#/state/shell' import { @@ -29,23 +26,15 @@ import * as Toggle from '#/components/forms/Toggle' import {IconCircle} from '#/components/IconCircle' import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwise} from '#/components/icons/ArrowRotateCounterClockwise' import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' -import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' import {EmojiSad_Stroke2_Corner0_Rounded as EmojiSad} from '#/components/icons/Emoji' import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' -const PROMPT_HEIGHT = isWeb ? 42 : 36 -// matches the padding of the OnboardingControls.Portal -const PROMPT_OFFSET = isWeb ? a.pb_2xl.paddingBottom : a.pb_lg.paddingBottom -const MIN_INTERESTS = 3 - export function StepInterests() { const {_} = useLingui() const t = useTheme() const {gtMobile} = useBreakpoints() - const {track} = useAnalytics() - const gate = useGate() const interestsDisplayNames = useInterestsDisplayNames() const {state, dispatch} = React.useContext(Context) @@ -99,7 +88,6 @@ export function StepInterests() { `onboarding: getTaggedSuggestions fetch or processing failed`, ) logger.error(e) - track('OnboardingV2:StepInterests:Error') throw new Error(`a network error occurred`) } @@ -117,11 +105,6 @@ export function StepInterests() { selectedInterests: interests, }) dispatch({type: 'next'}) - - track('OnboardingV2:StepInterests:End', { - selectedInterests: interests, - selectedInterestsLength: interests.length, - }) logEvent('onboarding:interests:nextPressed', { selectedInterests: interests, selectedInterestsLength: interests.length, @@ -130,23 +113,12 @@ export function StepInterests() { logger.info(`onboading: error saving interests`) logger.error(e) } - }, [interests, data, setSaving, dispatch, track]) + }, [interests, data, setSaving, dispatch]) const skipOnboarding = React.useCallback(() => { onboardDispatch({type: 'finish'}) dispatch({type: 'finish'}) - track('OnboardingV2:Skip') - }, [onboardDispatch, dispatch, track]) - - React.useEffect(() => { - track('OnboardingV2:Begin') - track('OnboardingV2:StepInterests:Start') - }, [track]) - - const isMinimumInterestsEnabled = gate('onboarding_minimum_interests') - const meetsMinimumRequirement = isMinimumInterestsEnabled - ? interests.length >= MIN_INTERESTS - : true + }, [onboardDispatch, dispatch]) const title = isError ? ( Oh no! Something went wrong. @@ -185,13 +157,8 @@ export function StepInterests() { {title} {description} - {isMinimumInterestsEnabled && ( - - Choose 3 or more: - - )} - + {isLoading ? ( ) : isError || !data ? ( @@ -267,7 +234,7 @@ export function StepInterests() { ) : ( )} - - {!meetsMinimumRequirement && ( - - - - - - Choose at least {MIN_INTERESTS - interests.length} more - - - - - )} ) diff --git a/src/screens/Onboarding/StepProfile/PlaceholderCanvas.tsx b/src/screens/Onboarding/StepProfile/PlaceholderCanvas.tsx index d1d1af6d9f..acc0893350 100644 --- a/src/screens/Onboarding/StepProfile/PlaceholderCanvas.tsx +++ b/src/screens/Onboarding/StepProfile/PlaceholderCanvas.tsx @@ -1,14 +1,19 @@ import React from 'react' import {View} from 'react-native' -import ViewShot from 'react-native-view-shot' +import type ViewShot from 'react-native-view-shot' import {useAvatar} from '#/screens/Onboarding/StepProfile/index' import {atoms as a} from '#/alf' +const LazyViewShot = React.lazy( + // @ts-expect-error dynamic import + () => import('react-native-view-shot/src/index'), +) + const SIZE_MULTIPLIER = 5 export interface PlaceholderCanvasRef { - capture: () => Promise + capture: () => Promise } // This component is supposed to be invisible to the user. We only need this for ViewShot to have something to @@ -16,7 +21,7 @@ export interface PlaceholderCanvasRef { export const PlaceholderCanvas = React.forwardRef( function PlaceholderCanvas({}, ref) { const {avatar} = useAvatar() - const viewshotRef = React.useRef() + const viewshotRef = React.useRef(null) const Icon = avatar.placeholder.component const styles = React.useMemo( @@ -32,35 +37,40 @@ export const PlaceholderCanvas = React.forwardRef( ) React.useImperativeHandle(ref, () => ({ - // @ts-ignore this library doesn't have types - capture: viewshotRef.current.capture, + capture: async () => { + if (viewshotRef.current?.capture) { + return await viewshotRef.current.capture() + } + }, })) return ( - - - - - + + + + + + + ) }, diff --git a/src/screens/Onboarding/StepProfile/index.tsx b/src/screens/Onboarding/StepProfile/index.tsx index 5304aa5031..73472ec332 100644 --- a/src/screens/Onboarding/StepProfile/index.tsx +++ b/src/screens/Onboarding/StepProfile/index.tsx @@ -9,14 +9,13 @@ import { import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useAnalytics} from '#/lib/analytics/analytics' +import {usePhotoLibraryPermission} from '#/lib/hooks/usePermissions' +import {compressIfNeeded} from '#/lib/media/manip' +import {openCropper} from '#/lib/media/picker' +import {getDataUriSize} from '#/lib/media/util' +import {useRequestNotificationsPermission} from '#/lib/notifications/notifications' import {logEvent, useGate} from '#/lib/statsig/statsig' -import {usePhotoLibraryPermission} from 'lib/hooks/usePermissions' -import {compressIfNeeded} from 'lib/media/manip' -import {openCropper} from 'lib/media/picker' -import {getDataUriSize} from 'lib/media/util' -import {useRequestNotificationsPermission} from 'lib/notifications/notifications' -import {isNative, isWeb} from 'platform/detection' +import {isNative, isWeb} from '#/platform/detection' import { DescriptionText, OnboardingControls, @@ -33,6 +32,7 @@ import { import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' +import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' import {IconCircle} from '#/components/IconCircle' import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' import {CircleInfo_Stroke2_Corner0_Rounded} from '#/components/icons/CircleInfo' @@ -68,7 +68,6 @@ export function StepProfile() { const {_} = useLingui() const t = useTheme() const {gtMobile} = useBreakpoints() - const {track} = useAnalytics() const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() const gate = useGate() const requestNotificationsPermission = useRequestNotificationsPermission() @@ -87,23 +86,22 @@ export function StepProfile() { const canvasRef = React.useRef(null) - React.useEffect(() => { - track('OnboardingV2:StepProfile:Start') - }, [track]) - React.useEffect(() => { requestNotificationsPermission('StartOnboarding') }, [gate, requestNotificationsPermission]) + const sheetWrapper = useSheetWrapper() const openPicker = React.useCallback( async (opts?: ImagePickerOptions) => { - const response = await launchImageLibraryAsync({ - exif: false, - mediaTypes: MediaTypeOptions.Images, - quality: 1, - ...opts, - legacy: true, - }) + const response = await sheetWrapper( + launchImageLibraryAsync({ + exif: false, + mediaTypes: MediaTypeOptions.Images, + quality: 1, + ...opts, + legacy: true, + }), + ) return (response.assets ?? []) .slice(0, 1) @@ -127,11 +125,15 @@ export function StepProfile() { size: getDataUriSize(image.uri), })) }, - [_, setError], + [_, setError, sheetWrapper], ) const onContinue = React.useCallback(async () => { let imageUri = avatar?.image?.path + + // In the event that view-shot didn't load in time and the user pressed continue, this will just be undefined + // and the default avatar will be used. We don't want to block getting through create if this fails for some + // reason if (!imageUri || avatar.useCreatedAvatar) { imageUri = await canvasRef.current?.capture() } @@ -151,9 +153,8 @@ export function StepProfile() { } dispatch({type: 'next'}) - track('OnboardingV2:StepProfile:End') logEvent('onboarding:profile:nextPressed', {}) - }, [avatar, dispatch, track]) + }, [avatar, dispatch]) const onDoneCreating = React.useCallback(() => { setAvatar(prev => ({ @@ -171,9 +172,11 @@ export function StepProfile() { setError('') - const items = await openPicker({ - aspect: [1, 1], - }) + const items = await sheetWrapper( + openPicker({ + aspect: [1, 1], + }), + ) let image = items[0] if (!image) return @@ -199,7 +202,13 @@ export function StepProfile() { image, useCreatedAvatar: false, })) - }, [requestPhotoAccessIfNeeded, setAvatar, openPicker, setError]) + }, [ + requestPhotoAccessIfNeeded, + setAvatar, + openPicker, + setError, + sheetWrapper, + ]) const onSecondaryPress = React.useCallback(() => { if (avatar.useCreatedAvatar) { @@ -289,7 +298,6 @@ export function StepProfile() { - !!res.data.follows.length) const followUris = new Map() diff --git a/src/view/screens/PostLikedBy.tsx b/src/screens/Post/PostLikedBy.tsx similarity index 51% rename from src/view/screens/PostLikedBy.tsx rename to src/screens/Post/PostLikedBy.tsx index 604301544c..ea522488cc 100644 --- a/src/view/screens/PostLikedBy.tsx +++ b/src/screens/Post/PostLikedBy.tsx @@ -1,13 +1,17 @@ import React from 'react' -import {View} from 'react-native' -import {useFocusEffect} from '@react-navigation/native' -import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import {ViewHeader} from '../com/util/ViewHeader' -import {PostLikedBy as PostLikedByComponent} from '../com/post-thread/PostLikedBy' -import {makeRecordUri} from 'lib/strings/url-helpers' -import {useSetMinimalShellMode} from '#/state/shell' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useFocusEffect} from '@react-navigation/native' + +import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' +import {makeRecordUri} from '#/lib/strings/url-helpers' +import {useSetMinimalShellMode} from '#/state/shell' +import {isWeb} from 'platform/detection' +import {PostLikedBy as PostLikedByComponent} from '#/view/com/post-thread/PostLikedBy' +import {ViewHeader} from '#/view/com/util/ViewHeader' +import {CenteredView} from 'view/com/util/Views' +import {atoms as a} from '#/alf' +import {ListHeaderDesktop} from '#/components/Lists' type Props = NativeStackScreenProps export const PostLikedByScreen = ({route}: Props) => { @@ -23,9 +27,10 @@ export const PostLikedByScreen = ({route}: Props) => { ) return ( - - + + + - + ) } diff --git a/src/screens/Post/PostQuotes.tsx b/src/screens/Post/PostQuotes.tsx new file mode 100644 index 0000000000..0d59418f15 --- /dev/null +++ b/src/screens/Post/PostQuotes.tsx @@ -0,0 +1,36 @@ +import React from 'react' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useFocusEffect} from '@react-navigation/native' + +import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' +import {makeRecordUri} from '#/lib/strings/url-helpers' +import {useSetMinimalShellMode} from '#/state/shell' +import {isWeb} from 'platform/detection' +import {PostQuotes as PostQuotesComponent} from '#/view/com/post-thread/PostQuotes' +import {ViewHeader} from '#/view/com/util/ViewHeader' +import {CenteredView} from 'view/com/util/Views' +import {atoms as a} from '#/alf' +import {ListHeaderDesktop} from '#/components/Lists' + +type Props = NativeStackScreenProps +export const PostQuotesScreen = ({route}: Props) => { + const setMinimalShellMode = useSetMinimalShellMode() + const {name, rkey} = route.params + const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) + const {_} = useLingui() + + useFocusEffect( + React.useCallback(() => { + setMinimalShellMode(false) + }, [setMinimalShellMode]), + ) + + return ( + + + + + + ) +} diff --git a/src/view/screens/PostRepostedBy.tsx b/src/screens/Post/PostRepostedBy.tsx similarity index 51% rename from src/view/screens/PostRepostedBy.tsx rename to src/screens/Post/PostRepostedBy.tsx index 07017d6920..f8c058ff79 100644 --- a/src/view/screens/PostRepostedBy.tsx +++ b/src/screens/Post/PostRepostedBy.tsx @@ -1,13 +1,17 @@ import React from 'react' -import {View} from 'react-native' -import {useFocusEffect} from '@react-navigation/native' -import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import {ViewHeader} from '../com/util/ViewHeader' -import {PostRepostedBy as PostRepostedByComponent} from '../com/post-thread/PostRepostedBy' -import {makeRecordUri} from 'lib/strings/url-helpers' -import {useSetMinimalShellMode} from '#/state/shell' -import {useLingui} from '@lingui/react' import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useFocusEffect} from '@react-navigation/native' + +import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' +import {makeRecordUri} from '#/lib/strings/url-helpers' +import {useSetMinimalShellMode} from '#/state/shell' +import {isWeb} from 'platform/detection' +import {PostRepostedBy as PostRepostedByComponent} from '#/view/com/post-thread/PostRepostedBy' +import {ViewHeader} from '#/view/com/util/ViewHeader' +import {CenteredView} from 'view/com/util/Views' +import {atoms as a} from '#/alf' +import {ListHeaderDesktop} from '#/components/Lists' type Props = NativeStackScreenProps export const PostRepostedByScreen = ({route}: Props) => { @@ -23,9 +27,10 @@ export const PostRepostedByScreen = ({route}: Props) => { ) return ( - - + + + - + ) } diff --git a/src/screens/Profile/Header/DisplayName.tsx b/src/screens/Profile/Header/DisplayName.tsx index 955e3d72c8..6d4eea2ebc 100644 --- a/src/screens/Profile/Header/DisplayName.tsx +++ b/src/screens/Profile/Header/DisplayName.tsx @@ -2,10 +2,10 @@ import React from 'react' import {View} from 'react-native' import {AppBskyActorDefs, ModerationDecision} from '@atproto/api' +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {sanitizeHandle} from '#/lib/strings/handles' import {Shadow} from '#/state/cache/types' -import {sanitizeDisplayName} from 'lib/strings/display-names' -import {sanitizeHandle} from 'lib/strings/handles' -import {atoms as a, useTheme} from '#/alf' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {Text} from '#/components/Typography' export function ProfileHeaderDisplayName({ @@ -16,11 +16,19 @@ export function ProfileHeaderDisplayName({ moderation: ModerationDecision }) { const t = useTheme() + const {gtMobile} = useBreakpoints() + return ( + style={[ + t.atoms.text, + gtMobile ? a.text_4xl : a.text_3xl, + a.self_start, + a.font_heavy, + ]}> {sanitizeDisplayName( profile.displayName || sanitizeHandle(profile.handle), moderation.ui('displayName'), diff --git a/src/screens/Profile/Header/GrowableAvatar.tsx b/src/screens/Profile/Header/GrowableAvatar.tsx new file mode 100644 index 0000000000..20ac14892d --- /dev/null +++ b/src/screens/Profile/Header/GrowableAvatar.tsx @@ -0,0 +1,61 @@ +import React from 'react' +import {StyleProp, View, ViewStyle} from 'react-native' +import Animated, { + Extrapolation, + interpolate, + SharedValue, + useAnimatedStyle, +} from 'react-native-reanimated' + +import {isIOS} from '#/platform/detection' +import {usePagerHeaderContext} from '#/view/com/pager/PagerHeaderContext' + +export function GrowableAvatar({ + children, + style, +}: { + children: React.ReactNode + style?: StyleProp +}) { + const pagerContext = usePagerHeaderContext() + + // pagerContext should only be present on iOS, but better safe than sorry + if (!pagerContext || !isIOS) { + return {children} + } + + const {scrollY} = pagerContext + + return ( + + {children} + + ) +} + +function GrowableAvatarInner({ + scrollY, + children, + style, +}: { + scrollY: SharedValue + children: React.ReactNode + style?: StyleProp +}) { + const animatedStyle = useAnimatedStyle(() => ({ + transform: [ + { + scale: interpolate(scrollY.value, [-150, 0], [1.2, 1], { + extrapolateRight: Extrapolation.CLAMP, + }), + }, + ], + })) + + return ( + + {children} + + ) +} diff --git a/src/screens/Profile/Header/GrowableBanner.tsx b/src/screens/Profile/Header/GrowableBanner.tsx new file mode 100644 index 0000000000..e1bb8e00ef --- /dev/null +++ b/src/screens/Profile/Header/GrowableBanner.tsx @@ -0,0 +1,212 @@ +import React, {useEffect, useState} from 'react' +import {View} from 'react-native' +import {ActivityIndicator} from 'react-native' +import Animated, { + Extrapolation, + interpolate, + runOnJS, + SharedValue, + useAnimatedProps, + useAnimatedReaction, + useAnimatedStyle, +} from 'react-native-reanimated' +import {BlurView} from 'expo-blur' +import {useIsFetching} from '@tanstack/react-query' + +import {isIOS} from '#/platform/detection' +import {RQKEY_ROOT as STARTERPACK_RQKEY_ROOT} from '#/state/queries/actor-starter-packs' +import {RQKEY_ROOT as FEED_RQKEY_ROOT} from '#/state/queries/post-feed' +import {RQKEY_ROOT as FEEDGEN_RQKEY_ROOT} from '#/state/queries/profile-feedgens' +import {RQKEY_ROOT as LIST_RQKEY_ROOT} from '#/state/queries/profile-lists' +import {usePagerHeaderContext} from '#/view/com/pager/PagerHeaderContext' +import {atoms as a} from '#/alf' + +const AnimatedBlurView = Animated.createAnimatedComponent(BlurView) + +export function GrowableBanner({ + backButton, + children, +}: { + backButton?: React.ReactNode + children: React.ReactNode +}) { + const pagerContext = usePagerHeaderContext() + + // pagerContext should only be present on iOS, but better safe than sorry + if (!pagerContext || !isIOS) { + return ( + + {backButton} + {children} + + ) + } + + const {scrollY} = pagerContext + + return ( + + {children} + + ) +} + +function GrowableBannerInner({ + scrollY, + backButton, + children, +}: { + scrollY: SharedValue + backButton?: React.ReactNode + children: React.ReactNode +}) { + const isFetching = useIsProfileFetching() + const animateSpinner = useShouldAnimateSpinner({isFetching, scrollY}) + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [ + { + scale: interpolate(scrollY.value, [-150, 0], [2, 1], { + extrapolateRight: Extrapolation.CLAMP, + }), + }, + ], + })) + + const animatedBlurViewProps = useAnimatedProps(() => { + return { + intensity: interpolate( + scrollY.value, + [-300, -65, -15], + [50, 40, 0], + Extrapolation.CLAMP, + ), + } + }) + + const animatedSpinnerStyle = useAnimatedStyle(() => { + return { + display: scrollY.value < 0 ? 'flex' : 'none', + opacity: interpolate( + scrollY.value, + [-60, -15], + [1, 0], + Extrapolation.CLAMP, + ), + transform: [ + {translateY: interpolate(scrollY.value, [-150, 0], [-75, 0])}, + {rotate: '90deg'}, + ], + } + }) + + const animatedBackButtonStyle = useAnimatedStyle(() => ({ + transform: [ + { + translateY: interpolate(scrollY.value, [-150, 60], [-150, 60], { + extrapolateRight: Extrapolation.CLAMP, + }), + }, + ], + })) + + return ( + <> + + {children} + + + + + + + + + {backButton} + + + ) +} + +function useIsProfileFetching() { + // are any of the profile-related queries fetching? + return [ + useIsFetching({queryKey: [FEED_RQKEY_ROOT]}), + useIsFetching({queryKey: [FEEDGEN_RQKEY_ROOT]}), + useIsFetching({queryKey: [LIST_RQKEY_ROOT]}), + useIsFetching({queryKey: [STARTERPACK_RQKEY_ROOT]}), + ].some(isFetching => isFetching) +} + +function useShouldAnimateSpinner({ + isFetching, + scrollY, +}: { + isFetching: boolean + scrollY: SharedValue +}) { + const [isOverscrolled, setIsOverscrolled] = useState(false) + // HACK: it reports a scroll pos of 0 for a tick when fetching finishes + // so paper over that by keeping it true for a bit -sfn + const stickyIsOverscrolled = useStickyToggle(isOverscrolled, 10) + + useAnimatedReaction( + () => scrollY.value < -5, + (value, prevValue) => { + if (value !== prevValue) { + runOnJS(setIsOverscrolled)(value) + } + }, + [scrollY], + ) + + const [isAnimating, setIsAnimating] = useState(isFetching) + + if (isFetching && !isAnimating) { + setIsAnimating(true) + } + + if (!isFetching && isAnimating && !stickyIsOverscrolled) { + setIsAnimating(false) + } + + return isAnimating +} + +// stayed true for at least `delay` ms before returning to false +function useStickyToggle(value: boolean, delay: number) { + const [prevValue, setPrevValue] = useState(value) + const [isSticking, setIsSticking] = useState(false) + + useEffect(() => { + if (isSticking) { + const timeout = setTimeout(() => setIsSticking(false), delay) + return () => clearTimeout(timeout) + } + }, [isSticking, delay]) + + if (value !== prevValue) { + setIsSticking(prevValue) // Going true -> false should stick. + setPrevValue(value) + return prevValue ? true : value + } + + return isSticking ? true : value +} diff --git a/src/screens/Profile/Header/Handle.tsx b/src/screens/Profile/Header/Handle.tsx index 0344f1a234..ba869b6626 100644 --- a/src/screens/Profile/Header/Handle.tsx +++ b/src/screens/Profile/Header/Handle.tsx @@ -1,11 +1,12 @@ import React from 'react' import {View} from 'react-native' import {AppBskyActorDefs} from '@atproto/api' -import {Trans} from '@lingui/macro' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {isInvalidHandle} from '#/lib/strings/handles' +import {isIOS} from '#/platform/detection' import {Shadow} from '#/state/cache/types' -import {isInvalidHandle} from 'lib/strings/handles' -import {isIOS} from 'platform/detection' import {atoms as a, useTheme, web} from '#/alf' import {NewskieDialog} from '#/components/NewskieDialog' import {Text} from '#/components/Typography' @@ -18,6 +19,7 @@ export function ProfileHeaderHandle({ disableTaps?: boolean }) { const t = useTheme() + const {_} = useLingui() const invalidHandle = isInvalidHandle(profile.handle) const blockHide = profile.viewer?.blocking || profile.viewer?.blockedBy return ( @@ -33,6 +35,7 @@ export function ProfileHeaderHandle({ ) : undefined} - {invalidHandle ? ⚠Invalid Handle : `@${profile.handle}`} + {invalidHandle ? _(msg`⚠Invalid Handle`) : `@${profile.handle}`} ) diff --git a/src/screens/Profile/Header/Metrics.tsx b/src/screens/Profile/Header/Metrics.tsx index e3537f44be..756eb1f89e 100644 --- a/src/screens/Profile/Header/Metrics.tsx +++ b/src/screens/Profile/Header/Metrics.tsx @@ -17,9 +17,9 @@ export function ProfileHeaderMetrics({ profile: Shadow }) { const t = useTheme() - const {_} = useLingui() - const following = formatCount(profile.followsCount || 0) - const followers = formatCount(profile.followersCount || 0) + const {_, i18n} = useLingui() + const following = formatCount(i18n, profile.followsCount || 0) + const followers = formatCount(i18n, profile.followersCount || 0) const pluralizedFollowers = plural(profile.followersCount || 0, { one: 'follower', other: 'followers', @@ -54,7 +54,7 @@ export function ProfileHeaderMetrics({ - {formatCount(profile.postsCount || 0)}{' '} + {formatCount(i18n, profile.postsCount || 0)}{' '} {plural(profile.postsCount || 0, {one: 'post', other: 'posts'})} diff --git a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx index 51e555a6e2..8c95413a8c 100644 --- a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx +++ b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx @@ -12,21 +12,20 @@ import {useLingui} from '@lingui/react' // eslint-disable-next-line @typescript-eslint/no-unused-vars import {MAX_LABELERS} from '#/lib/constants' +import {useHaptics} from '#/lib/haptics' import {isAppLabeler} from '#/lib/moderation' import {logger} from '#/logger' +import {isIOS} from '#/platform/detection' +import {useProfileShadow} from '#/state/cache/profile-shadow' import {Shadow} from '#/state/cache/types' import {useModalControls} from '#/state/modals' import {useLabelerSubscriptionMutation} from '#/state/queries/labeler' import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like' import {usePreferencesQuery} from '#/state/queries/preferences' import {useRequireAuth, useSession} from '#/state/session' -import {useAnalytics} from 'lib/analytics/analytics' -import {useHaptics} from 'lib/haptics' -import {isIOS} from 'platform/detection' -import {useProfileShadow} from 'state/cache/profile-shadow' import {ProfileMenu} from '#/view/com/profile/ProfileMenu' import * as Toast from '#/view/com/util/Toast' -import {atoms as a, tokens, useBreakpoints, useTheme} from '#/alf' +import {atoms as a, tokens, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' import {DialogOuterProps} from '#/components/Dialog' import { @@ -62,11 +61,9 @@ let ProfileHeaderLabeler = ({ const profile: Shadow = useProfileShadow(profileUnshadowed) const t = useTheme() - const {gtMobile} = useBreakpoints() const {_} = useLingui() const {currentAccount, hasSession} = useSession() const {openModal} = useModalControls() - const {track} = useAnalytics() const requireAuth = useRequireAuth() const playHaptic = useHaptics() const cantSubscribePrompt = Prompt.usePromptControl() @@ -102,12 +99,10 @@ let ProfileHeaderLabeler = ({ if (likeUri) { await unlikeMod({uri: likeUri}) - track('CustomFeed:Unlike') setLikeCount(c => c - 1) setLikeUri('') } else { const res = await likeMod({uri: labeler.uri, cid: labeler.cid}) - track('CustomFeed:Like') setLikeCount(c => c + 1) setLikeUri(res.uri) } @@ -116,18 +111,18 @@ let ProfileHeaderLabeler = ({ _( msg`There was an an issue contacting the server, please check your internet connection and try again.`, ), + 'xmark', ) logger.error(`Failed to toggle labeler like`, {message: e.message}) } - }, [labeler, playHaptic, likeUri, unlikeMod, track, likeMod, _]) + }, [labeler, playHaptic, likeUri, unlikeMod, likeMod, _]) const onPressEditProfile = React.useCallback(() => { - track('ProfileHeader:EditProfileButtonClicked') openModal({ name: 'edit-profile', profile, }) - }, [track, openModal, profile]) + }, [openModal, profile]) const onPressSubscribe = React.useCallback( () => @@ -171,7 +166,7 @@ let ProfileHeaderLabeler = ({ style={[a.px_lg, a.pt_md, a.pb_sm]} pointerEvents={isIOS ? 'auto' : 'box-none'}> {isMe ? ( - - )} + {hasSession && } diff --git a/src/screens/Signup/StepCaptcha/index.tsx b/src/screens/Signup/StepCaptcha/index.tsx index bf35764908..9050ca0610 100644 --- a/src/screens/Signup/StepCaptcha/index.tsx +++ b/src/screens/Signup/StepCaptcha/index.tsx @@ -8,7 +8,7 @@ import {createFullHandle} from '#/lib/strings/handles' import {logger} from '#/logger' import {logEvent} from 'lib/statsig/statsig' import {ScreenTransition} from '#/screens/Login/ScreenTransition' -import {useSignupContext, useSubmitSignup} from '#/screens/Signup/state' +import {useSignupContext} from '#/screens/Signup/state' import {CaptchaWebView} from '#/screens/Signup/StepCaptcha/CaptchaWebView' import {atoms as a, useTheme} from '#/alf' import {FormError} from '#/components/forms/FormError' @@ -20,7 +20,6 @@ export function StepCaptcha() { const {_} = useLingui() const theme = useTheme() const {state, dispatch} = useSignupContext() - const submit = useSubmitSignup({state, dispatch}) const [completed, setCompleted] = React.useState(false) @@ -42,9 +41,12 @@ export function StepCaptcha() { (code: string) => { setCompleted(true) logEvent('signup:captchaSuccess', {}) - submit(code) + dispatch({ + type: 'submit', + task: {verificationCode: code, mutableProcessed: false}, + }) }, - [submit], + [dispatch], ) const onError = React.useCallback( diff --git a/src/screens/Signup/StepHandle.tsx b/src/screens/Signup/StepHandle.tsx index b443e822a4..0ff0506f4e 100644 --- a/src/screens/Signup/StepHandle.tsx +++ b/src/screens/Signup/StepHandle.tsx @@ -7,9 +7,10 @@ import {logEvent} from '#/lib/statsig/statsig' import {createFullHandle, validateHandle} from '#/lib/strings/handles' import {useAgent} from '#/state/session' import {ScreenTransition} from '#/screens/Login/ScreenTransition' -import {useSignupContext, useSubmitSignup} from '#/screens/Signup/state' +import {useSignupContext} from '#/screens/Signup/state' import {atoms as a, useTheme} from '#/alf' import * as TextField from '#/components/forms/TextField' +import {useThrottledValue} from '#/components/hooks/useThrottledValue' import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At' import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times' @@ -20,10 +21,10 @@ export function StepHandle() { const {_} = useLingui() const t = useTheme() const {state, dispatch} = useSignupContext() - const submit = useSubmitSignup({state, dispatch}) const agent = useAgent() const handleValueRef = useRef(state.handle) const [draftValue, setDraftValue] = React.useState(state.handle) + const isLoading = useThrottledValue(state.isLoading, 500) const onNextPress = React.useCallback(async () => { const handle = handleValueRef.current.trim() @@ -64,7 +65,10 @@ export function StepHandle() { }) // phoneVerificationRequired is actually whether a captcha is required if (!state.serviceDescription?.phoneVerificationRequired) { - submit() + dispatch({ + type: 'submit', + task: {verificationCode: undefined, mutableProcessed: false}, + }) return } dispatch({type: 'next'}) @@ -74,7 +78,6 @@ export function StepHandle() { state.activeStep, state.serviceDescription?.phoneVerificationRequired, state.userDomain, - submit, agent, ]) @@ -175,7 +178,7 @@ export function StepHandle() { )} + {_(msg`Terms of Service`)} , ) } if (pp) { els.push( - + {_(msg`Privacy Policy`)} , ) diff --git a/src/screens/Signup/StepInfo/index.tsx b/src/screens/Signup/StepInfo/index.tsx index 47fb4c70ba..d9b680602a 100644 --- a/src/screens/Signup/StepInfo/index.tsx +++ b/src/screens/Signup/StepInfo/index.tsx @@ -3,8 +3,10 @@ import {View} from 'react-native' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import * as EmailValidator from 'email-validator' +import type tldts from 'tldts' import {logEvent} from '#/lib/statsig/statsig' +import {isEmailMaybeInvalid} from '#/lib/strings/email' import {logger} from '#/logger' import {ScreenTransition} from '#/screens/Login/ScreenTransition' import {is13, is18, useSignupContext} from '#/screens/Signup/state' @@ -46,13 +48,44 @@ export function StepInfo({ const inviteCodeValueRef = useRef(state.inviteCode) const emailValueRef = useRef(state.email) + const prevEmailValueRef = useRef(state.email) const passwordValueRef = useRef(state.password) - const onNextPress = React.useCallback(async () => { + const [hasWarnedEmail, setHasWarnedEmail] = React.useState(false) + + const tldtsRef = React.useRef() + React.useEffect(() => { + // @ts-expect-error - valid path + import('tldts/dist/index.cjs.min.js').then(tldts => { + tldtsRef.current = tldts + }) + // This will get used in the avatar creator a few steps later, so lets preload it now + // @ts-expect-error - valid path + import('react-native-view-shot/src/index') + }, []) + + const onNextPress = () => { const inviteCode = inviteCodeValueRef.current const email = emailValueRef.current + const emailChanged = prevEmailValueRef.current !== email const password = passwordValueRef.current + if (emailChanged && tldtsRef.current) { + if (isEmailMaybeInvalid(email, tldtsRef.current)) { + prevEmailValueRef.current = email + setHasWarnedEmail(true) + return dispatch({ + type: 'setError', + value: _( + msg`It looks like you may have entered your email address incorrectly. Are you sure it's right?`, + ), + }) + } + } else if (hasWarnedEmail) { + setHasWarnedEmail(false) + } + prevEmailValueRef.current = email + if (!is13(state.dateOfBirth)) { return } @@ -89,13 +122,7 @@ export function StepInfo({ logEvent('signup:nextPressed', { activeStep: state.activeStep, }) - }, [ - _, - dispatch, - state.activeStep, - state.dateOfBirth, - state.serviceDescription?.inviteCodeRequired, - ]) + } return ( @@ -148,6 +175,9 @@ export function StepInfo({ testID="emailInput" onChangeText={value => { emailValueRef.current = value.trim() + if (hasWarnedEmail) { + setHasWarnedEmail(false) + } }} label={_(msg`Enter your email address`)} defaultValue={state.email} @@ -172,6 +202,7 @@ export function StepInfo({ defaultValue={state.password} secureTextEntry autoComplete="new-password" + autoCapitalize="none" /> @@ -207,6 +238,7 @@ export function StepInfo({ onBackPress={onPressBack} onNextPress={onNextPress} onRetryPress={refetchServer} + overrideNextText={hasWarnedEmail ? _(msg`It's correct`) : undefined} /> ) diff --git a/src/screens/Signup/index.tsx b/src/screens/Signup/index.tsx index da0383884b..e3da053c09 100644 --- a/src/screens/Signup/index.tsx +++ b/src/screens/Signup/index.tsx @@ -5,17 +5,17 @@ import {AppBskyGraphStarterpack} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useAnalytics} from '#/lib/analytics/analytics' import {FEEDBACK_FORM_URL} from '#/lib/constants' import {useServiceQuery} from '#/state/queries/service' -import {useStarterPackQuery} from 'state/queries/starter-packs' -import {useActiveStarterPack} from 'state/shell/starter-pack' +import {useStarterPackQuery} from '#/state/queries/starter-packs' +import {useActiveStarterPack} from '#/state/shell/starter-pack' import {LoggedOutLayout} from '#/view/com/util/layouts/LoggedOutLayout' import { initialState, reducer, SignupContext, SignupStep, + useSubmitSignup, } from '#/screens/Signup/state' import {StepCaptcha} from '#/screens/Signup/StepCaptcha' import {StepHandle} from '#/screens/Signup/StepHandle' @@ -30,9 +30,9 @@ import {Text} from '#/components/Typography' export function Signup({onPressBack}: {onPressBack: () => void}) { const {_} = useLingui() const t = useTheme() - const {screen} = useAnalytics() const [state, dispatch] = React.useReducer(reducer, initialState) const {gtMobile} = useBreakpoints() + const submit = useSubmitSignup() const activeStarterPack = useActiveStarterPack() const { @@ -54,10 +54,6 @@ export function Signup({onPressBack}: {onPressBack: () => void}) { refetch, } = useServiceQuery(state.serviceUrl) - React.useEffect(() => { - screen('CreateAccount') - }, [screen]) - React.useEffect(() => { if (isFetching) { dispatch({type: 'setIsLoading', value: true}) @@ -81,6 +77,15 @@ export function Signup({onPressBack}: {onPressBack: () => void}) { } }, [_, serviceInfo, isError]) + React.useEffect(() => { + if (state.pendingSubmit) { + if (!state.pendingSubmit.mutableProcessed) { + state.pendingSubmit.mutableProcessed = true + submit(state, dispatch) + } + } + }, [state, dispatch, submit]) + return ( void}) { !gtMobile && {paddingBottom: 100}, ]}> - + Step {state.activeStep + 1} of{' '} {state.serviceDescription && @@ -166,6 +171,7 @@ export function Signup({onPressBack}: {onPressBack: () => void}) { Having trouble?{' '} Contact support diff --git a/src/screens/Signup/state.ts b/src/screens/Signup/state.ts index 826cbf1d31..4addf35805 100644 --- a/src/screens/Signup/state.ts +++ b/src/screens/Signup/state.ts @@ -26,6 +26,11 @@ export enum SignupStep { CAPTCHA, } +type SubmitTask = { + verificationCode: string | undefined + mutableProcessed: boolean // OK to mutate assuming it's never read in render. +} + export type SignupState = { hasPrev: boolean activeStep: SignupStep @@ -41,6 +46,8 @@ export type SignupState = { error: string isLoading: boolean + + pendingSubmit: null | SubmitTask } export type SignupAction = @@ -55,9 +62,9 @@ export type SignupAction = | {type: 'setDateOfBirth'; value: Date} | {type: 'setInviteCode'; value: string} | {type: 'setHandle'; value: string} - | {type: 'setVerificationCode'; value: string} | {type: 'setError'; value: string} | {type: 'setIsLoading'; value: boolean} + | {type: 'submit'; task: SubmitTask} export const initialState: SignupState = { hasPrev: false, @@ -74,6 +81,8 @@ export const initialState: SignupState = { error: '', isLoading: false, + + pendingSubmit: null, } export function is13(date: Date) { @@ -149,6 +158,10 @@ export function reducer(s: SignupState, a: SignupAction): SignupState { next.error = a.value break } + case 'submit': { + next.pendingSubmit = a.task + break + } } next.hasPrev = next.activeStep !== SignupStep.INFO @@ -169,19 +182,13 @@ interface IContext { export const SignupContext = React.createContext({} as IContext) export const useSignupContext = () => React.useContext(SignupContext) -export function useSubmitSignup({ - state, - dispatch, -}: { - state: SignupState - dispatch: (action: SignupAction) => void -}) { +export function useSubmitSignup() { const {_} = useLingui() const {createAccount} = useSessionApi() const onboardingDispatch = useOnboardingDispatch() return useCallback( - async (verificationCode?: string) => { + async (state: SignupState, dispatch: (action: SignupAction) => void) => { if (!state.email) { dispatch({type: 'setStep', value: SignupStep.INFO}) return dispatch({ @@ -212,7 +219,7 @@ export function useSubmitSignup({ } if ( state.serviceDescription?.phoneVerificationRequired && - !verificationCode + !state.pendingSubmit?.verificationCode ) { dispatch({type: 'setStep', value: SignupStep.CAPTCHA}) logger.error('Signup Flow Error', { @@ -235,7 +242,7 @@ export function useSubmitSignup({ password: state.password, birthDate: state.dateOfBirth, inviteCode: state.inviteCode.trim(), - verificationCode: verificationCode, + verificationCode: state.pendingSubmit?.verificationCode, }) /* * Must happen last so that if the user has multiple tabs open and @@ -270,19 +277,6 @@ export function useSubmitSignup({ dispatch({type: 'setIsLoading', value: false}) } }, - [ - state.email, - state.password, - state.handle, - state.serviceDescription?.phoneVerificationRequired, - state.serviceUrl, - state.userDomain, - state.inviteCode, - state.dateOfBirth, - dispatch, - _, - onboardingDispatch, - createAccount, - ], + [_, onboardingDispatch, createAccount], ) } diff --git a/src/screens/SignupQueued.tsx b/src/screens/SignupQueued.tsx index 4e4fedcfae..e7336569c6 100644 --- a/src/screens/SignupQueued.tsx +++ b/src/screens/SignupQueued.tsx @@ -23,7 +23,7 @@ export function SignupQueued() { const insets = useSafeAreaInsets() const {gtMobile} = useBreakpoints() const onboardingDispatch = useOnboardingDispatch() - const {logout} = useSessionApi() + const {logoutCurrentAccount} = useSessionApi() const agent = useAgent() const [isProcessing, setProcessing] = React.useState(false) @@ -40,7 +40,7 @@ export function SignupQueued() { const res = await agent.com.atproto.temp.checkSignupQueue() if (res.data.activated) { // ready to go, exchange the access token for a usable one and kick off onboarding - await agent.refreshSession() + await agent.sessionManager.refreshSession() if (!isSignupQueued(agent.session?.accessJwt)) { onboardingDispatch({type: 'start'}) } @@ -153,7 +153,7 @@ export function SignupQueued() { variant="ghost" size="large" label={_(msg`Log out`)} - onPress={() => logout('SignupQueued')}> + onPress={() => logoutCurrentAccount('SignupQueued')}> Log out @@ -182,7 +182,7 @@ export function SignupQueued() { variant="ghost" size="large" label={_(msg`Log out`)} - onPress={() => logout('SignupQueued')}> + onPress={() => logoutCurrentAccount('SignupQueued')}> Log out diff --git a/src/screens/StarterPack/StarterPackLandingScreen.tsx b/src/screens/StarterPack/StarterPackLandingScreen.tsx index 643a722119..68ff3aa7bc 100644 --- a/src/screens/StarterPack/StarterPackLandingScreen.tsx +++ b/src/screens/StarterPack/StarterPackLandingScreen.tsx @@ -11,30 +11,32 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {isAndroidWeb} from '#/lib/browser' import {JOINED_THIS_WEEK} from '#/lib/constants' -import {isAndroidWeb} from 'lib/browser' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {logEvent} from 'lib/statsig/statsig' -import {createStarterPackGooglePlayUri} from 'lib/strings/starter-pack' -import {isWeb} from 'platform/detection' -import {useModerationOpts} from 'state/preferences/moderation-opts' -import {useStarterPackQuery} from 'state/queries/starter-packs' +import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' +import {logEvent} from '#/lib/statsig/statsig' +import {createStarterPackGooglePlayUri} from '#/lib/strings/starter-pack' +import {isWeb} from '#/platform/detection' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useStarterPackQuery} from '#/state/queries/starter-packs' import { useActiveStarterPack, useSetActiveStarterPack, -} from 'state/shell/starter-pack' +} from '#/state/shell/starter-pack' +import {LoggedOutScreenState} from '#/view/com/auth/LoggedOut' import {formatCount} from '#/view/com/util/numeric/format' -import {LoggedOutScreenState} from 'view/com/auth/LoggedOut' -import {CenteredView} from 'view/com/util/Views' -import {Logo} from 'view/icons/Logo' +import {CenteredView} from '#/view/com/util/Views' +import {Logo} from '#/view/icons/Logo' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' import {useDialogControl} from '#/components/Dialog' import * as FeedCard from '#/components/FeedCard' +import {useRichText} from '#/components/hooks/useRichText' import {LinearGradientBackground} from '#/components/LinearGradientBackground' import {ListMaybePlaceholder} from '#/components/Lists' import {Default as ProfileCard} from '#/components/ProfileCard' import * as Prompt from '#/components/Prompt' +import {RichText} from '#/components/RichText' import {Text} from '#/components/Typography' const AnimatedPressable = Animated.createAnimatedComponent(Pressable) @@ -82,9 +84,15 @@ export function LandingScreen({ return } + // Just for types, this cannot be hit + if (!AppBskyGraphStarterpack.isRecord(starterPack.record)) { + return null + } + return ( @@ -93,22 +101,25 @@ export function LandingScreen({ function LandingScreenLoaded({ starterPack, + starterPackRecord: record, setScreenState, // TODO apply this to profile card moderationOpts, }: { starterPack: AppBskyGraphDefs.StarterPackView + starterPackRecord: AppBskyGraphStarterpack.Record setScreenState: (state: LoggedOutScreenState) => void moderationOpts: ModerationOpts }) { - const {record, creator, listItemsSample, feeds} = starterPack - const {_} = useLingui() + const {creator, listItemsSample, feeds} = starterPack + const {_, i18n} = useLingui() const t = useTheme() const activeStarterPack = useActiveStarterPack() const setActiveStarterPack = useSetActiveStarterPack() const {isTabletOrDesktop} = useWebMediaQueries() const androidDialogControl = useDialogControl() + const [descriptionRt] = useRichText(record.description || '') const [appClipOverlayVisible, setAppClipOverlayVisible] = React.useState(false) @@ -147,10 +158,6 @@ function LandingScreenLoaded({ } } - if (!AppBskyGraphStarterpack.isRecord(record)) { - return null - } - return ( + style={[a.text_center, a.font_bold, a.text_md, {color: 'white'}]}> Starter pack by {`@${creator.handle}`} {record.description ? ( - - {record.description} - + ) : null}