Merge branch 'main' into custom-algos

zio/stable
Paul Frazee 2023-05-17 12:30:54 -05:00
commit 7aa1d9010e
99 changed files with 4234 additions and 716 deletions

1
.env.example 100644
View File

@ -0,0 +1 @@
SENTRY_AUTH_TOKEN=

View File

@ -0,0 +1,35 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
**Describe the bug**
<!-- A clear and concise description of what the bug is. -->
**To Reproduce**
Steps to reproduce the behavior:
1.
**Expected behavior**
<!-- A clear and concise description of what you expected to happen. -->
**Screenshots**
<!-- If applicable, add screenshots to help explain your problem. -->
**Details**
- Platform: <!-- desktop chrome windows, mobile safari, iOS, Android -->
- Platform version:
- App version:
**Additional context**
<!-- Add any other context about the problem here. -->

View File

@ -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.**
<!-- A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] -->
**Describe the solution you'd like**
<!-- A clear and concise description of what you want to happen. -->
**Describe alternatives you've considered**
<!-- A clear and concise description of any alternative solutions or features you've considered. -->
**Additional context**
<!-- Add any other context or screenshots about the feature request here. -->

View File

@ -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.
#

7
LICENSE 100644
View File

@ -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.

35
Makefile 100644
View File

@ -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

121
README.md
View File

@ -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=<HANDLE> --password=<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 <release name> \
upload-sourcemaps \
--dist <Android Update ID> \
--rewrite \
dist/bundles/index.android.bundle dist/bundles/android-<hash>.map`
- Command for iOS:
`node_modules/@sentry/cli/bin/sentry-cli releases \
files <release name> \
upload-sourcemaps \
--dist <iOS Update ID> \
--rewrite \
dist/bundles/main.jsbundle dist/bundles/ios-<hash>.map`
We ❤️ you and all of the ways you support us. Thank you for making Bluesky a great place!

View File

@ -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,
)

View File

@ -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')

View File

@ -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')

View File

@ -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()
})
})

View File

@ -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')

View File

@ -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"

View File

@ -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)

View File

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1.00001, viewport-fit=cover">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, viewport-fit=cover">
<meta name="referrer" content="origin-when-cross-origin">
<title>{%- block head_title -%}Bluesky{%- endblock -%}</title>

109
docs/build.md 100644
View File

@ -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=<HANDLE> --password=<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 <release name> \
upload-sourcemaps \
--dist <Android Update ID> \
--rewrite \
dist/bundles/index.android.bundle dist/bundles/android-<hash>.map`
- Command for iOS:
`node_modules/@sentry/cli/bin/sentry-cli releases \
files <release name> \
upload-sourcemaps \
--dist <iOS Update ID> \
--rewrite \
dist/bundles/main.jsbundle dist/bundles/ios-<hash>.map`

View File

@ -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": {

View File

@ -337,6 +337,32 @@ class Mocker {
])
.execute()
}
async createMuteList(user: string, name: string): Promise<string> {
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) =>

View File

@ -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",

View File

