diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000..b4213aea24 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +SENTRY_AUTH_TOKEN= diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000000..c608f66000 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,35 @@ +--- +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/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000000..0ac6b3c416 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,24 @@ +--- +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/Dockerfile b/Dockerfile index 95f0ec02e8..fbd13bebf2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,9 +35,6 @@ RUN \. "$NVM_DIR/nvm.sh" && \ # DEBUG RUN find ./bskyweb/static && find ./web-build/static -# Copy the bundle js files. -RUN cp --verbose ./web-build/static/js/*.* ./bskyweb/static/js/ - # # Generate the bksyweb Go binary. # diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000..8016366443 --- /dev/null +++ b/LICENSE @@ -0,0 +1,7 @@ +Copyright 2023 Bluesky PBLLC + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000..e93b6357a5 --- /dev/null +++ b/Makefile @@ -0,0 +1,35 @@ + +SHELL = /bin/bash +.SHELLFLAGS = -o pipefail -c + +.PHONY: help +help: ## Print info about all commands + @echo "Commands:" + @echo + @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[01;32m%-20s\033[0m %s\n", $$1, $$2}' + +.PHONY: build-web +build-web: ## Compile web bundle, copy to bskyweb directory + yarn build-web + +.PHONY: test +test: ## Run all tests + yarn test + +.PHONY: lint +lint: ## Run style checks and verify syntax + yarn run lint + +#.PHONY: fmt +#fmt: ## Run syntax re-formatting +# yarn prettier + +.PHONY: deps +deps: ## Installs dependent libs using 'yarn install' + yarn install --frozen-lockfile + +.PHONY: nvm-setup +nvm-setup: ## Use NVM to install and activate node+yarn + nvm install 18 + nvm use 18 + npm install --global yarn diff --git a/README.md b/README.md index b392579d95..1782b8ee27 100644 --- a/README.md +++ b/README.md @@ -1,106 +1,61 @@ -# Bluesky +# Bluesky Social App -## Build instructions +Welcome friends! This is the codebase for the Bluesky Social app. It serves as a resource to engineers building on the [AT Protocol](https://atproto.com). -- Setup your environment [using the react native instructions](https://reactnative.dev/docs/environment-setup). -- Setup your environment [for e2e testing using detox](https://wix.github.io/Detox/docs/introduction/getting-started): - - yarn global add detox-cli - - brew tap wix/brew - - brew install applesimutils -- After initial setup: - - `npx expo prebuild` -> you will also need to run this anytime `app.json` or `package.json` changes -- Start the dev servers - - `git clone git@github.com:bluesky-social/atproto.git` - - `cd atproto` - - `yarn` - - `cd packages/dev-env && yarn start` -- Run the dev app - - iOS: `yarn ios` - - Android: `yarn android` - - Web: `yarn web` -- Run e2e tests - - Start in various console tabs: - - `yarn e2e:server` - - `yarn e2e:metro` - - Run once: `yarn e2e:build` - - Each test run: `yarn e2e:run` -- Tips - - `npx react-native info` Checks what has been installed. - - The android simulator won't be able to access localhost services unless you run `adb reverse tcp:{PORT} tcp:{PORT}` - - For instance, the localhosted dev-wallet will need `adb reverse tcp:3001 tcp:3001` - - For some reason, the typescript compiler chokes on platform-specific files (e.g. `foo.native.ts`) but only when compiling for Web thus far. Therefore we always have one version of the file which doesn't use a platform specifier, and that should bee the Web version. ([More info](https://stackoverflow.com/questions/44001050/platform-specific-import-component-in-react-native-with-typescript).) +- **Web: [staging.bsky.app](https://staging.bsky.app)** +- **iOS: [App Store](https://apps.apple.com/us/app/bluesky-social/id6444370199)** +- **Android: [Play Store](https://play.google.com/store/apps/details?id=xyz.blueskyweb.app&hl=en_US&gl=US)** -## Build instructions (with Go) +Links: -### Prerequisites +- [Build instructions](./docs/build.md) +- [ATProto repo](https://github.com/bluesky-social/atproto) +- [ATProto docs](https://atproto.com) -- [Go](https://go.dev/) -- [Yarn](https://yarnpkg.com/) +## Rules & guidelines -### Steps +--- -To run the build with Go, use staging credentials, your own, or any other account you create. +ℹ️ While we do accept contributions, we prioritize high quality issues and pull requests. Adhering to the below guidelines will ensure a more timely review. -``` -cd social-app -yarn && yarn build-web -cp ./web-build/static/js/*.* bskyweb/static/js/ -cd bskyweb/ -go mod tidy -go build -v -tags timetzdata -o bskyweb ./cmd/bskyweb -./bskyweb serve --pds-host=https://staging.bsky.dev --handle= --password= -``` +--- -On build success, access the application at [http://localhost:8100/](http://localhost:8100/). Subsequent changes require re-running the above steps in order to be reflected. +**Rules:** -## Various notes +- We may not respond to your issue or PR. +- We may close an issue or PR without much feedback. +- We may lock discussions or contributions if our attention is getting DDOSed. +- We're not going to provide support for build issues. -### Debugging +**Guidelines:** -- Note that since 0.70, debugging using the old debugger (which shows up using CMD+D) doesn't work anymore. Follow the instructions below to debug the code: https://reactnative.dev/docs/next/hermes#debugging-js-on-hermes-using-google-chromes-devtools +- Check for existing issues before filing a new one please. +- Open an issue and give some time for discussion before submitting a PR. +- Stay away from PRs like... + - Changing "Post" to "Skeet." + - Refactoring the codebase, eg to replace mobx with redux or something. + - Adding entirely new features without prior discussion. -### Developer Menu +Remember, we serve a wide community of users. Our day to day involves us constantly asking "which top priority is our top priority." If you submit well-written PRs that solve problems concisely, that's an awesome contribution. Otherwise, as much as we'd love to accept your ideas and contributions, we really don't have the bandwidth. That's what forking is for! -To open the [Developer Menu](https://docs.expo.dev/debugging/tools/#developer-menu) on an `expo-dev-client` app you can do the following: +## Forking guidelines -- Android Device: Shake the device vertically, or if your device is connected via USB, run adb shell input keyevent 82 in your terminal -- Android Emulator: Either press Cmd ⌘ + m or Ctrl + m or run adb shell input keyevent 82 in your terminal -- iOS Device: Shake the device, or touch 3 fingers to the screen -- iOS Simulator: Press Ctrl + Cmd ⌘ + z on a Mac in the emulator to simulate the shake gesture, or press Cmd ⌘ + d +You have our blessing 🪄✨ to fork this application! However, it's very important to be clear to users when you're giving them a fork. -### Running E2E Tests +Please be sure to: -- Make sure you've setup your environment following above -- Make sure Metro and the dev server are running -- Run `yarn e2e` -- Find the artifacts in the `artifact` folder +- Change all branding in the repository and UI to clearly differentiate from Bluesky. +- Change any support links (feedback, email, terms of service, etc) to your own systems. +- Replace any analytics or error-collection systems with your own so we don't get super confused. -### Polyfills +## Security disclosures -`./platform/polyfills.*.ts` adds polyfills to the environment. Currently this includes: +If you discover any security issues, please send an email to security@bsky.app. The email is automatically CCed to the entire team and we'll respond promptly. -- TextEncoder / TextDecoder +## License (MIT) -### Sentry sourcemaps +See [./LICENSE](./LICENSE) for the full license. -Sourcemaps should automatically be updated when a signed build is created using `eas build` and published using `eas submit` due to the postPublish hook setup in `app.json`. However, if an update is created and published OTA using `eas update`, we need to the take the following steps to upload sourcemaps to Sentry: +## P.S. -- Run eas update. This will generate a dist folder in your project root, which contains your JavaScript bundles and source maps. This command will also output the 'Android update ID' and 'iOS update ID' that we'll need in the next step. -- Copy or rename the bundle names in the `dist/bundles` folder to match `index.android.bundle` (Android) or `main.jsbundle` (iOS). -- Next, you can use the Sentry CLI to upload your bundles and source maps: - - release name should be set to `${bundleIdentifier}@${version}+${buildNumber}` (iOS) or `${androidPackage}@${version}+${versionCode}` (Android), so for example `com.domain.myapp@1.0.0+1`. - - `dist` should be set to the Update ID that `eas update` generated. -- Command for Android: - `node_modules/@sentry/cli/bin/sentry-cli releases \ -files \ -upload-sourcemaps \ ---dist \ ---rewrite \ -dist/bundles/index.android.bundle dist/bundles/android-.map` -- Command for iOS: - `node_modules/@sentry/cli/bin/sentry-cli releases \ -files \ -upload-sourcemaps \ ---dist \ ---rewrite \ -dist/bundles/main.jsbundle dist/bundles/ios-.map` +We ❤️ you and all of the ways you support us. Thank you for making Bluesky a great place! diff --git a/__e2e__/mock-server.ts b/__e2e__/mock-server.ts index 6744f697ff..6ddfe3ca02 100644 --- a/__e2e__/mock-server.ts +++ b/__e2e__/mock-server.ts @@ -91,6 +91,7 @@ async function main() { 'always-warn-profile', 'always-warn-posts', 'muted-account', + 'muted-by-list-account', ]) { await server.mocker.createUser(user) await server.mocker.follow('alice', user) @@ -258,11 +259,32 @@ async function main() { await server.mocker.createPost('muted-account', 'muted post') await server.mocker.createQuotePost( 'muted-account', - 'account quote post', + 'muted quote post', anchorPost, ) await server.mocker.createReply( 'muted-account', + 'muted reply', + anchorPost, + ) + + const list = await server.mocker.createMuteList( + 'alice', + 'Muted Users', + ) + await server.mocker.addToMuteList( + 'alice', + list, + server.mocker.users['muted-by-list-account'].did, + ) + await server.mocker.createPost('muted-by-list-account', 'muted post') + await server.mocker.createQuotePost( + 'muted-by-list-account', + 'account quote post', + anchorPost, + ) + await server.mocker.createReply( + 'muted-by-list-account', 'account reply', anchorPost, ) diff --git a/__e2e__/tests/create-account.test.ts b/__e2e__/tests/create-account.test.ts index 38466ed8e8..7db4e912a6 100644 --- a/__e2e__/tests/create-account.test.ts +++ b/__e2e__/tests/create-account.test.ts @@ -20,7 +20,6 @@ describe('Create account', () => { await element(by.id('nextBtn')).tap() await element(by.id('emailInput')).typeText('example@test.com') await element(by.id('passwordInput')).typeText('hunter2') - await element(by.id('is13Input')).tap() await device.takeScreenshot('4- entered account details') await element(by.id('nextBtn')).tap() await element(by.id('handleInput')).typeText('e2e-test') diff --git a/__e2e__/tests/invite-codes.test.ts b/__e2e__/tests/invite-codes.test.ts index e3bb5d7f22..846d3b7688 100644 --- a/__e2e__/tests/invite-codes.test.ts +++ b/__e2e__/tests/invite-codes.test.ts @@ -37,7 +37,6 @@ describe('invite-codes', () => { await element(by.id('inviteCodeInput')).typeText(inviteCode) await element(by.id('emailInput')).typeText('example@test.com') await element(by.id('passwordInput')).typeText('hunter2') - await element(by.id('is13Input')).tap() await device.takeScreenshot('4- entered account details') await element(by.id('nextBtn')).tap() await element(by.id('handleInput')).typeText('e2e-test') diff --git a/__e2e__/tests/mute-lists.test.ts b/__e2e__/tests/mute-lists.test.ts new file mode 100644 index 0000000000..e931625139 --- /dev/null +++ b/__e2e__/tests/mute-lists.test.ts @@ -0,0 +1,141 @@ +/* eslint-env detox/detox */ + +import {openApp, login, createServer, sleep} from '../util' + +describe('Profile screen', () => { + let service: string + beforeAll(async () => { + service = await createServer('?users&follows&labels') + await openApp({ + permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'}, + }) + }) + + it('Login and view my mutelists', async () => { + await expect(element(by.id('signInButton'))).toBeVisible() + await login(service, 'alice', 'hunter2') + await element(by.id('viewHeaderDrawerBtn')).tap() + await expect(element(by.id('drawer'))).toBeVisible() + await element(by.id('menuItemButton-Moderation')).tap() + await element(by.id('mutelistsBtn')).tap() + await expect(element(by.id('list-Muted Users'))).toBeVisible() + await element(by.id('list-Muted Users')).tap() + await expect( + element(by.id('user-muted-by-list-account.test')), + ).toBeVisible() + }) + + it('Toggle subscription', async () => { + await element(by.id('unsubscribeListBtn')).tap() + await element(by.id('subscribeListBtn')).tap() + }) + + it('Edit display name and description via the edit mutelist modal', async () => { + await element(by.id('editListBtn')).tap() + await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible() + await element(by.id('editNameInput')).clearText() + await element(by.id('editNameInput')).typeText('Bad Ppl') + await element(by.id('editDescriptionInput')).clearText() + await element(by.id('editDescriptionInput')).typeText('They bad') + await element(by.id('saveBtn')).tap() + await expect(element(by.id('createOrEditMuteListModal'))).not.toBeVisible() + await expect(element(by.id('listName'))).toHaveText('Bad Ppl') + await expect(element(by.id('listDescription'))).toHaveText('They bad') + // have to wait for the toast to clear + await waitFor(element(by.id('editListBtn'))) + .toBeVisible() + .withTimeout(5000) + }) + + it('Remove description via the edit mutelist modal', async () => { + await element(by.id('editListBtn')).tap() + await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible() + await element(by.id('editDescriptionInput')).clearText() + await element(by.id('saveBtn')).tap() + await expect(element(by.id('createOrEditMuteListModal'))).not.toBeVisible() + await expect(element(by.id('listDescription'))).not.toBeVisible() + // have to wait for the toast to clear + await waitFor(element(by.id('editListBtn'))) + .toBeVisible() + .withTimeout(5000) + }) + + it('Set avi via the edit mutelist modal', async () => { + await expect(element(by.id('userAvatarFallback'))).toExist() + await element(by.id('editListBtn')).tap() + await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible() + await element(by.id('changeAvatarBtn')).tap() + await element(by.id('changeAvatarLibraryBtn')).tap() + await sleep(3e3) + await element(by.id('saveBtn')).tap() + await expect(element(by.id('createOrEditMuteListModal'))).not.toBeVisible() + await expect(element(by.id('userAvatarImage'))).toExist() + // have to wait for the toast to clear + await waitFor(element(by.id('editListBtn'))) + .toBeVisible() + .withTimeout(5000) + }) + + it('Remove avi via the edit mutelist modal', async () => { + await expect(element(by.id('userAvatarImage'))).toExist() + await element(by.id('editListBtn')).tap() + await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible() + await element(by.id('changeAvatarBtn')).tap() + await element(by.id('changeAvatarRemoveBtn')).tap() + await element(by.id('saveBtn')).tap() + await expect(element(by.id('createOrEditMuteListModal'))).not.toBeVisible() + await expect(element(by.id('userAvatarFallback'))).toExist() + // have to wait for the toast to clear + await waitFor(element(by.id('editListBtn'))) + .toBeVisible() + .withTimeout(5000) + }) + + it('Delete the mutelist', async () => { + await element(by.id('deleteListBtn')).tap() + await element(by.id('confirmBtn')).tap() + await expect(element(by.id('emptyMuteLists'))).toBeVisible() + }) + + it('Create a new mutelist', async () => { + await element(by.id('emptyMuteLists-button')).tap() + await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible() + await element(by.id('editNameInput')).typeText('Bad Ppl') + await element(by.id('editDescriptionInput')).typeText('They bad') + await element(by.id('saveBtn')).tap() + await expect(element(by.id('createOrEditMuteListModal'))).not.toBeVisible() + await expect(element(by.id('listName'))).toHaveText('Bad Ppl') + await expect(element(by.id('listDescription'))).toHaveText('They bad') + // have to wait for the toast to clear + await waitFor(element(by.id('editListBtn'))) + .toBeVisible() + .withTimeout(5000) + }) + + it('Shows the mutelist on my profile', async () => { + await element(by.id('bottomBarProfileBtn')).tap() + await element(by.id('selector-2')).tap() + await element(by.id('list-Bad Ppl')).tap() + }) + + it('Adds and removes users on mutelists', async () => { + await element(by.id('bottomBarSearchBtn')).tap() + await element(by.id('searchTextInput')).typeText('bob') + await element(by.id('searchAutoCompleteResult-bob.test')).tap() + await expect(element(by.id('profileView'))).toBeVisible() + + await element(by.id('profileHeaderDropdownBtn')).tap() + await element(by.id('profileHeaderDropdownListAddRemoveBtn')).tap() + await expect(element(by.id('listAddRemoveUserModal'))).toBeVisible() + await element(by.id('toggleBtn-Bad Ppl')).tap() + await element(by.id('saveBtn')).tap() + await expect(element(by.id('listAddRemoveUserModal'))).not.toBeVisible() + + await element(by.id('profileHeaderDropdownBtn')).tap() + await element(by.id('profileHeaderDropdownListAddRemoveBtn')).tap() + await expect(element(by.id('listAddRemoveUserModal'))).toBeVisible() + await element(by.id('toggleBtn-Bad Ppl')).tap() + await element(by.id('saveBtn')).tap() + await expect(element(by.id('listAddRemoveUserModal'))).not.toBeVisible() + }) +}) diff --git a/__e2e__/util.ts b/__e2e__/util.ts index 78d9f9f5d1..d53c72dfe1 100644 --- a/__e2e__/util.ts +++ b/__e2e__/util.ts @@ -42,6 +42,7 @@ export async function login( await device.takeScreenshot('2- opened service selector') } await element(by.id('customServerTextInput')).typeText(service) + await element(by.id('customServerTextInput')).tapReturnKey() await element(by.id('customServerSelectBtn')).tap() if (takeScreenshots) { await device.takeScreenshot('3- input custom service') diff --git a/app.json b/app.json index c1dc592cc2..8a2fe039a9 100644 --- a/app.json +++ b/app.json @@ -14,7 +14,7 @@ "backgroundColor": "#ffffff" }, "ios": { - "buildNumber": "2", + "buildNumber": "3", "supportsTablet": false, "bundleIdentifier": "xyz.blueskyweb.app", "config": { @@ -40,7 +40,7 @@ "backgroundColor": "#ffffff" }, "android": { - "versionCode": 13, + "versionCode": 14, "adaptiveIcon": { "foregroundImage": "./assets/adaptive-icon.png", "backgroundColor": "#ffffff" diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index 902d1ffc1a..7c230041e7 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -106,10 +106,12 @@ func serve(cctx *cli.Context) error { // generic routes e.GET("/search", server.WebGeneric) e.GET("/notifications", server.WebGeneric) + e.GET("/moderation", server.WebGeneric) + e.GET("/moderation/mute-lists", server.WebGeneric) + e.GET("/moderation/muted-accounts", server.WebGeneric) + e.GET("/moderation/blocked-accounts", server.WebGeneric) e.GET("/settings", server.WebGeneric) e.GET("/settings/app-passwords", server.WebGeneric) - e.GET("/settings/muted-accounts", server.WebGeneric) - e.GET("/settings/blocked-accounts", server.WebGeneric) e.GET("/sys/debug", server.WebGeneric) e.GET("/sys/log", server.WebGeneric) e.GET("/support", server.WebGeneric) @@ -122,6 +124,7 @@ func serve(cctx *cli.Context) error { e.GET("/profile/:handle", server.WebProfile) e.GET("/profile/:handle/follows", server.WebGeneric) e.GET("/profile/:handle/followers", server.WebGeneric) + e.GET("/profile/:handle/lists/:rkey", server.WebGeneric) // post endpoints; only first populates info e.GET("/profile/:handle/post/:rkey", server.WebPost) diff --git a/bskyweb/templates/base.html b/bskyweb/templates/base.html index 1d51b4f20a..5725b7af0e 100644 --- a/bskyweb/templates/base.html +++ b/bskyweb/templates/base.html @@ -3,7 +3,7 @@ - + {%- block head_title -%}Bluesky{%- endblock -%} diff --git a/docs/build.md b/docs/build.md new file mode 100644 index 0000000000..33e7f6751b --- /dev/null +++ b/docs/build.md @@ -0,0 +1,109 @@ +# Build instructions + +## App Build + +- Setup your environment [using the react native instructions](https://reactnative.dev/docs/environment-setup). +- Setup your environment [for e2e testing using detox](https://wix.github.io/Detox/docs/introduction/getting-started): + - yarn global add detox-cli + - brew tap wix/brew + - brew install applesimutils +- After initial setup: + - `npx expo prebuild` -> you will also need to run this anytime `app.json` or `package.json` changes +- Start the dev servers + - `git clone git@github.com:bluesky-social/atproto.git` + - `cd atproto` + - `yarn` + - `cd packages/dev-env && yarn start` +- Run the dev app + - iOS: `yarn ios` + - Android: `yarn android` + - Web: `yarn web` +- If you are cloning or forking this repo as an open source developer, please check the tips below as well +- Run e2e tests + - Start in various console tabs: + - `yarn e2e:mock-server` + - `yarn e2e:metro` + - Run once: `yarn e2e:build` + - Each test run: `yarn e2e:run` +- Tips + - Make sure you copy the `.env.example` to `.env` and add the appropiate tokens (e.g. `SENTRY_AUTH_TOKEN` can be created on the Sentry dashboard). If this is not required, you can remove it from `eas.json` and `package.json`, as well as any mentions in the code. + - If you want to use Expo EAS on your own builds without ejecting from Expo, make sure to change the `owner` as well as `extra.eas.projectId` properties. If you do not have an Expo account, you may remove these properties. + - `npx react-native info` Checks what has been installed. + - The android simulator won't be able to access localhost services unless you run `adb reverse tcp:{PORT} tcp:{PORT}` + - For instance, the localhosted dev-wallet will need `adb reverse tcp:3001 tcp:3001` + - For some reason, the typescript compiler chokes on platform-specific files (e.g. `foo.native.ts`) but only when compiling for Web thus far. Therefore we always have one version of the file which doesn't use a platform specifier, and that should be the Web version. ([More info](https://stackoverflow.com/questions/44001050/platform-specific-import-component-in-react-native-with-typescript).) + +## Go-Server Build + +### Prerequisites + +- [Go](https://go.dev/) +- [Yarn](https://yarnpkg.com/) + +### Steps + +To run the build with Go, use staging credentials, your own, or any other account you create. + +``` +cd social-app +yarn && yarn build-web +cp ./web-build/static/js/*.* bskyweb/static/js/ +cd bskyweb/ +go mod tidy +go build -v -tags timetzdata -o bskyweb ./cmd/bskyweb +./bskyweb serve --pds-host=https://staging.bsky.dev --handle= --password= +``` + +On build success, access the application at [http://localhost:8100/](http://localhost:8100/). Subsequent changes require re-running the above steps in order to be reflected. + +## Various notes + +### Debugging + +- Note that since 0.70, debugging using the old debugger (which shows up using CMD+D) doesn't work anymore. Follow the instructions below to debug the code: https://reactnative.dev/docs/next/hermes#debugging-js-on-hermes-using-google-chromes-devtools + +### Developer Menu + +To open the [Developer Menu](https://docs.expo.dev/debugging/tools/#developer-menu) on an `expo-dev-client` app you can do the following: + +- Android Device: Shake the device vertically, or if your device is connected via USB, run adb shell input keyevent 82 in your terminal +- Android Emulator: Either press Cmd ⌘ + m or Ctrl + m or run adb shell input keyevent 82 in your terminal +- iOS Device: Shake the device, or touch 3 fingers to the screen +- iOS Simulator: Press Ctrl + Cmd ⌘ + z on a Mac in the emulator to simulate the shake gesture, or press Cmd ⌘ + d + +### Running E2E Tests + +- Make sure you've setup your environment following above +- Make sure Metro and the dev server are running +- Run `yarn e2e` +- Find the artifacts in the `artifact` folder + +### Polyfills + +`./platform/polyfills.*.ts` adds polyfills to the environment. Currently this includes: + +- TextEncoder / TextDecoder + +### Sentry sourcemaps + +Sourcemaps should automatically be updated when a signed build is created using `eas build` and published using `eas submit` due to the postPublish hook setup in `app.json`. However, if an update is created and published OTA using `eas update`, we need to the take the following steps to upload sourcemaps to Sentry: + +- Run eas update. This will generate a dist folder in your project root, which contains your JavaScript bundles and source maps. This command will also output the 'Android update ID' and 'iOS update ID' that we'll need in the next step. +- Copy or rename the bundle names in the `dist/bundles` folder to match `index.android.bundle` (Android) or `main.jsbundle` (iOS). +- Next, you can use the Sentry CLI to upload your bundles and source maps: + - release name should be set to `${bundleIdentifier}@${version}+${buildNumber}` (iOS) or `${androidPackage}@${version}+${versionCode}` (Android), so for example `com.domain.myapp@1.0.0+1`. + - `dist` should be set to the Update ID that `eas update` generated. +- Command for Android: + `node_modules/@sentry/cli/bin/sentry-cli releases \ +files \ +upload-sourcemaps \ +--dist \ +--rewrite \ +dist/bundles/index.android.bundle dist/bundles/android-.map` +- Command for iOS: + `node_modules/@sentry/cli/bin/sentry-cli releases \ +files \ +upload-sourcemaps \ +--dist \ +--rewrite \ +dist/bundles/main.jsbundle dist/bundles/ios-.map` diff --git a/eas.json b/eas.json index 32d5b13d9f..60c8be378f 100644 --- a/eas.json +++ b/eas.json @@ -11,29 +11,20 @@ "simulator": true, "resourceClass": "medium" }, - "channel": "development", - "env": { - "SENTRY_AUTH_TOKEN": "89c975413cd543fbb683b11bec984fc2163d9a77312c41c0b4480a570f3daa65" - } + "channel": "development" }, "preview": { "distribution": "internal", "ios": { "resourceClass": "medium" }, - "channel": "preview", - "env": { - "SENTRY_AUTH_TOKEN": "89c975413cd543fbb683b11bec984fc2163d9a77312c41c0b4480a570f3daa65" - } + "channel": "preview" }, "production": { "ios": { "resourceClass": "medium" }, - "channel": "production", - "env": { - "SENTRY_AUTH_TOKEN": "89c975413cd543fbb683b11bec984fc2163d9a77312c41c0b4480a570f3daa65" - } + "channel": "production" } }, "submit": { diff --git a/jest/test-pds.ts b/jest/test-pds.ts index 7f8d202323..a75a0034fe 100644 --- a/jest/test-pds.ts +++ b/jest/test-pds.ts @@ -337,6 +337,32 @@ class Mocker { ]) .execute() } + + async createMuteList(user: string, name: string): Promise { + const res = await this.users[user]?.agent.app.bsky.graph.list.create( + {repo: this.users[user]?.did}, + { + purpose: 'app.bsky.graph.defs#modlist', + name, + createdAt: new Date().toISOString(), + }, + ) + await this.users[user]?.agent.app.bsky.graph.muteActorList({ + list: res.uri, + }) + return res.uri + } + + async addToMuteList(owner: string, list: string, subject: string) { + await this.users[owner]?.agent.app.bsky.graph.listitem.create( + {repo: this.users[owner]?.did}, + { + list, + subject, + createdAt: new Date().toISOString(), + }, + ) + } } const checkAvailablePort = (port: number) => diff --git a/package.json b/package.json index 6445163a23..c8eb64997f 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "android": "expo run:android", "ios": "expo run:ios", "web": "expo start --web", - "build-web": "expo export:web && node ./scripts/post-web-build.js", + "build-web": "expo export:web && node ./scripts/post-web-build.js && cp --verbose ./web-build/static/js/*.* ./bskyweb/static/js/", "start": "expo start --dev-client", "clean-cache": "rm -rf node_modules/.cache/babel-loader/*", "test": "jest --forceExit --testTimeout=20000 --bail", @@ -22,7 +22,7 @@ "e2e:run": "detox test --configuration ios.sim.debug --take-screenshots all" }, "dependencies": { - "@atproto/api": "0.2.11", + "@atproto/api": "0.3.3", "@bam.tech/react-native-image-resizer": "^3.0.4", "@braintree/sanitize-url": "^6.0.2", "@expo/webpack-config": "^18.0.1", @@ -73,9 +73,9 @@ "expo-constants": "~14.2.1", "expo-dev-client": "~2.1.1", "expo-device": "~5.2.1", - "expo-image": "^1.2.1", + "expo-image": "^1.2.3", "expo-image-manipulator": "^11.1.1", - "expo-image-picker": "~14.1.1", + "expo-image-picker": "^14.1.1", "expo-localization": "~14.1.1", "expo-media-library": "~15.2.3", "expo-sharing": "~11.2.2", @@ -141,7 +141,7 @@ "zod": "^3.20.2" }, "devDependencies": { - "@atproto/pds": "^0.1.5", + "@atproto/pds": "^0.1.8", "@babel/core": "^7.20.0", "@babel/preset-env": "^7.20.0", "@babel/runtime": "^7.20.0", diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 17d80dfdc8..45ab439b65 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -33,11 +33,14 @@ import {useStores} from './state' import {HomeScreen} from './view/screens/Home' import {SearchScreen} from './view/screens/Search' import {NotificationsScreen} from './view/screens/Notifications' +import {ModerationScreen} from './view/screens/Moderation' +import {ModerationMuteListsScreen} from './view/screens/ModerationMuteLists' import {NotFoundScreen} from './view/screens/NotFound' import {SettingsScreen} from './view/screens/Settings' import {ProfileScreen} from './view/screens/Profile' import {ProfileFollowersScreen} from './view/screens/ProfileFollowers' import {ProfileFollowsScreen} from './view/screens/ProfileFollows' +import {ProfileListScreen} from './view/screens/ProfileList' import {PostThreadScreen} from './view/screens/PostThread' import {PostLikedByScreen} from './view/screens/PostLikedBy' import {PostRepostedByScreen} from './view/screens/PostRepostedBy' @@ -49,12 +52,13 @@ import {TermsOfServiceScreen} from './view/screens/TermsOfService' import {CommunityGuidelinesScreen} from './view/screens/CommunityGuidelines' import {CopyrightPolicyScreen} from './view/screens/CopyrightPolicy' import {AppPasswords} from 'view/screens/AppPasswords' -import {MutedAccounts} from 'view/screens/MutedAccounts' -import {BlockedAccounts} from 'view/screens/BlockedAccounts' +import {ModerationMutedAccounts} from 'view/screens/ModerationMutedAccounts' +import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts' import {getRoutingInstrumentation} from 'lib/sentry' import {SavedFeeds} from './view/screens/SavedFeeds' import {CustomFeed} from './view/screens/CustomFeed' import {PinnedFeeds} from 'view/screens/PinnedFeeds' +import {bskyTitle} from 'lib/strings/headings' const navigationRef = createNavigationContainerRef() @@ -69,36 +73,125 @@ const Tab = createBottomTabNavigator() /** * These "common screens" are reused across stacks. */ -function commonScreens(Stack: typeof HomeTab) { +function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { + const title = (page: string) => bskyTitle(page, unreadCountLabel) + return ( <> - - - + + + + + + + ({title: title(`@${route.params.name}`)})} + /> ({ + title: title(`People following @${route.params.name}`), + })} + /> + ({ + title: title(`People followed by @${route.params.name}`), + })} + /> + + ({title: title(`Post by @${route.params.name}`)})} + /> + ({title: title(`Post by @${route.params.name}`)})} + /> + ({title: title(`Post by @${route.params.name}`)})} + /> + + + + + - - - - - - - - - + + - - ) } @@ -210,8 +303,10 @@ const MyProfileTabNavigator = observer(() => { * The FlatNavigator is used by Web to represent the routes * in a single ("flat") stack. */ -function FlatNavigator() { +const FlatNavigator = observer(() => { const pal = usePalette('default') + const unreadCountLabel = useStores().me.notifications.unreadCountLabel + const title = (page: string) => bskyTitle(page, unreadCountLabel) return ( - - - - {commonScreens(Flat as typeof HomeTab)} + + + + {commonScreens(Flat as typeof HomeTab, unreadCountLabel)} ) -} +}) /** * The RoutesContainer should wrap all components which need access diff --git a/src/lib/constants.ts b/src/lib/constants.ts index df325b059b..6d0d4797b2 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -33,42 +33,50 @@ export function TEAM_HANDLES(serviceUrl: string) { } } +// NOTE +// this is a temporary list that we periodically update +// it is used in the search interface if the user doesn't follow anybody +// -prf export const PROD_SUGGESTED_FOLLOWS = [ - 'faithlove.art', - 'danielkoeth.bsky.social', 'bsky.app', 'jay.bsky.team', 'pfrazee.com', 'why.bsky.team', - 'support.bsky.team', + 'dholms.xyz', + 'emily.bsky.team', + 'rose.bsky.team', 'jack.bsky.social', - 'earthquake.bsky.social', + 'faithlove.art', + 'annaghughes.bsky.social', + 'astrokatie.com', + 'whysharksmatter.bsky.social', 'jamesgunn.bsky.social', 'seangunn.bsky.social', 'kumail.bsky.social', 'craignewmark.bsky.social', - 'grimes.bsky.social', 'xychelsea.tv', + 'catsofyore.bsky.social', 'mcq.bsky.social', 'mmasnick.bsky.social', - 'nitasha.bsky.social', - 'kenklippenstein.bsky.social', - 'jaypeters.bsky.social', - 'miyagawa.bsky.social', - 'anildash.com', - 'tiffani.bsky.social', 'kelseyhightower.com', 'aliafonzy.bsky.social', - 'tszzl.bsky.social', + 'bradfitz.com', 'danabramov.bsky.social', 'shinyakato.dev', 'karpathy.bsky.social', 'lookitup.baby', + 'pariss.blacktechpipeline.com', + 'swiftonsecurity.com', + 'ericajoy.astrel.la', + 'b0rk.jvns.ca', + 'vickiboykis.com', 'brooke.vibe.camp', - 'mollywhite.net', 'amir.blue', - 'zoink.bsky.social', 'moskov.bsky.social', + 'neilhimself.bsky.social', + 'kylierobison.com', + 'carnage4life.bsky.social', + 'lolennui.bsky.social', ] export const STAGING_SUGGESTED_FOLLOWS = ['arcalinea', 'paul', 'paul2'].map( handle => `${handle}.staging.bsky.dev`, diff --git a/src/lib/hooks/useSetTitle.ts b/src/lib/hooks/useSetTitle.ts new file mode 100644 index 0000000000..c5c7a5ca16 --- /dev/null +++ b/src/lib/hooks/useSetTitle.ts @@ -0,0 +1,20 @@ +import {useEffect} from 'react' +import {useNavigation} from '@react-navigation/native' + +import {NavigationProp} from 'lib/routes/types' +import {bskyTitle} from 'lib/strings/headings' +import {useStores} from 'state/index' + +/** + * Requires consuming component to be wrapped in `observer`: + * https://stackoverflow.com/a/71488009 + */ +export function useSetTitle(title?: string) { + const navigation = useNavigation() + const {unreadCountLabel} = useStores().me.notifications + useEffect(() => { + if (title) { + navigation.setOptions({title: bskyTitle(title, unreadCountLabel)}) + } + }, [title, navigation, unreadCountLabel]) +} diff --git a/src/lib/icons.tsx b/src/lib/icons.tsx index 960090ad73..0c7b7512a5 100644 --- a/src/lib/icons.tsx +++ b/src/lib/icons.tsx @@ -320,6 +320,35 @@ export function MoonIcon({ ) } +// Copyright (c) 2020 Refactoring UI Inc. +// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE +export function SunIcon({ + style, + size, + strokeWidth = 1.5, +}: { + style?: StyleProp + size?: string | number + strokeWidth?: number +}) { + return ( + + + + ) +} + // Copyright (c) 2020 Refactoring UI Inc. // https://github.com/tailwindlabs/heroicons/blob/master/LICENSE export function UserIcon({ @@ -828,3 +857,29 @@ export function InfoCircleIcon({ ) } + +export function HandIcon({ + style, + size, + strokeWidth = 1.5, +}: { + style?: StyleProp + size?: string | number + strokeWidth?: number +}) { + return ( + + + + + + ) +} diff --git a/src/lib/labeling/helpers.ts b/src/lib/labeling/helpers.ts index baac0ed5a6..447b0a99ae 100644 --- a/src/lib/labeling/helpers.ts +++ b/src/lib/labeling/helpers.ts @@ -1,5 +1,6 @@ import { AppBskyActorDefs, + AppBskyGraphDefs, AppBskyEmbedRecordWithMedia, AppBskyEmbedRecord, AppBskyEmbedImages, @@ -16,6 +17,7 @@ import { Label, LabelValGroup, ModerationBehaviorCode, + ModerationBehavior, PostModeration, ProfileModeration, PostLabelInfo, @@ -127,11 +129,15 @@ export function getPostModeration( // muting if (postInfo.isMuted) { + let msg = 'Post from an account you muted.' + if (postInfo.mutedByList) { + msg = `Muted by ${postInfo.mutedByList.name}` + } return { avatar, - list: hide('Post from an account you muted.'), - thread: warn('Post from an account you muted.'), - view: warn('Post from an account you muted.'), + list: isMute(hide(msg)), + thread: isMute(warn(msg)), + view: isMute(warn(msg)), } } @@ -273,6 +279,7 @@ export function getProfileViewBasicLabelInfo( profileLabels: filterProfileLabels(profile.labels), isMuted: profile.viewer?.muted || false, isBlocking: !!profile.viewer?.blocking || false, + isBlockedBy: !!profile.viewer?.blockedBy || false, } } @@ -302,6 +309,21 @@ export function getEmbedMuted(embed?: Embed): boolean { return false } +export function getEmbedMutedByList( + embed?: Embed, +): AppBskyGraphDefs.ListViewBasic | undefined { + if (!embed) { + return undefined + } + if ( + AppBskyEmbedRecord.isView(embed) && + AppBskyEmbedRecord.isViewRecord(embed.record) + ) { + return embed.record.author.viewer?.mutedByList + } + return undefined +} + export function getEmbedBlocking(embed?: Embed): boolean { if (!embed) { return false @@ -401,6 +423,11 @@ function warnContent(reason: string) { } } +function isMute(behavior: ModerationBehavior): ModerationBehavior { + behavior.isMute = true + return behavior +} + function warnImages(reason: string) { return { behavior: ModerationBehaviorCode.WarnImages, diff --git a/src/lib/labeling/types.ts b/src/lib/labeling/types.ts index 078043076e..1ee058024d 100644 --- a/src/lib/labeling/types.ts +++ b/src/lib/labeling/types.ts @@ -1,4 +1,4 @@ -import {ComAtprotoLabelDefs} from '@atproto/api' +import {ComAtprotoLabelDefs, AppBskyGraphDefs} from '@atproto/api' import {LabelPreferencesModel} from 'state/models/ui/preferences' export type Label = ComAtprotoLabelDefs.Label @@ -22,6 +22,7 @@ export interface PostLabelInfo { accountLabels: Label[] profileLabels: Label[] isMuted: boolean + mutedByList?: AppBskyGraphDefs.ListViewBasic isBlocking: boolean isBlockedBy: boolean } @@ -44,6 +45,7 @@ export enum ModerationBehaviorCode { export interface ModerationBehavior { behavior: ModerationBehaviorCode + isMute?: boolean noOverride?: boolean reason?: string } diff --git a/src/lib/media/picker.tsx b/src/lib/media/picker.tsx index af4a3e4d34..7c6dfcc44c 100644 --- a/src/lib/media/picker.tsx +++ b/src/lib/media/picker.tsx @@ -1,12 +1,16 @@ import { - openPicker as openPickerFn, openCamera as openCameraFn, openCropper as openCropperFn, - ImageOrVideo, + Image as RNImage, } from 'react-native-image-crop-picker' import {RootStoreModel} from 'state/index' -import {PickerOpts, CameraOpts, CropperOptions} from './types' -import {Image as RNImage} from 'react-native-image-crop-picker' +import {CameraOpts, CropperOptions} from './types' +import { + ImagePickerOptions, + launchImageLibraryAsync, + MediaTypeOptions, +} from 'expo-image-picker' +import {getDataUriSize} from './util' /** * NOTE @@ -19,27 +23,22 @@ import {Image as RNImage} from 'react-native-image-crop-picker' export async function openPicker( _store: RootStoreModel, - opts?: PickerOpts, -): Promise { - const items = await openPickerFn({ - mediaType: 'photo', // TODO: eventually add other media types - multiple: opts?.multiple, - maxFiles: opts?.maxFiles, - forceJpg: true, // ios only - compressImageQuality: 0.8, + opts?: ImagePickerOptions, +) { + const response = await launchImageLibraryAsync({ + exif: false, + mediaTypes: MediaTypeOptions.Images, + quality: 1, + ...opts, }) - const toMedia = (item: ImageOrVideo) => ({ - path: item.path, - mime: item.mime, - size: item.size, - width: item.width, - height: item.height, - }) - if (Array.isArray(items)) { - return items.map(toMedia) - } - return [toMedia(items)] + return (response.assets ?? []).map(image => ({ + mime: 'image/jpeg', + height: image.height, + width: image.width, + path: image.uri, + size: getDataUriSize(image.uri), + })) } export async function openCamera( @@ -55,6 +54,7 @@ export async function openCamera( forceJpg: true, // ios only compressImageQuality: 0.8, }) + return { path: item.path, mime: item.mime, @@ -67,11 +67,10 @@ export async function openCamera( export async function openCropper( _store: RootStoreModel, opts: CropperOptions, -): Promise { +) { const item = await openCropperFn({ ...opts, forceJpg: true, // ios only - compressImageQuality: 0.8, }) return { diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index b1dcbb999a..8b96aaad73 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -5,13 +5,19 @@ export type {NativeStackScreenProps} from '@react-navigation/native-stack' export type CommonNavigatorParams = { NotFound: undefined + Moderation: undefined + ModerationMuteLists: undefined + ModerationMutedAccounts: undefined + ModerationBlockedAccounts: undefined Settings: undefined Profile: {name: string; hideBackButton?: boolean} ProfileFollowers: {name: string} ProfileFollows: {name: string} + ProfileList: {name: string; rkey: string} PostThread: {name: string; rkey: string} PostLikedBy: {name: string; rkey: string} PostRepostedBy: {name: string; rkey: string} + CustomFeed: {name: string; rkey: string; displayName?: string} Debug: undefined Log: undefined Support: undefined @@ -22,9 +28,6 @@ export type CommonNavigatorParams = { AppPasswords: undefined SavedFeeds: undefined PinnedFeeds: undefined - CustomFeed: {name: string; rkey: string; displayName?: string} - MutedAccounts: undefined - BlockedAccounts: undefined } export type BottomTabNavigatorParams = CommonNavigatorParams & { diff --git a/src/lib/strings/display-names.ts b/src/lib/strings/display-names.ts index 555151b553..b98153732d 100644 --- a/src/lib/strings/display-names.ts +++ b/src/lib/strings/display-names.ts @@ -10,3 +10,18 @@ export function sanitizeDisplayName(str: string): string { } return '' } + +export function combinedDisplayName({ + handle, + displayName, +}: { + handle?: string + displayName?: string +}): string { + if (!handle) { + return '' + } + return displayName + ? `${sanitizeDisplayName(displayName)} (@${handle})` + : `@${handle}` +} diff --git a/src/lib/strings/headings.ts b/src/lib/strings/headings.ts new file mode 100644 index 0000000000..a88a696458 --- /dev/null +++ b/src/lib/strings/headings.ts @@ -0,0 +1,4 @@ +export function bskyTitle(page: string, unreadCountLabel?: string) { + const unreadPrefix = unreadCountLabel ? `(${unreadCountLabel}) ` : '' + return `${unreadPrefix}${page} - Bluesky` +} diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts index 549587f743..a5412920e0 100644 --- a/src/lib/strings/url-helpers.ts +++ b/src/lib/strings/url-helpers.ts @@ -94,6 +94,15 @@ export function convertBskyAppUrlIfNeeded(url: string): string { return url } +export function listUriToHref(url: string): string { + try { + const {hostname, rkey} = new AtUri(url) + return `/profile/${hostname}/lists/${rkey}` + } catch { + return '' + } +} + export function getYoutubeVideoId(link: string): string | undefined { let url try { diff --git a/src/routes.ts b/src/routes.ts index 6998226c0d..7501e7abf9 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -5,20 +5,23 @@ export const router = new Router({ Search: '/search', Notifications: '/notifications', Settings: '/settings', + Moderation: '/moderation', + ModerationMuteLists: '/moderation/mute-lists', + ModerationMutedAccounts: '/moderation/muted-accounts', + ModerationBlockedAccounts: '/moderation/blocked-accounts', Profile: '/profile/:name', ProfileFollowers: '/profile/:name/followers', ProfileFollows: '/profile/:name/follows', + ProfileList: '/profile/:name/lists/:rkey', PostThread: '/profile/:name/post/:rkey', PostLikedBy: '/profile/:name/post/:rkey/liked-by', PostRepostedBy: '/profile/:name/post/:rkey/reposted-by', + CustomFeed: '/profile/:name/feed/:rkey', Debug: '/sys/debug', Log: '/sys/log', AppPasswords: '/settings/app-passwords', SavedFeeds: '/settings/saved-feeds', PinnedFeeds: '/settings/pinned-feeds', - CustomFeed: '/profile/:name/feed/:rkey', - MutedAccounts: '/settings/muted-accounts', - BlockedAccounts: '/settings/blocked-accounts', Support: '/support', PrivacyPolicy: '/support/privacy', TermsOfService: '/support/tos', diff --git a/src/state/models/content/list-membership.ts b/src/state/models/content/list-membership.ts new file mode 100644 index 0000000000..b4af4472b3 --- /dev/null +++ b/src/state/models/content/list-membership.ts @@ -0,0 +1,112 @@ +import {makeAutoObservable} from 'mobx' +import {AtUri, AppBskyGraphListitem} from '@atproto/api' +import {runInAction} from 'mobx' +import {RootStoreModel} from '../root-store' + +const PAGE_SIZE = 100 +interface Membership { + uri: string + value: AppBskyGraphListitem.Record +} + +export class ListMembershipModel { + // data + memberships: Membership[] = [] + + constructor(public rootStore: RootStoreModel, public subject: string) { + makeAutoObservable( + this, + { + rootStore: false, + }, + {autoBind: true}, + ) + } + + // public api + // = + + async fetch() { + // NOTE + // this approach to determining list membership is too inefficient to work at any scale + // it needs to be replaced with server side list membership queries + // -prf + let cursor + let records = [] + for (let i = 0; i < 100; i++) { + const res = await this.rootStore.agent.app.bsky.graph.listitem.list({ + repo: this.rootStore.me.did, + cursor, + limit: PAGE_SIZE, + }) + records = records.concat( + res.records.filter(record => record.value.subject === this.subject), + ) + cursor = res.cursor + if (!cursor) { + break + } + } + runInAction(() => { + this.memberships = records + }) + } + + getMembership(listUri: string) { + return this.memberships.find(m => m.value.list === listUri) + } + + isMember(listUri: string) { + return !!this.getMembership(listUri) + } + + async add(listUri: string) { + if (this.isMember(listUri)) { + return + } + const res = await this.rootStore.agent.app.bsky.graph.listitem.create( + { + repo: this.rootStore.me.did, + }, + { + subject: this.subject, + list: listUri, + createdAt: new Date().toISOString(), + }, + ) + const {rkey} = new AtUri(res.uri) + const record = await this.rootStore.agent.app.bsky.graph.listitem.get({ + repo: this.rootStore.me.did, + rkey, + }) + runInAction(() => { + this.memberships = this.memberships.concat([record]) + }) + } + + async remove(listUri: string) { + const membership = this.getMembership(listUri) + if (!membership) { + return + } + const {rkey} = new AtUri(membership.uri) + await this.rootStore.agent.app.bsky.graph.listitem.delete({ + repo: this.rootStore.me.did, + rkey, + }) + runInAction(() => { + this.memberships = this.memberships.filter(m => m.value.list !== listUri) + }) + } + + async updateTo(uris: string) { + for (const uri of uris) { + await this.add(uri) + } + for (const membership of this.memberships) { + if (!uris.includes(membership.value.list)) { + await this.remove(membership.value.list) + } + } + } +} diff --git a/src/state/models/content/list.ts b/src/state/models/content/list.ts new file mode 100644 index 0000000000..673ee9430d --- /dev/null +++ b/src/state/models/content/list.ts @@ -0,0 +1,257 @@ +import {makeAutoObservable} from 'mobx' +import { + AtUri, + AppBskyGraphGetList as GetList, + AppBskyGraphDefs as GraphDefs, + AppBskyGraphList, +} from '@atproto/api' +import {Image as RNImage} from 'react-native-image-crop-picker' +import {RootStoreModel} from '../root-store' +import * as apilib from 'lib/api/index' +import {cleanError} from 'lib/strings/errors' +import {bundleAsync} from 'lib/async/bundle' + +const PAGE_SIZE = 30 + +export class ListModel { + // state + isLoading = false + isRefreshing = false + hasLoaded = false + error = '' + loadMoreError = '' + hasMore = true + loadMoreCursor?: string + + // data + list: GraphDefs.ListView | null = null + items: GraphDefs.ListItemView[] = [] + + static async createModList( + rootStore: RootStoreModel, + { + name, + description, + avatar, + }: {name: string; description: string; avatar: RNImage | undefined}, + ) { + const record: AppBskyGraphList.Record = { + purpose: 'app.bsky.graph.defs#modlist', + name, + description, + avatar: undefined, + createdAt: new Date().toISOString(), + } + if (avatar) { + const blobRes = await apilib.uploadBlob( + rootStore, + avatar.path, + avatar.mime, + ) + record.avatar = blobRes.data.blob + } + const res = await rootStore.agent.app.bsky.graph.list.create( + { + repo: rootStore.me.did, + }, + record, + ) + await rootStore.agent.app.bsky.graph.muteActorList({list: res.uri}) + return res + } + + constructor(public rootStore: RootStoreModel, public uri: string) { + makeAutoObservable( + this, + { + rootStore: false, + }, + {autoBind: true}, + ) + } + + get hasContent() { + return this.items.length > 0 + } + + get hasError() { + return this.error !== '' + } + + get isEmpty() { + return this.hasLoaded && !this.hasContent + } + + get isOwner() { + return this.list?.creator.did === this.rootStore.me.did + } + + // public api + // = + + async refresh() { + return this.loadMore(true) + } + + loadMore = bundleAsync(async (replace: boolean = false) => { + if (!replace && !this.hasMore) { + return + } + this._xLoading(replace) + try { + const res = await this.rootStore.agent.app.bsky.graph.getList({ + list: this.uri, + limit: PAGE_SIZE, + cursor: replace ? undefined : this.loadMoreCursor, + }) + if (replace) { + this._replaceAll(res) + } else { + this._appendAll(res) + } + this._xIdle() + } catch (e: any) { + this._xIdle(replace ? e : undefined, !replace ? e : undefined) + } + }) + + async updateMetadata({ + name, + description, + avatar, + }: { + name: string + description: string + avatar: RNImage | null | undefined + }) { + if (!this.isOwner) { + throw new Error('Cannot edit this list') + } + + // get the current record + const {rkey} = new AtUri(this.uri) + const {value: record} = await this.rootStore.agent.app.bsky.graph.list.get({ + repo: this.rootStore.me.did, + rkey, + }) + + // update the fields + record.name = name + record.description = description + if (avatar) { + const blobRes = await apilib.uploadBlob( + this.rootStore, + avatar.path, + avatar.mime, + ) + record.avatar = blobRes.data.blob + } else if (avatar === null) { + record.avatar = undefined + } + return await this.rootStore.agent.com.atproto.repo.putRecord({ + repo: this.rootStore.me.did, + collection: 'app.bsky.graph.list', + rkey, + record, + }) + } + + async delete() { + // fetch all the listitem records that belong to this list + let cursor + let records = [] + for (let i = 0; i < 100; i++) { + const res = await this.rootStore.agent.app.bsky.graph.listitem.list({ + repo: this.rootStore.me.did, + cursor, + limit: PAGE_SIZE, + }) + records = records.concat( + res.records.filter(record => record.value.list === this.uri), + ) + cursor = res.cursor + if (!cursor) { + break + } + } + + // batch delete the list and listitem records + const createDel = (uri: string) => { + const urip = new AtUri(uri) + return { + $type: 'com.atproto.repo.applyWrites#delete', + collection: urip.collection, + rkey: urip.rkey, + } + } + await this.rootStore.agent.com.atproto.repo.applyWrites({ + repo: this.rootStore.me.did, + writes: [createDel(this.uri)].concat( + records.map(record => createDel(record.uri)), + ), + }) + } + + async subscribe() { + await this.rootStore.agent.app.bsky.graph.muteActorList({ + list: this.list.uri, + }) + await this.refresh() + } + + async unsubscribe() { + await this.rootStore.agent.app.bsky.graph.unmuteActorList({ + list: this.list.uri, + }) + await this.refresh() + } + + /** + * Attempt to load more again after a failure + */ + async retryLoadMore() { + this.loadMoreError = '' + this.hasMore = true + return this.loadMore() + } + + // state transitions + // = + + _xLoading(isRefreshing = false) { + this.isLoading = true + this.isRefreshing = isRefreshing + this.error = '' + } + + _xIdle(err?: any, loadMoreErr?: any) { + this.isLoading = false + this.isRefreshing = false + this.hasLoaded = true + this.error = cleanError(err) + this.loadMoreError = cleanError(loadMoreErr) + if (err) { + this.rootStore.log.error('Failed to fetch user items', err) + } + if (loadMoreErr) { + this.rootStore.log.error('Failed to fetch user items', loadMoreErr) + } + } + + // helper functions + // = + + _replaceAll(res: GetList.Response) { + this.items = [] + this._appendAll(res) + } + + _appendAll(res: GetList.Response) { + this.loadMoreCursor = res.data.cursor + this.hasMore = !!this.loadMoreCursor + this.list = res.data.list + this.items = this.items.concat( + res.data.items.map(item => ({...item, _reactKey: item.subject})), + ) + } +} diff --git a/src/state/models/content/post-thread.ts b/src/state/models/content/post-thread.ts index a0f75493ac..74a75d803c 100644 --- a/src/state/models/content/post-thread.ts +++ b/src/state/models/content/post-thread.ts @@ -14,6 +14,7 @@ import {PostLabelInfo, PostModeration} from 'lib/labeling/types' import { getEmbedLabels, getEmbedMuted, + getEmbedMutedByList, getEmbedBlocking, getEmbedBlockedBy, filterAccountLabels, @@ -70,6 +71,9 @@ export class PostThreadItemModel { this.post.author.viewer?.muted || getEmbedMuted(this.post.embed) || false, + mutedByList: + this.post.author.viewer?.mutedByList || + getEmbedMutedByList(this.post.embed), isBlocking: !!this.post.author.viewer?.blocking || getEmbedBlocking(this.post.embed) || diff --git a/src/state/models/content/profile.ts b/src/state/models/content/profile.ts index dddf488a3c..9d8378f795 100644 --- a/src/state/models/content/profile.ts +++ b/src/state/models/content/profile.ts @@ -2,6 +2,7 @@ import {makeAutoObservable, runInAction} from 'mobx' import { AtUri, ComAtprotoLabelDefs, + AppBskyGraphDefs, AppBskyActorGetProfile as GetProfile, AppBskyActorProfile, RichText, @@ -18,10 +19,9 @@ import { filterProfileLabels, } from 'lib/labeling/helpers' -export const ACTOR_TYPE_USER = 'app.bsky.system.actorUser' - export class ProfileViewerModel { muted?: boolean + mutedByList?: AppBskyGraphDefs.ListViewBasic following?: string followedBy?: string blockedBy?: boolean diff --git a/src/state/models/feeds/notifications.ts b/src/state/models/feeds/notifications.ts index 3ffd10b99e..73424f03ed 100644 --- a/src/state/models/feeds/notifications.ts +++ b/src/state/models/feeds/notifications.ts @@ -111,6 +111,7 @@ export class NotificationsFeedItemModel { addedInfo?.profileLabels || [], ), isMuted: this.author.viewer?.muted || addedInfo?.isMuted || false, + mutedByList: this.author.viewer?.mutedByList || addedInfo?.mutedByList, isBlocking: !!this.author.viewer?.blocking || addedInfo?.isBlocking || false, isBlockedBy: diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts index 7adc1cb1c9..dfd92b35c3 100644 --- a/src/state/models/feeds/posts.ts +++ b/src/state/models/feeds/posts.ts @@ -25,6 +25,7 @@ import {PostLabelInfo, PostModeration} from 'lib/labeling/types' import { getEmbedLabels, getEmbedMuted, + getEmbedMutedByList, getEmbedBlocking, getEmbedBlockedBy, getPostModeration, @@ -106,6 +107,9 @@ export class PostsFeedItemModel { this.post.author.viewer?.muted || getEmbedMuted(this.post.embed) || false, + mutedByList: + this.post.author.viewer?.mutedByList || + getEmbedMutedByList(this.post.embed), isBlocking: !!this.post.author.viewer?.blocking || getEmbedBlocking(this.post.embed) || diff --git a/src/state/models/lists/lists-list.ts b/src/state/models/lists/lists-list.ts new file mode 100644 index 0000000000..309ab0e032 --- /dev/null +++ b/src/state/models/lists/lists-list.ts @@ -0,0 +1,214 @@ +import {makeAutoObservable} from 'mobx' +import { + AppBskyGraphGetLists as GetLists, + AppBskyGraphGetListMutes as GetListMutes, + AppBskyGraphDefs as GraphDefs, +} from '@atproto/api' +import {RootStoreModel} from '../root-store' +import {cleanError} from 'lib/strings/errors' +import {bundleAsync} from 'lib/async/bundle' + +const PAGE_SIZE = 30 + +export class ListsListModel { + // state + isLoading = false + isRefreshing = false + hasLoaded = false + error = '' + loadMoreError = '' + hasMore = true + loadMoreCursor?: string + + // data + lists: GraphDefs.ListView[] = [] + + constructor( + public rootStore: RootStoreModel, + public source: 'my-modlists' | string, + ) { + makeAutoObservable( + this, + { + rootStore: false, + }, + {autoBind: true}, + ) + } + + get hasContent() { + return this.lists.length > 0 + } + + get hasError() { + return this.error !== '' + } + + get isEmpty() { + return this.hasLoaded && !this.hasContent + } + + // public api + // = + + async refresh() { + return this.loadMore(true) + } + + loadMore = bundleAsync(async (replace: boolean = false) => { + if (!replace && !this.hasMore) { + return + } + this._xLoading(replace) + try { + let res + if (this.source === 'my-modlists') { + res = { + success: true, + headers: {}, + data: { + subject: undefined, + lists: [], + }, + } + const [res1, res2] = await Promise.all([ + fetchAllUserLists(this.rootStore, this.rootStore.me.did), + fetchAllMyMuteLists(this.rootStore), + ]) + for (let list of res1.data.lists) { + if (list.purpose === 'app.bsky.graph.defs#modlist') { + res.data.lists.push(list) + } + } + for (let list of res2.data.lists) { + if ( + list.purpose === 'app.bsky.graph.defs#modlist' && + !res.data.lists.find(l => l.uri === list.uri) + ) { + res.data.lists.push(list) + } + } + } else { + res = await this.rootStore.agent.app.bsky.graph.getLists({ + actor: this.source, + limit: PAGE_SIZE, + cursor: replace ? undefined : this.loadMoreCursor, + }) + } + if (replace) { + this._replaceAll(res) + } else { + this._appendAll(res) + } + this._xIdle() + } catch (e: any) { + this._xIdle(replace ? e : undefined, !replace ? e : undefined) + } + }) + + /** + * Attempt to load more again after a failure + */ + async retryLoadMore() { + this.loadMoreError = '' + this.hasMore = true + return this.loadMore() + } + + // state transitions + // = + + _xLoading(isRefreshing = false) { + this.isLoading = true + this.isRefreshing = isRefreshing + this.error = '' + } + + _xIdle(err?: any, loadMoreErr?: any) { + this.isLoading = false + this.isRefreshing = false + this.hasLoaded = true + this.error = cleanError(err) + this.loadMoreError = cleanError(loadMoreErr) + if (err) { + this.rootStore.log.error('Failed to fetch user lists', err) + } + if (loadMoreErr) { + this.rootStore.log.error('Failed to fetch user lists', loadMoreErr) + } + } + + // helper functions + // = + + _replaceAll(res: GetLists.Response | GetListMutes.Response) { + this.lists = [] + this._appendAll(res) + } + + _appendAll(res: GetLists.Response | GetListMutes.Response) { + this.loadMoreCursor = res.data.cursor + this.hasMore = !!this.loadMoreCursor + this.lists = this.lists.concat( + res.data.lists.map(list => ({...list, _reactKey: list.uri})), + ) + } +} + +async function fetchAllUserLists( + store: RootStoreModel, + did: string, +): Promise { + let acc: GetLists.Response = { + success: true, + headers: {}, + data: { + subject: undefined, + lists: [], + }, + } + + let cursor + for (let i = 0; i < 100; i++) { + const res = await store.agent.app.bsky.graph.getLists({ + actor: did, + cursor, + limit: 50, + }) + cursor = res.data.cursor + acc.data.lists = acc.data.lists.concat(res.data.lists) + if (!cursor) { + break + } + } + + return acc +} + +async function fetchAllMyMuteLists( + store: RootStoreModel, +): Promise { + let acc: GetListMutes.Response = { + success: true, + headers: {}, + data: { + subject: undefined, + lists: [], + }, + } + + let cursor + for (let i = 0; i < 100; i++) { + const res = await store.agent.app.bsky.graph.getListMutes({ + cursor, + limit: 50, + }) + cursor = res.data.cursor + acc.data.lists = acc.data.lists.concat(res.data.lists) + if (!cursor) { + break + } + } + + return acc +} diff --git a/src/state/models/media/gallery.ts b/src/state/models/media/gallery.ts index 86bf8a314b..67f8d2ea1e 100644 --- a/src/state/models/media/gallery.ts +++ b/src/state/models/media/gallery.ts @@ -52,16 +52,14 @@ export class GalleryModel { } async edit(image: ImageModel) { - if (!isNative) { + if (isNative) { + this.crop(image) + } else { this.rootStore.shell.openModal({ name: 'edit-image', image, gallery: this, }) - - return - } else { - this.crop(image) } } @@ -104,10 +102,14 @@ export class GalleryModel { async pick() { const images = await openPicker(this.rootStore, { - multiple: true, - maxFiles: 4 - this.images.length, + selectionLimit: 4 - this.size, + allowsMultipleSelection: true, }) - await Promise.all(images.map(image => this.add(image))) + return await Promise.all( + images.map(image => { + this.add(image) + }), + ) } } diff --git a/src/state/models/media/image.ts b/src/state/models/media/image.ts index ff464a5a9a..ec93bf5b65 100644 --- a/src/state/models/media/image.ts +++ b/src/state/models/media/image.ts @@ -13,12 +13,12 @@ import {compressAndResizeImageForPost} from 'lib/media/manip' // Cases to consider: ExternalEmbed export interface ImageManipulationAttributes { + aspectRatio?: '4:3' | '1:1' | '3:4' | 'None' rotate?: number scale?: number position?: Position flipHorizontal?: boolean flipVertical?: boolean - aspectRatio?: '4:3' | '1:1' | '3:4' | 'None' } export class ImageModel implements RNImage { @@ -34,14 +34,14 @@ export class ImageModel implements RNImage { scaledHeight: number = POST_IMG_MAX.height // Web manipulation - aspectRatio?: ImageManipulationAttributes['aspectRatio'] - position?: Position = undefined - prev?: RNImage = undefined - rotation?: number = 0 - scale?: number = 1 - flipHorizontal?: boolean = false - flipVertical?: boolean = false - + prev?: RNImage + attributes: ImageManipulationAttributes = { + aspectRatio: '1:1', + scale: 1, + flipHorizontal: false, + flipVertical: false, + rotate: 0, + } prevAttributes: ImageManipulationAttributes = {} constructor(public rootStore: RootStoreModel, image: RNImage) { @@ -65,6 +65,25 @@ export class ImageModel implements RNImage { // : MAX_IMAGE_SIZE_IN_BYTES / this.size // } + setRatio(aspectRatio: ImageManipulationAttributes['aspectRatio']) { + this.attributes.aspectRatio = aspectRatio + } + + setRotate(degrees: number) { + this.attributes.rotate = degrees + this.manipulate({}) + } + + flipVertical() { + this.attributes.flipVertical = !this.attributes.flipVertical + this.manipulate({}) + } + + flipHorizontal() { + this.attributes.flipHorizontal = !this.attributes.flipHorizontal + this.manipulate({}) + } + get ratioMultipliers() { return { '4:3': 4 / 3, @@ -116,7 +135,7 @@ export class ImageModel implements RNImage { // Only for mobile async crop() { try { - const cropped = await openCropper(this.rootStore, { + const cropped = await openCropper({ mediaType: 'photo', path: this.path, freeStyleCropEnabled: true, @@ -162,33 +181,19 @@ export class ImageModel implements RNImage { crop?: ActionCrop['crop'] } & ImageManipulationAttributes, ) { - const {aspectRatio, crop, flipHorizontal, flipVertical, rotate, scale} = - attributes + const {aspectRatio, crop, position, scale} = attributes const modifiers = [] - if (flipHorizontal !== undefined) { - this.flipHorizontal = flipHorizontal - } - - if (flipVertical !== undefined) { - this.flipVertical = flipVertical - } - - if (this.flipHorizontal) { + if (this.attributes.flipHorizontal) { modifiers.push({flip: FlipType.Horizontal}) } - if (this.flipVertical) { + if (this.attributes.flipVertical) { modifiers.push({flip: FlipType.Vertical}) } - // TODO: Fix rotation -- currently not functional - if (rotate !== undefined) { - this.rotation = rotate - } - - if (this.rotation !== undefined) { - modifiers.push({rotate: this.rotation}) + if (this.attributes.rotate !== undefined) { + modifiers.push({rotate: this.attributes.rotate}) } if (crop !== undefined) { @@ -203,18 +208,21 @@ export class ImageModel implements RNImage { } if (scale !== undefined) { - this.scale = scale + this.attributes.scale = scale + } + + if (position !== undefined) { + this.attributes.position = position } if (aspectRatio !== undefined) { - this.aspectRatio = aspectRatio + this.attributes.aspectRatio = aspectRatio } - const ratioMultiplier = this.ratioMultipliers[this.aspectRatio ?? '1:1'] + const ratioMultiplier = + this.ratioMultipliers[this.attributes.aspectRatio ?? '1:1'] - // TODO: Ollie - should support up to 2000 but smaller images that scale - // up need an updated compression factor calculation. Use 1000 for now. - const MAX_SIDE = 1000 + const MAX_SIDE = 2000 const result = await ImageManipulator.manipulateAsync( this.path, @@ -223,7 +231,7 @@ export class ImageModel implements RNImage { {resize: ratioMultiplier > 1 ? {width: MAX_SIDE} : {height: MAX_SIDE}}, ], { - compress: 0.7, // TODO: revisit compression calculation + compress: 0.9, format: SaveFormat.JPEG, }, ) @@ -238,16 +246,12 @@ export class ImageModel implements RNImage { }) } + resetCompressed() { + this.manipulate({}) + } + previous() { this.compressed = this.prev - - const {flipHorizontal, flipVertical, rotate, position, scale} = - this.prevAttributes - - this.scale = scale - this.rotation = rotate - this.flipHorizontal = flipHorizontal - this.flipVertical = flipVertical - this.position = position + this.attributes = this.prevAttributes } } diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index 8cd23efcd7..f2a352a794 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -37,7 +37,7 @@ export class RootStoreModel { log = new LogModel() session = new SessionModel(this) shell = new ShellUiModel(this) - preferences = new PreferencesModel() + preferences = new PreferencesModel(this) me = new MeModel(this) invitedUsers = new InvitedUsers(this) profiles = new ProfilesCache(this) @@ -126,6 +126,7 @@ export class RootStoreModel { this.log.debug('RootStoreModel:handleSessionChange') this.agent = agent this.me.clear() + /* dont await */ this.preferences.sync() await this.me.load() if (!hadSession) { resetNavigation() @@ -161,6 +162,7 @@ export class RootStoreModel { } try { await this.me.updateIfNeeded() + await this.preferences.sync() } catch (e: any) { this.log.error('Failed to fetch latest state', e) } diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts index fcd33af8e9..1471420fc1 100644 --- a/src/state/models/ui/preferences.ts +++ b/src/state/models/ui/preferences.ts @@ -1,7 +1,8 @@ -import {makeAutoObservable} from 'mobx' +import {makeAutoObservable, runInAction} from 'mobx' import {getLocales} from 'expo-localization' import {isObj, hasProp} from 'lib/type-guards' -import {ComAtprotoLabelDefs} from '@atproto/api' +import {RootStoreModel} from '../root-store' +import {ComAtprotoLabelDefs, AppBskyActorDefs} from '@atproto/api' import {LabelValGroup} from 'lib/labeling/types' import {getLabelValueGroup} from 'lib/labeling/helpers' import { @@ -15,6 +16,15 @@ import {isIOS} from 'platform/detection' const deviceLocales = getLocales() export type LabelPreference = 'show' | 'warn' | 'hide' +const LABEL_GROUPS = [ + 'nsfw', + 'nudity', + 'suggestive', + 'gore', + 'hate', + 'spam', + 'impersonation', +] export class LabelPreferencesModel { nsfw: LabelPreference = 'hide' @@ -36,7 +46,7 @@ export class PreferencesModel { deviceLocales?.map?.(locale => locale.languageCode) || [] contentLabels = new LabelPreferencesModel() - constructor() { + constructor(public rootStore: RootStoreModel) { makeAutoObservable(this, {}, {autoBind: true}) } @@ -65,6 +75,35 @@ export class PreferencesModel { } } + async sync() { + const res = await this.rootStore.agent.app.bsky.actor.getPreferences({}) + runInAction(() => { + for (const pref of res.data.preferences) { + if ( + AppBskyActorDefs.isAdultContentPref(pref) && + AppBskyActorDefs.validateAdultContentPref(pref).success + ) { + this.adultContentEnabled = pref.enabled + } else if ( + AppBskyActorDefs.isContentLabelPref(pref) && + AppBskyActorDefs.validateAdultContentPref(pref).success + ) { + if (LABEL_GROUPS.includes(pref.label)) { + this.contentLabels[pref.label] = pref.visibility + } + } + } + }) + } + + async update(cb: (prefs: AppBskyActorDefs.Preferences) => void) { + const res = await this.rootStore.agent.app.bsky.actor.getPreferences({}) + cb(res.data.preferences) + await this.rootStore.agent.app.bsky.actor.putPreferences({ + preferences: res.data.preferences, + }) + } + hasContentLanguage(code2: string) { return this.contentLanguages.includes(code2) } @@ -79,11 +118,48 @@ export class PreferencesModel { } } - setContentLabelPref( + async setContentLabelPref( key: keyof LabelPreferencesModel, value: LabelPreference, ) { this.contentLabels[key] = value + + await this.update((prefs: AppBskyActorDefs.Preferences) => { + const existing = prefs.find( + pref => + AppBskyActorDefs.isContentLabelPref(pref) && + AppBskyActorDefs.validateAdultContentPref(pref).success && + pref.label === key, + ) + if (existing) { + existing.visibility = value + } else { + prefs.push({ + $type: 'app.bsky.actor.defs#contentLabelPref', + label: key, + visibility: value, + }) + } + }) + } + + async setAdultContentEnabled(v: boolean) { + this.adultContentEnabled = v + await this.update((prefs: AppBskyActorDefs.Preferences) => { + const existing = prefs.find( + pref => + AppBskyActorDefs.isAdultContentPref(pref) && + AppBskyActorDefs.validateAdultContentPref(pref).success, + ) + if (existing) { + existing.enabled = v + } else { + prefs.push({ + $type: 'app.bsky.actor.defs#adultContentPref', + enabled: v, + }) + } + }) } getLabelPreference(labels: ComAtprotoLabelDefs.Label[] | undefined): { diff --git a/src/state/models/ui/profile.ts b/src/state/models/ui/profile.ts index 855955d126..4f604bfc0b 100644 --- a/src/state/models/ui/profile.ts +++ b/src/state/models/ui/profile.ts @@ -1,20 +1,23 @@ import {makeAutoObservable} from 'mobx' +import {AppBskyFeedDefs} from '@atproto/api' import {RootStoreModel} from '../root-store' import {ProfileModel} from '../content/profile' import {PostsFeedModel} from '../feeds/posts' import {ActorFeedsModel} from '../feeds/algo/actor' -import {AppBskyFeedDefs} from '@atproto/api' +import {ListsListModel} from '../lists/lists-list' export enum Sections { Posts = 'Posts', PostsWithReplies = 'Posts & replies', CustomAlgorithms = 'Algos', + Lists = 'Lists', } const USER_SELECTOR_ITEMS = [ Sections.Posts, Sections.PostsWithReplies, Sections.CustomAlgorithms, + Sections.Lists, ] export interface ProfileUiParams { @@ -30,6 +33,7 @@ export class ProfileUiModel { profile: ProfileModel feed: PostsFeedModel algos: ActorFeedsModel + lists: ListsListModel // ui state selectedViewIndex = 0 @@ -52,14 +56,17 @@ export class ProfileUiModel { limit: 10, }) this.algos = new ActorFeedsModel(rootStore, {actor: params.user}) + this.lists = new ListsListModel(rootStore, params.user) } - get currentView(): PostsFeedModel | ActorFeedsModel { + get currentView(): PostsFeedModel | ActorFeedsModel | ListsListModel { if ( this.selectedView === Sections.Posts || this.selectedView === Sections.PostsWithReplies ) { return this.feed + } else if (this.selectedView === Sections.Lists) { + return this.lists } if (this.selectedView === Sections.CustomAlgorithms) { return this.algos @@ -121,6 +128,12 @@ export class ProfileUiModel { } else if (this.feed.isEmpty) { arr = arr.concat([ProfileUiModel.EMPTY_ITEM]) } + } else if (this.selectedView === Sections.Lists) { + if (this.lists.hasContent) { + arr = this.lists.lists + } else if (this.lists.isEmpty) { + arr = arr.concat([ProfileUiModel.EMPTY_ITEM]) + } } else { // fallback, add empty item, to show empty message arr = arr.concat([ProfileUiModel.EMPTY_ITEM]) @@ -135,6 +148,8 @@ export class ProfileUiModel { this.selectedView === Sections.PostsWithReplies ) { return this.feed.hasContent && this.feed.hasMore && this.feed.isLoading + } else if (this.selectedView === Sections.Lists) { + return this.lists.hasContent && this.lists.hasMore && this.lists.isLoading } return false } @@ -155,6 +170,11 @@ export class ProfileUiModel { .setup() .catch(err => this.rootStore.log.error('Failed to fetch feed', err)), ]) + // HACK: need to use the DID as a param, not the username -prf + this.lists.source = this.profile.did + this.lists + .loadMore() + .catch(err => this.rootStore.log.error('Failed to fetch lists', err)) } async update() { diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts index 67f8e16d49..9b9a176bed 100644 --- a/src/state/models/ui/shell.ts +++ b/src/state/models/ui/shell.ts @@ -5,6 +5,7 @@ import {ProfileModel} from '../content/profile' import {isObj, hasProp} from 'lib/type-guards' import {Image as RNImage} from 'react-native-image-crop-picker' import {ImageModel} from '../media/image' +import {ListModel} from '../content/list' import {GalleryModel} from '../media/gallery' export interface ConfirmModal { @@ -38,6 +39,19 @@ export interface ReportAccountModal { did: string } +export interface CreateOrEditMuteListModal { + name: 'create-or-edit-mute-list' + list?: ListModel + onSave?: (uri: string) => void +} + +export interface ListAddRemoveUserModal { + name: 'list-add-remove-user' + subject: string + displayName: string + onUpdate?: () => void +} + export interface EditImageModal { name: 'edit-image' image: ImageModel @@ -102,9 +116,11 @@ export type Modal = | ContentFilteringSettingsModal | ContentLanguagesSettingsModal - // Reporting + // Moderation | ReportAccountModal | ReportPostModal + | CreateMuteListModal + | ListAddRemoveUserModal // Posts | AltTextImageModal diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx index accd968035..436824952a 100644 --- a/src/view/com/composer/photos/Gallery.tsx +++ b/src/view/com/composer/photos/Gallery.tsx @@ -61,7 +61,6 @@ export const Gallery = observer(function ({gallery}: Props) { borderRadius: 5, paddingHorizontal: 10, position: 'absolute' as const, - width: 46, zIndex: 1, ...(isOverflow ? { @@ -112,11 +111,11 @@ export const Gallery = observer(function ({gallery}: Props) { testID="altTextButton" accessibilityRole="button" accessibilityLabel="Add alt text" - accessibilityHint="Opens modal for inputting image alt text" + accessibilityHint="" onPress={() => { handleAddImageAltText(image) }} - style={[styles.imageControl, imageControlLabelStyle]}> + style={imageControlLabelStyle}> ALT @@ -187,9 +186,14 @@ const styles = StyleSheet.create({ justifyContent: 'center', }, imageControlTextContent: { + borderRadius: 6, color: 'white', fontSize: 12, fontWeight: 'bold', letterSpacing: 1, + backgroundColor: 'rgba(0, 0, 0, 0.75)', + borderWidth: 0.5, + paddingHorizontal: 10, + paddingVertical: 3, }, }) diff --git a/src/view/com/composer/text-input/web/Autocomplete.tsx b/src/view/com/composer/text-input/web/Autocomplete.tsx index 475ec119b2..87820b97bd 100644 --- a/src/view/com/composer/text-input/web/Autocomplete.tsx +++ b/src/view/com/composer/text-input/web/Autocomplete.tsx @@ -4,7 +4,7 @@ import React, { useImperativeHandle, useState, } from 'react' -import {StyleSheet, View} from 'react-native' +import {Pressable, StyleSheet, View} from 'react-native' import {ReactRenderer} from '@tiptap/react' import tippy, {Instance as TippyInstance} from 'tippy.js' import { @@ -158,7 +158,7 @@ const MentionList = forwardRef( const isSelected = selectedIndex === index return ( - ( : index === items.length - 1 ? styles.lastMention : undefined, - ]}> + ]} + onPress={() => { + selectItem(index) + }} + accessibilityRole="button"> @@ -179,7 +183,7 @@ const MentionList = forwardRef( @{item.handle} - + ) }) ) : ( diff --git a/src/view/com/lightbox/Lightbox.web.tsx b/src/view/com/lightbox/Lightbox.web.tsx index eff9af2d22..d389279b1e 100644 --- a/src/view/com/lightbox/Lightbox.web.tsx +++ b/src/view/com/lightbox/Lightbox.web.tsx @@ -21,6 +21,9 @@ interface Img { export const Lightbox = observer(function Lightbox() { const store = useStores() + + const onClose = useCallback(() => store.shell.closeLightbox(), [store.shell]) + if (!store.shell.isLightboxActive) { return null } @@ -29,8 +32,6 @@ export const Lightbox = observer(function Lightbox() { const initialIndex = activeLightbox instanceof models.ImagesLightbox ? activeLightbox.index : 0 - const onClose = () => store.shell.closeLightbox() - let imgs: Img[] | undefined if (activeLightbox instanceof models.ProfileImageLightbox) { const opts = activeLightbox diff --git a/src/view/com/lists/ListCard.tsx b/src/view/com/lists/ListCard.tsx new file mode 100644 index 0000000000..7cbdaaf648 --- /dev/null +++ b/src/view/com/lists/ListCard.tsx @@ -0,0 +1,155 @@ +import React from 'react' +import {StyleSheet, View} from 'react-native' +import {AtUri, AppBskyGraphDefs, RichText} from '@atproto/api' +import {Link} from '../util/Link' +import {Text} from '../util/text/Text' +import {RichText as RichTextCom} from '../util/text/RichText' +import {UserAvatar} from '../util/UserAvatar' +import {s} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {useStores} from 'state/index' +import {sanitizeDisplayName} from 'lib/strings/display-names' + +export const ListCard = ({ + testID, + list, + noBg, + noBorder, + renderButton, +}: { + testID?: string + list: AppBskyGraphDefs.ListView + noBg?: boolean + noBorder?: boolean + renderButton?: () => JSX.Element +}) => { + const pal = usePalette('default') + const store = useStores() + + const rkey = React.useMemo(() => { + try { + const urip = new AtUri(list.uri) + return urip.rkey + } catch { + return '' + } + }, [list]) + + const descriptionRichText = React.useMemo(() => { + if (list.description) { + return new RichText({ + text: list.description, + facets: list.descriptionFacets, + }) + } + return undefined + }, [list]) + + return ( + + + + + + + + {sanitizeDisplayName(list.name)} + + + {list.purpose === 'app.bsky.graph.defs#modlist' && 'Mute list'} by{' '} + {list.creator.did === store.me.did + ? 'you' + : `@${list.creator.handle}`} + + {!!list.viewer?.muted && ( + + + + Subscribed + + + + )} + + {renderButton ? ( + {renderButton()} + ) : undefined} + + {descriptionRichText ? ( + + + + ) : undefined} + + ) +} + +const styles = StyleSheet.create({ + outer: { + borderTopWidth: 1, + paddingHorizontal: 6, + }, + outerNoBorder: { + borderTopWidth: 0, + }, + layout: { + flexDirection: 'row', + alignItems: 'center', + }, + layoutAvi: { + width: 54, + paddingLeft: 4, + paddingTop: 8, + paddingBottom: 10, + }, + avi: { + width: 40, + height: 40, + borderRadius: 20, + resizeMode: 'cover', + }, + layoutContent: { + flex: 1, + paddingRight: 10, + paddingTop: 10, + paddingBottom: 10, + }, + layoutButton: { + paddingRight: 10, + }, + details: { + paddingLeft: 54, + paddingRight: 10, + paddingBottom: 10, + }, + pill: { + borderRadius: 4, + paddingHorizontal: 6, + paddingVertical: 2, + }, + btn: { + paddingVertical: 7, + borderRadius: 50, + marginLeft: 6, + paddingHorizontal: 14, + }, +}) diff --git a/src/view/com/lists/ListItems.tsx b/src/view/com/lists/ListItems.tsx new file mode 100644 index 0000000000..32cafdb835 --- /dev/null +++ b/src/view/com/lists/ListItems.tsx @@ -0,0 +1,387 @@ +import React, {MutableRefObject} from 'react' +import { + ActivityIndicator, + RefreshControl, + StyleProp, + StyleSheet, + View, + ViewStyle, +} from 'react-native' +import {AppBskyActorDefs, AppBskyGraphDefs, RichText} from '@atproto/api' +import {observer} from 'mobx-react-lite' +import {FlatList} from '../util/Views' +import {ProfileCardFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' +import {ErrorMessage} from '../util/error/ErrorMessage' +import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' +import {ProfileCard} from '../profile/ProfileCard' +import {Button} from '../util/forms/Button' +import {Text} from '../util/text/Text' +import {RichText as RichTextCom} from '../util/text/RichText' +import {UserAvatar} from '../util/UserAvatar' +import {TextLink} from '../util/Link' +import {ListModel} from 'state/models/content/list' +import {useAnalytics} from 'lib/analytics' +import {usePalette} from 'lib/hooks/usePalette' +import {useStores} from 'state/index' +import {s} from 'lib/styles' +import {isDesktopWeb} from 'platform/detection' + +const LOADING_ITEM = {_reactKey: '__loading__'} +const HEADER_ITEM = {_reactKey: '__header__'} +const EMPTY_ITEM = {_reactKey: '__empty__'} +const ERROR_ITEM = {_reactKey: '__error__'} +const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} + +export const ListItems = observer( + ({ + list, + style, + scrollElRef, + onPressTryAgain, + onToggleSubscribed, + onPressEditList, + onPressDeleteList, + renderEmptyState, + testID, + headerOffset = 0, + }: { + list: ListModel + style?: StyleProp + scrollElRef?: MutableRefObject | null> + onPressTryAgain?: () => void + onToggleSubscribed?: () => void + onPressEditList?: () => void + onPressDeleteList?: () => void + renderEmptyState?: () => JSX.Element + testID?: string + headerOffset?: number + }) => { + const pal = usePalette('default') + const store = useStores() + const {track} = useAnalytics() + const [isRefreshing, setIsRefreshing] = React.useState(false) + + const data = React.useMemo(() => { + let items: any[] = [HEADER_ITEM] + if (list.hasLoaded) { + if (list.hasError) { + items = items.concat([ERROR_ITEM]) + } + if (list.isEmpty) { + items = items.concat([EMPTY_ITEM]) + } else { + items = items.concat(list.items) + } + if (list.loadMoreError) { + items = items.concat([LOAD_MORE_ERROR_ITEM]) + } + } else if (list.isLoading) { + items = items.concat([LOADING_ITEM]) + } + return items + }, [ + list.hasError, + list.hasLoaded, + list.isLoading, + list.isEmpty, + list.items, + list.loadMoreError, + ]) + + // events + // = + + const onRefresh = React.useCallback(async () => { + track('Lists:onRefresh') + setIsRefreshing(true) + try { + await list.refresh() + } catch (err) { + list.rootStore.log.error('Failed to refresh lists', err) + } + setIsRefreshing(false) + }, [list, track, setIsRefreshing]) + + const onEndReached = React.useCallback(async () => { + track('Lists:onEndReached') + try { + await list.loadMore() + } catch (err) { + list.rootStore.log.error('Failed to load more lists', err) + } + }, [list, track]) + + const onPressRetryLoadMore = React.useCallback(() => { + list.retryLoadMore() + }, [list]) + + const onPressEditMembership = React.useCallback( + (profile: AppBskyActorDefs.ProfileViewBasic) => { + store.shell.openModal({ + name: 'list-add-remove-user', + subject: profile.did, + displayName: profile.displayName || profile.handle, + onUpdate() { + list.refresh() + }, + }) + }, + [store, list], + ) + + // rendering + // = + + const renderMemberButton = React.useCallback( + (profile: AppBskyActorDefs.ProfileViewBasic) => { + if (!list.isOwner) { + return null + } + return ( + + + ) +} + +const styles = StyleSheet.create({ + createNewContainer: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 18, + paddingTop: 18, + paddingBottom: 16, + }, + createNewButton: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + feedFooter: {paddingTop: 20}, +}) diff --git a/src/view/com/modals/ChangeHandle.tsx b/src/view/com/modals/ChangeHandle.tsx index ad7ff5a6d5..a5c74bc0bc 100644 --- a/src/view/com/modals/ChangeHandle.tsx +++ b/src/view/com/modals/ChangeHandle.tsx @@ -144,8 +144,11 @@ export function Component({onChanged}: {onChanged: () => void}) { - - Change my handle + + Change handle {isProcessing ? ( diff --git a/src/view/com/modals/ContentFilteringSettings.tsx b/src/view/com/modals/ContentFilteringSettings.tsx index 30b465562c..1256bd4209 100644 --- a/src/view/com/modals/ContentFilteringSettings.tsx +++ b/src/view/com/modals/ContentFilteringSettings.tsx @@ -7,23 +7,66 @@ import {useStores} from 'state/index' import {LabelPreference} from 'state/models/ui/preferences' import {s, colors, gradients} from 'lib/styles' import {Text} from '../util/text/Text' +import {TextLink} from '../util/Link' +import {ToggleButton} from '../util/forms/ToggleButton' import {usePalette} from 'lib/hooks/usePalette' import {CONFIGURABLE_LABEL_GROUPS} from 'lib/labeling/const' -import {isDesktopWeb} from 'platform/detection' +import {isDesktopWeb, isIOS} from 'platform/detection' +import * as Toast from '../util/Toast' export const snapPoints = ['90%'] -export function Component({}: {}) { +export const Component = observer(({}: {}) => { const store = useStores() const pal = usePalette('default') + + React.useEffect(() => { + store.preferences.sync() + }, [store]) + + const onToggleAdultContent = React.useCallback(async () => { + if (isIOS) { + return + } + try { + await store.preferences.setAdultContentEnabled( + !store.preferences.adultContentEnabled, + ) + } catch (e) { + Toast.show('There was an issue syncing your preferences with the server') + store.log.error('Failed to update preferences with server', {e}) + } + }, [store]) + const onPressDone = React.useCallback(() => { store.shell.closeModal() }, [store]) return ( - - Content Moderation + + Content Filtering + + {isIOS ? ( + + Adult content can only be enabled via the Web at{' '} + + . + + ) : ( + + )} + ) -} +}) // TODO: Refactor this component to pass labels down to each tab const ContentLabelPref = observer( @@ -76,6 +119,21 @@ const ContentLabelPref = observer( }) => { const store = useStores() const pal = usePalette('default') + + const onChange = React.useCallback( + async (v: LabelPreference) => { + try { + await store.preferences.setContentLabelPref(group, v) + } catch (e) { + Toast.show( + 'There was an issue syncing your preferences with the server', + ) + store.log.error('Failed to update preferences with server', {e}) + } + }, + [store, group], + ) + return ( @@ -95,7 +153,7 @@ const ContentLabelPref = observer( ) : ( store.preferences.setContentLabelPref(group, v)} + onChange={onChange} group={group} /> )} @@ -250,4 +308,7 @@ const styles = StyleSheet.create({ padding: 14, backgroundColor: colors.gray1, }, + toggleBtn: { + paddingHorizontal: 0, + }, }) diff --git a/src/view/com/modals/CreateOrEditMuteList.tsx b/src/view/com/modals/CreateOrEditMuteList.tsx new file mode 100644 index 0000000000..0c13f243aa --- /dev/null +++ b/src/view/com/modals/CreateOrEditMuteList.tsx @@ -0,0 +1,279 @@ +import React, {useState, useCallback} from 'react' +import * as Toast from '../util/Toast' +import { + ActivityIndicator, + KeyboardAvoidingView, + ScrollView, + StyleSheet, + TextInput, + TouchableOpacity, + View, +} from 'react-native' +import LinearGradient from 'react-native-linear-gradient' +import {Image as RNImage} from 'react-native-image-crop-picker' +import {Text} from '../util/text/Text' +import {ErrorMessage} from '../util/error/ErrorMessage' +import {useStores} from 'state/index' +import {ListModel} from 'state/models/content/list' +import {s, colors, gradients} from 'lib/styles' +import {enforceLen} from 'lib/strings/helpers' +import {compressIfNeeded} from 'lib/media/manip' +import {UserAvatar} from '../util/UserAvatar' +import {usePalette} from 'lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' +import {useAnalytics} from 'lib/analytics' +import {cleanError, isNetworkError} from 'lib/strings/errors' +import {isDesktopWeb} from 'platform/detection' + +const MAX_NAME = 64 // todo +const MAX_DESCRIPTION = 300 // todo + +export const snapPoints = ['fullscreen'] + +export function Component({ + onSave, + list, +}: { + onSave?: (uri: string) => void + list?: ListModel +}) { + const store = useStores() + const [error, setError] = useState('') + const pal = usePalette('default') + const theme = useTheme() + const {track} = useAnalytics() + + const [isProcessing, setProcessing] = useState(false) + const [name, setName] = useState(list?.list.name || '') + const [description, setDescription] = useState( + list?.list.description || '', + ) + const [avatar, setAvatar] = useState(list?.list.avatar) + const [newAvatar, setNewAvatar] = useState() + + const onPressCancel = useCallback(() => { + store.shell.closeModal() + }, [store]) + + const onSelectNewAvatar = useCallback( + async (img: RNImage | null) => { + if (!img) { + setNewAvatar(null) + setAvatar(null) + return + } + track('CreateMuteList:AvatarSelected') + try { + const finalImg = await compressIfNeeded(img, 1000000) + setNewAvatar(finalImg) + setAvatar(finalImg.path) + } catch (e: any) { + setError(cleanError(e)) + } + }, + [track, setNewAvatar, setAvatar, setError], + ) + + const onPressSave = useCallback(async () => { + track('CreateMuteList:Save') + const nameTrimmed = name.trim() + if (!nameTrimmed) { + setError('Name is required') + return + } + setProcessing(true) + if (error) { + setError('') + } + try { + if (list) { + await list.updateMetadata({ + name: nameTrimmed, + description: description.trim(), + avatar: newAvatar, + }) + Toast.show('Mute list updated') + onSave?.(list.uri) + } else { + const res = await ListModel.createModList(store, { + name, + description, + avatar: newAvatar, + }) + Toast.show('Mute list created') + onSave?.(res.uri) + } + store.shell.closeModal() + } catch (e: any) { + if (isNetworkError(e)) { + setError( + 'Failed to create the mute list. Check your internet connection and try again.', + ) + } else { + setError(cleanError(e)) + } + } + setProcessing(false) + }, [ + track, + setProcessing, + setError, + error, + onSave, + store, + name, + description, + newAvatar, + list, + ]) + + return ( + + + + {list ? 'Edit Mute List' : 'New Mute List'} + + {error !== '' && ( + + + + )} + List Avatar + + + + + + + List Name + + setName(enforceLen(v, MAX_NAME))} + accessible={true} + accessibilityLabel="Name" + accessibilityHint="" + accessibilityLabelledBy="list-name" + /> + + + + Description + + setDescription(enforceLen(v, MAX_DESCRIPTION))} + accessible={true} + accessibilityLabel="Description" + accessibilityHint="" + accessibilityLabelledBy="list-description" + /> + + {isProcessing ? ( + + + + ) : ( + + + Save + + + )} + + + Cancel + + + + + + ) +} + +const styles = StyleSheet.create({ + container: { + paddingHorizontal: isDesktopWeb ? 0 : 16, + }, + title: { + textAlign: 'center', + fontWeight: 'bold', + fontSize: 24, + marginBottom: 18, + }, + label: { + fontWeight: 'bold', + paddingHorizontal: 4, + paddingBottom: 4, + marginTop: 20, + }, + form: { + paddingHorizontal: 6, + }, + textInput: { + borderWidth: 1, + borderRadius: 6, + paddingHorizontal: 14, + paddingVertical: 10, + fontSize: 16, + }, + textArea: { + borderWidth: 1, + borderRadius: 6, + paddingHorizontal: 12, + paddingTop: 10, + fontSize: 16, + height: 100, + textAlignVertical: 'top', + }, + btn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + width: '100%', + borderRadius: 32, + padding: 10, + marginBottom: 10, + }, + avi: { + width: 84, + height: 84, + borderWidth: 2, + borderRadius: 42, + marginTop: 4, + }, + errorContainer: {marginTop: 20}, +}) diff --git a/src/view/com/modals/EditImage.tsx b/src/view/com/modals/EditImage.tsx index 4a5d9bfded..eab472a782 100644 --- a/src/view/com/modals/EditImage.tsx +++ b/src/view/com/modals/EditImage.tsx @@ -18,148 +18,114 @@ import {Slider} from '@miblanchard/react-native-slider' import {MaterialIcons} from '@expo/vector-icons' import {observer} from 'mobx-react-lite' import {getKeys} from 'lib/type-assertions' +import {isDesktopWeb} from 'platform/detection' export const snapPoints = ['80%'] +const RATIOS = { + '4:3': { + Icon: RectWideIcon, + }, + '1:1': { + Icon: SquareIcon, + }, + '3:4': { + Icon: RectTallIcon, + }, + None: { + label: 'None', + Icon: MaterialIcons, + name: 'do-not-disturb-alt', + }, +} as const + +type AspectRatio = keyof typeof RATIOS + interface Props { image: ImageModel gallery: GalleryModel } -// This is only used for desktop web export const Component = observer(function ({image, gallery}: Props) { const pal = usePalette('default') - const store = useStores() - const {shell} = store const theme = useTheme() - const winDim = useWindowDimensions() + const store = useStores() + const windowDimensions = useWindowDimensions() - const [altText, setAltText] = useState(image.altText) - const [aspectRatio, setAspectRatio] = useState( - image.aspectRatio ?? 'None', - ) - const [flipHorizontal, setFlipHorizontal] = useState( - image.flipHorizontal ?? false, - ) - const [flipVertical, setFlipVertical] = useState( - image.flipVertical ?? false, - ) + const { + aspectRatio, + // rotate = 0 + } = image.attributes - // TODO: doesn't seem to be working correctly with crop - // const [rotation, setRotation] = useState(image.rotation ?? 0) - const [scale, setScale] = useState(image.scale ?? 1) - const [position, setPosition] = useState() - const [isEditing, setIsEditing] = useState(false) const editorRef = useRef(null) - - const imgEditorStyles = useMemo(() => { - const dim = Math.min(425, winDim.width - 24) - return {width: dim, height: dim} - }, [winDim.width]) - - const manipulationAttributes = useMemo( - () => ({ - // TODO: doesn't seem to be working correctly with crop - // ...(rotation !== undefined ? {rotate: rotation} : {}), - ...(flipHorizontal !== undefined ? {flipHorizontal} : {}), - ...(flipVertical !== undefined ? {flipVertical} : {}), - }), - [flipHorizontal, flipVertical], + const [scale, setScale] = useState(image.attributes.scale ?? 1) + const [position, setPosition] = useState( + image.attributes.position, ) - - useEffect(() => { - const manipulateImage = async () => { - await image.manipulate(manipulationAttributes) - } - - manipulateImage() - }, [image, manipulationAttributes]) - - const ratios = useMemo( - () => - ({ - '4:3': { - hint: 'Sets image aspect ratio to wide', - Icon: RectWideIcon, - }, - '1:1': { - hint: 'Sets image aspect ratio to square', - Icon: SquareIcon, - }, - '3:4': { - hint: 'Sets image aspect ratio to tall', - Icon: RectTallIcon, - }, - None: { - label: 'None', - hint: 'Sets image aspect ratio to tall', - Icon: MaterialIcons, - name: 'do-not-disturb-alt', - }, - } as const), - [], - ) - - type AspectRatio = keyof typeof ratios + const [altText, setAltText] = useState('') const onFlipHorizontal = useCallback(() => { - setFlipHorizontal(!flipHorizontal) - image.manipulate({flipHorizontal}) - }, [flipHorizontal, image]) + image.flipHorizontal() + }, [image]) const onFlipVertical = useCallback(() => { - setFlipVertical(!flipVertical) - image.manipulate({flipVertical}) - }, [flipVertical, image]) + image.flipVertical() + }, [image]) + + // const onSetRotate = useCallback( + // (direction: 'left' | 'right') => { + // const rotation = (rotate + 90 * (direction === 'left' ? -1 : 1)) % 360 + // image.setRotate(rotation) + // }, + // [rotate, image], + // ) + + const onSetRatio = useCallback( + (ratio: AspectRatio) => { + image.setRatio(ratio) + }, + [image], + ) const adjustments = useMemo( - () => - [ - // { - // name: 'rotate-left', - // label: 'Rotate left', - // hint: 'Rotate image left', - // onPress: () => { - // const rotate = (rotation - 90) % 360 - // setRotation(rotate) - // image.manipulate({rotate}) - // }, - // }, - // { - // name: 'rotate-right', - // label: 'Rotate right', - // hint: 'Rotate image right', - // onPress: () => { - // const rotate = (rotation + 90) % 360 - // setRotation(rotate) - // image.manipulate({rotate}) - // }, - // }, - { - name: 'flip', - label: 'Flip horizontal', - hint: 'Flip image horizontally', - onPress: onFlipHorizontal, - }, - { - name: 'flip', - label: 'Flip vertically', - hint: 'Flip image vertically', - onPress: onFlipVertical, - }, - ] as const, + () => [ + // { + // name: 'rotate-left' as const, + // label: 'Rotate left', + // onPress: () => { + // onSetRotate('left') + // }, + // }, + // { + // name: 'rotate-right' as const, + // label: 'Rotate right', + // onPress: () => { + // onSetRotate('right') + // }, + // }, + { + name: 'flip' as const, + label: 'Flip horizontal', + onPress: onFlipHorizontal, + }, + { + name: 'flip' as const, + label: 'Flip vertically', + onPress: onFlipVertical, + }, + ], [onFlipHorizontal, onFlipVertical], ) useEffect(() => { image.prev = image.compressed - setIsEditing(true) + image.prevAttributes = image.attributes + image.resetCompressed() }, [image]) const onCloseModal = useCallback(() => { - shell.closeModal() - setIsEditing(false) - }, [shell]) + store.shell.closeModal() + }, [store.shell]) const onPressCancel = useCallback(async () => { await gallery.previous(image) @@ -184,25 +150,12 @@ export const Component = observer(function ({image, gallery}: Props) { ...(position !== undefined ? {position} : {}), } : {}), - ...manipulationAttributes, - aspectRatio, }) - image.prevAttributes = manipulationAttributes + image.prev = image.compressed + image.prevAttributes = image.attributes onCloseModal() - }, [ - altText, - aspectRatio, - image, - manipulationAttributes, - position, - scale, - onCloseModal, - ]) - - const onPressRatio = useCallback((as: AspectRatio) => { - setAspectRatio(as) - }, []) + }, [altText, image, position, scale, onCloseModal]) const getLabelIconSize = useCallback((as: AspectRatio) => { switch (as) { @@ -220,40 +173,55 @@ export const Component = observer(function ({image, gallery}: Props) { return null } - const {width, height} = image.getDisplayDimensions( - aspectRatio, - imgEditorStyles.width, - ) + const computedWidth = + windowDimensions.width > 500 ? 410 : windowDimensions.width - 80 + const sideLength = isDesktopWeb ? 300 : computedWidth + + const dimensions = image.getDisplayDimensions(aspectRatio, sideLength) + const imgContainerStyles = {width: sideLength, height: sideLength} + + const imgControlStyles = { + alignItems: 'center' as const, + flexDirection: isDesktopWeb ? ('row' as const) : ('column' as const), + gap: isDesktopWeb ? 5 : 0, + } return ( Edit image - - - + + + + + + setScale(Array.isArray(v) ? v[0] : v) + } + minimumValue={1} + maximumValue={3} /> - - setScale(Array.isArray(v) ? v[0] : v) - } - minimumValue={1} - maximumValue={3} - /> - - - {getKeys(ratios).map(ratio => { - const {hint, Icon, ...props} = ratios[ratio] + + {isDesktopWeb ? ( + + Ratios + + ) : null} + + {getKeys(RATIOS).map(ratio => { + const {Icon, ...props} = RATIOS[ratio] const labelIconSize = getLabelIconSize(ratio) const isSelected = aspectRatio === ratio @@ -261,10 +229,10 @@ export const Component = observer(function ({image, gallery}: Props) { { - onPressRatio(ratio) + onSetRatio(ratio) }} accessibilityLabel={ratio} - accessibilityHint={hint}> + accessibilityHint=""> - - - {adjustments.map(({label, hint, name, onPress}) => ( + {isDesktopWeb ? ( + + Transformations + + ) : null} + + {adjustments.map(({label, name, onPress}) => ( - + + + Accessibility + setAltText(enforceLen(text, MAX_ALT_TEXT))} - placeholder="Image description" - placeholderTextColor={pal.colors.textLight} - accessibilityLabel="Image alt text" - accessibilityHint="Sets image alt text for screenreaders" - accessibilityLabelledBy="imageAltText" + accessibilityLabel="Alt text" + accessibilityHint="" + accessibilityLabelledBy="alt-text" /> @@ -345,30 +318,16 @@ export const Component = observer(function ({image, gallery}: Props) { const styles = StyleSheet.create({ container: { gap: 18, - paddingVertical: 18, - paddingHorizontal: 12, + paddingHorizontal: isDesktopWeb ? undefined : 16, height: '100%', width: '100%', }, - gap18: { - gap: 18, - }, - + subsection: {marginTop: 12}, + gap18: {gap: 18}, title: { fontWeight: 'bold', fontSize: 24, }, - - textArea: { - borderWidth: 1, - borderRadius: 6, - paddingTop: 10, - paddingHorizontal: 12, - fontSize: 16, - height: 100, - textAlignVertical: 'top', - }, - btns: { flexDirection: 'row', alignItems: 'center', @@ -379,28 +338,12 @@ const styles = StyleSheet.create({ paddingVertical: 8, paddingHorizontal: 24, }, - - verticalSep: { - borderLeftWidth: 1, - }, - - imgControls: { - flexDirection: 'row', - gap: 5, - }, imgControl: { display: 'flex', alignItems: 'center', justifyContent: 'center', height: 40, }, - flipVertical: { - transform: [{rotate: '90deg'}], - }, - flipBtn: { - paddingHorizontal: 4, - paddingVertical: 8, - }, imgEditor: { maxWidth: '100%', }, @@ -408,11 +351,29 @@ const styles = StyleSheet.create({ display: 'flex', alignItems: 'center', justifyContent: 'center', - height: 425, - width: 425, borderWidth: 1, - borderRadius: 8, borderStyle: 'solid', - overflow: 'hidden', + marginBottom: 4, + }, + flipVertical: { + transform: [{rotate: '90deg'}], + }, + flipBtn: { + paddingHorizontal: 4, + paddingVertical: 8, + }, + textArea: { + borderWidth: 1, + borderRadius: 6, + paddingTop: 10, + paddingHorizontal: 12, + fontSize: 16, + height: 100, + textAlignVertical: 'top', + maxHeight: isDesktopWeb ? undefined : 50, + }, + bottomSection: { + borderTopWidth: 1, + paddingTop: 18, }, }) diff --git a/src/view/com/modals/EditProfile.tsx b/src/view/com/modals/EditProfile.tsx index c26592fa98..f37a0f71ae 100644 --- a/src/view/com/modals/EditProfile.tsx +++ b/src/view/com/modals/EditProfile.tsx @@ -65,7 +65,7 @@ export function Component({ } const onSelectNewAvatar = useCallback( async (img: RNImage | null) => { - if (!img) { + if (img === null) { setNewUserAvatar(null) setUserAvatar(null) return @@ -81,6 +81,7 @@ export function Component({ }, [track, setNewUserAvatar, setUserAvatar, setError], ) + const onSelectNewBanner = useCallback( async (img: RNImage | null) => { if (!img) { @@ -99,6 +100,7 @@ export function Component({ }, [track, setNewUserBanner, setUserBanner, setError], ) + const onPressSave = useCallback(async () => { track('EditProfile:Save') setProcessing(true) diff --git a/src/view/com/modals/InviteCodes.tsx b/src/view/com/modals/InviteCodes.tsx index 52d6fa46a8..b3fe9dd3ff 100644 --- a/src/view/com/modals/InviteCodes.tsx +++ b/src/view/com/modals/InviteCodes.tsx @@ -57,7 +57,7 @@ export function Component({}: {}) { code works once! - ( You'll receive one invite code every two weeks. ) + (You'll receive one invite code every two weeks.) {store.me.invites.map((invite, i) => ( diff --git a/src/view/com/modals/ListAddRemoveUser.tsx b/src/view/com/modals/ListAddRemoveUser.tsx new file mode 100644 index 0000000000..91fe67c177 --- /dev/null +++ b/src/view/com/modals/ListAddRemoveUser.tsx @@ -0,0 +1,253 @@ +import React, {useCallback} from 'react' +import {observer} from 'mobx-react-lite' +import {Pressable, StyleSheet, View} from 'react-native' +import {AppBskyGraphDefs as GraphDefs} from '@atproto/api' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {Text} from '../util/text/Text' +import {UserAvatar} from '../util/UserAvatar' +import {ListsList} from '../lists/ListsList' +import {ListsListModel} from 'state/models/lists/lists-list' +import {ListMembershipModel} from 'state/models/content/list-membership' +import {EmptyStateWithButton} from '../util/EmptyStateWithButton' +import {Button} from '../util/forms/Button' +import * as Toast from '../util/Toast' +import {useStores} from 'state/index' +import {sanitizeDisplayName} from 'lib/strings/display-names' +import {s} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {isDesktopWeb, isAndroid} from 'platform/detection' + +export const snapPoints = ['fullscreen'] + +export const Component = observer( + ({ + subject, + displayName, + onUpdate, + }: { + subject: string + displayName: string + onUpdate?: () => void + }) => { + const store = useStores() + const pal = usePalette('default') + const palPrimary = usePalette('primary') + const palInverted = usePalette('inverted') + const [selected, setSelected] = React.useState([]) + + const listsList: ListsListModel = React.useMemo( + () => new ListsListModel(store, store.me.did), + [store], + ) + const memberships: ListMembershipModel = React.useMemo( + () => new ListMembershipModel(store, subject), + [store, subject], + ) + React.useEffect(() => { + listsList.refresh() + memberships.fetch().then( + () => { + setSelected(memberships.memberships.map(m => m.value.list)) + }, + err => { + store.log.error('Failed to fetch memberships', {err}) + }, + ) + }, [memberships, listsList, store, setSelected]) + + const onPressCancel = useCallback(() => { + store.shell.closeModal() + }, [store]) + + const onPressSave = useCallback(async () => { + try { + await memberships.updateTo(selected) + } catch (err) { + store.log.error('Failed to update memberships', {err}) + return + } + Toast.show('Lists updated') + onUpdate?.() + store.shell.closeModal() + }, [store, selected, memberships, onUpdate]) + + const onPressNewMuteList = useCallback(() => { + store.shell.openModal({ + name: 'create-or-edit-mute-list', + onSave: (_uri: string) => { + listsList.refresh() + }, + }) + }, [store, listsList]) + + const onToggleSelected = useCallback( + (uri: string) => { + if (selected.includes(uri)) { + setSelected(selected.filter(uri2 => uri2 !== uri)) + } else { + setSelected([...selected, uri]) + } + }, + [selected, setSelected], + ) + + const renderItem = useCallback( + (list: GraphDefs.ListView) => { + const isSelected = selected.includes(list.uri) + return ( + onToggleSelected(list.uri)}> + + + + + + {sanitizeDisplayName(list.name)} + + + {list.purpose === 'app.bsky.graph.defs#modlist' && 'Mute list'}{' '} + by{' '} + {list.creator.did === store.me.did + ? 'you' + : `@${list.creator.handle}`} + + + + {isSelected && ( + + )} + + + ) + }, + [pal, palPrimary, palInverted, onToggleSelected, selected, store.me.did], + ) + + const renderEmptyState = React.useCallback(() => { + return ( + + ) + }, [onPressNewMuteList]) + + return ( + + Add {displayName} to lists + + + + + + ) +} +const styles = StyleSheet.create({ + container: { + height: '100%', + paddingVertical: 40, + paddingHorizontal: 30, + }, + iconContainer: { + marginBottom: 16, + }, + icon: { + marginLeft: 'auto', + marginRight: 'auto', + }, + btns: { + flexDirection: 'row', + justifyContent: 'center', + }, + btn: { + gap: 10, + marginVertical: 20, + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 14, + paddingHorizontal: 24, + borderRadius: 30, + }, + notice: { + borderRadius: 12, + paddingHorizontal: 12, + paddingVertical: 10, + marginHorizontal: 30, + }, +}) diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index a2e607e474..f3679326fa 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -66,6 +66,7 @@ export function UserAvatar({ if (!(await requestCameraAccessIfNeeded())) { return } + onSelectNewAvatar?.( await openCamera(store, { width: 1000, @@ -83,20 +84,21 @@ export function UserAvatar({ if (!(await requestPhotoAccessIfNeeded())) { return } + const items = await openPicker(store, { + aspect: [1, 1], + }) + const item = items[0] + + const croppedImage = await openCropper(store, { mediaType: 'photo', - multiple: false, + cropperCircleOverlay: true, + height: item.height, + width: item.width, + path: item.path, }) - onSelectNewAvatar?.( - await openCropper(store, { - mediaType: 'photo', - path: items[0].path, - width: 1000, - height: 1000, - cropperCircleOverlay: true, - }), - ) + onSelectNewAvatar?.(croppedImage) }, }, { diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index 51cfbccbbc..6e08be505f 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -55,10 +55,8 @@ export function UserBanner({ if (!(await requestPhotoAccessIfNeeded())) { return } - const items = await openPicker(store, { - mediaType: 'photo', - multiple: false, - }) + const items = await openPicker(store) + onSelectNewBanner?.( await openCropper(store, { mediaType: 'photo', diff --git a/src/view/com/util/ViewHeader.tsx b/src/view/com/util/ViewHeader.tsx index 7f5b5b7c21..97802394e8 100644 --- a/src/view/com/util/ViewHeader.tsx +++ b/src/view/com/util/ViewHeader.tsx @@ -20,11 +20,13 @@ export const ViewHeader = observer(function ({ canGoBack, hideOnScroll, showOnDesktop, + renderButton, }: { title: string canGoBack?: boolean hideOnScroll?: boolean showOnDesktop?: boolean + renderButton?: () => JSX.Element }) { const pal = usePalette('default') const store = useStores() @@ -46,7 +48,7 @@ export const ViewHeader = observer(function ({ if (isDesktopWeb) { if (showOnDesktop) { - return + return } return null } else { @@ -79,13 +81,23 @@ export const ViewHeader = observer(function ({ {title} - + {renderButton ? ( + renderButton() + ) : ( + + )} ) } }) -function DesktopWebHeader({title}: {title: string}) { +function DesktopWebHeader({ + title, + renderButton, +}: { + title: string + renderButton?: () => JSX.Element +}) { const pal = usePalette('default') return ( @@ -94,6 +106,7 @@ function DesktopWebHeader({title}: {title: string}) { {title} + {renderButton?.()} ) } diff --git a/src/view/com/util/Views.web.tsx b/src/view/com/util/Views.web.tsx index 804192a37a..9d6501d543 100644 --- a/src/view/com/util/Views.web.tsx +++ b/src/view/com/util/Views.web.tsx @@ -22,7 +22,7 @@ import { View, ViewProps, } from 'react-native' -import {addStyle, colors} from 'lib/styles' +import {addStyle} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' interface AddedProps { @@ -124,12 +124,6 @@ const styles = StyleSheet.create({ marginLeft: 'auto', marginRight: 'auto', }, - containerLight: { - backgroundColor: colors.gray1, - }, - containerDark: { - backgroundColor: colors.gray7, - }, fixedHeight: { height: '100vh', }, diff --git a/src/view/com/util/forms/Button.tsx b/src/view/com/util/forms/Button.tsx index 1c9b1cf516..6a5f19f998 100644 --- a/src/view/com/util/forms/Button.tsx +++ b/src/view/com/util/forms/Button.tsx @@ -38,6 +38,7 @@ export function Button({ accessibilityLabel, accessibilityHint, accessibilityLabelledBy, + onAccessibilityEscape, }: React.PropsWithChildren<{ type?: ButtonType label?: string @@ -48,6 +49,7 @@ export function Button({ accessibilityLabel?: string accessibilityHint?: string accessibilityLabelledBy?: string + onAccessibilityEscape?: () => void }>) { const theme = useTheme() const typeOuterStyle = choose>( @@ -126,6 +128,7 @@ export function Button({ }, }, ) + const onPressWrapped = React.useCallback( (event: Event) => { event.stopPropagation() @@ -134,15 +137,30 @@ export function Button({ }, [onPress], ) + + const getStyle = React.useCallback( + state => { + const arr = [typeOuterStyle, styles.outer, style] + if (state.pressed) { + arr.push({opacity: 0.6}) + } else if (state.hovered) { + arr.push({opacity: 0.8}) + } + return arr + }, + [typeOuterStyle, style], + ) + return ( + accessibilityLabelledBy={accessibilityLabelledBy} + onAccessibilityEscape={onAccessibilityEscape}> {label ? ( {label} diff --git a/src/view/com/util/forms/DropdownButton.tsx b/src/view/com/util/forms/DropdownButton.tsx index 04346d91f4..36ef1f4098 100644 --- a/src/view/com/util/forms/DropdownButton.tsx +++ b/src/view/com/util/forms/DropdownButton.tsx @@ -209,7 +209,7 @@ export function PostDropdownBtn({ }, }, {sep: true}, - { + !isAuthor && { testID: 'postDropdownReportBtn', icon: 'circle-exclamation', label: 'Report post', @@ -339,7 +339,9 @@ const DropdownItems = ({ color={pal.text.color as string} /> )} - {item.label} + + {item.label} + ) } else if (isSep(item)) { diff --git a/src/view/com/util/images/Gallery.tsx b/src/view/com/util/images/Gallery.tsx index 1a29b45306..723db289c1 100644 --- a/src/view/com/util/images/Gallery.tsx +++ b/src/view/com/util/images/Gallery.tsx @@ -63,6 +63,5 @@ const styles = StyleSheet.create({ position: 'absolute', left: 6, bottom: 6, - width: 46, }, }) diff --git a/src/view/com/util/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx similarity index 78% rename from src/view/com/util/PostCtrls.tsx rename to src/view/com/util/post-ctrls/PostCtrls.tsx index 3be6b59f13..9980e9de07 100644 --- a/src/view/com/util/PostCtrls.tsx +++ b/src/view/com/util/post-ctrls/PostCtrls.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, {useCallback} from 'react' import { StyleProp, StyleSheet, @@ -18,18 +18,14 @@ import ReactNativeHapticFeedback, { // TriggerableAnimated, // TriggerableAnimatedRef, // } from './anim/TriggerableAnimated' -import {Text} from './text/Text' -import {PostDropdownBtn} from './forms/DropdownButton' -import { - HeartIcon, - HeartIconSolid, - RepostIcon, - CommentBottomArrow, -} from 'lib/icons' +import {Text} from '../text/Text' +import {PostDropdownBtn} from '../forms/DropdownButton' +import {HeartIcon, HeartIconSolid, CommentBottomArrow} from 'lib/icons' import {s, colors} from 'lib/styles' import {useTheme} from 'lib/ThemeContext' import {useStores} from 'state/index' -import {isIOS} from 'platform/detection' +import {isIOS, isNative} from 'platform/detection' +import {RepostButton} from './RepostButton' interface PostCtrlsOpts { itemUri: string @@ -112,10 +108,12 @@ export function PostCtrls(opts: PostCtrlsOpts) { // DISABLED see #135 // const repostRef = React.useRef(null) // const likeRef = React.useRef(null) - const onRepost = () => { + const onRepost = useCallback(() => { store.shell.closeModal() if (!opts.isReposted) { - ReactNativeHapticFeedback.trigger(hapticImpact) + if (isNative) { + ReactNativeHapticFeedback.trigger(hapticImpact) + } opts.onPressToggleRepost().catch(_e => undefined) // DISABLED see #135 // repostRef.current?.trigger( @@ -128,9 +126,9 @@ export function PostCtrls(opts: PostCtrlsOpts) { } else { opts.onPressToggleRepost().catch(_e => undefined) } - } + }, [opts, store.shell]) - const onQuote = () => { + const onQuote = useCallback(() => { store.shell.closeModal() store.shell.openComposer({ quote: { @@ -141,17 +139,18 @@ export function PostCtrls(opts: PostCtrlsOpts) { indexedAt: opts.indexedAt, }, }) - ReactNativeHapticFeedback.trigger(hapticImpact) - } - const onPressToggleRepostWrapper = () => { - store.shell.openModal({ - name: 'repost', - onRepost: onRepost, - onQuote: onQuote, - isReposted: opts.isReposted, - }) - } + if (isNative) { + ReactNativeHapticFeedback.trigger(hapticImpact) + } + }, [ + opts.author, + opts.indexedAt, + opts.itemCid, + opts.itemUri, + opts.text, + store.shell, + ]) const onPressToggleLikeWrapper = async () => { if (!opts.isLiked) { @@ -181,7 +180,7 @@ export function PostCtrls(opts: PostCtrlsOpts) { onPress={opts.onPressReply} accessibilityRole="button" accessibilityLabel="Reply" - accessibilityHint="Opens reply composer"> + accessibilityHint="reply composer"> ) : undefined} - - ) - : defaultCtrlColor - } - strokeWidth={2.4} - size={opts.big ? 24 : 20} - /> - {typeof opts.repostCount !== 'undefined' ? ( - - {opts.repostCount} - - ) : undefined} - + {opts.isLiked ? ( void + onQuote: () => void +} + +export const RepostButton = ({ + isReposted, + repostCount, + big, + onRepost, + onQuote, +}: Props) => { + const store = useStores() + const theme = useTheme() + + const defaultControlColor = React.useMemo( + () => ({ + color: theme.palette.default.postCtrl, + }), + [theme], + ) + + const onPressToggleRepostWrapper = useCallback(() => { + store.shell.openModal({ + name: 'repost', + onRepost: onRepost, + onQuote: onQuote, + isReposted, + }) + }, [onRepost, onQuote, isReposted, store.shell]) + + return ( + + ) + : defaultControlColor + } + strokeWidth={2.4} + size={big ? 24 : 20} + /> + {typeof repostCount !== 'undefined' ? ( + + {repostCount} + + ) : undefined} + + ) +} + +const styles = StyleSheet.create({ + control: { + flexDirection: 'row', + alignItems: 'center', + padding: 5, + margin: -5, + }, + reposted: { + color: colors.green3, + }, + repostCount: { + color: 'currentColor', + }, +}) diff --git a/src/view/com/util/post-ctrls/RepostButton.web.tsx b/src/view/com/util/post-ctrls/RepostButton.web.tsx new file mode 100644 index 0000000000..66cc0d123b --- /dev/null +++ b/src/view/com/util/post-ctrls/RepostButton.web.tsx @@ -0,0 +1,86 @@ +import React, {useMemo} from 'react' +import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' +import {RepostIcon} from 'lib/icons' +import {DropdownButton} from '../forms/DropdownButton' +import {colors} from 'lib/styles' +import {useTheme} from 'lib/ThemeContext' +import {Text} from '../text/Text' + +interface Props { + isReposted: boolean + repostCount?: number + big?: boolean + onRepost: () => void + onQuote: () => void +} + +export const RepostButton = ({ + isReposted, + repostCount, + big, + onRepost, + onQuote, +}: Props) => { + const theme = useTheme() + + const defaultControlColor = React.useMemo( + () => ({ + color: theme.palette.default.postCtrl, + }), + [theme], + ) + + const items = useMemo( + () => [ + { + label: isReposted ? 'Undo repost' : 'Repost', + icon: 'retweet' as const, + onPress: onRepost, + }, + {label: 'Quote post', icon: 'quote-left' as const, onPress: onQuote}, + ], + [isReposted, onRepost, onQuote], + ) + + return ( + + , + ]}> + + {typeof repostCount !== 'undefined' ? ( + + {repostCount ?? 0} + + ) : undefined} + + + ) +} + +const styles = StyleSheet.create({ + control: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + reposted: { + color: colors.green3, + }, + repostCount: { + color: 'currentColor', + }, +}) diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx index 621bd7c0ff..328b9305b9 100644 --- a/src/view/com/util/post-embeds/index.tsx +++ b/src/view/com/util/post-embeds/index.tsx @@ -210,6 +210,5 @@ const styles = StyleSheet.create({ position: 'absolute', left: 6, bottom: 6, - width: 46, }, }) diff --git a/src/view/index.ts b/src/view/index.ts index 9c8d023ddf..84fc3f315e 100644 --- a/src/view/index.ts +++ b/src/view/index.ts @@ -39,6 +39,8 @@ import {faEye} from '@fortawesome/free-solid-svg-icons/faEye' import {faEyeSlash as farEyeSlash} from '@fortawesome/free-regular-svg-icons/faEyeSlash' import {faGear} from '@fortawesome/free-solid-svg-icons/faGear' import {faGlobe} from '@fortawesome/free-solid-svg-icons/faGlobe' +import {faHand} from '@fortawesome/free-solid-svg-icons/faHand' +import {faHand as farHand} from '@fortawesome/free-regular-svg-icons/faHand' import {faHeart} from '@fortawesome/free-regular-svg-icons/faHeart' import {faHeart as fasHeart} from '@fortawesome/free-solid-svg-icons/faHeart' import {faHouse} from '@fortawesome/free-solid-svg-icons/faHouse' @@ -47,6 +49,7 @@ import {faImage} from '@fortawesome/free-solid-svg-icons/faImage' import {faInfo} from '@fortawesome/free-solid-svg-icons/faInfo' import {faLanguage} from '@fortawesome/free-solid-svg-icons/faLanguage' import {faLink} from '@fortawesome/free-solid-svg-icons/faLink' +import {faListUl} from '@fortawesome/free-solid-svg-icons/faListUl' import {faLock} from '@fortawesome/free-solid-svg-icons/faLock' import {faMagnifyingGlass} from '@fortawesome/free-solid-svg-icons/faMagnifyingGlass' import {faMessage} from '@fortawesome/free-regular-svg-icons/faMessage' @@ -68,8 +71,10 @@ import {faRss} from '@fortawesome/free-solid-svg-icons/faRss' import {faUser} from '@fortawesome/free-regular-svg-icons/faUser' import {faUsers} from '@fortawesome/free-solid-svg-icons/faUsers' import {faUserCheck} from '@fortawesome/free-solid-svg-icons/faUserCheck' +import {faUserSlash} from '@fortawesome/free-solid-svg-icons/faUserSlash' import {faUserPlus} from '@fortawesome/free-solid-svg-icons/faUserPlus' import {faUserXmark} from '@fortawesome/free-solid-svg-icons/faUserXmark' +import {faUsersSlash} from '@fortawesome/free-solid-svg-icons/faUsersSlash' import {faTicket} from '@fortawesome/free-solid-svg-icons/faTicket' import {faTrashCan} from '@fortawesome/free-regular-svg-icons/faTrashCan' import {faX} from '@fortawesome/free-solid-svg-icons/faX' @@ -119,6 +124,8 @@ export function setup() { farEyeSlash, faGear, faGlobe, + faHand, + farHand, faHeart, fasHeart, faHouse, @@ -127,6 +134,7 @@ export function setup() { faInfo, faLanguage, faLink, + faListUl, faLock, faMagnifyingGlass, faMessage, @@ -148,8 +156,10 @@ export function setup() { faUser, faUsers, faUserCheck, + faUserSlash, faUserPlus, faUserXmark, + faUsersSlash, faTicket, faTrashCan, faThumbtack, diff --git a/src/view/screens/AppPasswords.tsx b/src/view/screens/AppPasswords.tsx index cb1896b4aa..3adbf09df4 100644 --- a/src/view/screens/AppPasswords.tsx +++ b/src/view/screens/AppPasswords.tsx @@ -140,8 +140,8 @@ function AppPasswordsHeader() { pal.text, isDesktopWeb && styles.descriptionDesktop, ]}> - These passwords can be used to log onto Bluesky in other apps without - giving them full access to your account or your password. + Use app passwords to login to other Bluesky clients without giving full + access to your account or password. ) @@ -289,5 +289,6 @@ const styles = StyleSheet.create({ trashIcon: { color: 'red', + minWidth: 16, }, }) diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 3db9a43783..1457478d5a 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -62,7 +62,7 @@ export const HomeScreen = withAuthRequired( setSelectedPage(index) store.shell.setIsDrawerSwipeDisabled(index > 0) }, - [store], + [store, setSelectedPage], ) const onPressSelected = React.useCallback(() => { diff --git a/src/view/screens/Moderation.tsx b/src/view/screens/Moderation.tsx new file mode 100644 index 0000000000..4c52301cb6 --- /dev/null +++ b/src/view/screens/Moderation.tsx @@ -0,0 +1,137 @@ +import React from 'react' +import {StyleSheet, TouchableOpacity, View} from 'react-native' +import {useFocusEffect} from '@react-navigation/native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {observer} from 'mobx-react-lite' +import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' +import {withAuthRequired} from 'view/com/auth/withAuthRequired' +import {useStores} from 'state/index' +import {s} from 'lib/styles' +import {CenteredView} from '../com/util/Views' +import {ViewHeader} from '../com/util/ViewHeader' +import {Link} from '../com/util/Link' +import {Text} from '../com/util/text/Text' +import {usePalette} from 'lib/hooks/usePalette' +import {useAnalytics} from 'lib/analytics' +import {isDesktopWeb} from 'platform/detection' + +type Props = NativeStackScreenProps +export const ModerationScreen = withAuthRequired( + observer(function Moderation({}: Props) { + const pal = usePalette('default') + const store = useStores() + const {screen, track} = useAnalytics() + + useFocusEffect( + React.useCallback(() => { + screen('Moderation') + store.shell.setMinimalShellMode(false) + }, [screen, store]), + ) + + const onPressContentFiltering = React.useCallback(() => { + track('Moderation:ContentfilteringButtonClicked') + store.shell.openModal({name: 'content-filtering-settings'}) + }, [track, store]) + + return ( + + + + + + + + + Content filtering + + + + + + + + Mute lists + + + + + + + + Muted accounts + + + + + + + + Blocked accounts + + + + ) + }), +) + +const styles = StyleSheet.create({ + desktopContainer: { + borderLeftWidth: 1, + borderRightWidth: 1, + }, + spacer: { + height: 6, + }, + linkCard: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 12, + paddingHorizontal: 18, + marginBottom: 1, + }, + iconContainer: { + alignItems: 'center', + justifyContent: 'center', + width: 40, + height: 40, + borderRadius: 30, + marginRight: 12, + }, +}) diff --git a/src/view/screens/BlockedAccounts.tsx b/src/view/screens/ModerationBlockedAccounts.tsx similarity index 96% rename from src/view/screens/BlockedAccounts.tsx rename to src/view/screens/ModerationBlockedAccounts.tsx index 1950685109..cd506d6305 100644 --- a/src/view/screens/BlockedAccounts.tsx +++ b/src/view/screens/ModerationBlockedAccounts.tsx @@ -22,8 +22,11 @@ import {ViewHeader} from '../com/util/ViewHeader' import {CenteredView} from 'view/com/util/Views' import {ProfileCard} from 'view/com/profile/ProfileCard' -type Props = NativeStackScreenProps -export const BlockedAccounts = withAuthRequired( +type Props = NativeStackScreenProps< + CommonNavigatorParams, + 'ModerationBlockedAccounts' +> +export const ModerationBlockedAccounts = withAuthRequired( observer(({}: Props) => { const pal = usePalette('default') const store = useStores() diff --git a/src/view/screens/ModerationMuteLists.tsx b/src/view/screens/ModerationMuteLists.tsx new file mode 100644 index 0000000000..0b81f432f6 --- /dev/null +++ b/src/view/screens/ModerationMuteLists.tsx @@ -0,0 +1,122 @@ +import React from 'react' +import {StyleSheet} from 'react-native' +import {useFocusEffect, useNavigation} from '@react-navigation/native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {AtUri} from '@atproto/api' +import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' +import {withAuthRequired} from 'view/com/auth/withAuthRequired' +import {EmptyStateWithButton} from 'view/com/util/EmptyStateWithButton' +import {useStores} from 'state/index' +import {ListsListModel} from 'state/models/lists/lists-list' +import {ListsList} from 'view/com/lists/ListsList' +import {Button} from 'view/com/util/forms/Button' +import {NavigationProp} from 'lib/routes/types' +import {usePalette} from 'lib/hooks/usePalette' +import {CenteredView} from 'view/com/util/Views' +import {ViewHeader} from 'view/com/util/ViewHeader' +import {isDesktopWeb} from 'platform/detection' + +type Props = NativeStackScreenProps< + CommonNavigatorParams, + 'ModerationMuteLists' +> +export const ModerationMuteListsScreen = withAuthRequired(({}: Props) => { + const pal = usePalette('default') + const store = useStores() + const navigation = useNavigation() + + const mutelists: ListsListModel = React.useMemo( + () => new ListsListModel(store, 'my-modlists'), + [store], + ) + + useFocusEffect( + React.useCallback(() => { + store.shell.setMinimalShellMode(false) + mutelists.refresh() + }, [store, mutelists]), + ) + + const onPressNewMuteList = React.useCallback(() => { + store.shell.openModal({ + name: 'create-or-edit-mute-list', + onSave: (uri: string) => { + try { + const urip = new AtUri(uri) + navigation.navigate('ProfileList', { + name: urip.hostname, + rkey: urip.rkey, + }) + } catch {} + }, + }) + }, [store, navigation]) + + const renderEmptyState = React.useCallback(() => { + return ( + + ) + }, [onPressNewMuteList]) + + const renderHeaderButton = React.useCallback( + () => ( + + ), + [onPressNewMuteList, pal], + ) + + return ( + + + + + ) +}) + +const styles = StyleSheet.create({ + container: { + flex: 1, + paddingBottom: isDesktopWeb ? 0 : 100, + }, + containerDesktop: { + borderLeftWidth: 1, + borderRightWidth: 1, + }, + createBtn: { + width: 40, + }, +}) diff --git a/src/view/screens/MutedAccounts.tsx b/src/view/screens/ModerationMutedAccounts.tsx similarity index 96% rename from src/view/screens/MutedAccounts.tsx rename to src/view/screens/ModerationMutedAccounts.tsx index 8f3d1f8a56..22b8c0d33c 100644 --- a/src/view/screens/MutedAccounts.tsx +++ b/src/view/screens/ModerationMutedAccounts.tsx @@ -22,8 +22,11 @@ import {ViewHeader} from '../com/util/ViewHeader' import {CenteredView} from 'view/com/util/Views' import {ProfileCard} from 'view/com/profile/ProfileCard' -type Props = NativeStackScreenProps -export const MutedAccounts = withAuthRequired( +type Props = NativeStackScreenProps< + CommonNavigatorParams, + 'ModerationMutedAccounts' +> +export const ModerationMutedAccounts = withAuthRequired( observer(({}: Props) => { const pal = usePalette('default') const store = useStores() diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 68aa97b66b..9c8dd458c2 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -7,12 +7,16 @@ import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {ViewSelector} from '../com/util/ViewSelector' import {CenteredView} from '../com/util/Views' import {ScreenHider} from 'view/com/util/moderation/ScreenHider' -import {ProfileUiModel} from 'state/models/ui/profile' +import {ProfileUiModel, Sections} from 'state/models/ui/profile' import {useStores} from 'state/index' import {PostsFeedSliceModel} from 'state/models/feeds/posts' import {ProfileHeader} from '../com/profile/ProfileHeader' import {FeedSlice} from '../com/posts/FeedSlice' -import {PostFeedLoadingPlaceholder} from '../com/util/LoadingPlaceholder' +import {ListCard} from 'view/com/lists/ListCard' +import { + PostFeedLoadingPlaceholder, + ProfileCardFeedLoadingPlaceholder, +} from '../com/util/LoadingPlaceholder' import {ErrorScreen} from '../com/util/error/ErrorScreen' import {ErrorMessage} from '../com/util/error/ErrorMessage' import {EmptyState} from '../com/util/EmptyState' @@ -23,6 +27,8 @@ import {useAnalytics} from 'lib/analytics' import {ComposeIcon2} from 'lib/icons' import AlgoItem from 'view/com/algos/AlgoItem' import {AlgoItemModel} from 'state/models/feeds/algo/algo-item' +import {useSetTitle} from 'lib/hooks/useSetTitle' +import {combinedDisplayName} from 'lib/strings/display-names' type Props = NativeStackScreenProps export const ProfileScreen = withAuthRequired( @@ -39,6 +45,7 @@ export const ProfileScreen = withAuthRequired( () => new ProfileUiModel(store, {user: route.params.name}), [route.params.name, store], ) + useSetTitle(combinedDisplayName(uiState.profile)) useFocusEffect( React.useCallback(() => { @@ -113,53 +120,87 @@ export const ProfileScreen = withAuthRequired( }, [uiState.showLoadingMoreFooter]) const renderItem = React.useCallback( (item: any) => { - if (item === ProfileUiModel.END_ITEM) { - return - end of feed - - } else if (item === ProfileUiModel.LOADING_ITEM) { - return - } else if (item._reactKey === '__error__') { - if (uiState.feed.isBlocking) { + if (uiState.selectedView === Sections.Lists) { + if (item === ProfileUiModel.LOADING_ITEM) { + return + } else if (item._reactKey === '__error__') { + return ( + + + + ) + } else if (item === ProfileUiModel.EMPTY_ITEM) { return ( ) + } else { + return } - if (uiState.feed.isBlockedBy) { + } else { + if (item === ProfileUiModel.END_ITEM) { + return - end of feed - + } else if (item === ProfileUiModel.LOADING_ITEM) { + return + } else if (item._reactKey === '__error__') { + if (uiState.feed.isBlocking) { + return ( + + ) + } + if (uiState.feed.isBlockedBy) { + return ( + + ) + } + return ( + + + + ) + } else if (item === ProfileUiModel.EMPTY_ITEM) { return ( ) + } else if (item instanceof PostsFeedSliceModel) { + return ( + + ) + } else if (item instanceof AlgoItemModel) { + return } - return ( - - - - ) - } else if (item === ProfileUiModel.EMPTY_ITEM) { - return ( - - ) - } else if (item instanceof PostsFeedSliceModel) { - return - } else if (item instanceof AlgoItemModel) { - return } return }, - [onPressTryAgain, uiState], + [ + onPressTryAgain, + uiState.selectedView, + uiState.profile.did, + uiState.feed.isBlocking, + uiState.feed.isBlockedBy, + ], ) return ( diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx new file mode 100644 index 0000000000..01f27bae13 --- /dev/null +++ b/src/view/screens/ProfileList.tsx @@ -0,0 +1,177 @@ +import React from 'react' +import {StyleSheet, View} from 'react-native' +import {useFocusEffect} from '@react-navigation/native' +import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' +import {useNavigation} from '@react-navigation/native' +import {observer} from 'mobx-react-lite' +import {withAuthRequired} from 'view/com/auth/withAuthRequired' +import {ViewHeader} from 'view/com/util/ViewHeader' +import {CenteredView} from 'view/com/util/Views' +import {ListItems} from 'view/com/lists/ListItems' +import {EmptyState} from 'view/com/util/EmptyState' +import {Button} from 'view/com/util/forms/Button' +import * as Toast from 'view/com/util/Toast' +import {ListModel} from 'state/models/content/list' +import {useStores} from 'state/index' +import {usePalette} from 'lib/hooks/usePalette' +import {useSetTitle} from 'lib/hooks/useSetTitle' +import {NavigationProp} from 'lib/routes/types' +import {isDesktopWeb} from 'platform/detection' + +type Props = NativeStackScreenProps +export const ProfileListScreen = withAuthRequired( + observer(({route}: Props) => { + const store = useStores() + const navigation = useNavigation() + const pal = usePalette('default') + const {name, rkey} = route.params + + const list: ListModel = React.useMemo(() => { + const model = new ListModel( + store, + `at://${name}/app.bsky.graph.list/${rkey}`, + ) + return model + }, [store, name, rkey]) + useSetTitle(list.list?.name) + + useFocusEffect( + React.useCallback(() => { + store.shell.setMinimalShellMode(false) + list.loadMore(true) + }, [store, list]), + ) + + const onToggleSubscribed = React.useCallback(async () => { + try { + if (list.list?.viewer?.muted) { + await list.unsubscribe() + } else { + await list.subscribe() + } + } catch (err) { + Toast.show( + 'There was an an issue updating your subscription, please check your internet connection and try again.', + ) + store.log.error('Failed up update subscription', {err}) + } + }, [store, list]) + + const onPressEditList = React.useCallback(() => { + store.shell.openModal({ + name: 'create-or-edit-mute-list', + list, + onSave() { + list.refresh() + }, + }) + }, [store, list]) + + const onPressDeleteList = React.useCallback(() => { + store.shell.openModal({ + name: 'confirm', + title: 'Delete List', + message: 'Are you sure?', + async onPressConfirm() { + await list.delete() + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } + }, + }) + }, [store, list, navigation]) + + const renderEmptyState = React.useCallback(() => { + return + }, []) + + const renderHeaderBtn = React.useCallback(() => { + return ( + + {list?.isOwner && ( +