@ -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<AllNavigatorParams>()
@ -69,36 +73,125 @@ const Tab = createBottomTabNavigator<BottomTabNavigatorParams>()
/**
* 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 (
<>
<Stack.Screen name="NotFound" component={NotFoundScreen} />
<Stack.Screen name="Settings" component={SettingsScreen} />
<Stack.Screen name="Profile" component={ProfileScreen} />
<Stack.Screen
name="NotFound"
component={NotFoundScreen}
options={{title: title('Not Found')}}
/>
<Stack.Screen
name="Moderation"
component={ModerationScreen}
options={{title: title('Moderation')}}
/>
<Stack.Screen
name="ModerationMuteLists"
component={ModerationMuteListsScreen}
options={{title: title('Mute Lists')}}
/>
<Stack.Screen
name="ModerationMutedAccounts"
component={ModerationMutedAccounts}
options={{title: title('Muted Accounts')}}
/>
<Stack.Screen
name="ModerationBlockedAccounts"
component={ModerationBlockedAccounts}
options={{title: title('Blocked Accounts')}}
/>
<Stack.Screen
name="Settings"
component={SettingsScreen}
options={{title: title('Settings')}}
/>
<Stack.Screen
name="Profile"
component={ProfileScreen}
options={({route}) => ({title: title(`@${route.params.name}`)})}
/>
<Stack.Screen
name="ProfileFollowers"
component={ProfileFollowersScreen}
options={({route}) => ({
title: title(`People following @${route.params.name}`),
})}
/>
<Stack.Screen
name="ProfileFollows"
component={ProfileFollowsScreen}
options={({route}) => ({
title: title(`People followed by @${route.params.name}`),
})}
/>
<Stack.Screen
name="ProfileList"
component={ProfileListScreen}
options={{title: title('Mute List')}}
/>
<Stack.Screen
name="PostThread"
component={PostThreadScreen}
options={({route}) => ({title: title(`Post by @${route.params.name}`)})}
/>
<Stack.Screen
name="PostLikedBy"
component={PostLikedByScreen}
options={({route}) => ({title: title(`Post by @${route.params.name}`)})}
/>
<Stack.Screen
name="PostRepostedBy"
component={PostRepostedByScreen}
options={({route}) => ({title: title(`Post by @${route.params.name}`)})}
/>
<Stack.Screen
name="Debug"
component={DebugScreen}
options={{title: title('Debug')}}
/>
<Stack.Screen
name="Log"
component={LogScreen}
options={{title: title('Log')}}
/>
<Stack.Screen
name="Support"
component={SupportScreen}
options={{title: title('Support')}}
/>
<Stack.Screen
name="PrivacyPolicy"
component={PrivacyPolicyScreen}
options={{title: title('Privacy Policy')}}
/>
<Stack.Screen
name="TermsOfService"
component={TermsOfServiceScreen}
options={{title: title('Terms of Service')}}
/>
<Stack.Screen name="ProfileFollows" component={ProfileFollowsScreen} />
<Stack.Screen name="PostThread" component={PostThreadScreen} />
<Stack.Screen name="PostLikedBy" component={PostLikedByScreen} />
<Stack.Screen name="PostRepostedBy" component={PostRepostedByScreen} />
<Stack.Screen name="Debug" component={DebugScreen} />
<Stack.Screen name="Log" component={LogScreen} />
<Stack.Screen name="Support" component={SupportScreen} />
<Stack.Screen name="PrivacyPolicy" component={PrivacyPolicyScreen} />
<Stack.Screen name="TermsOfService" component={TermsOfServiceScreen} />
<Stack.Screen
name="CommunityGuidelines"
component={CommunityGuidelinesScreen}
options={{title: title('Community Guidelines')}}
/>
<Stack.Screen
name="CopyrightPolicy"
component={CopyrightPolicyScreen}
options={{title: title('Copyright Policy')}}
/>
<Stack.Screen
name="AppPasswords"
component={AppPasswords}
options={{title: title('App Passwords')}}
/>
<Stack.Screen name="CopyrightPolicy" component={CopyrightPolicyScreen} />
<Stack.Screen name="AppPasswords" component={AppPasswords} />
<Stack.Screen name="SavedFeeds" component={SavedFeeds} />
<Stack.Screen name="PinnedFeeds" component={PinnedFeeds} />
<Stack.Screen name="CustomFeed" component={CustomFeed} />
<Stack.Screen name="MutedAccounts" component={MutedAccounts} />
<Stack.Screen name="BlockedAccounts" component={BlockedAccounts} />
</>
)
}
@ -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 (
<Flat.Navigator
screenOptions={{
@ -221,13 +316,25 @@ function FlatNavigator() {
animationDuration: 250,
contentStyle: [pal.view],
}}>
<Flat.Screen name="Home" component={HomeScreen} />
<Flat.Screen name="Search" component={SearchScreen} />
<Flat.Screen name="Notifications" component={NotificationsScreen} />
{commonScreens(Flat as typeof HomeTab)}
<Flat.Screen
name="Home"
component={HomeScreen}
options={{title: title('Home')}}
/>
<Flat.Screen
name="Search"
component={SearchScreen}
options={{title: title('Search')}}
/>
<Flat.Screen
name="Notifications"
component={NotificationsScreen}
options={{title: title('Notifications')}}
/>
{commonScreens(Flat as typeof HomeTab, unreadCountLabel)}
</Flat.Navigator>
)
}
})
/**
* The RoutesContainer should wrap all components which need access

View File

@ -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`,

View File

@ -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<NavigationProp>()
const {unreadCountLabel} = useStores().me.notifications
useEffect(() => {
if (title) {
navigation.setOptions({title: bskyTitle(title, unreadCountLabel)})
}
}, [title, navigation, unreadCountLabel])
}

View File

@ -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<ViewStyle>
size?: string | number
strokeWidth?: number
}) {
return (
<Svg
fill="none"
viewBox="0 0 24 24"
width={size || 32}
height={size || 32}
strokeWidth={strokeWidth}
stroke="currentColor"
style={style}>
<Path
d="M12 3V5.25M18.364 5.63604L16.773 7.22703M21 12H18.75M18.364 18.364L16.773 16.773M12 18.75V21M7.22703 16.773L5.63604 18.364M5.25 12H3M7.22703 7.22703L5.63604 5.63604M15.75 12C15.75 14.0711 14.0711 15.75 12 15.75C9.92893 15.75 8.25 14.0711 8.25 12C8.25 9.92893 9.92893 8.25 12 8.25C14.0711 8.25 15.75 9.92893 15.75 12Z"
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
)
}
// Copyright (c) 2020 Refactoring UI Inc.
// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE
export function UserIcon({
@ -828,3 +857,29 @@ export function InfoCircleIcon({
</Svg>
)
}
export function HandIcon({
style,
size,
strokeWidth = 1.5,
}: {
style?: StyleProp<TextStyle>
size?: string | number
strokeWidth?: number
}) {
return (
<Svg
width={size}
height={size}
viewBox="0 0 76 76"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
fill="none"
style={style}>
<Path d="M33.5 37V11.5C33.5 8.46243 31.0376 6 28 6V6C24.9624 6 22.5 8.46243 22.5 11.5V48V48C22.5 48.5802 21.8139 48.8874 21.3811 48.501L13.2252 41.2189C10.72 38.9821 6.81945 39.4562 4.92296 42.228L4.77978 42.4372C3.17708 44.7796 3.50863 47.9385 5.56275 49.897L16.0965 59.9409C20.9825 64.5996 26.7533 68.231 33.0675 70.6201V70.6201C38.8234 72.798 45.1766 72.798 50.9325 70.6201L51.9256 70.2444C57.4044 68.1713 61.8038 63.9579 64.1113 58.5735V58.5735C65.6874 54.8962 66.5 50.937 66.5 46.9362V22.5C66.5 19.4624 64.0376 17 61 17V17C57.9624 17 55.5 19.4624 55.5 22.5V36.5" />
<Path d="M55.5 37V11.5C55.5 8.46243 53.0376 6 50 6V6C46.9624 6 44.5 8.46243 44.5 11.5V37" />
<Path d="M44.5 37V8.5C44.5 5.46243 42.0376 3 39 3V3C35.9624 3 33.5 5.46243 33.5 8.5V37" />
</Svg>
)
}

View File

@ -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,

View File

@ -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
}

View File

@ -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<RNImage[]> {
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<RNImage> {
) {
const item = await openCropperFn({
...opts,
forceJpg: true, // ios only
compressImageQuality: 0.8,
})
return {

View File

@ -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 & {

View File

@ -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}`
}

View File

@ -0,0 +1,4 @@
export function bskyTitle(page: string, unreadCountLabel?: string) {
const unreadPrefix = unreadCountLabel ? `(${unreadCountLabel}) ` : ''
return `${unreadPrefix}${page} - Bluesky`
}

View File

@ -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 {

View File

@ -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',

View File

@ -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)
}
}
}
}

View File

@ -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})),
)
}
}

View File

@ -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) ||

View File

@ -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

View File

@ -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:

View File

@ -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) ||

View File

@ -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<GetLists.Response> {
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<GetListMutes.Response> {
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
}

View File

@ -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)
}),
)
}
}

View File

@ -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
}
}

View File

@ -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)
}

View File

@ -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): {

View File

@ -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() {

View File

@ -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

View File

@ -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}>
<Text style={styles.imageControlTextContent}>ALT</Text>
</TouchableOpacity>
<View style={imageControlsSubgroupStyle}>
@ -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,
},
})

View File

@ -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<MentionListRef, SuggestionProps>(
const isSelected = selectedIndex === index
return (
<View
<Pressable
key={item.handle}
style={[
isSelected ? pal.viewLight : undefined,
@ -169,7 +169,11 @@ const MentionList = forwardRef<MentionListRef, SuggestionProps>(
: index === items.length - 1
? styles.lastMention
: undefined,
]}>
]}
onPress={() => {
selectItem(index)
}}
accessibilityRole="button">
<View style={styles.avatarAndDisplayName}>
<UserAvatar avatar={item.avatar ?? null} size={26} />
<Text style={pal.text} numberOfLines={1}>
@ -179,7 +183,7 @@ const MentionList = forwardRef<MentionListRef, SuggestionProps>(
<Text type="xs" style={pal.textLight} numberOfLines={1}>
@{item.handle}
</Text>
</View>
</Pressable>
)
})
) : (

View File

@ -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

View File

@ -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 (
<Link
testID={testID}
style={[
styles.outer,
pal.border,
noBorder && styles.outerNoBorder,
!noBg && pal.view,
]}
href={`/profile/${list.creator.did}/lists/${rkey}`}
title={list.name}
asAnchor
anchorNoUnderline>
<View style={styles.layout}>
<View style={styles.layoutAvi}>
<UserAvatar size={40} avatar={list.avatar} />
</View>
<View style={styles.layoutContent}>
<Text
type="lg"
style={[s.bold, pal.text]}
numberOfLines={1}
lineHeight={1.2}>
{sanitizeDisplayName(list.name)}
</Text>
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
{list.purpose === 'app.bsky.graph.defs#modlist' && 'Mute list'} by{' '}
{list.creator.did === store.me.did
? 'you'
: `@${list.creator.handle}`}
</Text>
{!!list.viewer?.muted && (
<View style={s.flexRow}>
<View style={[s.mt5, pal.btn, styles.pill]}>
<Text type="xs" style={pal.text}>
Subscribed
</Text>
</View>
</View>
)}
</View>
{renderButton ? (
<View style={styles.layoutButton}>{renderButton()}</View>
) : undefined}
</View>
{descriptionRichText ? (
<View style={styles.details}>
<RichTextCom
style={pal.text}
numberOfLines={20}
richText={descriptionRichText}
/>
</View>
) : undefined}
</Link>
)
}
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,
},
})

View File

@ -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<ViewStyle>
scrollElRef?: MutableRefObject<FlatList<any> | 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 (
<Button
type="default"
label="Edit"
onPress={() => onPressEditMembership(profile)}
/>
)
},
[list, onPressEditMembership],
)
const renderItem = React.useCallback(
({item}: {item: any}) => {
if (item === EMPTY_ITEM) {
if (renderEmptyState) {
return renderEmptyState()
}
return <View />
} else if (item === HEADER_ITEM) {
return list.list ? (
<ListHeader
list={list.list}
isOwner={list.isOwner}
onToggleSubscribed={onToggleSubscribed}
onPressEditList={onPressEditList}
onPressDeleteList={onPressDeleteList}
/>
) : null
} else if (item === ERROR_ITEM) {
return (
<ErrorMessage
message={list.error}
onPressTryAgain={onPressTryAgain}
/>
)
} else if (item === LOAD_MORE_ERROR_ITEM) {
return (
<LoadMoreRetryBtn
label="There was an issue fetching the list. Tap here to try again."
onPress={onPressRetryLoadMore}
/>
)
} else if (item === LOADING_ITEM) {
return <ProfileCardFeedLoadingPlaceholder />
}
return (
<ProfileCard
testID={`user-${
(item as AppBskyGraphDefs.ListItemView).subject.handle
}`}
profile={(item as AppBskyGraphDefs.ListItemView).subject}
renderButton={renderMemberButton}
/>
)
},
[
list,
onPressTryAgain,
onPressRetryLoadMore,
renderMemberButton,
onPressEditList,
onPressDeleteList,
onToggleSubscribed,
renderEmptyState,
],
)
const Footer = React.useCallback(
() =>
list.isLoading ? (
<View style={styles.feedFooter}>
<ActivityIndicator />
</View>
) : (
<View />
),
[list],
)
return (
<View testID={testID} style={style}>
{data.length > 0 && (
<FlatList
testID={testID ? `${testID}-flatlist` : undefined}
ref={scrollElRef}
data={data}
keyExtractor={item => item._reactKey}
renderItem={renderItem}
ListFooterComponent={Footer}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={onRefresh}
tintColor={pal.colors.text}
titleColor={pal.colors.text}
progressViewOffset={headerOffset}
/>
}
contentContainerStyle={s.contentContainer}
style={{paddingTop: headerOffset}}
onEndReached={onEndReached}
onEndReachedThreshold={0.6}
removeClippedSubviews={true}
contentOffset={{x: 0, y: headerOffset * -1}}
// @ts-ignore our .web version only -prf
desktopFixedHeight
/>
)}
</View>
)
},
)
const ListHeader = observer(
({
list,
isOwner,
onToggleSubscribed,
onPressEditList,
onPressDeleteList,
}: {
list: AppBskyGraphDefs.ListView
isOwner: boolean
onToggleSubscribed?: () => void
onPressEditList?: () => void
onPressDeleteList?: () => void
}) => {
const pal = usePalette('default')
const store = useStores()
const descriptionRT = React.useMemo(
() =>
list?.description &&
new RichText({text: list.description, facets: list.descriptionFacets}),
[list],
)
return (
<>
<View style={[styles.header, pal.border]}>
<View style={s.flex1}>
<Text testID="listName" type="title-xl" style={[pal.text, s.bold]}>
{list.name}
</Text>
{list && (
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
{list.purpose === 'app.bsky.graph.defs#modlist' && 'Mute list '}
by{' '}
{list.creator.did === store.me.did ? (
'you'
) : (
<TextLink
text={`@${list.creator.handle}`}
href={`/profile/${list.creator.did}`}
/>
)}
</Text>
)}
{descriptionRT && (
<RichTextCom
testID="listDescription"
style={[pal.text, styles.headerDescription]}
richText={descriptionRT}
/>
)}
{isDesktopWeb && (
<View style={styles.headerBtns}>
{list.viewer?.muted ? (
<Button
type="inverted"
label="Unsubscribe"
accessibilityLabel="Unsubscribe"
accessibilityHint=""
onPress={onToggleSubscribed}
/>
) : (
<Button
type="primary"
label="Subscribe & Mute"
accessibilityLabel="Subscribe and mute"
accessibilityHint=""
onPress={onToggleSubscribed}
/>
)}
{isOwner && (
<Button
type="default"
label="Edit List"
accessibilityLabel="Edit list"
accessibilityHint=""
onPress={onPressEditList}
/>
)}
{isOwner && (
<Button
type="default"
label="Delete List"
accessibilityLabel="Delete list"
accessibilityHint=""
onPress={onPressDeleteList}
/>
)}
</View>
)}
</View>
<View>
<UserAvatar avatar={list.avatar} size={64} />
</View>
</View>
<View style={[styles.fakeSelector, pal.border]}>
<View
style={[styles.fakeSelectorItem, {borderColor: pal.colors.link}]}>
<Text type="md-medium" style={[pal.text]}>
Muted users
</Text>
</View>
</View>
</>
)
},
)
const styles = StyleSheet.create({
header: {
flexDirection: 'row',
gap: 12,
paddingHorizontal: 16,
paddingTop: 12,
paddingBottom: 16,
borderTopWidth: 1,
},
headerDescription: {
marginTop: 8,
},
headerBtns: {
flexDirection: 'row',
gap: 8,
marginTop: 12,
},
fakeSelector: {
flexDirection: 'row',
paddingHorizontal: isDesktopWeb ? 16 : 6,
},
fakeSelectorItem: {
paddingHorizontal: 12,
paddingBottom: 8,
borderBottomWidth: 3,
},
feedFooter: {paddingTop: 20},
})

View File

@ -0,0 +1,240 @@
import React, {MutableRefObject} from 'react'
import {
ActivityIndicator,
RefreshControl,
StyleProp,
StyleSheet,
View,
ViewStyle,
} from 'react-native'
import {observer} from 'mobx-react-lite'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {AppBskyGraphDefs as GraphDefs} from '@atproto/api'
import {FlatList} from '../util/Views'
import {ListCard} from './ListCard'
import {ProfileCardFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
import {Button} from '../util/forms/Button'
import {Text} from '../util/text/Text'
import {ListsListModel} from 'state/models/lists/lists-list'
import {useAnalytics} from 'lib/analytics'
import {usePalette} from 'lib/hooks/usePalette'
import {s} from 'lib/styles'
const LOADING_ITEM = {_reactKey: '__loading__'}
const CREATENEW_ITEM = {_reactKey: '__loading__'}
const EMPTY_ITEM = {_reactKey: '__empty__'}
const ERROR_ITEM = {_reactKey: '__error__'}
const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
export const ListsList = observer(
({
listsList,
showAddBtns,
style,
scrollElRef,
onPressTryAgain,
onPressCreateNew,
renderItem,
renderEmptyState,
testID,
headerOffset = 0,
}: {
listsList: ListsListModel
showAddBtns?: boolean
style?: StyleProp<ViewStyle>
scrollElRef?: MutableRefObject<FlatList<any> | null>
onPressCreateNew: () => void
onPressTryAgain?: () => void
renderItem?: (list: GraphDefs.ListView) => JSX.Element
renderEmptyState?: () => JSX.Element
testID?: string
headerOffset?: number
}) => {
const pal = usePalette('default')
const {track} = useAnalytics()
const [isRefreshing, setIsRefreshing] = React.useState(false)
const data = React.useMemo(() => {
let items: any[] = []
if (listsList.hasLoaded) {
if (listsList.hasError) {
items = items.concat([ERROR_ITEM])
}
if (listsList.isEmpty) {
items = items.concat([EMPTY_ITEM])
} else {
if (showAddBtns) {
items = items.concat([CREATENEW_ITEM])
}
items = items.concat(listsList.lists)
}
if (listsList.loadMoreError) {
items = items.concat([LOAD_MORE_ERROR_ITEM])
}
} else if (listsList.isLoading) {
items = items.concat([LOADING_ITEM])
}
return items
}, [
listsList.hasError,
listsList.hasLoaded,
listsList.isLoading,
listsList.isEmpty,
listsList.lists,
listsList.loadMoreError,
showAddBtns,
])
// events
// =
const onRefresh = React.useCallback(async () => {
track('Lists:onRefresh')
setIsRefreshing(true)
try {
await listsList.refresh()
} catch (err) {
listsList.rootStore.log.error('Failed to refresh lists', err)
}
setIsRefreshing(false)
}, [listsList, track, setIsRefreshing])
const onEndReached = React.useCallback(async () => {
track('Lists:onEndReached')
try {
await listsList.loadMore()
} catch (err) {
listsList.rootStore.log.error('Failed to load more lists', err)
}
}, [listsList, track])
const onPressRetryLoadMore = React.useCallback(() => {
listsList.retryLoadMore()
}, [listsList])
// rendering
// =
const renderItemInner = React.useCallback(
({item}: {item: any}) => {
if (item === EMPTY_ITEM) {
if (renderEmptyState) {
return renderEmptyState()
}
return <View />
} else if (item === CREATENEW_ITEM) {
return <CreateNewItem onPress={onPressCreateNew} />
} else if (item === ERROR_ITEM) {
return (
<ErrorMessage
message={listsList.error}
onPressTryAgain={onPressTryAgain}
/>
)
} else if (item === LOAD_MORE_ERROR_ITEM) {
return (
<LoadMoreRetryBtn
label="There was an issue fetching your lists. Tap here to try again."
onPress={onPressRetryLoadMore}
/>
)
} else if (item === LOADING_ITEM) {
return <ProfileCardFeedLoadingPlaceholder />
}
return renderItem ? (
renderItem(item)
) : (
<ListCard list={item} testID={`list-${item.name}`} />
)
},
[
listsList,
onPressTryAgain,
onPressRetryLoadMore,
onPressCreateNew,
renderItem,
renderEmptyState,
],
)
const Footer = React.useCallback(
() =>
listsList.isLoading ? (
<View style={styles.feedFooter}>
<ActivityIndicator />
</View>
) : (
<View />
),
[listsList],
)
return (
<View testID={testID} style={style}>
{data.length > 0 && (
<FlatList
testID={testID ? `${testID}-flatlist` : undefined}
ref={scrollElRef}
data={data}
keyExtractor={item => item._reactKey}
renderItem={renderItemInner}
ListFooterComponent={Footer}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={onRefresh}
tintColor={pal.colors.text}
titleColor={pal.colors.text}
progressViewOffset={headerOffset}
/>
}
contentContainerStyle={s.contentContainer}
style={{paddingTop: headerOffset}}
onEndReached={onEndReached}
onEndReachedThreshold={0.6}
removeClippedSubviews={true}
contentOffset={{x: 0, y: headerOffset * -1}}
// @ts-ignore our .web version only -prf
desktopFixedHeight
/>
)}
</View>
)
},
)
function CreateNewItem({onPress}: {onPress: () => void}) {
const pal = usePalette('default')
return (
<View style={[styles.createNewContainer]}>
<Button type="default" onPress={onPress} style={styles.createNewButton}>
<FontAwesomeIcon icon="plus" style={pal.text as FontAwesomeIconStyle} />
<Text type="button" style={pal.text}>
New Mute List
</Text>
</Button>
</View>
)
}
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},
})

View File

@ -144,8 +144,11 @@ export function Component({onChanged}: {onChanged: () => void}) {
</Text>
</TouchableOpacity>
</View>
<Text type="2xl-bold" style={[styles.titleMiddle, pal.text]}>
Change my handle
<Text
type="2xl-bold"
style={[styles.titleMiddle, pal.text]}
numberOfLines={1}>
Change handle
</Text>
<View style={styles.titleRight}>
{isProcessing ? (

View File

@ -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 (
<View testID="contentModerationModal" style={[pal.view, styles.container]}>
<Text style={[pal.text, styles.title]}>Content Moderation</Text>
<View testID="contentFilteringModal" style={[pal.view, styles.container]}>
<Text style={[pal.text, styles.title]}>Content Filtering</Text>
<ScrollView style={styles.scrollContainer}>
<View style={s.mb10}>
{isIOS ? (
<Text type="md" style={pal.textLight}>
Adult content can only be enabled via the Web at{' '}
<TextLink
style={pal.link}
href="https://staging.bsky.app"
text="staging.bsky.app"
/>
.
</Text>
) : (
<ToggleButton
type="default-light"
label="Enable Adult Content"
isSelected={store.preferences.adultContentEnabled}
onPress={onToggleAdultContent}
style={styles.toggleBtn}
/>
)}
</View>
<ContentLabelPref
group="nsfw"
disabled={!store.preferences.adultContentEnabled}
@ -50,7 +93,7 @@ export function Component({}: {}) {
testID="sendReportBtn"
onPress={onPressDone}
accessibilityRole="button"
accessibilityLabel="Confirm content moderation settings"
accessibilityLabel="Done"
accessibilityHint="">
<LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]}
@ -63,7 +106,7 @@ export function Component({}: {}) {
</View>
</View>
)
}
})
// 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 (
<View style={[styles.contentLabelPref, pal.border]}>
<View style={s.flex1}>
@ -95,7 +153,7 @@ const ContentLabelPref = observer(
) : (
<SelectGroup
current={store.preferences.contentLabels[group]}
onChange={v => 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,
},
})

View File

@ -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<string>('')
const pal = usePalette('default')
const theme = useTheme()
const {track} = useAnalytics()
const [isProcessing, setProcessing] = useState<boolean>(false)
const [name, setName] = useState<string>(list?.list.name || '')
const [description, setDescription] = useState<string>(
list?.list.description || '',
)
const [avatar, setAvatar] = useState<string | undefined>(list?.list.avatar)
const [newAvatar, setNewAvatar] = useState<RNImage | undefined | null>()
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 (
<KeyboardAvoidingView behavior="height">
<ScrollView
style={[pal.view, styles.container]}
testID="createOrEditMuteListModal">
<Text style={[styles.title, pal.text]}>
{list ? 'Edit Mute List' : 'New Mute List'}
</Text>
{error !== '' && (
<View style={styles.errorContainer}>
<ErrorMessage message={error} />
</View>
)}
<Text style={[styles.label, pal.text]}>List Avatar</Text>
<View style={[styles.avi, {borderColor: pal.colors.background}]}>
<UserAvatar
size={80}
avatar={avatar}
onSelectNewAvatar={onSelectNewAvatar}
/>
</View>
<View style={styles.form}>
<View>
<Text style={[styles.label, pal.text]} nativeID="list-name">
List Name
</Text>
<TextInput
testID="editNameInput"
style={[styles.textInput, pal.border, pal.text]}
placeholder="e.g. Spammers"
placeholderTextColor={colors.gray4}
value={name}
onChangeText={v => setName(enforceLen(v, MAX_NAME))}
accessible={true}
accessibilityLabel="Name"
accessibilityHint=""
accessibilityLabelledBy="list-name"
/>
</View>
<View style={s.pb10}>
<Text style={[styles.label, pal.text]} nativeID="list-description">
Description
</Text>
<TextInput
testID="editDescriptionInput"
style={[styles.textArea, pal.border, pal.text]}
placeholder="e.g. Users that repeatedly reply with ads."
placeholderTextColor={colors.gray4}
keyboardAppearance={theme.colorScheme}
multiline
value={description}
onChangeText={v => setDescription(enforceLen(v, MAX_DESCRIPTION))}
accessible={true}
accessibilityLabel="Description"
accessibilityHint=""
accessibilityLabelledBy="list-description"
/>
</View>
{isProcessing ? (
<View style={[styles.btn, s.mt10, {backgroundColor: colors.gray2}]}>
<ActivityIndicator />
</View>
) : (
<TouchableOpacity
testID="saveBtn"
style={s.mt10}
onPress={onPressSave}
accessibilityRole="button"
accessibilityLabel="Save"
accessibilityHint="Creates the mute list">
<LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[styles.btn]}>
<Text style={[s.white, s.bold]}>Save</Text>
</LinearGradient>
</TouchableOpacity>
)}
<TouchableOpacity
testID="cancelBtn"
style={s.mt5}
onPress={onPressCancel}
accessibilityRole="button"
accessibilityLabel="Cancel"
accessibilityHint=""
onAccessibilityEscape={onPressCancel}>
<View style={[styles.btn]}>
<Text style={[s.black, s.bold, pal.text]}>Cancel</Text>
</View>
</TouchableOpacity>
</View>
</ScrollView>
</KeyboardAvoidingView>
)
}
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},
})

View File

@ -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<AspectRatio>(
image.aspectRatio ?? 'None',
)
const [flipHorizontal, setFlipHorizontal] = useState<boolean>(
image.flipHorizontal ?? false,
)
const [flipVertical, setFlipVertical] = useState<boolean>(
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<number>(image.scale ?? 1)
const [position, setPosition] = useState<Position>()
const [isEditing, setIsEditing] = useState(false)
const editorRef = useRef<ImageEditor>(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<number>(image.attributes.scale ?? 1)
const [position, setPosition] = useState<Position | undefined>(
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',
// name: 'rotate-left' as const,
// label: 'Rotate left',
// hint: 'Rotate image left',
// onPress: () => {
// const rotate = (rotation - 90) % 360
// setRotation(rotate)
// image.manipulate({rotate})
// onSetRotate('left')
// },
// },
// {
// name: 'rotate-right',
// name: 'rotate-right' as const,
// label: 'Rotate right',
// hint: 'Rotate image right',
// onPress: () => {
// const rotate = (rotation + 90) % 360
// setRotation(rotate)
// image.manipulate({rotate})
// onSetRotate('right')
// },
// },
{
name: 'flip',
name: 'flip' as const,
label: 'Flip horizontal',
hint: 'Flip image horizontally',
onPress: onFlipHorizontal,
},
{
name: 'flip',
name: 'flip' as const,
label: 'Flip vertically',
hint: 'Flip image vertically',
onPress: onFlipVertical,
},
] as const,
],
[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,26 +173,35 @@ 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 (
<View testID="editImageModal" style={[pal.view, styles.container, s.flex1]}>
<Text style={[styles.title, pal.text]}>Edit image</Text>
<View style={[styles.gap18, s.flexRow]}>
<View>
<View style={[styles.imgContainer, imgEditorStyles, pal.borderDark]}>
<View
style={[styles.imgContainer, pal.borderDark, imgContainerStyles]}>
<ImageEditor
ref={editorRef}
style={styles.imgEditor}
image={isEditing ? image.compressed.path : image.path}
width={width}
height={height}
image={image.compressed.path}
scale={scale}
border={0}
position={position}
onPositionChange={setPosition}
{...dimensions}
/>
</View>
<Slider
@ -250,10 +212,16 @@ export const Component = observer(function ({image, gallery}: Props) {
minimumValue={1}
maximumValue={3}
/>
<View style={[s.flexRow, styles.gap18]}>
<View style={styles.imgControls}>
{getKeys(ratios).map(ratio => {
const {hint, Icon, ...props} = ratios[ratio]
</View>
<View>
{isDesktopWeb ? (
<Text type="sm-bold" style={pal.text}>
Ratios
</Text>
) : null}
<View style={imgControlStyles}>
{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) {
<Pressable
key={ratio}
onPress={() => {
onPressRatio(ratio)
onSetRatio(ratio)
}}
accessibilityLabel={ratio}
accessibilityHint={hint}>
accessibilityHint="">
<Icon
size={labelIconSize}
style={[styles.imgControl, isSelected ? s.blue3 : pal.text]}
@ -281,18 +249,22 @@ export const Component = observer(function ({image, gallery}: Props) {
)
})}
</View>
<View style={[styles.verticalSep, pal.border]} />
<View style={styles.imgControls}>
{adjustments.map(({label, hint, name, onPress}) => (
{isDesktopWeb ? (
<Text type="sm-bold" style={[pal.text, styles.subsection]}>
Transformations
</Text>
) : null}
<View style={imgControlStyles}>
{adjustments.map(({label, name, onPress}) => (
<Pressable
key={label}
onPress={onPress}
accessibilityLabel={label}
accessibilityHint={hint}
accessibilityHint=""
style={styles.flipBtn}>
<MaterialIcons
name={name}
size={label.startsWith('Flip') ? 22 : 24}
size={label?.startsWith('Flip') ? 22 : 24}
style={[
pal.text,
label === 'Flip vertically'
@ -305,7 +277,10 @@ export const Component = observer(function ({image, gallery}: Props) {
</View>
</View>
</View>
<View style={[styles.gap18]}>
<View style={[styles.gap18, styles.bottomSection, pal.border]}>
<Text type="sm-bold" style={pal.text} nativeID="alt-text">
Accessibility
</Text>
<TextInput
testID="altTextImageInput"
style={[styles.textArea, pal.border, pal.text]}
@ -313,11 +288,9 @@ export const Component = observer(function ({image, gallery}: Props) {
multiline
value={altText}
onChangeText={text => 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"
/>
</View>
<View style={styles.btns}>
@ -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,
},
})

View File

@ -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)

View File

@ -57,7 +57,7 @@ export function Component({}: {}) {
code works once!
</Text>
<Text type="sm" style={[styles.description, pal.textLight]}>
( You'll receive one invite code every two weeks. )
(You'll receive one invite code every two weeks.)
</Text>
<ScrollView style={[styles.scrollContainer, pal.border]}>
{store.me.invites.map((invite, i) => (

View File

@ -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 (
<Pressable
testID={`toggleBtn-${list.name}`}
style={[styles.listItem, pal.border]}
accessibilityLabel={`${isSelected ? 'Remove from' : 'Add to'} ${
list.name
}`}
accessibilityHint=""
onPress={() => onToggleSelected(list.uri)}>
<View style={styles.listItemAvi}>
<UserAvatar size={40} avatar={list.avatar} />
</View>
<View style={styles.listItemContent}>
<Text
type="lg"
style={[s.bold, pal.text]}
numberOfLines={1}
lineHeight={1.2}>
{sanitizeDisplayName(list.name)}
</Text>
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
{list.purpose === 'app.bsky.graph.defs#modlist' && 'Mute list'}{' '}
by{' '}
{list.creator.did === store.me.did
? 'you'
: `@${list.creator.handle}`}
</Text>
</View>
<View
style={
isSelected
? [styles.checkbox, palPrimary.border, palPrimary.view]
: [styles.checkbox, pal.borderDark]
}>
{isSelected && (
<FontAwesomeIcon
icon="check"
style={palInverted.text as FontAwesomeIconStyle}
/>
)}
</View>
</Pressable>
)
},
[pal, palPrimary, palInverted, onToggleSelected, selected, store.me.did],
)
const renderEmptyState = React.useCallback(() => {
return (
<EmptyStateWithButton
icon="users-slash"
message="You can subscribe to mute lists to automatically mute all of the users they include. Mute lists are public but your subscription to a mute list is private."
buttonLabel="New Mute List"
onPress={onPressNewMuteList}
/>
)
}, [onPressNewMuteList])
return (
<View testID="listAddRemoveUserModal" style={s.hContentRegion}>
<Text style={[styles.title, pal.text]}>Add {displayName} to lists</Text>
<ListsList
listsList={listsList}
showAddBtns
onPressCreateNew={onPressNewMuteList}
renderItem={renderItem}
renderEmptyState={renderEmptyState}
style={[styles.list, pal.border]}
/>
<View style={[styles.btns, pal.border]}>
<Button
testID="cancelBtn"
type="default"
onPress={onPressCancel}
style={styles.footerBtn}
accessibilityLabel="Cancel"
accessibilityHint=""
onAccessibilityEscape={onPressCancel}
label="Cancel"
/>
<Button
testID="saveBtn"
type="primary"
onPress={onPressSave}
style={styles.footerBtn}
accessibilityLabel="Save changes"
accessibilityHint=""
onAccessibilityEscape={onPressSave}
label="Save Changes"
/>
</View>
</View>
)
},
)
const styles = StyleSheet.create({
container: {
paddingHorizontal: isDesktopWeb ? 0 : 16,
},
title: {
textAlign: 'center',
fontWeight: 'bold',
fontSize: 24,
marginBottom: 10,
},
list: {
flex: 1,
borderTopWidth: 1,
},
btns: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 10,
paddingTop: 10,
paddingBottom: isAndroid ? 10 : 0,
borderTopWidth: 1,
},
footerBtn: {
paddingHorizontal: 24,
paddingVertical: 12,
},
listItem: {
flexDirection: 'row',
alignItems: 'center',
borderTopWidth: 1,
paddingHorizontal: 14,
paddingVertical: 10,
},
listItemAvi: {
width: 54,
paddingLeft: 4,
paddingTop: 8,
paddingBottom: 10,
},
listItemContent: {
flex: 1,
paddingRight: 10,
paddingTop: 10,
paddingBottom: 10,
},
checkbox: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
width: 24,
height: 24,
borderRadius: 6,
marginRight: 8,
},
})

View File

@ -12,6 +12,8 @@ import * as EditProfileModal from './EditProfile'
import * as ServerInputModal from './ServerInput'
import * as ReportPostModal from './ReportPost'
import * as RepostModal from './Repost'
import * as CreateOrEditMuteListModal from './CreateOrEditMuteList'
import * as ListAddRemoveUserModal from './ListAddRemoveUser'
import * as AltImageModal from './AltImage'
import * as ReportAccountModal from './ReportAccount'
import * as DeleteAccountModal from './DeleteAccount'
@ -66,6 +68,12 @@ export const ModalsContainer = observer(function ModalsContainer() {
} else if (activeModal?.name === 'report-account') {
snapPoints = ReportAccountModal.snapPoints
element = <ReportAccountModal.Component {...activeModal} />
} else if (activeModal?.name === 'create-or-edit-mute-list') {
snapPoints = CreateOrEditMuteListModal.snapPoints
element = <CreateOrEditMuteListModal.Component {...activeModal} />
} else if (activeModal?.name === 'list-add-remove-user') {
snapPoints = ListAddRemoveUserModal.snapPoints
element = <ListAddRemoveUserModal.Component {...activeModal} />
} else if (activeModal?.name === 'delete-account') {
snapPoints = DeleteAccountModal.snapPoints
element = <DeleteAccountModal.Component />

View File

@ -11,6 +11,8 @@ import * as EditProfileModal from './EditProfile'
import * as ServerInputModal from './ServerInput'
import * as ReportPostModal from './ReportPost'
import * as ReportAccountModal from './ReportAccount'
import * as CreateOrEditMuteListModal from './CreateOrEditMuteList'
import * as ListAddRemoveUserModal from './ListAddRemoveUser'
import * as DeleteAccountModal from './DeleteAccount'
import * as RepostModal from './Repost'
import * as CropImageModal from './crop-image/CropImage.web'
@ -69,6 +71,10 @@ function Modal({modal}: {modal: ModalIface}) {
element = <ReportPostModal.Component {...modal} />
} else if (modal.name === 'report-account') {
element = <ReportAccountModal.Component {...modal} />
} else if (modal.name === 'create-or-edit-mute-list') {
element = <CreateOrEditMuteListModal.Component {...modal} />
} else if (modal.name === 'list-add-remove-user') {
element = <ListAddRemoveUserModal.Component {...modal} />
} else if (modal.name === 'crop-image') {
element = <CropImageModal.Component {...modal} />
} else if (modal.name === 'delete-account') {

View File

@ -19,7 +19,7 @@ import {usePalette} from 'lib/hooks/usePalette'
const DMCA_LINK = 'https://bsky.app/support/copyright'
export const snapPoints = [500]
export const snapPoints = [550]
export function Component({
postUri,
@ -72,6 +72,19 @@ export function Component({
</View>
),
},
{
key: ComAtprotoModerationDefs.REASONRUDE,
label: (
<View>
<Text style={pal.text} type="md-bold">
Anti-Social Behavior
</Text>
<Text style={pal.textLight}>
Harassment, trolling, or intolerance
</Text>
</View>
),
},
{
key: ComAtprotoModerationDefs.REASONVIOLATION,
label: (

View File

@ -77,7 +77,7 @@ export function TabBar({
],
)
const onLayout = () => {
const onLayout = React.useCallback(() => {
const promises = []
for (let i = 0; i < items.length; i++) {
promises.push(
@ -98,14 +98,17 @@ export function TabBar({
Promise.all(promises).then((layouts: Layout[]) => {
setItemLayouts(layouts)
})
}
}, [containerRef, itemRefs, setItemLayouts, items.length])
const onPressItem = (index: number) => {
const onPressItem = React.useCallback(
(index: number) => {
onSelect?.(index)
if (index === selectedPage) {
onPressSelected?.()
}
}
},
[onSelect, onPressSelected, selectedPage],
)
return (
<View

View File

@ -24,8 +24,10 @@ import {Text} from '../util/text/Text'
import {s} from 'lib/styles'
import {isDesktopWeb, isMobileWeb} from 'platform/detection'
import {usePalette} from 'lib/hooks/usePalette'
import {useSetTitle} from 'lib/hooks/useSetTitle'
import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from 'lib/routes/types'
import {sanitizeDisplayName} from 'lib/strings/display-names'
const REPLY_PROMPT = {_reactKey: '__reply__', _isHighlightedPost: false}
const DELETED = {_reactKey: '__deleted__', _isHighlightedPost: false}
@ -59,6 +61,13 @@ export const PostThread = observer(function PostThread({
}
return []
}, [view.thread])
useSetTitle(
view.thread?.postRecord &&
`${sanitizeDisplayName(
view.thread.post.author.displayName ||
`@${view.thread.post.author.handle}`,
)}: "${view.thread?.postRecord?.text}"`,
)
// events
// =

View File

@ -21,7 +21,7 @@ import {pluralize} from 'lib/strings/helpers'
import {useStores} from 'state/index'
import {PostMeta} from '../util/PostMeta'
import {PostEmbeds} from '../util/post-embeds'
import {PostCtrls} from '../util/PostCtrls'
import {PostCtrls} from '../util/post-ctrls/PostCtrls'
import {PostHider} from '../util/moderation/PostHider'
import {ContentHider} from '../util/moderation/ContentHider'
import {ImageHider} from '../util/moderation/ImageHider'

View File

@ -20,7 +20,7 @@ import {Link} from '../util/Link'
import {UserInfoText} from '../util/UserInfoText'
import {PostMeta} from '../util/PostMeta'
import {PostEmbeds} from '../util/post-embeds'
import {PostCtrls} from '../util/PostCtrls'
import {PostCtrls} from '../util/post-ctrls/PostCtrls'
import {PostHider} from '../util/moderation/PostHider'
import {ContentHider} from '../util/moderation/ContentHider'
import {ImageHider} from '../util/moderation/ImageHider'

View File

@ -8,11 +8,12 @@ import {
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {PostsFeedItemModel} from 'state/models/feeds/posts'
import {ModerationBehaviorCode} from 'lib/labeling/types'
import {Link, DesktopWebTextLink} from '../util/Link'
import {Text} from '../util/text/Text'
import {UserInfoText} from '../util/UserInfoText'
import {PostMeta} from '../util/PostMeta'
import {PostCtrls} from '../util/PostCtrls'
import {PostCtrls} from '../util/post-ctrls/PostCtrls'
import {PostEmbeds} from '../util/post-embeds'
import {PostHider} from '../util/moderation/PostHider'
import {ContentHider} from '../util/moderation/ContentHider'
@ -31,13 +32,14 @@ export const FeedItem = observer(function ({
isThreadChild,
isThreadParent,
showFollowBtn,
ignoreMuteFor,
}: {
item: PostsFeedItemModel
isThreadChild?: boolean
isThreadParent?: boolean
showReplyLine?: boolean
showFollowBtn?: boolean
ignoreMuteFor?: string // NOTE currently disabled, will be addressed in the next PR -prf
ignoreMuteFor?: string
}) {
const store = useStores()
const pal = usePalette('default')
@ -142,12 +144,22 @@ export const FeedItem = observer(function ({
isThreadParent ? styles.outerNoBottom : undefined,
]
// moderation override
let moderation = item.moderation.list
if (
ignoreMuteFor === item.post.author.did &&
moderation.isMute &&
!moderation.noOverride
) {
moderation = {behavior: ModerationBehaviorCode.Show}
}
return (
<PostHider
testID={`feedItem-by-${item.post.author.handle}`}
style={outerStyles}
href={itemHref}
moderation={item.moderation.list}>
moderation={moderation}>
{isThreadChild && (
<View
style={[styles.topReplyLine, {borderColor: pal.colors.replyLine}]}
@ -237,7 +249,7 @@ export const FeedItem = observer(function ({
</View>
)}
<ContentHider
moderation={item.moderation.list}
moderation={moderation}
containerStyle={styles.contentHider}>
{item.richText?.text ? (
<View style={styles.postTextContainer}>

View File

@ -19,8 +19,10 @@ export function FeedSlice({
ignoreMuteFor?: string
}) {
if (slice.moderation.list.behavior === ModerationBehaviorCode.Hide) {
if (!ignoreMuteFor && !slice.moderation.list.noOverride) {
return null
}
}
if (slice.isThread && slice.items.length > 3) {
const last = slice.items.length - 1
return (

View File

@ -32,7 +32,7 @@ export const ProfileCard = observer(
noBorder?: boolean
followers?: AppBskyActorDefs.ProfileView[] | undefined
overrideModeration?: boolean
renderButton?: () => JSX.Element
renderButton?: (profile: AppBskyActorDefs.ProfileViewBasic) => JSX.Element
}) => {
const store = useStores()
const pal = usePalette('default')
@ -92,7 +92,7 @@ export const ProfileCard = observer(
)}
</View>
{renderButton ? (
<View style={styles.layoutButton}>{renderButton()}</View>
<View style={styles.layoutButton}>{renderButton(profile)}</View>
) : undefined}
</View>
{profile.description ? (

View File

@ -23,6 +23,7 @@ import {DropdownButton, DropdownItem} from '../util/forms/DropdownButton'
import * as Toast from '../util/Toast'
import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
import {Text} from '../util/text/Text'
import {TextLink} from '../util/Link'
import {RichText} from '../util/text/RichText'
import {UserAvatar} from '../util/UserAvatar'
import {UserBanner} from '../util/UserBanner'
@ -30,6 +31,7 @@ import {ProfileHeaderWarnings} from '../util/moderation/ProfileHeaderWarnings'
import {usePalette} from 'lib/hooks/usePalette'
import {useAnalytics} from 'lib/analytics'
import {NavigationProp} from 'lib/routes/types'
import {listUriToHref} from 'lib/strings/url-helpers'
import {isDesktopWeb, isNative} from 'platform/detection'
import {FollowState} from 'state/models/cache/my-follows'
import {shareUrl} from 'lib/sharing'
@ -146,12 +148,21 @@ const ProfileHeaderLoaded = observer(
navigation.push('ProfileFollows', {name: view.handle})
}, [track, navigation, view])
const onPressShare = React.useCallback(async () => {
const onPressShare = React.useCallback(() => {
track('ProfileHeader:ShareButtonClicked')
const url = toShareUrl(`/profile/${view.handle}`)
shareUrl(url)
}, [track, view])
const onPressAddRemoveLists = React.useCallback(() => {
track('ProfileHeader:AddToListsButtonClicked')
store.shell.openModal({
name: 'list-add-remove-user',
subject: view.did,
displayName: view.displayName || view.handle,
})
}, [track, view, store])
const onPressMuteAccount = React.useCallback(async () => {
track('ProfileHeader:MuteAccountButtonClicked')
try {
@ -233,6 +244,11 @@ const ProfileHeaderLoaded = observer(
label: 'Share',
onPress: onPressShare,
},
{
testID: 'profileHeaderDropdownListAddRemoveBtn',
label: 'Add to Lists',
onPress: onPressAddRemoveLists,
},
]
if (!isMe) {
items.push({sep: true})
@ -269,6 +285,7 @@ const ProfileHeaderLoaded = observer(
onPressUnblockAccount,
onPressBlockAccount,
onPressReportAccount,
onPressAddRemoveLists,
])
const blockHide = !isMe && (view.viewer.blocking || view.viewer.blockedBy)
@ -422,31 +439,42 @@ const ProfileHeaderLoaded = observer(
{view.viewer.blocking ? (
<View
testID="profileHeaderBlockedNotice"
style={[styles.moderationNotice, pal.view, pal.border]}>
<FontAwesomeIcon icon="ban" style={[pal.text, s.mr5]} />
<Text type="md" style={[s.mr2, pal.text]}>
style={[styles.moderationNotice, pal.viewLight]}>
<FontAwesomeIcon icon="ban" style={[pal.text]} />
<Text type="lg-medium" style={pal.text}>
Account blocked
</Text>
</View>
) : view.viewer.muted ? (
<View
testID="profileHeaderMutedNotice"
style={[styles.moderationNotice, pal.view, pal.border]}>
style={[styles.moderationNotice, pal.viewLight]}>
<FontAwesomeIcon
icon={['far', 'eye-slash']}
style={[pal.text, s.mr5]}
style={[pal.text]}
/>
<Text type="md" style={[s.mr2, pal.text]}>
Account muted
<Text type="lg-medium" style={pal.text}>
Account muted{' '}
{view.viewer.mutedByList && (
<Text type="lg-medium" style={pal.text}>
by{' '}
<TextLink
type="lg-medium"
style={pal.link}
href={listUriToHref(view.viewer.mutedByList.uri)}
text={view.viewer.mutedByList.name}
/>
</Text>
)}
</Text>
</View>
) : undefined}
{view.viewer.blockedBy && (
<View
testID="profileHeaderBlockedNotice"
style={[styles.moderationNotice, pal.view, pal.border]}>
<FontAwesomeIcon icon="ban" style={[pal.text, s.mr5]} />
<Text type="md" style={[s.mr2, pal.text]}>
style={[styles.moderationNotice, pal.viewLight]}>
<FontAwesomeIcon icon="ban" style={[pal.text]} />
<Text type="lg-medium" style={pal.text}>
This account has blocked you
</Text>
</View>
@ -595,10 +623,10 @@ const styles = StyleSheet.create({
moderationNotice: {
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
paddingHorizontal: 16,
paddingVertical: 14,
gap: 8,
},
br40: {borderRadius: 40},

View File

@ -14,7 +14,8 @@ export const BlurView = ({
...props
}: React.PropsWithChildren<BlurViewProps>) => {
// @ts-ignore using an RNW-specific attribute here -prf
style = addStyle(style, {backdropFilter: `blur(${blurAmount || 10}px`})
let blur = `blur(${blurAmount || 10}px`
style = addStyle(style, {backdropFilter: blur, WebkitBackdropFilter: blur})
if (blurType === 'dark') {
style = addStyle(style, styles.dark)
} else {

View File

@ -10,17 +10,19 @@ import {UserGroupIcon} from 'lib/icons'
import {usePalette} from 'lib/hooks/usePalette'
export function EmptyState({
testID,
icon,
message,
style,
}: {
testID?: string
icon: IconProp | 'user-group'
message: string
style?: StyleProp<ViewStyle>
}) {
const pal = usePalette('default')
return (
<View style={[styles.container, style]}>
<View testID={testID} style={[styles.container, style]}>
<View style={styles.iconContainer}>
{icon === 'user-group' ? (
<UserGroupIcon size="64" style={styles.icon} />

View File

@ -0,0 +1,88 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {IconProp} from '@fortawesome/fontawesome-svg-core'
import {Text} from './text/Text'
import {Button} from './forms/Button'
import {usePalette} from 'lib/hooks/usePalette'
import {s} from 'lib/styles'
interface Props {
testID?: string
icon: IconProp
message: string
buttonLabel: string
onPress: () => void
}
export function EmptyStateWithButton(props: Props) {
const pal = usePalette('default')
const palInverted = usePalette('inverted')
return (
<View testID={props.testID} style={styles.container}>
<View style={styles.iconContainer}>
<FontAwesomeIcon
icon={props.icon}
style={[styles.icon, pal.text]}
size={62}
/>
</View>
<Text type="xl-medium" style={[s.textCenter, pal.text]}>
{props.message}
</Text>
<View style={styles.btns}>
<Button
testID={props.testID ? `${props.testID}-button` : undefined}
type="inverted"
style={styles.btn}
onPress={props.onPress}>
<FontAwesomeIcon
icon="plus"
style={palInverted.text as FontAwesomeIconStyle}
size={14}
/>
<Text type="lg-medium" style={palInverted.text}>
{props.buttonLabel}
</Text>
</Button>
</View>
</View>
)
}
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,
},
})

View File

@ -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)
},
},
{

View File

@ -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',

View File

@ -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 <DesktopWebHeader title={title} />
return <DesktopWebHeader title={title} renderButton={renderButton} />
}
return null
} else {
@ -79,13 +81,23 @@ export const ViewHeader = observer(function ({
{title}
</Text>
</View>
{renderButton ? (
renderButton()
) : (
<View style={canGoBack ? styles.backBtn : styles.backBtnWide} />
)}
</Container>
)
}
})
function DesktopWebHeader({title}: {title: string}) {
function DesktopWebHeader({
title,
renderButton,
}: {
title: string
renderButton?: () => JSX.Element
}) {
const pal = usePalette('default')
return (
<CenteredView style={[styles.header, styles.desktopHeader, pal.border]}>
@ -94,6 +106,7 @@ function DesktopWebHeader({title}: {title: string}) {
{title}
</Text>
</View>
{renderButton?.()}
</CenteredView>
)
}

View File

@ -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',
},

View File

@ -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<ViewStyle, Record<ButtonType, ViewStyle>>(
@ -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 (
<Pressable
style={[typeOuterStyle, styles.outer, style]}
style={getStyle}
onPress={onPressWrapped}
testID={testID}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
accessibilityHint={accessibilityHint}
accessibilityLabelledBy={accessibilityLabelledBy}>
accessibilityLabelledBy={accessibilityLabelledBy}
onAccessibilityEscape={onAccessibilityEscape}>
{label ? (
<Text type="button" style={[typeLabelStyle, labelStyle]}>
{label}

View File

@ -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}
/>
)}
<Text style={[styles.label, pal.text]}>{item.label}</Text>
<Text style={[styles.label, pal.text]} numberOfLines={1}>
{item.label}
</Text>
</TouchableOpacity>
)
} else if (isSep(item)) {

View File

@ -63,6 +63,5 @@ const styles = StyleSheet.create({
position: 'absolute',
left: 6,
bottom: 6,
width: 46,
},
})

View File

@ -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<TriggerableAnimatedRef | null>(null)
// const likeRef = React.useRef<TriggerableAnimatedRef | null>(null)
const onRepost = () => {
const onRepost = useCallback(() => {
store.shell.closeModal()
if (!opts.isReposted) {
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,
},
})
if (isNative) {
ReactNativeHapticFeedback.trigger(hapticImpact)
}
const onPressToggleRepostWrapper = () => {
store.shell.openModal({
name: 'repost',
onRepost: onRepost,
onQuote: onQuote,
isReposted: opts.isReposted,
})
}
}, [
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">
<CommentBottomArrow
style={[defaultCtrlColor, opts.big ? s.mt2 : styles.mt1]}
strokeWidth={3}
@ -193,39 +192,7 @@ export function PostCtrls(opts: PostCtrlsOpts) {
</Text>
) : undefined}
</TouchableOpacity>
<TouchableOpacity
testID="repostBtn"
hitSlop={HITSLOP}
onPress={onPressToggleRepostWrapper}
style={styles.ctrl}
accessibilityRole="button"
accessibilityLabel={opts.isReposted ? 'Undo repost' : 'Repost'}
accessibilityHint={
opts.isReposted
? `Remove your repost of ${opts.author}'s post`
: `Repost or quote post ${opts.author}'s post`
}>
<RepostIcon
style={
opts.isReposted
? (styles.ctrlIconReposted as StyleProp<ViewStyle>)
: defaultCtrlColor
}
strokeWidth={2.4}
size={opts.big ? 24 : 20}
/>
{typeof opts.repostCount !== 'undefined' ? (
<Text
testID="repostCount"
style={
opts.isReposted
? [s.bold, s.green3, s.f15, s.ml5]
: [defaultCtrlColor, s.f15, s.ml5]
}>
{opts.repostCount}
</Text>
) : undefined}
</TouchableOpacity>
<RepostButton {...opts} onRepost={onRepost} onQuote={onQuote} />
<TouchableOpacity
testID="likeBtn"
style={styles.ctrl}
@ -234,9 +201,7 @@ export function PostCtrls(opts: PostCtrlsOpts) {
accessibilityRole="button"
accessibilityLabel={opts.isLiked ? 'Unlike' : 'Like'}
accessibilityHint={
opts.isReposted
? `Removes like from ${opts.author}'s post`
: `Like ${opts.author}'s post`
opts.isReposted ? `Removes like from the post` : `Like the post`
}>
{opts.isLiked ? (
<HeartIconSolid
@ -309,9 +274,6 @@ const styles = StyleSheet.create({
padding: 5,
margin: -5,
},
ctrlIconReposted: {
color: colors.green3,
},
ctrlIconLiked: {
color: colors.red3,
},

View File

@ -0,0 +1,95 @@
import React, {useCallback} from 'react'
import {StyleProp, StyleSheet, TouchableOpacity, ViewStyle} from 'react-native'
import {RepostIcon} from 'lib/icons'
import {s, colors} from 'lib/styles'
import {useTheme} from 'lib/ThemeContext'
import {Text} from '../text/Text'
import {useStores} from 'state/index'
const HITSLOP = {top: 5, left: 5, bottom: 5, right: 5}
interface Props {
isReposted: boolean
repostCount?: number
big?: boolean
onRepost: () => 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 (
<TouchableOpacity
testID="repostBtn"
hitSlop={HITSLOP}
onPress={onPressToggleRepostWrapper}
style={styles.control}
accessibilityRole="button"
accessibilityLabel={isReposted ? 'Undo repost' : 'Repost'}
accessibilityHint={
isReposted
? `Remove your repost of the post`
: `Repost or quote post the post`
}>
<RepostIcon
style={
isReposted
? (styles.reposted as StyleProp<ViewStyle>)
: defaultControlColor
}
strokeWidth={2.4}
size={big ? 24 : 20}
/>
{typeof repostCount !== 'undefined' ? (
<Text
testID="repostCount"
style={
isReposted
? [s.bold, s.green3, s.f15, s.ml5]
: [defaultControlColor, s.f15, s.ml5]
}>
{repostCount}
</Text>
) : undefined}
</TouchableOpacity>
)
}
const styles = StyleSheet.create({
control: {
flexDirection: 'row',
alignItems: 'center',
padding: 5,
margin: -5,
},
reposted: {
color: colors.green3,
},
repostCount: {
color: 'currentColor',
},
})

View File

@ -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 (
<DropdownButton
type="bare"
items={items}
bottomOffset={4}
openToRight
rightOffset={-40}>
<View
style={[
styles.control,
(isReposted
? styles.reposted
: defaultControlColor) as StyleProp<ViewStyle>,
]}>
<RepostIcon strokeWidth={2.4} size={big ? 24 : 20} />
{typeof repostCount !== 'undefined' ? (
<Text
testID="repostCount"
type={isReposted ? 'md-bold' : 'md-medium'}
style={styles.repostCount}>
{repostCount ?? 0}
</Text>
) : undefined}
</View>
</DropdownButton>
)
}
const styles = StyleSheet.create({
control: {
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
reposted: {
color: colors.green3,
},
repostCount: {
color: 'currentColor',
},
})

View File

@ -210,6 +210,5 @@ const styles = StyleSheet.create({
position: 'absolute',
left: 6,
bottom: 6,
width: 46,
},
})

View File

@ -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,

View File

@ -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.
</Text>
</>
)
@ -289,5 +289,6 @@ const styles = StyleSheet.create({
trashIcon: {
color: 'red',
minWidth: 16,
},
})

View File

@ -62,7 +62,7 @@ export const HomeScreen = withAuthRequired(
setSelectedPage(index)
store.shell.setIsDrawerSwipeDisabled(index > 0)
},
[store],
[store, setSelectedPage],
)
const onPressSelected = React.useCallback(() => {

View File

@ -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<CommonNavigatorParams, 'Moderation'>
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 (
<CenteredView
style={[
s.hContentRegion,
pal.border,
isDesktopWeb ? styles.desktopContainer : pal.viewLight,
]}
testID="moderationScreen">
<ViewHeader title="Moderation" showOnDesktop />
<View style={styles.spacer} />
<TouchableOpacity
testID="contentFilteringBtn"
style={[styles.linkCard, pal.view]}
onPress={onPressContentFiltering}
accessibilityRole="tab"
accessibilityHint="Content filtering"
accessibilityLabel="">
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="eye"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
<Text type="lg" style={pal.text}>
Content filtering
</Text>
</TouchableOpacity>
<Link
testID="mutelistsBtn"
style={[styles.linkCard, pal.view]}
href="/moderation/mute-lists">
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="users-slash"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
<Text type="lg" style={pal.text}>
Mute lists
</Text>
</Link>
<Link
testID="mutedAccountsBtn"
style={[styles.linkCard, pal.view]}
href="/moderation/muted-accounts">
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="user-slash"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
<Text type="lg" style={pal.text}>
Muted accounts
</Text>
</Link>
<Link
testID="blockedAccountsBtn"
style={[styles.linkCard, pal.view]}
href="/moderation/blocked-accounts">
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="ban"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
<Text type="lg" style={pal.text}>
Blocked accounts
</Text>
</Link>
</CenteredView>
)
}),
)
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,
},
})

View File

@ -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<CommonNavigatorParams, 'BlockedAccounts'>
export const BlockedAccounts = withAuthRequired(
type Props = NativeStackScreenProps<
CommonNavigatorParams,
'ModerationBlockedAccounts'
>
export const ModerationBlockedAccounts = withAuthRequired(
observer(({}: Props) => {
const pal = usePalette('default')
const store = useStores()

View File

@ -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<NavigationProp>()
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 (
<EmptyStateWithButton
testID="emptyMuteLists"
icon="users-slash"
message="You can subscribe to mute lists to automatically mute all of the users they include. Mute lists are public but your subscription to a mute list is private."
buttonLabel="New Mute List"
onPress={onPressNewMuteList}
/>
)
}, [onPressNewMuteList])
const renderHeaderButton = React.useCallback(
() => (
<Button
type="primary-light"
onPress={onPressNewMuteList}
style={styles.createBtn}>
<FontAwesomeIcon
icon="plus"
style={pal.link as FontAwesomeIconStyle}
size={18}
/>
</Button>
),
[onPressNewMuteList, pal],
)
return (
<CenteredView
style={[
styles.container,
isDesktopWeb && styles.containerDesktop,
pal.view,
pal.border,
]}
testID="moderationMutelistsScreen">
<ViewHeader
title="Mute Lists"
showOnDesktop
renderButton={renderHeaderButton}
/>
<ListsList
listsList={mutelists}
showAddBtns={isDesktopWeb}
renderEmptyState={renderEmptyState}
onPressCreateNew={onPressNewMuteList}
/>
</CenteredView>
)
})
const styles = StyleSheet.create({
container: {
flex: 1,
paddingBottom: isDesktopWeb ? 0 : 100,
},
containerDesktop: {
borderLeftWidth: 1,
borderRightWidth: 1,
},
createBtn: {
width: 40,
},
})

View File

@ -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<CommonNavigatorParams, 'MutedAccounts'>
export const MutedAccounts = withAuthRequired(
type Props = NativeStackScreenProps<
CommonNavigatorParams,
'ModerationMutedAccounts'
>
export const ModerationMutedAccounts = withAuthRequired(
observer(({}: Props) => {
const pal = usePalette('default')
const store = useStores()

View File

@ -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<CommonNavigatorParams, 'Profile'>
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,6 +120,31 @@ export const ProfileScreen = withAuthRequired(
}, [uiState.showLoadingMoreFooter])
const renderItem = React.useCallback(
(item: any) => {
if (uiState.selectedView === Sections.Lists) {
if (item === ProfileUiModel.LOADING_ITEM) {
return <ProfileCardFeedLoadingPlaceholder />
} else if (item._reactKey === '__error__') {
return (
<View style={s.p5}>
<ErrorMessage
message={item.error}
onPressTryAgain={onPressTryAgain}
/>
</View>
)
} else if (item === ProfileUiModel.EMPTY_ITEM) {
return (
<EmptyState
testID="listsEmpty"
icon="list-ul"
message="No lists yet!"
style={styles.emptyState}
/>
)
} else {
return <ListCard testID={`list-${item.name}`} list={item} />
}
} else {
if (item === ProfileUiModel.END_ITEM) {
return <Text style={styles.endItem}>- end of feed -</Text>
} else if (item === ProfileUiModel.LOADING_ITEM) {
@ -153,13 +185,22 @@ export const ProfileScreen = withAuthRequired(
/>
)
} else if (item instanceof PostsFeedSliceModel) {
return <FeedSlice slice={item} ignoreMuteFor={uiState.profile.did} />
return (
<FeedSlice slice={item} ignoreMuteFor={uiState.profile.did} />
)
} else if (item instanceof AlgoItemModel) {
return <AlgoItem item={item} />
}
}
return <View />
},
[onPressTryAgain, uiState],
[
onPressTryAgain,
uiState.selectedView,
uiState.profile.did,
uiState.feed.isBlocking,
uiState.feed.isBlockedBy,
],
)
return (

View File

@ -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<CommonNavigatorParams, 'ProfileList'>
export const ProfileListScreen = withAuthRequired(
observer(({route}: Props) => {
const store = useStores()
const navigation = useNavigation<NavigationProp>()
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 <EmptyState icon="users-slash" message="This list is empty!" />
}, [])
const renderHeaderBtn = React.useCallback(() => {
return (
<View style={styles.headerBtns}>
{list?.isOwner && (
<Button
type="default"
label="Delete List"
testID="deleteListBtn"
accessibilityLabel="Delete list"
accessibilityHint=""
onPress={onPressDeleteList}
/>
)}
{list?.isOwner && (
<Button
type="default"
label="Edit List"
testID="editListBtn"
accessibilityLabel="Edit list"
accessibilityHint=""
onPress={onPressEditList}
/>
)}
{list.list?.viewer?.muted ? (
<Button
type="inverted"
label="Unsubscribe"
testID="unsubscribeListBtn"
accessibilityLabel="Unsubscribe from list"
accessibilityHint=""
onPress={onToggleSubscribed}
/>
) : (
<Button
type="primary"
label="Subscribe & Mute"
testID="subscribeListBtn"
accessibilityLabel="Subscribe to this list"
accessibilityHint="Mutes the users included in this list"
onPress={onToggleSubscribed}
/>
)}
</View>
)
}, [
list?.isOwner,
list.list?.viewer?.muted,
onPressDeleteList,
onPressEditList,
onToggleSubscribed,
])
return (
<CenteredView
style={[
styles.container,
isDesktopWeb && styles.containerDesktop,
pal.view,
pal.border,
]}
testID="moderationMutelistsScreen">
<ViewHeader title="" renderButton={renderHeaderBtn} />
<ListItems
list={list}
renderEmptyState={renderEmptyState}
onToggleSubscribed={onToggleSubscribed}
onPressEditList={onPressEditList}
onPressDeleteList={onPressDeleteList}
/>
</CenteredView>
)
}),
)
const styles = StyleSheet.create({
headerBtns: {
flexDirection: 'row',
gap: 8,
},
container: {
flex: 1,
paddingBottom: isDesktopWeb ? 0 : 100,
},
containerDesktop: {
borderLeftWidth: 1,
borderRightWidth: 1,
},
})

View File

@ -121,7 +121,7 @@ export const SearchScreen = withAuthRequired(
<TouchableWithoutFeedback onPress={onPress} accessible={false}>
<View style={[pal.view, styles.container]}>
<HeaderWithInput
isInputFocused={true}
isInputFocused={isInputFocused}
query={query}
setIsInputFocused={setIsInputFocused}
onChangeQuery={onChangeQuery}

View File

@ -127,11 +127,6 @@ export const SettingsScreen = withAuthRequired(
store.shell.openModal({name: 'invite-codes'})
}, [track, store])
const onPressContentFiltering = React.useCallback(() => {
track('Settings:ContentfilteringButtonClicked')
store.shell.openModal({name: 'content-filtering-settings'})
}, [track, store])
const onPressContentLanguages = React.useCallback(() => {
track('Settings:ContentlanguagesButtonClicked')
store.shell.openModal({name: 'content-languages-settings'})
@ -252,7 +247,9 @@ export const SettingsScreen = withAuthRequired(
Add account
</Text>
</TouchableOpacity>
<View style={styles.spacer20} />
<Text type="xl-bold" style={[pal.text, styles.heading]}>
Invite a friend
</Text>
@ -287,9 +284,6 @@ export const SettingsScreen = withAuthRequired(
<View style={styles.spacer20} />
<Text type="xl-bold" style={[pal.text, styles.heading]}>
Moderation
</Text>
<Link
testID="bookmarkedAlgosBtn"
style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
@ -306,51 +300,7 @@ export const SettingsScreen = withAuthRequired(
Custom Algorithms
</Text>
</Link>
<TouchableOpacity
testID="contentFilteringBtn"
style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
onPress={isSwitching ? undefined : onPressContentFiltering}
accessibilityHint="Content moderation"
accessibilityLabel="Opens configurable content moderation settings">
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="eye"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
<Text type="lg" style={pal.text}>
Content moderation
</Text>
</TouchableOpacity>
<Link
testID="mutedAccountsBtn"
style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
href="/settings/muted-accounts">
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon={['far', 'eye-slash']}
style={pal.text as FontAwesomeIconStyle}
/>
</View>
<Text type="lg" style={pal.text}>
Muted accounts
</Text>
</Link>
<Link
testID="blockedAccountsBtn"
style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
href="/settings/blocked-accounts">
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="ban"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
<Text type="lg" style={pal.text}>
Blocked accounts
</Text>
</Link>
<View style={styles.spacer20} />
<Text type="xl-bold" style={[pal.text, styles.heading]}>
Advanced
</Text>
@ -398,8 +348,8 @@ export const SettingsScreen = withAuthRequired(
style={pal.text as FontAwesomeIconStyle}
/>
</View>
<Text type="lg" style={pal.text}>
Change my handle
<Text type="lg" style={pal.text} numberOfLines={1}>
Change handle
</Text>
</TouchableOpacity>
<View style={styles.spacer20} />

View File

@ -28,6 +28,7 @@ import {
MagnifyingGlassIcon2Solid,
MoonIcon,
UserIconSolid,
HandIcon,
} from 'lib/icons'
import {UserAvatar} from 'view/com/util/UserAvatar'
import {Text} from 'view/com/util/text/Text'
@ -94,6 +95,12 @@ export const DrawerContent = observer(() => {
onPressTab('MyProfile')
}, [onPressTab])
const onPressModeration = React.useCallback(() => {
track('Menu:ItemClicked', {url: 'Moderation'})
navigation.navigate('Moderation')
store.shell.closeDrawer()
}, [navigation, track, store.shell])
const onPressSettings = React.useCallback(() => {
track('Menu:ItemClicked', {url: 'Settings'})
navigation.navigate('Settings')
@ -215,11 +222,28 @@ export const DrawerContent = observer(() => {
}
label="Notifications"
accessibilityLabel="Notifications"
accessibilityHint={`${store.me.notifications.unreadCountLabel} unread`}
accessibilityHint={
notifications.unreadCountLabel === ''
? ''
: `${notifications.unreadCountLabel} unread`
}
count={notifications.unreadCountLabel}
bold={isAtNotifications}
onPress={onPressNotifications}
/>
<MenuItem
icon={
<HandIcon
strokeWidth={5}
style={pal.text as FontAwesomeIconStyle}
size={24}
/>
}
label="Moderation"
accessibilityLabel="Moderation"
accessibilityHint=""
onPress={onPressModeration}
/>
<MenuItem
icon={
isAtMyProfile ? (
@ -404,6 +428,7 @@ const styles = StyleSheet.create({
flex: 1,
paddingTop: 20,
paddingBottom: 50,
maxWidth: 300,
},
viewDarkMode: {
backgroundColor: '#1B1919',

View File

@ -38,6 +38,7 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => {
useNavigationTabState()
const {footerMinimalShellTransform} = useMinimalShellMode()
const {notifications} = store.me
const onPressTab = React.useCallback(
(tab: string) => {
@ -138,11 +139,15 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => {
)
}
onPress={onPressNotifications}
notificationCount={store.me.notifications.unreadCountLabel}
notificationCount={notifications.unreadCountLabel}
accessible={true}
accessibilityRole="tab"
accessibilityLabel="Notifications"
accessibilityHint={`${store.me.notifications.unreadCountLabel} unread`}
accessibilityHint={
notifications.unreadCountLabel === ''
? ''
: `${notifications.unreadCountLabel} unread`
}
/>
<Btn
testID="bottomBarProfileBtn"

View File

@ -29,6 +29,7 @@ import {
CogIcon,
CogIconSolid,
ComposeIcon2,
HandIcon,
} from 'lib/icons'
import {getCurrentRoute, isTab, isStateAtTabRoot} from 'lib/routes/helpers'
import {NavigationProp} from 'lib/routes/types'
@ -203,6 +204,24 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() {
}
label="Notifications"
/>
<NavItem
href="/moderation"
icon={
<HandIcon
strokeWidth={5.5}
style={pal.text as FontAwesomeIconStyle}
size={24}
/>
}
iconFilled={
<FontAwesomeIcon
icon="hand"
style={pal.text as FontAwesomeIconStyle}
size={20}
/>
}
label="Moderation"
/>
{store.session.hasSession && (
<NavItem
href={`/profile/${store.me.handle}`}
@ -279,7 +298,7 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
width: 138,
width: 140,
borderRadius: 24,
paddingVertical: 10,
paddingHorizontal: 16,

View File

@ -11,13 +11,14 @@ import {s} from 'lib/styles'
import {useStores} from 'state/index'
import {pluralize} from 'lib/strings/helpers'
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
import {MoonIcon} from 'lib/icons'
import {MoonIcon, SunIcon} from 'lib/icons'
import {formatCount} from 'view/com/util/numeric/format'
export const DesktopRightNav = observer(function DesktopRightNav() {
const store = useStores()
const pal = usePalette('default')
const mode = useColorSchemeStyle('Light', 'Dark')
const otherMode = mode === 'Dark' ? 'Light' : 'Dark'
const onDarkmodePress = React.useCallback(() => {
store.shell.setDarkMode(!store.shell.darkMode)
@ -71,10 +72,14 @@ export const DesktopRightNav = observer(function DesktopRightNav() {
: 'Sets display to dark mode'
}>
<View style={[pal.viewLight, styles.darkModeToggleIcon]}>
{mode === 'Dark' ? (
<SunIcon size={18} style={pal.textLight} />
) : (
<MoonIcon size={18} style={pal.textLight} />
)}
</View>
<Text type="sm" style={pal.textLight}>
{mode} mode
{otherMode} mode
</Text>
</TouchableOpacity>
</View>

View File

@ -1,4 +1,4 @@
import React from 'react'
import React, {useEffect} from 'react'
import {observer} from 'mobx-react-lite'
import {View, StyleSheet, TouchableOpacity} from 'react-native'
import {useStores} from 'state/index'
@ -14,11 +14,21 @@ import {RoutesContainer, FlatNavigator} from '../../Navigation'
import {DrawerContent} from './Drawer'
import {useWebMediaQueries} from '../../lib/hooks/useWebMediaQueries'
import {BottomBarWeb} from './bottom-bar/BottomBarWeb'
import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from 'lib/routes/types'
const ShellInner = observer(() => {
const store = useStores()
const {isDesktop} = useWebMediaQueries()
const navigator = useNavigation<NavigationProp>()
useEffect(() => {
navigator.addListener('state', () => {
store.shell.closeAnyActiveElement()
})
}, [navigator, store.shell])
return (
<>
<View style={s.hContentRegion}>

View File

@ -29,7 +29,7 @@
jsonpointer "^5.0.0"
leven "^3.1.0"
"@atproto/api@*", "@atproto/api@0.2.11":
"@atproto/api@*":
version "0.2.11"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.2.11.tgz#53b70b0f4942b2e2dd5cb46433f133cde83917bf"
integrity sha512-5JY1Ii/81Bcy1ZTGRqALsaOdc8fIJTSlMNoSptpGH73uAPQE93weDrb8sc3KoxWi1G2ss3IIBSLPJWxALocJSQ==
@ -40,6 +40,17 @@
tlds "^1.234.0"
typed-emitter "^2.1.0"
"@atproto/api@0.3.3":
version "0.3.3"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.3.3.tgz#8c8d41567beb7b37217f76d2aacf2c280e9fd07e"
integrity sha512-BlgpYbdPO0KSBypg2KgqHM0kS2Pk82P3X0w2rJs/vrdcMl72d2WeI9kQ5PPFiz80p6C6XcLcpnzzKKtQeFvh4A==
dependencies:
"@atproto/common-web" "*"
"@atproto/uri" "*"
"@atproto/xrpc" "*"
tlds "^1.234.0"
typed-emitter "^2.1.0"
"@atproto/common-web@*":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.1.0.tgz#5529fa66f9533aa00cfd13f0a25757df7b26bd3d"
@ -137,10 +148,10 @@
resolved "https://registry.yarnpkg.com/@atproto/nsid/-/nsid-0.0.1.tgz#0cdc00cefe8f0b1385f352b9f57b3ad37fff09a4"
integrity sha512-t5M6/CzWBVYoBbIvfKDpqPj/+ZmyoK9ydZSStcTXosJ27XXwOPhz0VDUGKK2SM9G5Y7TPes8S5KTAU0UdVYFCw==
"@atproto/pds@^0.1.5":
version "0.1.5"
resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.1.5.tgz#59411497f2d85b6706ab793e8f7f618bdb8c51a3"
integrity sha512-QtTf2mbqO5MEsrXPTFU43dSb0WT3TzaLw5mL++9w18CZDMvdmv2uJXKeaSiU+u3WJEtRpRs5hoLSdfrJ2i3PuA==
"@atproto/pds@^0.1.8":
version "0.1.8"
resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.1.8.tgz#cf1a9bab2301c3fe1120c63576153ac5a20bf70d"
integrity sha512-I493U+/NNU9D8L8tVbM/OpD6gQ6/Mv7uE+/i4a1vfBGO6NqYJ6jKw3qeCy4jq3NVbTxcs+lSSpK27hgApx4PtA==
dependencies:
"@atproto/api" "*"
"@atproto/common" "*"
@ -8720,17 +8731,17 @@ expo-image-manipulator@^11.1.1:
dependencies:
expo-image-loader "~4.1.0"
expo-image-picker@~14.1.1:
expo-image-picker@^14.1.1:
version "14.1.1"
resolved "https://registry.yarnpkg.com/expo-image-picker/-/expo-image-picker-14.1.1.tgz#181f1348ba6a43df7b87cee4a601d45c79b7c2d7"
integrity sha512-SvWtnkLW7jp5Ntvk3lVcRQmhFYja8psmiR7O6P/+7S6f4llt3vaFwb4I3+pUXqJxxpi7BHc2+95qOLf0SFOIag==
dependencies:
expo-image-loader "~4.1.0"
expo-image@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/expo-image/-/expo-image-1.2.1.tgz#3f377cb3142de2107903f4e4f88a7f44785dee18"
integrity sha512-pYZFN0ctuIBA+sqUiw70rHQQ04WDyEcF549ObArdj0MNgSUCBJMFmu/jrWDmxOpEMF40lfLVIZKigJT7Bw+GYA==
expo-image@^1.2.3:
version "1.2.3"
resolved "https://registry.yarnpkg.com/expo-image/-/expo-image-1.2.3.tgz#f3582d725ffb7437f8ce946ad44fe33f0aa0603d"
integrity sha512-+Mnx6rcneWSUGfHkUDV3cQ3R4lVwoIDFs/tcXVqnlxyNJdNxpW2cge9pS2Hpj3UDoZVhZPLR8LHS8E9wEaC0NA==
expo-json-utils@~0.5.0:
version "0.5.1"