Merge branch 'main' into app-511-metrics-overhaul
This commit is contained in:
@@ -0,0 +1 @@
|
||||
SENTRY_AUTH_TOKEN=
|
||||
+5
-1
@@ -1,6 +1,10 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: '@react-native-community',
|
||||
extends: [
|
||||
'@react-native-community',
|
||||
'plugin:react-native-a11y/ios',
|
||||
'prettier',
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['@typescript-eslint', 'detox'],
|
||||
ignorePatterns: [
|
||||
|
||||
@@ -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. -->
|
||||
@@ -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. -->
|
||||
@@ -18,8 +18,10 @@ jobs:
|
||||
uses: actions/checkout@v3
|
||||
- name: Yarn install
|
||||
run: yarn
|
||||
- name: Typescript & Lint check
|
||||
- name: Lint check
|
||||
run: yarn lint
|
||||
- name: Type check
|
||||
run: yarn typecheck
|
||||
testing:
|
||||
name: Run tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
+5
-1
@@ -92,4 +92,8 @@ web-build/
|
||||
|
||||
# Android & iOS folders
|
||||
android/
|
||||
ios/
|
||||
ios/
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.*
|
||||
+2
-5
@@ -17,7 +17,7 @@ ENV CGO_ENABLED=1
|
||||
COPY . .
|
||||
|
||||
#
|
||||
# Generate the Javascript webpack.
|
||||
# Generate the JavaScript webpack.
|
||||
#
|
||||
RUN mkdir --parents $NVM_DIR && \
|
||||
wget \
|
||||
@@ -35,11 +35,8 @@ 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.
|
||||
# Generate the bskyweb Go binary.
|
||||
#
|
||||
RUN cd bskyweb/ && \
|
||||
go mod download && \
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -1,50 +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: [bsky.app](https://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)**
|
||||
|
||||
## Various notes
|
||||
Links:
|
||||
|
||||
### Debugging
|
||||
- [Build instructions](./docs/build.md)
|
||||
- [ATProto repo](https://github.com/bluesky-social/atproto)
|
||||
- [ATProto docs](https://atproto.com)
|
||||
|
||||
- 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
|
||||
## Rules & guidelines
|
||||
|
||||
### 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
|
||||
ℹ️ While we do accept contributions, we prioritize high quality issues and pull requests. Adhering to the below guidelines will ensure a more timely review.
|
||||
|
||||
### Polyfills
|
||||
---
|
||||
|
||||
`./platform/polyfills.*.ts` adds polyfills to the environment. Currently this includes:
|
||||
**Rules:**
|
||||
|
||||
- TextEncoder / TextDecoder
|
||||
- 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.
|
||||
|
||||
**Guidelines:**
|
||||
|
||||
- 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.
|
||||
|
||||
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!
|
||||
|
||||
## Forking guidelines
|
||||
|
||||
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.
|
||||
|
||||
Please be sure to:
|
||||
|
||||
- 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.
|
||||
|
||||
## Security disclosures
|
||||
|
||||
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.
|
||||
|
||||
## License (MIT)
|
||||
|
||||
See [./LICENSE](./LICENSE) for the full license.
|
||||
|
||||
## P.S.
|
||||
|
||||
We ❤️ you and all of the ways you support us. Thank you for making Bluesky a great place!
|
||||
|
||||
@@ -63,6 +63,232 @@ async function main() {
|
||||
},
|
||||
})
|
||||
}
|
||||
if ('labels' in url.query) {
|
||||
console.log('Generating naughty users with labels')
|
||||
|
||||
const anchorPost = await server.mocker.createPost(
|
||||
'alice',
|
||||
'Anchor post',
|
||||
)
|
||||
|
||||
for (const user of [
|
||||
'csam-account',
|
||||
'csam-profile',
|
||||
'csam-posts',
|
||||
'porn-account',
|
||||
'porn-profile',
|
||||
'porn-posts',
|
||||
'nudity-account',
|
||||
'nudity-profile',
|
||||
'nudity-posts',
|
||||
'unknown-account',
|
||||
'unknown-profile',
|
||||
'unknown-posts',
|
||||
'always-filter-account',
|
||||
'always-filter-profile',
|
||||
'always-filter-posts',
|
||||
'always-warn-account',
|
||||
'always-warn-profile',
|
||||
'always-warn-posts',
|
||||
'muted-account',
|
||||
'muted-by-list-account',
|
||||
]) {
|
||||
await server.mocker.createUser(user)
|
||||
await server.mocker.follow('alice', user)
|
||||
await server.mocker.follow(user, 'alice')
|
||||
await server.mocker.createPost(user, `Unlabeled post from ${user}`)
|
||||
await server.mocker.createReply(
|
||||
user,
|
||||
`Unlabeled reply from ${user}`,
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.like(user, anchorPost)
|
||||
}
|
||||
|
||||
await server.mocker.labelAccount('csam', 'csam-account')
|
||||
await server.mocker.labelProfile('csam', 'csam-profile')
|
||||
await server.mocker.labelPost(
|
||||
'csam',
|
||||
await server.mocker.createPost('csam-posts', 'csam post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'csam',
|
||||
await server.mocker.createQuotePost(
|
||||
'csam-posts',
|
||||
'csam quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'csam',
|
||||
await server.mocker.createReply(
|
||||
'csam-posts',
|
||||
'csam reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount('porn', 'porn-account')
|
||||
await server.mocker.labelProfile('porn', 'porn-profile')
|
||||
await server.mocker.labelPost(
|
||||
'porn',
|
||||
await server.mocker.createPost('porn-posts', 'porn post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'porn',
|
||||
await server.mocker.createQuotePost(
|
||||
'porn-posts',
|
||||
'porn quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'porn',
|
||||
await server.mocker.createReply(
|
||||
'porn-posts',
|
||||
'porn reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount('nudity', 'nudity-account')
|
||||
await server.mocker.labelProfile('nudity', 'nudity-profile')
|
||||
await server.mocker.labelPost(
|
||||
'nudity',
|
||||
await server.mocker.createPost('nudity-posts', 'nudity post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'nudity',
|
||||
await server.mocker.createQuotePost(
|
||||
'nudity-posts',
|
||||
'nudity quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'nudity',
|
||||
await server.mocker.createReply(
|
||||
'nudity-posts',
|
||||
'nudity reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount(
|
||||
'not-a-real-label',
|
||||
'unknown-account',
|
||||
)
|
||||
await server.mocker.labelProfile(
|
||||
'not-a-real-label',
|
||||
'unknown-profile',
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'not-a-real-label',
|
||||
await server.mocker.createPost('unknown-posts', 'unknown post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'not-a-real-label',
|
||||
await server.mocker.createQuotePost(
|
||||
'unknown-posts',
|
||||
'unknown quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'not-a-real-label',
|
||||
await server.mocker.createReply(
|
||||
'unknown-posts',
|
||||
'unknown reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount('!filter', 'always-filter-account')
|
||||
await server.mocker.labelProfile('!filter', 'always-filter-profile')
|
||||
await server.mocker.labelPost(
|
||||
'!filter',
|
||||
await server.mocker.createPost(
|
||||
'always-filter-posts',
|
||||
'always-filter post',
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'!filter',
|
||||
await server.mocker.createQuotePost(
|
||||
'always-filter-posts',
|
||||
'always-filter quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'!filter',
|
||||
await server.mocker.createReply(
|
||||
'always-filter-posts',
|
||||
'always-filter reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount('!warn', 'always-warn-account')
|
||||
await server.mocker.labelProfile('!warn', 'always-warn-profile')
|
||||
await server.mocker.labelPost(
|
||||
'!warn',
|
||||
await server.mocker.createPost(
|
||||
'always-warn-posts',
|
||||
'always-warn post',
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'!warn',
|
||||
await server.mocker.createQuotePost(
|
||||
'always-warn-posts',
|
||||
'always-warn quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'!warn',
|
||||
await server.mocker.createReply(
|
||||
'always-warn-posts',
|
||||
'always-warn reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.users.alice.agent.mute('muted-account.test')
|
||||
await server.mocker.createPost('muted-account', 'muted post')
|
||||
await server.mocker.createQuotePost(
|
||||
'muted-account',
|
||||
'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,
|
||||
)
|
||||
}
|
||||
}
|
||||
console.log('Ready')
|
||||
return res.writeHead(200).end(server.pdsUrl)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -57,7 +57,9 @@ describe('Home screen', () => {
|
||||
.tap()
|
||||
await element(by.id('postDropdownReportBtn')).tap()
|
||||
await expect(element(by.id('reportPostModal'))).toBeVisible()
|
||||
await element(by.id('reportPostRadios-spam')).tap()
|
||||
await element(
|
||||
by.id('reportPostRadios-com.atproto.moderation.defs#reasonSpam'),
|
||||
).tap()
|
||||
await element(by.id('sendReportBtn')).tap()
|
||||
await expect(element(by.id('reportPostModal'))).not.toBeVisible()
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -120,7 +120,9 @@ describe('Profile screen', () => {
|
||||
await element(by.id('profileHeaderDropdownBtn')).tap()
|
||||
await element(by.id('profileHeaderDropdownReportBtn')).tap()
|
||||
await expect(element(by.id('reportAccountModal'))).toBeVisible()
|
||||
await element(by.id('reportAccountRadios-spam')).tap()
|
||||
await element(
|
||||
by.id('reportAccountRadios-com.atproto.moderation.defs#reasonSpam'),
|
||||
).tap()
|
||||
await element(by.id('sendReportBtn')).tap()
|
||||
await expect(element(by.id('reportAccountModal'))).not.toBeVisible()
|
||||
})
|
||||
@@ -166,7 +168,9 @@ describe('Profile screen', () => {
|
||||
await element(by.id('postDropdownBtn').withAncestor(posts)).atIndex(0).tap()
|
||||
await element(by.id('postDropdownReportBtn')).tap()
|
||||
await expect(element(by.id('reportPostModal'))).toBeVisible()
|
||||
await element(by.id('reportPostRadios-spam')).tap()
|
||||
await element(
|
||||
by.id('reportPostRadios-com.atproto.moderation.defs#reasonSpam'),
|
||||
).tap()
|
||||
await element(by.id('sendReportBtn')).tap()
|
||||
await expect(element(by.id('reportPostModal'))).not.toBeVisible()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/* eslint-env detox/detox */
|
||||
|
||||
import {openApp, login, createServer} from '../util'
|
||||
|
||||
describe('Thread muting', () => {
|
||||
let service: string
|
||||
beforeAll(async () => {
|
||||
service = await createServer('?users&follows')
|
||||
await openApp({permissions: {notifications: 'YES'}})
|
||||
})
|
||||
|
||||
it('Login, create a thread, and log out', async () => {
|
||||
await login(service, 'alice', 'hunter2')
|
||||
await element(by.id('homeScreenFeedTabs-Following')).tap()
|
||||
await element(by.id('composeFAB')).tap()
|
||||
await element(by.id('composerTextInput')).typeText('Test thread')
|
||||
await element(by.id('composerPublishBtn')).tap()
|
||||
await expect(element(by.id('composeFAB'))).toBeVisible()
|
||||
await element(by.id('viewHeaderDrawerBtn')).tap()
|
||||
await element(by.id('menuItemButton-Settings')).tap()
|
||||
await element(by.id('signOutBtn')).tap()
|
||||
})
|
||||
|
||||
it('Login, reply to the thread, and log out', async () => {
|
||||
await login(service, 'bob', 'hunter2')
|
||||
await element(by.id('homeScreenFeedTabs-Following')).tap()
|
||||
const alicePosts = by.id('feedItem-by-alice.test')
|
||||
await element(by.id('replyBtn').withAncestor(alicePosts)).atIndex(0).tap()
|
||||
await element(by.id('composerTextInput')).typeText('Reply 1')
|
||||
await element(by.id('composerPublishBtn')).tap()
|
||||
await expect(element(by.id('composeFAB'))).toBeVisible()
|
||||
await element(by.id('viewHeaderDrawerBtn')).tap()
|
||||
await element(by.id('menuItemButton-Settings')).tap()
|
||||
await element(by.id('signOutBtn')).tap()
|
||||
})
|
||||
|
||||
it('Login, confirm notification exists, mute thread, and log out', async () => {
|
||||
await login(service, 'alice', 'hunter2')
|
||||
|
||||
await element(by.id('bottomBarNotificationsBtn')).tap()
|
||||
const bobNotifs = by.id('feedItem-by-bob.test')
|
||||
await expect(
|
||||
element(by.id('postText').withAncestor(bobNotifs)).atIndex(0),
|
||||
).toHaveText('Reply 1')
|
||||
await element(by.id('postDropdownBtn').withAncestor(bobNotifs))
|
||||
.atIndex(0)
|
||||
.tap()
|
||||
await element(by.id('postDropdownMuteThreadBtn')).tap()
|
||||
// have to wait for the toast to clear
|
||||
await waitFor(element(by.id('viewHeaderDrawerBtn')))
|
||||
.toBeVisible()
|
||||
.withTimeout(5000)
|
||||
|
||||
await element(by.id('viewHeaderDrawerBtn')).tap()
|
||||
await element(by.id('menuItemButton-Settings')).tap()
|
||||
await element(by.id('signOutBtn')).tap()
|
||||
})
|
||||
|
||||
it('Login, reply to the thread twice, and log out', async () => {
|
||||
await login(service, 'bob', 'hunter2')
|
||||
|
||||
await element(by.id('bottomBarProfileBtn')).tap()
|
||||
await element(by.id('selector-1')).tap()
|
||||
const bobPosts = by.id('feedItem-by-bob.test')
|
||||
await element(by.id('replyBtn').withAncestor(bobPosts)).atIndex(0).tap()
|
||||
await element(by.id('composerTextInput')).typeText('Reply 2')
|
||||
await element(by.id('composerPublishBtn')).tap()
|
||||
await expect(element(by.id('composeFAB'))).toBeVisible()
|
||||
|
||||
const alicePosts = by.id('feedItem-by-alice.test')
|
||||
await element(by.id('replyBtn').withAncestor(alicePosts)).atIndex(0).tap()
|
||||
await element(by.id('composerTextInput')).typeText('Reply 3')
|
||||
await element(by.id('composerPublishBtn')).tap()
|
||||
await expect(element(by.id('composeFAB'))).toBeVisible()
|
||||
|
||||
await element(by.id('bottomBarHomeBtn')).tap()
|
||||
await element(by.id('viewHeaderDrawerBtn')).tap()
|
||||
await element(by.id('menuItemButton-Settings')).tap()
|
||||
await element(by.id('signOutBtn')).tap()
|
||||
})
|
||||
|
||||
it('Login, confirm notifications dont exist, unmute the thread, confirm notifications exist', async () => {
|
||||
await login(service, 'alice', 'hunter2')
|
||||
|
||||
await element(by.id('bottomBarNotificationsBtn')).tap()
|
||||
const bobNotifs = by.id('feedItem-by-bob.test')
|
||||
await expect(
|
||||
element(by.id('postText').withAncestor(bobNotifs)).atIndex(0),
|
||||
).not.toExist()
|
||||
|
||||
await element(by.id('bottomBarHomeBtn')).tap()
|
||||
const alicePosts = by.id('feedItem-by-alice.test')
|
||||
await element(by.id('postDropdownBtn').withAncestor(alicePosts))
|
||||
.atIndex(0)
|
||||
.tap()
|
||||
await element(by.id('postDropdownMuteThreadBtn')).tap()
|
||||
|
||||
// TODO
|
||||
// the swipe down to trigger PTR isnt working and I dont want to block on this
|
||||
// -prf
|
||||
// await element(by.id('bottomBarNotificationsBtn')).tap()
|
||||
// await element(by.id('notifsFeed')).swipe('down', 'fast')
|
||||
// await waitFor(element(by.id('postText').withAncestor(bobNotifs)))
|
||||
// .toBeVisible()
|
||||
// .withTimeout(5000)
|
||||
// await expect(
|
||||
// element(by.id('postText').withAncestor(bobNotifs)).atIndex(0),
|
||||
// ).toHaveText('Reply 2')
|
||||
// await expect(
|
||||
// element(by.id('postText').withAncestor(bobNotifs)).atIndex(1),
|
||||
// ).toHaveText('Reply 3')
|
||||
// await expect(
|
||||
// element(by.id('postText').withAncestor(bobNotifs)).atIndex(2),
|
||||
// ).toHaveText('Reply 1')
|
||||
})
|
||||
})
|
||||
@@ -106,7 +106,9 @@ describe('Thread screen', () => {
|
||||
await element(by.id('postDropdownBtn').withAncestor(post)).atIndex(0).tap()
|
||||
await element(by.id('postDropdownReportBtn')).tap()
|
||||
await expect(element(by.id('reportPostModal'))).toBeVisible()
|
||||
await element(by.id('reportPostRadios-spam')).tap()
|
||||
await element(
|
||||
by.id('reportPostRadios-com.atproto.moderation.defs#reasonSpam'),
|
||||
).tap()
|
||||
await element(by.id('sendReportBtn')).tap()
|
||||
await expect(element(by.id('reportPostModal'))).not.toBeVisible()
|
||||
})
|
||||
@@ -116,7 +118,9 @@ describe('Thread screen', () => {
|
||||
await element(by.id('postDropdownBtn').withAncestor(post)).atIndex(0).tap()
|
||||
await element(by.id('postDropdownReportBtn')).tap()
|
||||
await expect(element(by.id('reportPostModal'))).toBeVisible()
|
||||
await element(by.id('reportPostRadios-spam')).tap()
|
||||
await element(
|
||||
by.id('reportPostRadios-com.atproto.moderation.defs#reasonSpam'),
|
||||
).tap()
|
||||
await element(by.id('sendReportBtn')).tap()
|
||||
await expect(element(by.id('reportPostModal'))).not.toBeVisible()
|
||||
})
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
import {resolveConfig} from 'detox/internals'
|
||||
import {execSync} from 'child_process'
|
||||
|
||||
const platform = device.getPlatform()
|
||||
|
||||
export async function openApp(opts: any) {
|
||||
opts = opts || {}
|
||||
const config = await resolveConfig()
|
||||
|
||||
if (device.getPlatform() === 'ios') {
|
||||
// disable password autofill
|
||||
execSync(
|
||||
`plutil -replace restrictedBool.allowPasswordAutoFill.value -bool NO ~/Library/Developer/CoreSimulator/Devices/${device.id}/data/Containers/Shared/SystemGroup/systemgroup.com.apple.configurationprofiles/Library/ConfigurationProfiles/UserSettings.plist`,
|
||||
)
|
||||
execSync(
|
||||
`plutil -replace restrictedBool.allowPasswordAutoFill.value -bool NO ~/Library/Developer/CoreSimulator/Devices/${device.id}/data/Library/UserConfigurationProfiles/EffectiveUserSettings.plist`,
|
||||
)
|
||||
execSync(
|
||||
`plutil -replace restrictedBool.allowPasswordAutoFill.value -bool NO ~/Library/Developer/CoreSimulator/Devices/${device.id}/data/Library/UserConfigurationProfiles/PublicInfo/PublicEffectiveUserSettings.plist`,
|
||||
)
|
||||
}
|
||||
if (config.configurationName.split('.').includes('debug')) {
|
||||
return await openAppForDebugBuild(platform, opts)
|
||||
} else {
|
||||
@@ -42,6 +56,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')
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const getLocales = jest.fn().mockResolvedValue([])
|
||||
@@ -1,106 +1,4 @@
|
||||
import {
|
||||
LikelyType,
|
||||
getLinkMeta,
|
||||
getLikelyType,
|
||||
} from '../../src/lib/link-meta/link-meta'
|
||||
import {exampleComHtml} from './__mocks__/exampleComHtml'
|
||||
import {BskyAgent} from '@atproto/api'
|
||||
import {DEFAULT_SERVICE, RootStoreModel} from '../../src/state'
|
||||
|
||||
describe('getLinkMeta', () => {
|
||||
let rootStore: RootStoreModel
|
||||
|
||||
beforeEach(() => {
|
||||
rootStore = new RootStoreModel(new BskyAgent({service: DEFAULT_SERVICE}))
|
||||
})
|
||||
|
||||
const inputs = [
|
||||
'',
|
||||
'httpbadurl',
|
||||
'https://example.com',
|
||||
'https://example.com/index.html',
|
||||
'https://example.com/image.png',
|
||||
'https://example.com/video.avi',
|
||||
'https://example.com/audio.ogg',
|
||||
'https://example.com/text.txt',
|
||||
'https://example.com/javascript.js',
|
||||
'https://bsky.app/',
|
||||
'https://bsky.app/index.html',
|
||||
]
|
||||
const outputs = [
|
||||
{
|
||||
error: 'Invalid URL',
|
||||
likelyType: LikelyType.Other,
|
||||
url: '',
|
||||
},
|
||||
{
|
||||
error: 'Invalid URL',
|
||||
likelyType: LikelyType.Other,
|
||||
url: 'httpbadurl',
|
||||
},
|
||||
{
|
||||
likelyType: LikelyType.HTML,
|
||||
url: 'https://example.com',
|
||||
title: 'Example Domain',
|
||||
description: 'An example website',
|
||||
},
|
||||
{
|
||||
likelyType: LikelyType.HTML,
|
||||
url: 'https://example.com/index.html',
|
||||
title: 'Example Domain',
|
||||
description: 'An example website',
|
||||
},
|
||||
{
|
||||
likelyType: LikelyType.Image,
|
||||
url: 'https://example.com/image.png',
|
||||
},
|
||||
{
|
||||
likelyType: LikelyType.Video,
|
||||
url: 'https://example.com/video.avi',
|
||||
},
|
||||
{
|
||||
likelyType: LikelyType.Audio,
|
||||
url: 'https://example.com/audio.ogg',
|
||||
},
|
||||
{
|
||||
likelyType: LikelyType.Text,
|
||||
url: 'https://example.com/text.txt',
|
||||
},
|
||||
{
|
||||
likelyType: LikelyType.Other,
|
||||
url: 'https://example.com/javascript.js',
|
||||
},
|
||||
{
|
||||
likelyType: LikelyType.AtpData,
|
||||
url: '/',
|
||||
},
|
||||
{
|
||||
likelyType: LikelyType.AtpData,
|
||||
url: '/index.html',
|
||||
},
|
||||
{
|
||||
likelyType: LikelyType.Other,
|
||||
url: '',
|
||||
title: '',
|
||||
},
|
||||
]
|
||||
it('correctly handles a set of text inputs', async () => {
|
||||
for (let i = 0; i < inputs.length; i++) {
|
||||
global.fetch = jest.fn().mockImplementationOnce(() => {
|
||||
return new Promise((resolve, _reject) => {
|
||||
resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => exampleComHtml,
|
||||
})
|
||||
})
|
||||
})
|
||||
const input = inputs[i]
|
||||
const output = await getLinkMeta(rootStore, input)
|
||||
expect(output).toEqual(outputs[i])
|
||||
}
|
||||
})
|
||||
})
|
||||
import {LikelyType, getLikelyType} from '../../src/lib/link-meta/link-meta'
|
||||
|
||||
describe('getLikelyType', () => {
|
||||
it('correctly handles non-parsed url', async () => {
|
||||
|
||||
@@ -48,7 +48,7 @@ describe('detectLinkables', () => {
|
||||
'Classic article https://socket3.wordpress.com/2018/02/03/designing-windows-95s-user-interface/ ',
|
||||
'https://foo.com https://bar.com/whatever https://baz.com',
|
||||
'punctuation https://foo.com, https://bar.com/whatever; https://baz.com.',
|
||||
'parenthentical (https://foo.com)',
|
||||
'parenthetical (https://foo.com)',
|
||||
'except for https://foo.com/thing_(cool)',
|
||||
]
|
||||
const outputs = [
|
||||
@@ -112,7 +112,7 @@ describe('detectLinkables', () => {
|
||||
{link: 'https://baz.com'},
|
||||
'.',
|
||||
],
|
||||
['parenthentical (', {link: 'https://foo.com'}, ')'],
|
||||
['parenthetical (', {link: 'https://foo.com'}, ')'],
|
||||
['except for ', {link: 'https://foo.com/thing_(cool)'}],
|
||||
]
|
||||
it('correctly handles a set of text inputs', () => {
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
"expo": {
|
||||
"name": "Bluesky",
|
||||
"slug": "bluesky",
|
||||
"scheme": "bluesky",
|
||||
"owner": "blueskysocial",
|
||||
"version": "1.18.0",
|
||||
"version": "1.32.0",
|
||||
"runtimeVersion": {
|
||||
"policy": "appVersion"
|
||||
},
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"splash": {
|
||||
"image": "./assets/cloud-splash.png",
|
||||
"resizeMode": "cover",
|
||||
@@ -26,27 +30,51 @@
|
||||
],
|
||||
"BGTaskSchedulerPermittedIdentifiers": [
|
||||
"com.transistorsoft.fetch"
|
||||
]
|
||||
}
|
||||
],
|
||||
"NSCameraUsageDescription": "Used for profile pictures, posts, and other kinds of content.",
|
||||
"NSMicrophoneUsageDescription": "Used for posts and other kinds of content.",
|
||||
"NSPhotoLibraryAddUsageDescription": "Used to save images to your library.",
|
||||
"NSPhotoLibraryUsageDescription": "Used for profile pictures, posts, and other kinds of content"
|
||||
},
|
||||
"associatedDomains": ["applinks:bsky.app", "applinks:staging.bsky.app"]
|
||||
},
|
||||
"androidStatusBar": {
|
||||
"barStyle": "dark-content",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"android": {
|
||||
"versionCode": 3,
|
||||
"versionCode": 18,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"package": "xyz.blueskyweb.app"
|
||||
"package": "xyz.blueskyweb.app",
|
||||
"intentFilters": [
|
||||
{
|
||||
"action": "VIEW",
|
||||
"autoVerify": true,
|
||||
"data": [
|
||||
{
|
||||
"scheme": "https",
|
||||
"host": "bsky.app"
|
||||
}
|
||||
],
|
||||
"category": ["BROWSABLE", "DEFAULT"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"updates": {
|
||||
"enabled": true,
|
||||
"fallbackToCacheTimeout": 1000,
|
||||
"url": "https://u.expo.dev/55bd077a-d905-4184-9c7f-94789ba0f302"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-localization",
|
||||
"react-native-background-fetch",
|
||||
"sentry-expo",
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
@@ -66,6 +94,17 @@
|
||||
"eas": {
|
||||
"projectId": "55bd077a-d905-4184-9c7f-94789ba0f302"
|
||||
}
|
||||
},
|
||||
"hooks": {
|
||||
"postPublish": [
|
||||
{
|
||||
"file": "sentry-expo/upload-sourcemaps",
|
||||
"config": {
|
||||
"organization": "blueskyweb",
|
||||
"project": "react-native"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,5 +4,11 @@ test-coverage.out
|
||||
# Don't check in the binary.
|
||||
/bskyweb
|
||||
|
||||
# Don't accidentally commit JS-generated code
|
||||
static/js/*.js
|
||||
static/js/*.map
|
||||
static/js/*.js.LICENSE.txt
|
||||
templates/scripts.html
|
||||
|
||||
# Don't ignore this file
|
||||
!.gitignore
|
||||
|
||||
+1
-1
@@ -39,4 +39,4 @@ check: ## Compile everything, checking syntax (does not output binaries)
|
||||
|
||||
.PHONY: run-dev-bskyweb
|
||||
run-dev-bskyweb: .env ## Runs 'bskyweb' for local dev
|
||||
GOLOG_LOG_LEVEL=info go run ./cmd/bskyweb serve
|
||||
GOLOG_LOG_LEVEL=info go run ./cmd/bskyweb serve --debug
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
### SPA Bundle (monolithic static javascript file)
|
||||
|
||||
To build the SPA bundle (`bundle.web.js`), first get a Javascript development
|
||||
To build the SPA bundle (`bundle.web.js`), first get a JavaScript development
|
||||
environment set up. Either follow the top-level README, or something quick
|
||||
like:
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
/bskyweb
|
||||
@@ -14,13 +14,15 @@ type Mailmodo struct {
|
||||
httpClient *http.Client
|
||||
APIKey string
|
||||
BaseURL string
|
||||
ListName string
|
||||
}
|
||||
|
||||
func NewMailmodo(apiKey string) *Mailmodo {
|
||||
func NewMailmodo(apiKey, listName string) *Mailmodo {
|
||||
return &Mailmodo{
|
||||
APIKey: apiKey,
|
||||
BaseURL: "https://api.mailmodo.com/api/v1",
|
||||
httpClient: &http.Client{},
|
||||
ListName: listName,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,9 +58,9 @@ func (m *Mailmodo) request(ctx context.Context, httpMethod string, apiMethod str
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Mailmodo) AddToList(ctx context.Context, listName, email string) error {
|
||||
func (m *Mailmodo) AddToList(ctx context.Context, email string) error {
|
||||
return m.request(ctx, "POST", "addToList", map[string]any{
|
||||
"listName": listName,
|
||||
"listName": m.ListName,
|
||||
"email": email,
|
||||
"data": map[string]any{
|
||||
"email_hashed": fmt.Sprintf("%x", sha256.Sum256([]byte(email))),
|
||||
|
||||
+187
-28
@@ -2,11 +2,17 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
comatproto "github.com/bluesky-social/indigo/api/atproto"
|
||||
appbsky "github.com/bluesky-social/indigo/api/bsky"
|
||||
@@ -15,13 +21,18 @@ import (
|
||||
"github.com/bluesky-social/social-app/bskyweb"
|
||||
|
||||
"github.com/flosch/pongo2/v6"
|
||||
"github.com/klauspost/compress/gzhttp"
|
||||
"github.com/klauspost/compress/gzip"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
xrpcc *xrpc.Client
|
||||
echo *echo.Echo
|
||||
httpd *http.Server
|
||||
mailmodo *Mailmodo
|
||||
xrpcc *xrpc.Client
|
||||
}
|
||||
|
||||
func serve(cctx *cli.Context) error {
|
||||
@@ -33,8 +44,11 @@ func serve(cctx *cli.Context) error {
|
||||
mailmodoAPIKey := cctx.String("mailmodo-api-key")
|
||||
mailmodoListName := cctx.String("mailmodo-list-name")
|
||||
|
||||
// Echo
|
||||
e := echo.New()
|
||||
|
||||
// Mailmodo client.
|
||||
mailmodo := NewMailmodo(mailmodoAPIKey)
|
||||
mailmodo := NewMailmodo(mailmodoAPIKey, mailmodoListName)
|
||||
|
||||
// create a new session
|
||||
// TODO: does this work with no auth at all?
|
||||
@@ -47,7 +61,7 @@ func serve(cctx *cli.Context) error {
|
||||
}
|
||||
|
||||
auth, err := comatproto.ServerCreateSession(context.TODO(), xrpcc, &comatproto.ServerCreateSession_Input{
|
||||
Identifier: &xrpcc.Auth.Handle,
|
||||
Identifier: xrpcc.Auth.Handle,
|
||||
Password: atpPassword,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -58,10 +72,76 @@ func serve(cctx *cli.Context) error {
|
||||
xrpcc.Auth.Did = auth.Did
|
||||
xrpcc.Auth.Handle = auth.Handle
|
||||
|
||||
server := Server{xrpcc}
|
||||
// httpd
|
||||
var (
|
||||
httpTimeout = 2 * time.Minute
|
||||
httpMaxHeaderBytes = 2 * (1024 * 1024)
|
||||
gzipMinSizeBytes = 1024 * 2
|
||||
gzipCompressionLevel = gzip.BestSpeed
|
||||
gzipExceptMIMETypes = []string{"image/png"}
|
||||
)
|
||||
|
||||
// Wrap the server handler in a gzip handler to compress larger responses.
|
||||
gzipHandler, err := gzhttp.NewWrapper(
|
||||
gzhttp.MinSize(gzipMinSizeBytes),
|
||||
gzhttp.CompressionLevel(gzipCompressionLevel),
|
||||
gzhttp.ExceptContentTypes(gzipExceptMIMETypes),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
//
|
||||
// server
|
||||
//
|
||||
server := &Server{
|
||||
echo: e,
|
||||
mailmodo: mailmodo,
|
||||
xrpcc: xrpcc,
|
||||
}
|
||||
|
||||
// Create the HTTP server.
|
||||
server.httpd = &http.Server{
|
||||
Handler: gzipHandler(server),
|
||||
Addr: httpAddress,
|
||||
WriteTimeout: httpTimeout,
|
||||
ReadTimeout: httpTimeout,
|
||||
MaxHeaderBytes: httpMaxHeaderBytes,
|
||||
}
|
||||
|
||||
e.HideBanner = true
|
||||
// SECURITY: Do not modify without due consideration.
|
||||
e.Use(middleware.SecureWithConfig(middleware.SecureConfig{
|
||||
ContentTypeNosniff: "nosniff",
|
||||
XFrameOptions: "SAMEORIGIN",
|
||||
HSTSMaxAge: 31536000, // 365 days
|
||||
// TODO:
|
||||
// ContentSecurityPolicy
|
||||
// XSSProtection
|
||||
}))
|
||||
e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
|
||||
// Don't log requests for static content.
|
||||
Skipper: func(c echo.Context) bool {
|
||||
return strings.HasPrefix(c.Request().URL.Path, "/static")
|
||||
},
|
||||
}))
|
||||
e.Renderer = NewRenderer("templates/", &bskyweb.TemplateFS, debug)
|
||||
e.HTTPErrorHandler = server.errorHandler
|
||||
|
||||
// redirect trailing slash to non-trailing slash.
|
||||
// all of our current endpoints have no trailing slash.
|
||||
e.Use(middleware.RemoveTrailingSlashWithConfig(middleware.TrailingSlashConfig{
|
||||
RedirectCode: http.StatusFound,
|
||||
}))
|
||||
|
||||
//
|
||||
// configure routes
|
||||
//
|
||||
|
||||
// static files
|
||||
staticHandler := http.FileServer(func() http.FileSystem {
|
||||
if debug {
|
||||
log.Debugf("serving static file from the local file system")
|
||||
return http.FS(os.DirFS("static"))
|
||||
}
|
||||
fsys, err := fs.Sub(bskyweb.StaticFS, "static")
|
||||
@@ -70,28 +150,28 @@ func serve(cctx *cli.Context) error {
|
||||
}
|
||||
return http.FS(fsys)
|
||||
}())
|
||||
|
||||
e := echo.New()
|
||||
e.HideBanner = true
|
||||
e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
|
||||
// Don't log requests for static content.
|
||||
Skipper: func(c echo.Context) bool {
|
||||
return strings.HasPrefix(c.Request().URL.Path, "/static")
|
||||
},
|
||||
Format: "method=${method} path=${uri} status=${status} latency=${latency_human}\n",
|
||||
}))
|
||||
e.Renderer = NewRenderer("templates/", &bskyweb.TemplateFS, debug)
|
||||
e.HTTPErrorHandler = customHTTPErrorHandler
|
||||
|
||||
// configure routes
|
||||
e.GET("/robots.txt", echo.WrapHandler(staticHandler))
|
||||
e.GET("/static/*", echo.WrapHandler(http.StripPrefix("/static/", staticHandler)))
|
||||
e.GET("/.well-known/*", echo.WrapHandler(staticHandler))
|
||||
e.GET("/security.txt", func(c echo.Context) error {
|
||||
return c.Redirect(http.StatusMovedPermanently, "/.well-known/security.txt")
|
||||
})
|
||||
|
||||
// home
|
||||
e.GET("/", server.WebHome)
|
||||
|
||||
// generic routes
|
||||
e.GET("/search", server.WebGeneric)
|
||||
e.GET("/search/feeds", server.WebGeneric)
|
||||
e.GET("/feeds", 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/saved-feeds", server.WebGeneric)
|
||||
e.GET("/sys/debug", server.WebGeneric)
|
||||
e.GET("/sys/log", server.WebGeneric)
|
||||
e.GET("/support", server.WebGeneric)
|
||||
@@ -104,6 +184,9 @@ 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)
|
||||
e.GET("/profile/:handle/feed/:rkey", server.WebGeneric)
|
||||
e.GET("/profile/:handle/feed/:rkey/liked-by", server.WebGeneric)
|
||||
|
||||
// post endpoints; only first populates info
|
||||
e.GET("/profile/:handle/post/:rkey", server.WebPost)
|
||||
@@ -111,19 +194,54 @@ func serve(cctx *cli.Context) error {
|
||||
e.GET("/profile/:handle/post/:rkey/reposted-by", server.WebGeneric)
|
||||
|
||||
// Mailmodo
|
||||
e.POST("/waitlist", func(c echo.Context) error {
|
||||
email := strings.TrimSpace(c.FormValue("email"))
|
||||
if err := mailmodo.AddToList(c.Request().Context(), mailmodoListName, email); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]bool{"success": true})
|
||||
})
|
||||
e.POST("/api/waitlist", server.apiWaitlist)
|
||||
|
||||
// Start the server.
|
||||
log.Infof("starting server address=%s", httpAddress)
|
||||
return e.Start(httpAddress)
|
||||
go func() {
|
||||
if err := server.httpd.ListenAndServe(); err != nil {
|
||||
if !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Errorf("HTTP server shutting down unexpectedly: %s", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for a signal to exit.
|
||||
log.Info("registering OS exit signal handler")
|
||||
quit := make(chan struct{})
|
||||
exitSignals := make(chan os.Signal, 1)
|
||||
signal.Notify(exitSignals, syscall.SIGINT, syscall.SIGTERM)
|
||||
go func() {
|
||||
sig := <-exitSignals
|
||||
log.Infof("received OS exit signal: %s", sig)
|
||||
|
||||
// Shut down the HTTP server.
|
||||
if err := server.Shutdown(); err != nil {
|
||||
log.Errorf("HTTP server shutdown error: %s", err)
|
||||
}
|
||||
|
||||
// Trigger the return that causes an exit.
|
||||
close(quit)
|
||||
}()
|
||||
<-quit
|
||||
log.Infof("graceful shutdown complete")
|
||||
return nil
|
||||
}
|
||||
|
||||
func customHTTPErrorHandler(err error, c echo.Context) {
|
||||
func (srv *Server) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||
srv.echo.ServeHTTP(rw, req)
|
||||
}
|
||||
|
||||
func (srv *Server) Shutdown() error {
|
||||
log.Info("shutting down")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
return srv.httpd.Shutdown(ctx)
|
||||
}
|
||||
|
||||
func (srv *Server) errorHandler(err error, c echo.Context) {
|
||||
code := http.StatusInternalServerError
|
||||
if he, ok := err.(*echo.HTTPError); ok {
|
||||
code = he.Code
|
||||
@@ -167,7 +285,13 @@ func (srv *Server) WebPost(c echo.Context) error {
|
||||
if err != nil {
|
||||
log.Warnf("failed to fetch post: %s\t%v", uri, err)
|
||||
} else {
|
||||
data["postView"] = tpv.Thread.FeedDefs_ThreadViewPost.Post
|
||||
req := c.Request()
|
||||
postView := tpv.Thread.FeedDefs_ThreadViewPost.Post
|
||||
data["postView"] = postView
|
||||
data["requestURI"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
|
||||
if postView.Embed != nil && postView.Embed.EmbedImages_View != nil {
|
||||
data["imgThumbUrl"] = postView.Embed.EmbedImages_View.Images[0].Thumb
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,9 +309,44 @@ func (srv *Server) WebProfile(c echo.Context) error {
|
||||
if err != nil {
|
||||
log.Warnf("failed to fetch handle: %s\t%v", handle, err)
|
||||
} else {
|
||||
req := c.Request()
|
||||
data["profileView"] = pv
|
||||
data["requestURI"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
|
||||
}
|
||||
}
|
||||
|
||||
return c.Render(http.StatusOK, "profile.html", data)
|
||||
}
|
||||
|
||||
func (srv *Server) apiWaitlist(c echo.Context) error {
|
||||
type jsonError struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// Read the API request.
|
||||
type apiRequest struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
bodyReader := http.MaxBytesReader(c.Response(), c.Request().Body, 16*1024)
|
||||
payload, err := ioutil.ReadAll(bodyReader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var req apiRequest
|
||||
if err := json.Unmarshal(payload, &req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, jsonError{Error: "Invalid API request"})
|
||||
}
|
||||
|
||||
if req.Email == "" {
|
||||
return c.JSON(http.StatusBadRequest, jsonError{Error: "Please enter a valid email address."})
|
||||
}
|
||||
|
||||
if err := srv.mailmodo.AddToList(c.Request().Context(), req.Email); err != nil {
|
||||
log.Errorf("adding email to waitlist failed: %s", err)
|
||||
return c.JSON(http.StatusBadRequest, jsonError{
|
||||
Error: "Storing email in waitlist failed. Please enter a valid email address.",
|
||||
})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
+15
-14
@@ -3,18 +3,19 @@ module github.com/bluesky-social/social-app/bskyweb
|
||||
go 1.20
|
||||
|
||||
require (
|
||||
github.com/bluesky-social/indigo v0.0.0-20230403211508-3cb4320bd5c8
|
||||
github.com/bluesky-social/indigo v0.0.0-20230504025040-8915cccc3319
|
||||
github.com/flosch/pongo2/v6 v6.0.0
|
||||
github.com/ipfs/go-log v1.0.5
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/klauspost/compress v1.16.5
|
||||
github.com/labstack/echo/v4 v4.10.2
|
||||
github.com/urfave/cli/v2 v2.25.1
|
||||
github.com/urfave/cli/v2 v2.25.3
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/benbjohnson/clock v1.3.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
|
||||
github.com/go-logr/logr v1.2.4 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
@@ -26,7 +27,7 @@ require (
|
||||
github.com/hashicorp/golang-lru v0.5.4 // indirect
|
||||
github.com/ipfs/bbloom v0.0.4 // indirect
|
||||
github.com/ipfs/go-block-format v0.1.2 // indirect
|
||||
github.com/ipfs/go-cid v0.4.0 // indirect
|
||||
github.com/ipfs/go-cid v0.4.1 // indirect
|
||||
github.com/ipfs/go-datastore v0.6.0 // indirect
|
||||
github.com/ipfs/go-ipfs-blockstore v1.3.0 // indirect
|
||||
github.com/ipfs/go-ipfs-ds-help v1.1.0 // indirect
|
||||
@@ -68,22 +69,22 @@ require (
|
||||
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/whyrusleeping/cbor-gen v0.0.0-20230331140348-1f892b517e70 // indirect
|
||||
github.com/whyrusleeping/cbor-gen v0.0.0-20230418232409-daab9ece03a0 // indirect
|
||||
github.com/whyrusleeping/go-did v0.0.0-20230301193428-2146016fc220 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
|
||||
go.opentelemetry.io/otel v1.14.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.14.0 // indirect
|
||||
go.uber.org/atomic v1.10.0 // indirect
|
||||
go.opentelemetry.io/otel v1.15.1 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.15.1 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.24.0 // indirect
|
||||
golang.org/x/crypto v0.7.0 // indirect
|
||||
golang.org/x/net v0.8.0 // indirect
|
||||
golang.org/x/sys v0.6.0 // indirect
|
||||
golang.org/x/text v0.8.0 // indirect
|
||||
golang.org/x/crypto v0.8.0 // indirect
|
||||
golang.org/x/net v0.9.0 // indirect
|
||||
golang.org/x/sys v0.7.0 // indirect
|
||||
golang.org/x/text v0.9.0 // indirect
|
||||
golang.org/x/time v0.3.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
|
||||
gorm.io/driver/postgres v1.5.0 // indirect
|
||||
gorm.io/driver/sqlite v1.4.4 // indirect
|
||||
gorm.io/gorm v1.24.7-0.20230306060331-85eaf9eeda11 // indirect
|
||||
gorm.io/driver/sqlite v1.5.0 // indirect
|
||||
gorm.io/gorm v1.25.0 // indirect
|
||||
lukechampine.com/blake3 v1.1.7 // indirect
|
||||
)
|
||||
|
||||
+30
-24
@@ -2,8 +2,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
|
||||
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A=
|
||||
github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/bluesky-social/indigo v0.0.0-20230403211508-3cb4320bd5c8 h1:6A8D48CksjnlmJb8r5WaFAB4J2osllkmQoQhhiREMHw=
|
||||
github.com/bluesky-social/indigo v0.0.0-20230403211508-3cb4320bd5c8/go.mod h1:9dcnKLtEnDPBdWm5/BLJHvbaPndCdKzAaBK7sbGt3S4=
|
||||
github.com/bluesky-social/indigo v0.0.0-20230504025040-8915cccc3319 h1:VCNXRXpgyK3xkaQ8fzL5WzswerwLycke4B9ggLs1uOA=
|
||||
github.com/bluesky-social/indigo v0.0.0-20230504025040-8915cccc3319/go.mod h1:Hc09SUJXAIujaAvq7JXxi8ZQQI887grzPkHgn4JyE1Q=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
@@ -12,8 +12,9 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0 h1:HbphB4TFFXpv7MNrT52FGrrgVXF1owhMVTHFZIlnvd4=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0/go.mod h1:DZGJHZMqrU4JJqFAWUS2UO1+lbSKsdiOoYi9Zzey7Fc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
|
||||
github.com/flosch/pongo2/v6 v6.0.0 h1:lsGru8IAzHgIAw6H2m4PCyleO58I40ow6apih0WprMU=
|
||||
github.com/flosch/pongo2/v6 v6.0.0/go.mod h1:CuDpFm47R0uGGE7z13/tTlt1Y6zdxvr2RLT5LJhsHEU=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
@@ -55,8 +56,8 @@ github.com/ipfs/go-cid v0.0.1/go.mod h1:GHWU/WuQdMPmIosc4Yn1bcCT7dSeX4lBafM7iqUP
|
||||
github.com/ipfs/go-cid v0.0.2/go.mod h1:GHWU/WuQdMPmIosc4Yn1bcCT7dSeX4lBafM7iqUPQvM=
|
||||
github.com/ipfs/go-cid v0.0.5/go.mod h1:plgt+Y5MnOey4vO4UlUazGqdbEXuFYitED67FexhXog=
|
||||
github.com/ipfs/go-cid v0.0.6/go.mod h1:6Ux9z5e+HpkQdckYoX1PG/6xqKspzlEIR5SDmgqgC/I=
|
||||
github.com/ipfs/go-cid v0.4.0 h1:a4pdZq0sx6ZSxbCizebnKiMCx/xI/aBBFlB73IgH4rA=
|
||||
github.com/ipfs/go-cid v0.4.0/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk=
|
||||
github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s=
|
||||
github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk=
|
||||
github.com/ipfs/go-datastore v0.5.0/go.mod h1:9zhEApYMTl17C8YDp7JmU7sQZi2/wqiYh73hakZ90Bk=
|
||||
github.com/ipfs/go-datastore v0.6.0 h1:JKyz+Gvz1QEZw0LsX1IBn+JFCJQH4SJVFtM4uWU0Myk=
|
||||
github.com/ipfs/go-datastore v0.6.0/go.mod h1:rt5M3nNbSO/8q1t4LNkLyUwRs8HupMeN/8O4Vn9YAT8=
|
||||
@@ -96,7 +97,6 @@ github.com/jbenet/goprocess v0.1.4 h1:DRGOFReOMqqDNXwW70QkacFW0YN9QnwLV0Vqk+3oU0
|
||||
github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
@@ -105,6 +105,8 @@ github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.16.5 h1:IFV2oUNUzZaz+XyusxpLzpzS8Pt5rh0Z16For/djlyI=
|
||||
github.com/klauspost/compress v1.16.5/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||
github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
|
||||
@@ -209,8 +211,8 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
|
||||
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
|
||||
github.com/urfave/cli/v2 v2.25.1 h1:zw8dSP7ghX0Gmm8vugrs6q9Ku0wzweqPyshy+syu9Gw=
|
||||
github.com/urfave/cli/v2 v2.25.1/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc=
|
||||
github.com/urfave/cli/v2 v2.25.3 h1:VJkt6wvEBOoSjPFQvOkv6iWIrsJyCrKGtCtxXWwmGeY=
|
||||
github.com/urfave/cli/v2 v2.25.3/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
@@ -218,8 +220,8 @@ github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQ
|
||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ=
|
||||
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw=
|
||||
github.com/whyrusleeping/cbor-gen v0.0.0-20230331140348-1f892b517e70 h1:iNBzUKTsJc9RqStEVX2VYgVHATTU39IuB7g0e8OPWXU=
|
||||
github.com/whyrusleeping/cbor-gen v0.0.0-20230331140348-1f892b517e70/go.mod h1:fgkXqYy7bV2cFeIEOkVTZS/WjXARfBqSH6Q2qHL33hQ=
|
||||
github.com/whyrusleeping/cbor-gen v0.0.0-20230418232409-daab9ece03a0 h1:XYEgH2nJgsrcrj32p+SAbx6T3s/6QknOXezXtz7kzbg=
|
||||
github.com/whyrusleeping/cbor-gen v0.0.0-20230418232409-daab9ece03a0/go.mod h1:fgkXqYy7bV2cFeIEOkVTZS/WjXARfBqSH6Q2qHL33hQ=
|
||||
github.com/whyrusleeping/go-did v0.0.0-20230301193428-2146016fc220 h1:EO/9z3yDvx1van1/0esdcqhalZZQGRj3I1BPTWr5k3A=
|
||||
github.com/whyrusleeping/go-did v0.0.0-20230301193428-2146016fc220/go.mod h1:qPtRyexGM5XMHFIfjH+EiA/A/1n2JakWEdMPC53pJAE=
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
|
||||
@@ -228,14 +230,14 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.opentelemetry.io/otel v1.14.0 h1:/79Huy8wbf5DnIPhemGB+zEPVwnN6fuQybr/SRXa6hM=
|
||||
go.opentelemetry.io/otel v1.14.0/go.mod h1:o4buv+dJzx8rohcUeRmWUZhqupFvzWis188WlggnNeU=
|
||||
go.opentelemetry.io/otel/trace v1.14.0 h1:wp2Mmvj41tDsyAJXiWDWpfNsOiIyd38fy85pyKcFq/M=
|
||||
go.opentelemetry.io/otel/trace v1.14.0/go.mod h1:8avnQLK+CG77yNLUae4ea2JDQ6iT+gozhnZjy/rw9G8=
|
||||
go.opentelemetry.io/otel v1.15.1 h1:3Iwq3lfRByPaws0f6bU3naAqOR1n5IeDWd9390kWHa8=
|
||||
go.opentelemetry.io/otel v1.15.1/go.mod h1:mHHGEHVDLal6YrKMmk9LqC4a3sF5g+fHfrttQIB1NTc=
|
||||
go.opentelemetry.io/otel/trace v1.15.1 h1:uXLo6iHJEzDfrNC0L0mNjItIp06SyaBQxu5t3xMlngY=
|
||||
go.opentelemetry.io/otel/trace v1.15.1/go.mod h1:IWdQG/5N1x7f6YUlmdLeJvH9yxtuJAfc4VW5Agv9r/8=
|
||||
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ=
|
||||
go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
|
||||
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
|
||||
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
|
||||
@@ -255,8 +257,9 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||
golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A=
|
||||
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
|
||||
golang.org/x/crypto v0.8.0 h1:pd9TJtTueMTVQXzk8E2XESSMQDj/U7OUu0PqJqPXQjQ=
|
||||
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
@@ -273,8 +276,9 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
|
||||
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||
golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM=
|
||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -297,8 +301,9 @@ golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU=
|
||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
@@ -307,8 +312,9 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68=
|
||||
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
|
||||
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -343,11 +349,11 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.5.0 h1:u2FXTy14l45qc3UeCJ7QaAXZmZfDDv0YrthvmRq1l0U=
|
||||
gorm.io/driver/postgres v1.5.0/go.mod h1:FUZXzO+5Uqg5zzwzv4KK49R8lvGIyscBOqYrtI1Ce9A=
|
||||
gorm.io/driver/sqlite v1.4.4 h1:gIufGoR0dQzjkyqDyYSCvsYR6fba1Gw5YKDqKeChxFc=
|
||||
gorm.io/driver/sqlite v1.4.4/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI=
|
||||
gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
|
||||
gorm.io/gorm v1.24.7-0.20230306060331-85eaf9eeda11 h1:9qNbmu21nNThCNnF5i2R3kw2aL27U8ZwbzccNjOmW0g=
|
||||
gorm.io/driver/sqlite v1.5.0 h1:zKYbzRCpBrT1bNijRnxLDJWPjVfImGEn0lSnUY5gZ+c=
|
||||
gorm.io/driver/sqlite v1.5.0/go.mod h1:kDMDfntV9u/vuMmz8APHtHF0b4nyBB7sfCieC6G8k8I=
|
||||
gorm.io/gorm v1.24.7-0.20230306060331-85eaf9eeda11/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
|
||||
gorm.io/gorm v1.25.0 h1:+KtYtb2roDz14EQe4bla8CbQlmb9dN3VejSai3lprfU=
|
||||
gorm.io/gorm v1.25.0/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
|
||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||
lukechampine.com/blake3 v1.1.7 h1:GgRMhmdsuK8+ii6UZFDL8Nb+VyMwadAgcJyfYHxG6n0=
|
||||
lukechampine.com/blake3 v1.1.7/go.mod h1:tkKEOtDkNtklkXtLNEOGNq5tcV90tJiA1vAA12R78LA=
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"applinks": {
|
||||
"apps": [],
|
||||
"details": [
|
||||
{
|
||||
"appID": "B3LX46C5HS.xyz.blueskyweb.app",
|
||||
"paths": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
[
|
||||
{
|
||||
"relation": ["delegate_permission/common.handle_all_urls"],
|
||||
"target": {
|
||||
"namespace": "android_app",
|
||||
"package_name": "xyz.blueskyweb.app",
|
||||
"sha256_cert_fingerprints":
|
||||
["C1:4D:3C:6B:B5:D6:D9:AE:CF:C5:0B:BC:C1:9B:29:6D:D4:E6:87:46:36:D5:4C:1A:64:1C:14:08:BF:7E:F9:62", "FA:C6:17:45:DC:09:03:78:6F:B9:ED:E6:2A:96:2B:39:9F:73:48:F0:BB:6F:89:9B:83:32:66:75:91:03:3B:9C"]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,4 @@
|
||||
Contact: mailto:security@bsky.app
|
||||
Preferred-Languages: en
|
||||
Canonical: https://bsky.app/.well-known/security.txt
|
||||
Acknowledgements: https://github.com/bluesky-social/atproto/blob/main/CONTRIBUTORS.md
|
||||
@@ -1 +1,9 @@
|
||||
# hello friends!
|
||||
# Hello Friends!
|
||||
# If you are considering bulk or automated crawling, you may want to look in
|
||||
# to our protocol (API), including a firehose of updates. See: https://atproto.com/
|
||||
|
||||
# By default, may crawl anything on this domain. HTTP 429 ("backoff") status
|
||||
# codes are used for rate-limiting. Up to a handful concurrent requests should
|
||||
# be ok.
|
||||
User-Agent: *
|
||||
Allow: /
|
||||
|
||||
+23
-48
@@ -1,9 +1,8 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<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 charset="UTF-8">
|
||||
<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>
|
||||
|
||||
@@ -57,10 +56,6 @@
|
||||
}
|
||||
}*/
|
||||
|
||||
/* Remove focus state on inputs */
|
||||
*:focus {
|
||||
outline: 0;
|
||||
}
|
||||
/* Remove default link styling */
|
||||
a {
|
||||
color: inherit;
|
||||
@@ -68,6 +63,9 @@
|
||||
a[role="link"]:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
a[role="link"][data-no-underline="1"]:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Styling hacks */
|
||||
*[data-word-wrap] {
|
||||
@@ -99,58 +97,35 @@
|
||||
color: #0085ff;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* OLLIE: TODO -- this is not accessible */
|
||||
/* Remove focus state on inputs */
|
||||
.ProseMirror-focused {
|
||||
outline: 0;
|
||||
}
|
||||
textarea:focus,
|
||||
input:focus {
|
||||
outline: 0;
|
||||
}
|
||||
.tippy-content .items {
|
||||
border-radius: 6px;
|
||||
background: #F3F3F8;
|
||||
border: 1px solid #e0d9d9;
|
||||
padding: 3px 3px;
|
||||
}
|
||||
.tippy-content .items .item {
|
||||
display: block;
|
||||
background: transparent;
|
||||
color: #8a8c9a;
|
||||
border: 0;
|
||||
font: 17px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
padding: 7px 10px 8px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
box-sizing: border-box;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.tippy-content .items .item.is-selected {
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
color: #333;
|
||||
width: fit-content;
|
||||
}
|
||||
</style>
|
||||
{% include "scripts.html" %}
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"/>
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"/>
|
||||
{% block head_page_meta -%}
|
||||
<meta property="og:title" content="Bluesky Social"/>
|
||||
<meta property="og:type" content="article"/>
|
||||
<meta property="og:image" content="/static/default-social-card.png"/>
|
||||
<meta name="twitter:title" content="Bluesky Social"/>
|
||||
<meta name="twitter:description" content="See what's next."/>
|
||||
<meta name="twitter:image" content="/static/default-social-card.png"/>
|
||||
<meta name="twitter:card" content="summary_large_image"/>
|
||||
<meta name="twitter:site" content="@bluesky"/>
|
||||
{%- endblock %}
|
||||
<!-- TODO: link rel=canonical -->
|
||||
<!-- TODO: analytics code -->
|
||||
<!-- TODO: could put <link rel="preload"> tags here -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
|
||||
{% block html_head_extra -%}{%- endblock %}
|
||||
<meta name="application-name" name="Bluesky">
|
||||
<meta name="generator" name="bskyweb">
|
||||
{% block head_metadata %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
{%- block body_all %}
|
||||
<div id="root"></div>
|
||||
<noscript>
|
||||
{%- block noscript_extra %}{% endblock -%}
|
||||
<h1>Javascript Required</h1>
|
||||
<p>This is a heavily interactive web application, and Javascript is required. Simple HTML interfaces are possible, but that is not what this is.
|
||||
<h1>JavaScript Required</h1>
|
||||
<p>This is a heavily interactive web application, and JavaScript is required. Simple HTML interfaces are possible, but that is not what this is.
|
||||
<p>Learn more about Bluesky at <a href="https://blueskyweb.xyz">blueskyweb.xyz</a> and <a href="https://atproto.com">atproto.com</a>.
|
||||
{% block noscript_extra %}{% endblock %}
|
||||
</noscript>
|
||||
{% endblock -%}
|
||||
</body>
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
|
||||
{% block head_title %}Bluesky{% endblock %}
|
||||
|
||||
{% block html_head_extra -%}
|
||||
<meta name="description" content="See what's next.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="Bluesky Social">
|
||||
<meta property="og:description" content="See what's next.">
|
||||
<meta property="og:image" content="/static/social-card-default.png">
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:site" content="@bluesky">
|
||||
{%- endblock %}
|
||||
|
||||
{% block noscript_extra %}
|
||||
<p>This is the home page.
|
||||
{% endblock %}
|
||||
|
||||
+40
-17
@@ -1,25 +1,48 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block head_page_meta -%}
|
||||
<!-- TODO: "same as" indication with at:// URI? -->
|
||||
{% block head_title %}
|
||||
{%- if postView -%}
|
||||
<meta property="og:type" content="article"/>
|
||||
<meta name="twitter:card" content="summary"/>
|
||||
{%- if postView.Author.DisplayName -%}
|
||||
<meta property="og:title" content="{{ postView.Author.DisplayName }} / {{ postView.Author.Handle }}"/>
|
||||
<meta name="twitter:title" content="{{ postView.Author.DisplayName }} / {{ postView.Author.Handle }}"/>
|
||||
{%- else -%}
|
||||
<meta property="og:title" content="{{ postView.Author.Handle }}"/>
|
||||
<meta name="twitter:title" content="{{ postView.Author.Handle }}"/>
|
||||
{%- endif -%}
|
||||
{%- if postView.Record.Text -%}
|
||||
<meta name="twitter:description" content="{{ postView.Record.Text }}"/>
|
||||
<!-- TODO: could put any images in here, or author avatar -->
|
||||
{%- endif -%}
|
||||
@{{ postView.Author.Handle }} on Bluesky
|
||||
{%- else -%}
|
||||
Bluesky
|
||||
{%- endif -%}
|
||||
{% endblock %}
|
||||
|
||||
{% block html_head_extra -%}
|
||||
{%- if postView -%}
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="Bluesky Social">
|
||||
{%- if requestURI %}
|
||||
<meta property="og:url" content="{{ requestURI }}">
|
||||
{% endif -%}
|
||||
{%- if postView.Author.DisplayName %}
|
||||
<meta property="og:title" content="{{ postView.Author.DisplayName }} (@{{ postView.Author.Handle }})">
|
||||
{% else %}
|
||||
<meta property="og:title" content="@{{ postView.Author.Handle }}">
|
||||
{% endif -%}
|
||||
{%- if postView.Record.Val.Text %}
|
||||
<meta name="description" content="{{ postView.Record.Val.Text }}">
|
||||
<meta property="og:description" content="{{ postView.Record.Val.Text }}">
|
||||
{% endif -%}
|
||||
{%- if imgThumbUrl %}
|
||||
<meta property="og:image" content="{{ imgThumbUrl }}">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
{%- elif postView.Author.Avatar %}
|
||||
{# Don't use avatar image in cards; usually looks bad #}
|
||||
<meta name="twitter:card" content="summary">
|
||||
{% endif %}
|
||||
<meta name="twitter:label1" content="Posted At">
|
||||
<meta name="twitter:value1" content="{{ postView.CreatedAt }}">
|
||||
<meta name="twitter:site" content="@bluesky">
|
||||
{% endif -%}
|
||||
{%- endblock %}
|
||||
|
||||
{% block noscript_extra -%}
|
||||
<p>{{ postView.Author.DisplayName }} / {{ postView.Author.Handle }}
|
||||
<p>{{ postView.Record.Text }}
|
||||
<div id="bsky_post_summary">
|
||||
<h3>Post</h3>
|
||||
<p id="bsky_display_name">{{ postView.Author.DisplayName }}</p>
|
||||
<p id="bsky_handle">{{ postView.Author.Handle }}</p>
|
||||
<p id="bsky_did">{{ postView.Author.Did }}</p>
|
||||
<p id="bsky_post_text">{{ postView.Record.Text }}</p>
|
||||
</div>
|
||||
{%- endblock %}
|
||||
|
||||
@@ -1,25 +1,48 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block head_page_meta -%}
|
||||
<!-- TODO: "same as" indication with DID? -->
|
||||
{% block head_title %}
|
||||
{%- if profileView -%}
|
||||
<meta property="og:type" content="article"/>
|
||||
<meta name="twitter:card" content="summary"/>
|
||||
{%- if profileView.DisplayName -%}
|
||||
<meta property="og:title" content="{{ profileView.DisplayName }} / {{ profileView.Handle }}"/>
|
||||
<meta name="twitter:title" content="{{ profileView.DisplayName }} / {{ profileView.Handle }}"/>
|
||||
{%- else -%}
|
||||
<meta property="og:title" content="{{ profileView.Handle }}"/>
|
||||
<meta name="twitter:title" content="{{ profileView.Handle }}"/>
|
||||
{%- endif -%}
|
||||
<meta name="twitter:description" content="{{ profileView.Description }}"/>
|
||||
{%- if profileView.Avatar -%}
|
||||
<meta name="twitter:image" content="{{ profileView.Avatar }}"/>
|
||||
{%- endif -%}
|
||||
@{{ profileView.Handle }} on Bluesky
|
||||
{%- else -%}
|
||||
Bluesky
|
||||
{%- endif -%}
|
||||
{% endblock %}
|
||||
|
||||
{% block html_head_extra -%}
|
||||
{%- if profileView -%}
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="Bluesky Social">
|
||||
{%- if requestURI %}
|
||||
<meta property="og:url" content="{{ requestURI }}">
|
||||
{% endif -%}
|
||||
{%- if profileView.DisplayName %}
|
||||
<meta property="og:title" content="{{ profileView.DisplayName }} (@{{ profileView.Handle }})">
|
||||
{% else %}
|
||||
<meta property="og:title" content="{{ profileView.Handle }}">
|
||||
{% endif -%}
|
||||
{%- if profileView.Description %}
|
||||
<meta name="description" content="{{ profileView.Description }}">
|
||||
<meta property="og:description" content="{{ profileView.Description }}">
|
||||
{% endif -%}
|
||||
{%- if profileView.Banner %}
|
||||
<meta property="og:image" content="{{ profileView.Banner }}">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
{%- elif profileView.Avatar -%}
|
||||
{# Don't use avatar image in cards; usually looks bad #}
|
||||
<meta name="twitter:card" content="summary">
|
||||
{% endif %}
|
||||
<meta name="twitter:label1" content="Account DID">
|
||||
<meta name="twitter:value1" content="{{ profileView.Did }}">
|
||||
<meta name="twitter:site" content="@bluesky">
|
||||
{% endif -%}
|
||||
{%- endblock %}
|
||||
|
||||
{% block noscript_extra -%}
|
||||
<p>{{ profileView.DisplayName }} / {{ profileView.Handle }}
|
||||
<p>{{ profileView.Description }}
|
||||
<div id="bsky_profile_summary">
|
||||
<h3>Profile</h3>
|
||||
<p id="bsky_display_name">{{ profileView.DisplayName }}</p>
|
||||
<p id="bsky_handle">{{ profileView.Handle }}</p>
|
||||
<p id="bsky_did">{{ profileView.Did }}</p>
|
||||
<p id="bsky_profile_description">{{ profileView.Description }}</p>
|
||||
</div>
|
||||
{%- endblock %}
|
||||
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
# 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 appropriate tokens (e.g. `SENTRY_AUTH_TOKEN` can be created on the Sentry dashboard using [these instructions](https://docs.expo.dev/guides/using-sentry/#sign-up-for-a-sentry-account-and-create-a-project)). If this is not required, you can remove it from `eas.json` and `package.json`, as well as any mentions in the code. Please check the section below on how to remove Sentry from the codebase
|
||||
- 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 locally-hosted 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).)
|
||||
|
||||
### Removing Sentry
|
||||
If you are part of the Bluesky team, you should have access to our Sentry dashboard, and you shouldn't need to remove Sentry. Even if you are not part of the Bluesky team, you can create your own Sentry account and add the `SENTRY_AUTH_TOKEN` env var and add your sentry account detials to `app.json` to make the app build and run successfully. However, if that is not possible, follow these steps to remove Sentry from the project (please don't commit this code in any PR):
|
||||
- `yarn remove sentry-expo @sentry/react-native`
|
||||
- Remove `sentry-expo` plugin in `app.json` and also remove the `postPublish` hook in `app.json`
|
||||
- Remove any mentions of `sentry` from the `App.native.tsx`, `App.web.tsx` and `Navigation.tsx` files. Also, delete `sentry.ts`
|
||||
- Run `rm -rf ios android` or delete the existing `android` and `ios` folders in the project (don't worry! `yarn prebuild` gets these back)
|
||||
- Run `yarn prebuild` and `yarn ios` and build the app!
|
||||
|
||||
## 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`
|
||||
|
||||
### OTA updates
|
||||
To create OTA updates, run `eas update` along with the `--branch` flag to indicate which branch you want to push the update to, and the `--message` flag to indicate a message for yourself and your team that shows up on https://expo.dev. ALl the channels (which make up the options for the `--branch` flag) are given in `eas.json`. [See more here](https://docs.expo.dev/eas-update/getting-started/)
|
||||
|
||||
The clients which can receive an OTA update is governed by the `runtimeVersion` property in `app.json`. Right now, it is set so that only apps with the same `appVersion` (same as `version` property in `app.json`) can receive the update and install it. However, we can manually set `"runtimeVersion": "1.34.0"` or anything along those lines as well. This is useful if very little native code changes from update-to-update. If we are manually setting `runtimeVersion`, we should increment the version each time native code is changed. [See more here](https://docs.expo.dev/eas-update/runtime-versions/)
|
||||
@@ -8,6 +8,7 @@
|
||||
"developmentClient": true,
|
||||
"distribution": "internal",
|
||||
"ios": {
|
||||
"simulator": true,
|
||||
"resourceClass": "medium"
|
||||
},
|
||||
"channel": "development"
|
||||
|
||||
+176
-2
@@ -2,6 +2,7 @@ import {AddressInfo} from 'net'
|
||||
import os from 'os'
|
||||
import net from 'net'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import * as crypto from '@atproto/crypto'
|
||||
import {PDS, ServerConfig, Database, MemoryBlobStore} from '@atproto/pds'
|
||||
import * as plc from '@did-plc/lib'
|
||||
@@ -12,6 +13,7 @@ const ADMIN_PASSWORD = 'admin-pass'
|
||||
const SECOND = 1000
|
||||
const MINUTE = SECOND * 60
|
||||
const HOUR = MINUTE * 60
|
||||
const DAY = HOUR * 24
|
||||
|
||||
export interface TestUser {
|
||||
email: string
|
||||
@@ -65,6 +67,8 @@ export async function createServer(
|
||||
adminPassword: ADMIN_PASSWORD,
|
||||
inviteRequired,
|
||||
didPlcUrl: plcUrl,
|
||||
didCacheMaxTTL: DAY,
|
||||
didCacheStaleTTL: HOUR,
|
||||
jwtSecret: 'jwt-secret',
|
||||
availableUserDomains: ['.test'],
|
||||
appUrlPasswordReset: 'app://forgot-password',
|
||||
@@ -104,9 +108,13 @@ export async function createServer(
|
||||
await pds.start()
|
||||
const pdsUrl = `http://localhost:${port}`
|
||||
|
||||
const profilePic = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'assets', 'default-avatar.jpg'),
|
||||
)
|
||||
|
||||
return {
|
||||
pdsUrl,
|
||||
mocker: new Mocker(pdsUrl),
|
||||
mocker: new Mocker(pds, pdsUrl, profilePic),
|
||||
async close() {
|
||||
await pds.destroy()
|
||||
await plcServer.destroy()
|
||||
@@ -118,7 +126,11 @@ class Mocker {
|
||||
agent: BskyAgent
|
||||
users: Record<string, TestUser> = {}
|
||||
|
||||
constructor(public service: string) {
|
||||
constructor(
|
||||
public pds: PDS,
|
||||
public service: string,
|
||||
public profilePic: Uint8Array,
|
||||
) {
|
||||
this.agent = new BskyAgent({service})
|
||||
}
|
||||
|
||||
@@ -152,6 +164,15 @@ class Mocker {
|
||||
handle: name + '.test',
|
||||
password: 'hunter2',
|
||||
})
|
||||
await agent.upsertProfile(async () => {
|
||||
const blob = await agent.uploadBlob(this.profilePic, {
|
||||
encoding: 'image/jpeg',
|
||||
})
|
||||
return {
|
||||
displayName: name,
|
||||
avatar: blob.data.blob,
|
||||
}
|
||||
})
|
||||
this.users[name] = {
|
||||
did: res.data.did,
|
||||
email,
|
||||
@@ -192,6 +213,159 @@ class Mocker {
|
||||
await this.follow('carla', 'alice')
|
||||
await this.follow('carla', 'bob')
|
||||
}
|
||||
|
||||
async createPost(user: string, text: string) {
|
||||
const agent = this.users[user]?.agent
|
||||
if (!agent) {
|
||||
throw new Error(`Not a user: ${user}`)
|
||||
}
|
||||
return await agent.post({
|
||||
text,
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
async createQuotePost(
|
||||
user: string,
|
||||
text: string,
|
||||
{uri, cid}: {uri: string; cid: string},
|
||||
) {
|
||||
const agent = this.users[user]?.agent
|
||||
if (!agent) {
|
||||
throw new Error(`Not a user: ${user}`)
|
||||
}
|
||||
return await agent.post({
|
||||
text,
|
||||
embed: {$type: 'app.bsky.embed.record', record: {uri, cid}},
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
async createReply(
|
||||
user: string,
|
||||
text: string,
|
||||
{uri, cid}: {uri: string; cid: string},
|
||||
) {
|
||||
const agent = this.users[user]?.agent
|
||||
if (!agent) {
|
||||
throw new Error(`Not a user: ${user}`)
|
||||
}
|
||||
return await agent.post({
|
||||
text,
|
||||
reply: {root: {uri, cid}, parent: {uri, cid}},
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
async like(user: string, {uri, cid}: {uri: string; cid: string}) {
|
||||
const agent = this.users[user]?.agent
|
||||
if (!agent) {
|
||||
throw new Error(`Not a user: ${user}`)
|
||||
}
|
||||
return await agent.like(uri, cid)
|
||||
}
|
||||
|
||||
async labelAccount(label: string, user: string) {
|
||||
const did = this.users[user]?.did
|
||||
if (!did) {
|
||||
throw new Error(`Invalid user: ${user}`)
|
||||
}
|
||||
const ctx = this.pds.ctx
|
||||
if (!ctx) {
|
||||
throw new Error('Invalid PDS')
|
||||
}
|
||||
|
||||
await ctx.db.db
|
||||
.insertInto('label')
|
||||
.values([
|
||||
{
|
||||
src: ctx.cfg.labelerDid,
|
||||
uri: did,
|
||||
cid: '',
|
||||
val: label,
|
||||
neg: 0,
|
||||
cts: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
.execute()
|
||||
}
|
||||
|
||||
async labelProfile(label: string, user: string) {
|
||||
const agent = this.users[user]?.agent
|
||||
const did = this.users[user]?.did
|
||||
if (!did) {
|
||||
throw new Error(`Invalid user: ${user}`)
|
||||
}
|
||||
|
||||
const profile = await agent.app.bsky.actor.profile.get({
|
||||
repo: user + '.test',
|
||||
rkey: 'self',
|
||||
})
|
||||
|
||||
const ctx = this.pds.ctx
|
||||
if (!ctx) {
|
||||
throw new Error('Invalid PDS')
|
||||
}
|
||||
await ctx.db.db
|
||||
.insertInto('label')
|
||||
.values([
|
||||
{
|
||||
src: ctx.cfg.labelerDid,
|
||||
uri: profile.uri,
|
||||
cid: profile.cid,
|
||||
val: label,
|
||||
neg: 0,
|
||||
cts: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
.execute()
|
||||
}
|
||||
|
||||
async labelPost(label: string, {uri, cid}: {uri: string; cid: string}) {
|
||||
const ctx = this.pds.ctx
|
||||
if (!ctx) {
|
||||
throw new Error('Invalid PDS')
|
||||
}
|
||||
await ctx.db.db
|
||||
.insertInto('label')
|
||||
.values([
|
||||
{
|
||||
src: ctx.cfg.labelerDid,
|
||||
uri,
|
||||
cid,
|
||||
val: label,
|
||||
neg: 0,
|
||||
cts: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
.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) =>
|
||||
|
||||
+27
-15
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.18.0",
|
||||
"version": "1.32.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"postinstall": "patch-package",
|
||||
@@ -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",
|
||||
@@ -16,21 +16,23 @@
|
||||
"test-ci": "jest --ci --forceExit --reporters=default --reporters=jest-junit",
|
||||
"test-coverage": "jest --coverage",
|
||||
"lint": "eslint ./src --ext .js,.jsx,.ts,.tsx",
|
||||
"typecheck": "tsc --project ./tsconfig.check.json",
|
||||
"e2e:mock-server": "ts-node __e2e__/mock-server.ts",
|
||||
"e2e:metro": "RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios",
|
||||
"e2e:build": "detox build -c ios.sim.debug",
|
||||
"e2e:run": "detox test --configuration ios.sim.debug --take-screenshots all"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "0.2.7",
|
||||
"@atproto/api": "0.3.8",
|
||||
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@expo/html-elements": "^0.4.2",
|
||||
"@expo/webpack-config": "^18.0.1",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.1.1",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.1.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.1.1",
|
||||
"@fortawesome/react-native-fontawesome": "^0.3.0",
|
||||
"@gorhom/bottom-sheet": "^4",
|
||||
"@gorhom/bottom-sheet": "^4.4.7",
|
||||
"@mattermost/react-native-paste-input": "^0.6.0",
|
||||
"@miblanchard/react-native-slider": "^2.2.0",
|
||||
"@notifee/react-native": "^7.4.0",
|
||||
@@ -38,6 +40,7 @@
|
||||
"@react-native-camera-roll/camera-roll": "^5.2.2",
|
||||
"@react-native-clipboard/clipboard": "^1.10.0",
|
||||
"@react-native-community/blur": "^4.3.0",
|
||||
"@react-native-community/datetimepicker": "6.7.3",
|
||||
"@react-navigation/bottom-tabs": "^6.5.7",
|
||||
"@react-navigation/drawer": "^6.6.2",
|
||||
"@react-navigation/native": "^6.1.6",
|
||||
@@ -46,8 +49,11 @@
|
||||
"@segment/analytics-react": "^1.0.0-rc1",
|
||||
"@segment/analytics-react-native": "^2.10.1",
|
||||
"@segment/sovran-react-native": "^0.4.5",
|
||||
"@sentry/react-native": "4.13.0",
|
||||
"@tiptap/core": "^2.0.0-beta.220",
|
||||
"@tiptap/extension-document": "^2.0.0-beta.220",
|
||||
"@tiptap/extension-hard-break": "^2.0.3",
|
||||
"@tiptap/extension-history": "^2.0.3",
|
||||
"@tiptap/extension-link": "^2.0.0-beta.220",
|
||||
"@tiptap/extension-mention": "^2.0.0-beta.220",
|
||||
"@tiptap/extension-paragraph": "^2.0.0-beta.220",
|
||||
@@ -56,30 +62,35 @@
|
||||
"@tiptap/pm": "^2.0.0-beta.220",
|
||||
"@tiptap/react": "^2.0.0-beta.220",
|
||||
"@tiptap/suggestion": "^2.0.0-beta.220",
|
||||
"@types/node": "^18.16.2",
|
||||
"@zxing/text-encoding": "^0.9.0",
|
||||
"await-lock": "^2.2.2",
|
||||
"base64-js": "^1.5.1",
|
||||
"email-validator": "^2.0.4",
|
||||
"expo": "~48.0.11",
|
||||
"eslint-plugin-react-native-a11y": "^3.3.0",
|
||||
"expo": "~48.0.18",
|
||||
"expo-application": "~5.1.1",
|
||||
"expo-build-properties": "~0.5.1",
|
||||
"expo-camera": "~13.2.1",
|
||||
"expo-constants": "~14.2.1",
|
||||
"expo-dev-client": "~2.1.1",
|
||||
"expo-image": "~1.0.0",
|
||||
"expo-image-picker": "~14.1.1",
|
||||
"expo-device": "~5.2.1",
|
||||
"expo-image": "^1.2.3",
|
||||
"expo-image-manipulator": "^11.1.1",
|
||||
"expo-image-picker": "^14.1.1",
|
||||
"expo-localization": "~14.1.1",
|
||||
"expo-media-library": "~15.2.3",
|
||||
"expo-splash-screen": "~0.18.1",
|
||||
"expo-sharing": "~11.2.2",
|
||||
"expo-splash-screen": "~0.18.2",
|
||||
"expo-status-bar": "~1.4.4",
|
||||
"expo-system-ui": "~2.2.1",
|
||||
"expo-updates": "~0.16.4",
|
||||
"fast-text-encoding": "^1.0.6",
|
||||
"graphemer": "^1.4.0",
|
||||
"he": "^1.2.0",
|
||||
"history": "^5.3.0",
|
||||
"js-sha256": "^0.9.0",
|
||||
"lande": "^1.0.10",
|
||||
"lodash.chunk": "^4.2.0",
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"lodash.isequal": "^4.5.0",
|
||||
"lodash.omit": "^4.5.0",
|
||||
@@ -97,9 +108,10 @@
|
||||
"react-avatar-editor": "^13.0.0",
|
||||
"react-circular-progressbar": "^2.1.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-native": "0.71.6",
|
||||
"react-native": "0.71.8",
|
||||
"react-native-appstate-hook": "^1.0.6",
|
||||
"react-native-background-fetch": "^4.1.8",
|
||||
"react-native-draggable-flatlist": "^4.0.1",
|
||||
"react-native-drawer-layout": "^3.2.0",
|
||||
"react-native-fs": "^2.20.0",
|
||||
"react-native-gesture-handler": "~2.9.0",
|
||||
@@ -110,7 +122,7 @@
|
||||
"react-native-linear-gradient": "^2.6.2",
|
||||
"react-native-pager-view": "6.1.4",
|
||||
"react-native-progress": "bluesky-social/react-native-progress",
|
||||
"react-native-reanimated": "~2.14.4",
|
||||
"react-native-reanimated": "^3.3.0",
|
||||
"react-native-root-siblings": "^4.1.1",
|
||||
"react-native-safe-area-context": "^4.4.1",
|
||||
"react-native-screens": "^3.13.1",
|
||||
@@ -123,12 +135,13 @@
|
||||
"react-native-web-linear-gradient": "^1.1.2",
|
||||
"react-responsive": "^9.0.2",
|
||||
"rn-fetch-blob": "^0.12.0",
|
||||
"sentry-expo": "~6.1.0",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tlds": "^1.234.0",
|
||||
"zod": "^3.20.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@atproto/pds": "^0.1.4",
|
||||
"@atproto/pds": "^0.1.10",
|
||||
"@babel/core": "^7.20.0",
|
||||
"@babel/preset-env": "^7.20.0",
|
||||
"@babel/runtime": "^7.20.0",
|
||||
@@ -140,7 +153,6 @@
|
||||
"@types/he": "^1.1.2",
|
||||
"@types/jest": "^29.4.0",
|
||||
"@types/lodash.chunk": "^4.2.7",
|
||||
"@types/lodash.clonedeep": "^4.5.7",
|
||||
"@types/lodash.debounce": "^4.0.7",
|
||||
"@types/lodash.isequal": "^4.5.6",
|
||||
"@types/lodash.omit": "^4.5.7",
|
||||
@@ -195,7 +207,7 @@
|
||||
"node"
|
||||
],
|
||||
"transformIgnorePatterns": [
|
||||
"node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|normalize-url|react-native-svg)"
|
||||
"node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|normalize-url|react-native-svg|@sentry/.*|sentry-expo)"
|
||||
],
|
||||
"modulePathIgnorePatterns": [
|
||||
"__tests__/.*/__mocks__",
|
||||
|
||||
+7
-6
@@ -1,9 +1,10 @@
|
||||
import 'react-native-url-polyfill/auto'
|
||||
import React, {useState, useEffect} from 'react'
|
||||
import 'lib/sentry' // must be relatively on top
|
||||
import {withSentry} from 'lib/sentry'
|
||||
import {Linking} from 'react-native'
|
||||
import {RootSiblingParent} from 'react-native-root-siblings'
|
||||
import * as SplashScreen from 'expo-splash-screen'
|
||||
import {SafeAreaProvider} from 'react-native-safe-area-context'
|
||||
import {GestureHandlerRootView} from 'react-native-gesture-handler'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {ThemeProvider} from 'lib/ThemeContext'
|
||||
@@ -16,6 +17,8 @@ import * as analytics from 'lib/analytics/analytics'
|
||||
import * as Toast from './view/com/util/Toast'
|
||||
import {handleLink} from './Navigation'
|
||||
|
||||
SplashScreen.preventAutoHideAsync()
|
||||
|
||||
const App = observer(() => {
|
||||
const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
|
||||
undefined,
|
||||
@@ -48,14 +51,12 @@ const App = observer(() => {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<ThemeProvider theme={rootStore.shell.darkMode ? 'dark' : 'light'}>
|
||||
<ThemeProvider theme={rootStore.shell.colorMode}>
|
||||
<RootSiblingParent>
|
||||
<analytics.Provider>
|
||||
<RootStoreProvider value={rootStore}>
|
||||
<GestureHandlerRootView style={s.h100pct}>
|
||||
<SafeAreaProvider>
|
||||
<Shell />
|
||||
</SafeAreaProvider>
|
||||
<Shell />
|
||||
</GestureHandlerRootView>
|
||||
</RootStoreProvider>
|
||||
</analytics.Provider>
|
||||
@@ -64,4 +65,4 @@ const App = observer(() => {
|
||||
)
|
||||
})
|
||||
|
||||
export default App
|
||||
export default withSentry(App)
|
||||
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
import React, {useState, useEffect} from 'react'
|
||||
import 'lib/sentry' // must be relatively on top
|
||||
import {SafeAreaProvider} from 'react-native-safe-area-context'
|
||||
import {RootSiblingParent} from 'react-native-root-siblings'
|
||||
import * as view from './view/index'
|
||||
@@ -29,7 +30,7 @@ const App = observer(() => {
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={rootStore.shell.darkMode ? 'dark' : 'light'}>
|
||||
<ThemeProvider theme={rootStore.shell.colorMode}>
|
||||
<RootSiblingParent>
|
||||
<analytics.Provider>
|
||||
<RootStoreProvider value={rootStore}>
|
||||
|
||||
+231
-31
@@ -1,15 +1,20 @@
|
||||
import * as React from 'react'
|
||||
import {StyleSheet} from 'react-native'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {
|
||||
NavigationContainer,
|
||||
createNavigationContainerRef,
|
||||
CommonActions,
|
||||
StackActions,
|
||||
DefaultTheme,
|
||||
DarkTheme,
|
||||
} from '@react-navigation/native'
|
||||
import {createNativeStackNavigator} from '@react-navigation/native-stack'
|
||||
import {createBottomTabNavigator} from '@react-navigation/bottom-tabs'
|
||||
import {
|
||||
HomeTabNavigatorParams,
|
||||
SearchTabNavigatorParams,
|
||||
FeedsTabNavigatorParams,
|
||||
NotificationsTabNavigatorParams,
|
||||
FlatNavigatorParams,
|
||||
AllNavigatorParams,
|
||||
@@ -23,15 +28,24 @@ import {colors} from 'lib/styles'
|
||||
import {isNative} from 'platform/detection'
|
||||
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
|
||||
import {router} from './routes'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useStores} from './state'
|
||||
|
||||
import {HomeScreen} from './view/screens/Home'
|
||||
import {SearchScreen} from './view/screens/Search'
|
||||
import {FeedsScreen} from './view/screens/Feeds'
|
||||
import {NotificationsScreen} from './view/screens/Notifications'
|
||||
import {ModerationScreen} from './view/screens/Moderation'
|
||||
import {ModerationMuteListsScreen} from './view/screens/ModerationMuteLists'
|
||||
import {DiscoverFeedsScreen} from 'view/screens/DiscoverFeeds'
|
||||
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 {CustomFeedScreen} from './view/screens/CustomFeed'
|
||||
import {CustomFeedLikedByScreen} from './view/screens/CustomFeedLikedBy'
|
||||
import {ProfileListScreen} from './view/screens/ProfileList'
|
||||
import {PostThreadScreen} from './view/screens/PostThread'
|
||||
import {PostLikedByScreen} from './view/screens/PostLikedBy'
|
||||
import {PostRepostedByScreen} from './view/screens/PostRepostedBy'
|
||||
@@ -42,13 +56,18 @@ import {PrivacyPolicyScreen} from './view/screens/PrivacyPolicy'
|
||||
import {TermsOfServiceScreen} from './view/screens/TermsOfService'
|
||||
import {CommunityGuidelinesScreen} from './view/screens/CommunityGuidelines'
|
||||
import {CopyrightPolicyScreen} from './view/screens/CopyrightPolicy'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useStores} from './state'
|
||||
import {AppPasswords} from 'view/screens/AppPasswords'
|
||||
import {ModerationMutedAccounts} from 'view/screens/ModerationMutedAccounts'
|
||||
import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts'
|
||||
import {SavedFeeds} from 'view/screens/SavedFeeds'
|
||||
import {getRoutingInstrumentation} from 'lib/sentry'
|
||||
import {bskyTitle} from 'lib/strings/headings'
|
||||
|
||||
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
|
||||
|
||||
const HomeTab = createNativeStackNavigator<HomeTabNavigatorParams>()
|
||||
const SearchTab = createNativeStackNavigator<SearchTabNavigatorParams>()
|
||||
const FeedsTab = createNativeStackNavigator<FeedsTabNavigatorParams>()
|
||||
const NotificationsTab =
|
||||
createNativeStackNavigator<NotificationsTabNavigatorParams>()
|
||||
const MyProfileTab = createNativeStackNavigator<MyProfileTabNavigatorParams>()
|
||||
@@ -58,30 +77,140 @@ 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="DiscoverFeeds"
|
||||
component={DiscoverFeedsScreen}
|
||||
options={{title: title('Discover Feeds')}}
|
||||
/>
|
||||
<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="CustomFeed"
|
||||
component={CustomFeedScreen}
|
||||
options={{title: title('Feed')}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="CustomFeedLikedBy"
|
||||
component={CustomFeedLikedByScreen}
|
||||
options={{title: title('Liked by')}}
|
||||
/>
|
||||
<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="SavedFeeds"
|
||||
component={SavedFeeds}
|
||||
options={{title: title('Edit My Feeds')}}
|
||||
/>
|
||||
<Stack.Screen name="CopyrightPolicy" component={CopyrightPolicyScreen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -96,14 +225,15 @@ function TabsNavigator() {
|
||||
<Tab.Navigator
|
||||
initialRouteName="HomeTab"
|
||||
backBehavior="initialRoute"
|
||||
screenOptions={{headerShown: false}}
|
||||
screenOptions={{headerShown: false, lazy: true}}
|
||||
tabBar={tabBar}>
|
||||
<Tab.Screen name="HomeTab" component={HomeTabNavigator} />
|
||||
<Tab.Screen name="SearchTab" component={SearchTabNavigator} />
|
||||
<Tab.Screen name="FeedsTab" component={FeedsTabNavigator} />
|
||||
<Tab.Screen
|
||||
name="NotificationsTab"
|
||||
component={NotificationsTabNavigator}
|
||||
/>
|
||||
<Tab.Screen name="SearchTab" component={SearchTabNavigator} />
|
||||
<Tab.Screen name="MyProfileTab" component={MyProfileTabNavigator} />
|
||||
</Tab.Navigator>
|
||||
)
|
||||
@@ -143,6 +273,23 @@ function SearchTabNavigator() {
|
||||
)
|
||||
}
|
||||
|
||||
function FeedsTabNavigator() {
|
||||
const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark)
|
||||
return (
|
||||
<FeedsTab.Navigator
|
||||
screenOptions={{
|
||||
gestureEnabled: true,
|
||||
fullScreenGestureEnabled: true,
|
||||
headerShown: false,
|
||||
animationDuration: 250,
|
||||
contentStyle,
|
||||
}}>
|
||||
<FeedsTab.Screen name="Feeds" component={FeedsScreen} />
|
||||
{commonScreens(FeedsTab as typeof HomeTab)}
|
||||
</FeedsTab.Navigator>
|
||||
)
|
||||
}
|
||||
|
||||
function NotificationsTabNavigator() {
|
||||
const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark)
|
||||
return (
|
||||
@@ -163,7 +310,7 @@ function NotificationsTabNavigator() {
|
||||
)
|
||||
}
|
||||
|
||||
function MyProfileTabNavigator() {
|
||||
const MyProfileTabNavigator = observer(() => {
|
||||
const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark)
|
||||
const store = useStores()
|
||||
return (
|
||||
@@ -180,21 +327,23 @@ function MyProfileTabNavigator() {
|
||||
// @ts-ignore // TODO: fix this broken type in ProfileScreen
|
||||
component={ProfileScreen}
|
||||
initialParams={{
|
||||
name: store.me.handle,
|
||||
name: store.me.did,
|
||||
hideBackButton: true,
|
||||
}}
|
||||
/>
|
||||
{commonScreens(MyProfileTab as typeof HomeTab)}
|
||||
</MyProfileTab.Navigator>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 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={{
|
||||
@@ -204,13 +353,30 @@ 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="Feeds"
|
||||
component={FeedsScreen}
|
||||
options={{title: title('Feeds')}}
|
||||
/>
|
||||
<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
|
||||
@@ -244,7 +410,16 @@ const LINKING = {
|
||||
if (name === 'Notifications') {
|
||||
return buildStateObject('NotificationsTab', 'Notifications', params)
|
||||
}
|
||||
return buildStateObject('HomeTab', name, params)
|
||||
if (name === 'Home') {
|
||||
return buildStateObject('HomeTab', 'Home', params)
|
||||
}
|
||||
// if the path is something else, like a post, profile, or even settings, we need to initialize the home tab as pre-existing state otherwise the back button will not work
|
||||
return buildStateObject('HomeTab', name, params, [
|
||||
{
|
||||
name: 'Home',
|
||||
params: {},
|
||||
},
|
||||
])
|
||||
} else {
|
||||
return buildStateObject('Flat', name, params)
|
||||
}
|
||||
@@ -252,8 +427,19 @@ const LINKING = {
|
||||
}
|
||||
|
||||
function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
const theme = useColorSchemeStyle(DefaultTheme, DarkTheme)
|
||||
return (
|
||||
<NavigationContainer ref={navigationRef} linking={LINKING}>
|
||||
<NavigationContainer
|
||||
ref={navigationRef}
|
||||
linking={LINKING}
|
||||
theme={theme}
|
||||
onReady={() => {
|
||||
// Register the navigation container with the Sentry instrumentation (only works on native)
|
||||
if (isNative) {
|
||||
const routingInstrumentation = getRoutingInstrumentation()
|
||||
routingInstrumentation.registerNavigationContainer(navigationRef)
|
||||
}
|
||||
}}>
|
||||
{children}
|
||||
</NavigationContainer>
|
||||
)
|
||||
@@ -277,7 +463,20 @@ function navigate<K extends keyof AllNavigatorParams>(
|
||||
function resetToTab(tabName: 'HomeTab' | 'SearchTab' | 'NotificationsTab') {
|
||||
if (navigationRef.isReady()) {
|
||||
navigate(tabName)
|
||||
navigationRef.dispatch(StackActions.popToTop())
|
||||
if (navigationRef.canGoBack()) {
|
||||
navigationRef.dispatch(StackActions.popToTop()) //we need to check .canGoBack() before calling it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (navigationRef.isReady()) {
|
||||
navigationRef.dispatch(
|
||||
CommonActions.reset({
|
||||
index: 0,
|
||||
routes: [{name: isNative ? 'HomeTab' : 'Home'}],
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,13 +518,14 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: colors.black,
|
||||
},
|
||||
bgLight: {
|
||||
backgroundColor: colors.gray1,
|
||||
backgroundColor: colors.white,
|
||||
},
|
||||
})
|
||||
|
||||
export {
|
||||
navigate,
|
||||
resetToTab,
|
||||
reset,
|
||||
handleLink,
|
||||
TabsNavigator,
|
||||
FlatNavigator,
|
||||
|
||||
@@ -78,7 +78,7 @@ export interface Theme {
|
||||
}
|
||||
|
||||
export interface ThemeProviderProps {
|
||||
theme?: ColorScheme
|
||||
theme?: 'light' | 'dark' | 'system'
|
||||
}
|
||||
|
||||
export const ThemeContext = createContext<Theme>(defaultTheme)
|
||||
@@ -89,11 +89,14 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
|
||||
theme,
|
||||
children,
|
||||
}) => {
|
||||
const colorScheme = useColorScheme()
|
||||
const colorSchemeFromRN = useColorScheme()
|
||||
|
||||
// if theme is 'system', use the device's configured color scheme
|
||||
let colorScheme = theme === 'system' ? colorSchemeFromRN : theme
|
||||
|
||||
const value = useMemo(
|
||||
() => ((theme || colorScheme) === 'dark' ? darkTheme : defaultTheme),
|
||||
[colorScheme, theme],
|
||||
() => (colorScheme === 'dark' ? darkTheme : defaultTheme),
|
||||
[colorScheme],
|
||||
)
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
||||
|
||||
@@ -11,7 +11,7 @@ export function doPolyfill() {
|
||||
interface FetchHandlerResponse {
|
||||
status: number
|
||||
headers: Record<string, string>
|
||||
body: ArrayBuffer | undefined
|
||||
body: any
|
||||
}
|
||||
|
||||
async function fetchHandler(
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedGetAuthorFeed as GetAuthorFeed,
|
||||
} from '@atproto/api'
|
||||
type ReasonRepost = AppBskyFeedDefs.ReasonRepost
|
||||
|
||||
async function getMultipleAuthorsPosts(
|
||||
rootStore: RootStoreModel,
|
||||
authors: string[],
|
||||
cursor: string | undefined = undefined,
|
||||
limit: number = 10,
|
||||
) {
|
||||
const responses = await Promise.all(
|
||||
authors.map((actor, index) =>
|
||||
rootStore.agent
|
||||
.getAuthorFeed({
|
||||
actor,
|
||||
limit,
|
||||
cursor: cursor ? cursor.split(',')[index] : undefined,
|
||||
})
|
||||
.catch(_err => ({success: false, headers: {}, data: {feed: []}})),
|
||||
),
|
||||
)
|
||||
return responses
|
||||
}
|
||||
|
||||
function mergePosts(
|
||||
responses: GetAuthorFeed.Response[],
|
||||
{repostsOnly, bestOfOnly}: {repostsOnly?: boolean; bestOfOnly?: boolean},
|
||||
) {
|
||||
let posts: AppBskyFeedDefs.FeedViewPost[] = []
|
||||
|
||||
if (bestOfOnly) {
|
||||
for (const res of responses) {
|
||||
if (res.success) {
|
||||
// filter the feed down to the post with the most likes
|
||||
res.data.feed = res.data.feed.reduce(
|
||||
(acc: AppBskyFeedDefs.FeedViewPost[], v) => {
|
||||
if (
|
||||
!acc?.[0] &&
|
||||
!v.reason &&
|
||||
!v.reply &&
|
||||
isRecentEnough(v.post.indexedAt)
|
||||
) {
|
||||
return [v]
|
||||
}
|
||||
if (
|
||||
acc &&
|
||||
!v.reason &&
|
||||
!v.reply &&
|
||||
(v.post.likeCount || 0) > (acc[0]?.post.likeCount || 0) &&
|
||||
isRecentEnough(v.post.indexedAt)
|
||||
) {
|
||||
return [v]
|
||||
}
|
||||
return acc
|
||||
},
|
||||
[],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// merge into one array
|
||||
for (const res of responses) {
|
||||
if (res.success) {
|
||||
posts = posts.concat(res.data.feed)
|
||||
}
|
||||
}
|
||||
|
||||
// filter down to reposts of other users
|
||||
const uris = new Set()
|
||||
posts = posts.filter(p => {
|
||||
if (repostsOnly && !isARepostOfSomeoneElse(p)) {
|
||||
return false
|
||||
}
|
||||
if (uris.has(p.post.uri)) {
|
||||
return false
|
||||
}
|
||||
uris.add(p.post.uri)
|
||||
return true
|
||||
})
|
||||
|
||||
// sort by index time
|
||||
posts.sort((a, b) => {
|
||||
return (
|
||||
Number(new Date(b.post.indexedAt)) - Number(new Date(a.post.indexedAt))
|
||||
)
|
||||
})
|
||||
|
||||
return posts
|
||||
}
|
||||
|
||||
function isARepostOfSomeoneElse(post: AppBskyFeedDefs.FeedViewPost): boolean {
|
||||
return (
|
||||
post.reason?.$type === 'app.bsky.feed.defs#reasonRepost' &&
|
||||
post.post.author.did !== (post.reason as ReasonRepost).by.did
|
||||
)
|
||||
}
|
||||
|
||||
function getCombinedCursors(responses: GetAuthorFeed.Response[]) {
|
||||
let hasCursor = false
|
||||
const cursors = responses.map(r => {
|
||||
if (r.data.cursor) {
|
||||
hasCursor = true
|
||||
return r.data.cursor
|
||||
}
|
||||
return ''
|
||||
})
|
||||
if (!hasCursor) {
|
||||
return undefined
|
||||
}
|
||||
const combinedCursors = cursors.join(',')
|
||||
return combinedCursors
|
||||
}
|
||||
|
||||
function isCombinedCursor(cursor: string) {
|
||||
return cursor.includes(',')
|
||||
}
|
||||
|
||||
const TWO_DAYS_AGO = Date.now() - 1e3 * 60 * 60 * 48
|
||||
function isRecentEnough(date: string) {
|
||||
try {
|
||||
const d = Number(new Date(date))
|
||||
return d > TWO_DAYS_AGO
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
getMultipleAuthorsPosts,
|
||||
mergePosts,
|
||||
getCombinedCursors,
|
||||
isCombinedCursor,
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* APP-700
|
||||
*
|
||||
* This is a temporary debug setting we're running on the Web build to
|
||||
* help the protocol team test some changes.
|
||||
*
|
||||
* It should be removed in ~2 weeks. It should only be used on the Web
|
||||
* version of the app.
|
||||
*/
|
||||
|
||||
import {useState, useCallback} from 'react'
|
||||
import {BskyAgent} from '@atproto/api'
|
||||
import {isWeb} from 'platform/detection'
|
||||
|
||||
export function useDebugHeaderSetting(agent: BskyAgent): [boolean, () => void] {
|
||||
const [enabled, setEnabled] = useState<boolean>(isEnabled())
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (!isWeb || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
if (!enabled) {
|
||||
localStorage.setItem('set-header-x-appview-proxy', 'yes')
|
||||
agent.api.xrpc.setHeader('x-appview-proxy', 'true')
|
||||
setEnabled(true)
|
||||
} else {
|
||||
localStorage.removeItem('set-header-x-appview-proxy')
|
||||
agent.api.xrpc.unsetHeader('x-appview-proxy')
|
||||
setEnabled(false)
|
||||
}
|
||||
}, [setEnabled, enabled, agent])
|
||||
|
||||
return [enabled, toggle]
|
||||
}
|
||||
|
||||
export function setDebugHeader(agent: BskyAgent, enabled: boolean) {
|
||||
if (!isWeb || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
if (enabled) {
|
||||
localStorage.setItem('set-header-x-appview-proxy', 'yes')
|
||||
agent.api.xrpc.setHeader('x-appview-proxy', 'true')
|
||||
} else {
|
||||
localStorage.removeItem('set-header-x-appview-proxy')
|
||||
agent.api.xrpc.unsetHeader('x-appview-proxy')
|
||||
}
|
||||
}
|
||||
|
||||
export function applyDebugHeader(agent: BskyAgent) {
|
||||
if (!isWeb) {
|
||||
return
|
||||
}
|
||||
if (isEnabled()) {
|
||||
agent.api.xrpc.setHeader('x-appview-proxy', 'true')
|
||||
}
|
||||
}
|
||||
|
||||
function isEnabled() {
|
||||
if (!isWeb || typeof window === 'undefined') {
|
||||
return false
|
||||
}
|
||||
return localStorage.getItem('set-header-x-appview-proxy') === 'yes'
|
||||
}
|
||||
+52
-30
@@ -1,4 +1,4 @@
|
||||
import {AppBskyFeedDefs} from '@atproto/api'
|
||||
import {AppBskyFeedDefs, AppBskyFeedPost} from '@atproto/api'
|
||||
import lande from 'lande'
|
||||
import {hasProp} from 'lib/type-guards'
|
||||
import {LANGUAGES_MAP_CODE2} from '../../locale/languages'
|
||||
@@ -48,6 +48,13 @@ export class FeedViewPostsSlice {
|
||||
return this.items[0]
|
||||
}
|
||||
|
||||
get isReply() {
|
||||
return (
|
||||
AppBskyFeedPost.isRecord(this.rootItem.post.record) &&
|
||||
!!this.rootItem.post.record.reply
|
||||
)
|
||||
}
|
||||
|
||||
containsUri(uri: string) {
|
||||
return !!this.items.find(item => item.post.uri === uri)
|
||||
}
|
||||
@@ -67,9 +74,12 @@ export class FeedViewPostsSlice {
|
||||
}
|
||||
|
||||
flattenReplyParent() {
|
||||
if (this.items[0].reply?.parent) {
|
||||
this.isFlattenedReply = true
|
||||
this.items.splice(0, 0, {post: this.items[0].reply?.parent})
|
||||
if (this.items[0].reply) {
|
||||
const reply = this.items[0].reply
|
||||
if (AppBskyFeedDefs.isPostView(reply.parent)) {
|
||||
this.isFlattenedReply = true
|
||||
this.items.splice(0, 0, {post: reply.parent})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,22 +133,20 @@ export class FeedTuner {
|
||||
|
||||
// turn non-threads with reply parents into threads
|
||||
for (const slice of slices) {
|
||||
if (
|
||||
!slice.isThread &&
|
||||
!slice.items[0].reason &&
|
||||
slice.items[0].reply?.parent &&
|
||||
!this.seenUris.has(slice.items[0].reply?.parent.uri) &&
|
||||
!soonToBeSeenUris.has(slice.items[0].reply?.parent.uri)
|
||||
) {
|
||||
const uri = slice.items[0].reply?.parent.uri
|
||||
slice.flattenReplyParent()
|
||||
soonToBeSeenUris.add(uri)
|
||||
if (!slice.isThread && !slice.items[0].reason && slice.items[0].reply) {
|
||||
const reply = slice.items[0].reply
|
||||
if (
|
||||
AppBskyFeedDefs.isPostView(reply.parent) &&
|
||||
!this.seenUris.has(reply.parent.uri) &&
|
||||
!soonToBeSeenUris.has(reply.parent.uri)
|
||||
) {
|
||||
const uri = reply.parent.uri
|
||||
slice.flattenReplyParent()
|
||||
soonToBeSeenUris.add(uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sort by slice roots' timestamps
|
||||
slices.sort((a, b) => b.ts.localeCompare(a.ts))
|
||||
|
||||
for (const slice of slices) {
|
||||
for (const item of slice.items) {
|
||||
this.seenUris.add(item.post.uri)
|
||||
@@ -176,9 +184,10 @@ export class FeedTuner {
|
||||
): FeedViewPostsSlice[] {
|
||||
// remove any replies without at least 2 likes
|
||||
for (let i = slices.length - 1; i >= 0; i--) {
|
||||
if (slices[i].isFullThread || !slices[i].rootItem.reply) {
|
||||
if (slices[i].isFullThread || !slices[i].isReply) {
|
||||
continue
|
||||
}
|
||||
|
||||
const item = slices[i].rootItem
|
||||
const isRepost = Boolean(item.reason)
|
||||
if (!isRepost && (item.post.likeCount || 0) < 2) {
|
||||
@@ -194,7 +203,9 @@ export class FeedTuner {
|
||||
tuner: FeedTuner,
|
||||
slices: FeedViewPostsSlice[],
|
||||
): FeedViewPostsSlice[] => {
|
||||
const origSlices = slices.concat()
|
||||
if (!langsCode2.length) {
|
||||
return slices
|
||||
}
|
||||
for (let i = slices.length - 1; i >= 0; i--) {
|
||||
let hasPreferredLang = false
|
||||
for (const item of slices[i].items) {
|
||||
@@ -202,29 +213,40 @@ export class FeedTuner {
|
||||
hasProp(item.post.record, 'text') &&
|
||||
typeof item.post.record.text === 'string'
|
||||
) {
|
||||
const res = lande(item.post.record.text)
|
||||
const contentLangCode3 = res[0][0]
|
||||
if (langsCode3.includes(contentLangCode3)) {
|
||||
// Treat empty text the same as no text.
|
||||
if (item.post.record.text.length === 0) {
|
||||
hasPreferredLang = true
|
||||
break
|
||||
}
|
||||
|
||||
const res = lande(item.post.record.text)
|
||||
|
||||
if (langsCode3.includes(res[0][0])) {
|
||||
hasPreferredLang = true
|
||||
break
|
||||
}
|
||||
} else {
|
||||
// no text? roll with it
|
||||
hasPreferredLang = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!hasPreferredLang) {
|
||||
slices.splice(i, 1)
|
||||
}
|
||||
}
|
||||
if (slices.length) {
|
||||
return slices
|
||||
}
|
||||
// fallback: give everything if the language filter left nothing
|
||||
return origSlices
|
||||
return slices
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getSelfReplyUri(item: FeedViewPost): string | undefined {
|
||||
return item.reply?.parent.author.did === item.post.author.did
|
||||
? item.reply?.parent.uri
|
||||
: undefined
|
||||
if (item.reply) {
|
||||
if (AppBskyFeedDefs.isPostView(item.reply.parent)) {
|
||||
return item.reply.parent.author.did === item.post.author.did
|
||||
? item.reply.parent.uri
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
+56
-49
@@ -10,15 +10,16 @@ import {
|
||||
import {AtUri} from '@atproto/api'
|
||||
import {RootStoreModel} from 'state/models/root-store'
|
||||
import {isNetworkError} from 'lib/strings/errors'
|
||||
import {Image} from 'lib/media/types'
|
||||
import {LinkMeta} from '../link-meta/link-meta'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {ImageModel} from 'state/models/media/image'
|
||||
|
||||
export interface ExternalEmbedDraft {
|
||||
uri: string
|
||||
isLoading: boolean
|
||||
meta?: LinkMeta
|
||||
localThumb?: Image
|
||||
embed?: AppBskyEmbedRecord.Main
|
||||
localThumb?: ImageModel
|
||||
}
|
||||
|
||||
export async function resolveName(store: RootStoreModel, didOrHandle: string) {
|
||||
@@ -61,7 +62,7 @@ interface PostOpts {
|
||||
cid: string
|
||||
}
|
||||
extLink?: ExternalEmbedDraft
|
||||
images?: string[]
|
||||
images?: ImageModel[]
|
||||
knownHandles?: Set<string>
|
||||
onStateChange?: (state: string) => void
|
||||
}
|
||||
@@ -109,10 +110,12 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
|
||||
const images: AppBskyEmbedImages.Image[] = []
|
||||
for (const image of opts.images) {
|
||||
opts.onStateChange?.(`Uploading image #${images.length + 1}...`)
|
||||
const res = await uploadBlob(store, image, 'image/jpeg')
|
||||
await image.compress()
|
||||
const path = image.compressed?.path ?? image.path
|
||||
const res = await uploadBlob(store, path, 'image/jpeg')
|
||||
images.push({
|
||||
image: res.data.blob,
|
||||
alt: '', // TODO supply alt text
|
||||
alt: image.altText ?? '',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -134,40 +137,54 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
|
||||
}
|
||||
|
||||
if (opts.extLink && !opts.images?.length) {
|
||||
let thumb
|
||||
if (opts.extLink.localThumb) {
|
||||
opts.onStateChange?.('Uploading link thumbnail...')
|
||||
let encoding
|
||||
if (opts.extLink.localThumb.mime) {
|
||||
encoding = opts.extLink.localThumb.mime
|
||||
} else if (opts.extLink.localThumb.path.endsWith('.png')) {
|
||||
encoding = 'image/png'
|
||||
} else if (
|
||||
opts.extLink.localThumb.path.endsWith('.jpeg') ||
|
||||
opts.extLink.localThumb.path.endsWith('.jpg')
|
||||
) {
|
||||
encoding = 'image/jpeg'
|
||||
} else {
|
||||
store.log.warn(
|
||||
'Unexpected image format for thumbnail, skipping',
|
||||
opts.extLink.localThumb.path,
|
||||
)
|
||||
if (opts.extLink.embed) {
|
||||
embed = opts.extLink.embed
|
||||
} else {
|
||||
let thumb
|
||||
if (opts.extLink.localThumb) {
|
||||
opts.onStateChange?.('Uploading link thumbnail...')
|
||||
let encoding
|
||||
if (opts.extLink.localThumb.mime) {
|
||||
encoding = opts.extLink.localThumb.mime
|
||||
} else if (opts.extLink.localThumb.path.endsWith('.png')) {
|
||||
encoding = 'image/png'
|
||||
} else if (
|
||||
opts.extLink.localThumb.path.endsWith('.jpeg') ||
|
||||
opts.extLink.localThumb.path.endsWith('.jpg')
|
||||
) {
|
||||
encoding = 'image/jpeg'
|
||||
} else {
|
||||
store.log.warn(
|
||||
'Unexpected image format for thumbnail, skipping',
|
||||
opts.extLink.localThumb.path,
|
||||
)
|
||||
}
|
||||
if (encoding) {
|
||||
const thumbUploadRes = await uploadBlob(
|
||||
store,
|
||||
opts.extLink.localThumb.path,
|
||||
encoding,
|
||||
)
|
||||
thumb = thumbUploadRes.data.blob
|
||||
}
|
||||
}
|
||||
if (encoding) {
|
||||
const thumbUploadRes = await uploadBlob(
|
||||
store,
|
||||
opts.extLink.localThumb.path,
|
||||
encoding,
|
||||
)
|
||||
thumb = thumbUploadRes.data.blob
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.quote) {
|
||||
embed = {
|
||||
$type: 'app.bsky.embed.recordWithMedia',
|
||||
record: embed,
|
||||
media: {
|
||||
if (opts.quote) {
|
||||
embed = {
|
||||
$type: 'app.bsky.embed.recordWithMedia',
|
||||
record: embed,
|
||||
media: {
|
||||
$type: 'app.bsky.embed.external',
|
||||
external: {
|
||||
uri: opts.extLink.uri,
|
||||
title: opts.extLink.meta?.title || '',
|
||||
description: opts.extLink.meta?.description || '',
|
||||
thumb,
|
||||
},
|
||||
} as AppBskyEmbedExternal.Main,
|
||||
} as AppBskyEmbedRecordWithMedia.Main
|
||||
} else {
|
||||
embed = {
|
||||
$type: 'app.bsky.embed.external',
|
||||
external: {
|
||||
uri: opts.extLink.uri,
|
||||
@@ -175,18 +192,8 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
|
||||
description: opts.extLink.meta?.description || '',
|
||||
thumb,
|
||||
},
|
||||
} as AppBskyEmbedExternal.Main,
|
||||
} as AppBskyEmbedRecordWithMedia.Main
|
||||
} else {
|
||||
embed = {
|
||||
$type: 'app.bsky.embed.external',
|
||||
external: {
|
||||
uri: opts.extLink.uri,
|
||||
title: opts.extLink.meta?.title || '',
|
||||
description: opts.extLink.meta?.description || '',
|
||||
thumb,
|
||||
},
|
||||
} as AppBskyEmbedExternal.Main
|
||||
} as AppBskyEmbedExternal.Main
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -1,4 +1,5 @@
|
||||
import VersionNumber from 'react-native-version-number'
|
||||
import * as Updates from 'expo-updates'
|
||||
export const updateChannel = Updates.channel
|
||||
|
||||
export const appVersion = VersionNumber.appVersion
|
||||
export const buildVersion = VersionNumber.buildVersion
|
||||
export const appVersion = `${VersionNumber.appVersion} (${VersionNumber.buildVersion})`
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
// TODO
|
||||
export const appVersion = 'TODO'
|
||||
export const buildVersion = 'TODO'
|
||||
import {version} from '../../package.json'
|
||||
export const appVersion = version
|
||||
|
||||
@@ -4,6 +4,22 @@ import set from 'lodash.set'
|
||||
|
||||
const ongoingActions = new Set<any>()
|
||||
|
||||
/**
|
||||
* This is a TypeScript function that optimistically updates data on the client-side before sending a
|
||||
* request to the server and rolling back changes if the request fails.
|
||||
* @param {T} model - The object or record that needs to be updated optimistically.
|
||||
* @param preUpdate - `preUpdate` is a function that is called before the server update is executed. It
|
||||
* can be used to perform any necessary actions or updates on the model or UI before the server update
|
||||
* is initiated.
|
||||
* @param serverUpdate - `serverUpdate` is a function that returns a Promise representing the server
|
||||
* update operation. This function is called after the previous state of the model has been recorded
|
||||
* and the `preUpdate` function has been executed. If the server update is successful, the `postUpdate`
|
||||
* function is called with the result
|
||||
* @param [postUpdate] - `postUpdate` is an optional callback function that will be called after the
|
||||
* server update is successful. It takes in the response from the server update as its parameter. If
|
||||
* this parameter is not provided, nothing will happen after the server update.
|
||||
* @returns A Promise that resolves to `void`.
|
||||
*/
|
||||
export const updateDataOptimistically = async <
|
||||
T extends Record<string, any>,
|
||||
U,
|
||||
|
||||
+76
-128
@@ -4,6 +4,28 @@ export const FEEDBACK_FORM_URL =
|
||||
export const MAX_DISPLAY_NAME = 64
|
||||
export const MAX_DESCRIPTION = 256
|
||||
|
||||
export const MAX_GRAPHEME_LENGTH = 300
|
||||
|
||||
// Recommended is 100 per: https://www.w3.org/WAI/GL/WCAG20/tests/test3.html
|
||||
// but increasing limit per user feedback
|
||||
export const MAX_ALT_TEXT = 1000
|
||||
|
||||
export function IS_LOCAL_DEV(url: string) {
|
||||
return url.includes('localhost')
|
||||
}
|
||||
|
||||
export function IS_STAGING(url: string) {
|
||||
return !IS_LOCAL_DEV(url) && !IS_PROD(url)
|
||||
}
|
||||
|
||||
export function IS_PROD(url: string) {
|
||||
// NOTE
|
||||
// until open federation, "production" is defined as the main server
|
||||
// this definition will not work once federation is enabled!
|
||||
// -prf
|
||||
return url.startsWith('https://bsky.social')
|
||||
}
|
||||
|
||||
export const PROD_TEAM_HANDLES = [
|
||||
'jay.bsky.social',
|
||||
'pfrazee.com',
|
||||
@@ -29,135 +51,46 @@ export function TEAM_HANDLES(serviceUrl: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export const PROD_SUGGESTED_FOLLOWS = [
|
||||
'christina',
|
||||
'wesam',
|
||||
'jim',
|
||||
'ab',
|
||||
'karalabe',
|
||||
'clun',
|
||||
'staltz',
|
||||
'gillian',
|
||||
'karpathy',
|
||||
'zoink',
|
||||
'john',
|
||||
'round',
|
||||
'vex',
|
||||
'umang',
|
||||
'atroyn',
|
||||
'poisonivy',
|
||||
'wongmjane',
|
||||
'lari',
|
||||
'arunwadhwa',
|
||||
'trav',
|
||||
'fred',
|
||||
'offscript',
|
||||
'satnam',
|
||||
'ella',
|
||||
'caspian',
|
||||
'spencer',
|
||||
'nickgrossman',
|
||||
'koji',
|
||||
'avy',
|
||||
'seymourstein',
|
||||
'joelg',
|
||||
'stig',
|
||||
'rabble',
|
||||
'hunterwalk',
|
||||
'evan',
|
||||
'aviral',
|
||||
'tami',
|
||||
'generativist',
|
||||
'gord',
|
||||
'ninjapleasedj',
|
||||
'robotics',
|
||||
'noahjnelson',
|
||||
'vijay',
|
||||
'scottbeale',
|
||||
'daybreakjung',
|
||||
'shelby',
|
||||
'joel',
|
||||
'space',
|
||||
'rish',
|
||||
'simon',
|
||||
'kelly',
|
||||
'maxbittker',
|
||||
'sylphrenetic',
|
||||
'caleb',
|
||||
'jik',
|
||||
'james',
|
||||
'neil',
|
||||
'tippenein',
|
||||
'mandel',
|
||||
'sharding',
|
||||
'tyler',
|
||||
'raymond',
|
||||
'visakanv',
|
||||
'saz',
|
||||
'steph',
|
||||
'ratzlaff',
|
||||
'beth',
|
||||
'weisser',
|
||||
'katherine',
|
||||
'annagat',
|
||||
'an',
|
||||
'kunal',
|
||||
'josh',
|
||||
'lurkshark',
|
||||
'amir',
|
||||
'amyxzh',
|
||||
'danielle',
|
||||
'jack-frazee',
|
||||
'daniellefong',
|
||||
'dystopiabreaker',
|
||||
'morgan',
|
||||
'vibes',
|
||||
'cat',
|
||||
'yuriy',
|
||||
'alvinreyes',
|
||||
'skoot',
|
||||
'patricia',
|
||||
'ara4n',
|
||||
'case',
|
||||
'armand',
|
||||
'ivan',
|
||||
'nicholas',
|
||||
'kelsey',
|
||||
'ericlee',
|
||||
'emily',
|
||||
'jake',
|
||||
'jennijuju',
|
||||
'ian5v',
|
||||
'bnewbold',
|
||||
'jasmine',
|
||||
'chris',
|
||||
'mtclai',
|
||||
'willscott',
|
||||
'michael',
|
||||
'kwkroeger',
|
||||
'broox',
|
||||
'iamrosewang',
|
||||
'jack-morrison',
|
||||
'pwang',
|
||||
'martin',
|
||||
'jack',
|
||||
'jay',
|
||||
]
|
||||
.map(handle => `${handle}.bsky.social`)
|
||||
.concat(['pfrazee.com', 'divy.zone', 'dholms.xyz', 'why.bsky.world'])
|
||||
export const STAGING_SUGGESTED_FOLLOWS = ['arcalinea', 'paul', 'paul2'].map(
|
||||
handle => `${handle}.staging.bsky.dev`,
|
||||
)
|
||||
export const DEV_SUGGESTED_FOLLOWS = ['alice', 'bob', 'carla'].map(
|
||||
handle => `${handle}.test`,
|
||||
)
|
||||
export function SUGGESTED_FOLLOWS(serviceUrl: string) {
|
||||
if (serviceUrl.includes('localhost')) {
|
||||
return DEV_SUGGESTED_FOLLOWS
|
||||
} else if (serviceUrl.includes('staging')) {
|
||||
return STAGING_SUGGESTED_FOLLOWS
|
||||
export const STAGING_DEFAULT_FEED = (rkey: string) =>
|
||||
`at://did:plc:wqzurwm3kmaig6e6hnc2gqwo/app.bsky.feed.generator/${rkey}`
|
||||
export const PROD_DEFAULT_FEED = (rkey: string) =>
|
||||
`at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/${rkey}`
|
||||
export async function DEFAULT_FEEDS(
|
||||
serviceUrl: string,
|
||||
resolveHandle: (name: string) => Promise<string>,
|
||||
) {
|
||||
if (IS_LOCAL_DEV(serviceUrl)) {
|
||||
// local dev
|
||||
const aliceDid = await resolveHandle('alice.test')
|
||||
return {
|
||||
pinned: [`at://${aliceDid}/app.bsky.feed.generator/alice-favs`],
|
||||
saved: [`at://${aliceDid}/app.bsky.feed.generator/alice-favs`],
|
||||
}
|
||||
} else if (IS_STAGING(serviceUrl)) {
|
||||
// staging
|
||||
return {
|
||||
pinned: [STAGING_DEFAULT_FEED('whats-hot')],
|
||||
saved: [
|
||||
STAGING_DEFAULT_FEED('bsky-team'),
|
||||
STAGING_DEFAULT_FEED('with-friends'),
|
||||
STAGING_DEFAULT_FEED('whats-hot'),
|
||||
STAGING_DEFAULT_FEED('hot-classic'),
|
||||
],
|
||||
}
|
||||
} else {
|
||||
return PROD_SUGGESTED_FOLLOWS
|
||||
// production
|
||||
return {
|
||||
pinned: [
|
||||
PROD_DEFAULT_FEED('whats-hot'),
|
||||
PROD_DEFAULT_FEED('with-friends'),
|
||||
],
|
||||
saved: [
|
||||
PROD_DEFAULT_FEED('bsky-team'),
|
||||
PROD_DEFAULT_FEED('with-friends'),
|
||||
PROD_DEFAULT_FEED('whats-hot'),
|
||||
PROD_DEFAULT_FEED('hot-classic'),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,3 +99,18 @@ export const POST_IMG_MAX = {
|
||||
height: 2000,
|
||||
size: 1000000,
|
||||
}
|
||||
|
||||
export const STAGING_LINK_META_PROXY =
|
||||
'https://cardyb.staging.bsky.dev/v1/extract?url='
|
||||
|
||||
export const PROD_LINK_META_PROXY = 'https://cardyb.bsky.app/v1/extract?url='
|
||||
|
||||
export function LINK_META_PROXY(serviceUrl: string) {
|
||||
if (IS_LOCAL_DEV(serviceUrl)) {
|
||||
return STAGING_LINK_META_PROXY
|
||||
} else if (IS_STAGING(serviceUrl)) {
|
||||
return STAGING_LINK_META_PROXY
|
||||
} else {
|
||||
return PROD_LINK_META_PROXY
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import {isIOS, isWeb} from 'platform/detection'
|
||||
import ReactNativeHapticFeedback, {
|
||||
HapticFeedbackTypes,
|
||||
} from 'react-native-haptic-feedback'
|
||||
|
||||
const hapticImpact: HapticFeedbackTypes = isIOS ? 'impactMedium' : 'impactLight' // Users said the medium impact was too strong on Android; see APP-537s
|
||||
|
||||
export class Haptics {
|
||||
static default() {
|
||||
if (isWeb) {
|
||||
return
|
||||
}
|
||||
ReactNativeHapticFeedback.trigger(hapticImpact)
|
||||
}
|
||||
static impact(type: HapticFeedbackTypes = hapticImpact) {
|
||||
if (isWeb) {
|
||||
return
|
||||
}
|
||||
ReactNativeHapticFeedback.trigger(type)
|
||||
}
|
||||
static selection() {
|
||||
if (isWeb) {
|
||||
return
|
||||
}
|
||||
ReactNativeHapticFeedback.trigger('selection')
|
||||
}
|
||||
static notification = (type: 'success' | 'warning' | 'error') => {
|
||||
if (isWeb) {
|
||||
return
|
||||
}
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return ReactNativeHapticFeedback.trigger('notificationSuccess')
|
||||
case 'warning':
|
||||
return ReactNativeHapticFeedback.trigger('notificationWarning')
|
||||
case 'error':
|
||||
return ReactNativeHapticFeedback.trigger('notificationError')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {useStores} from 'state/index'
|
||||
import {CustomFeedModel} from 'state/models/feeds/custom-feed'
|
||||
|
||||
export function useCustomFeed(uri: string): CustomFeedModel | undefined {
|
||||
const store = useStores()
|
||||
const [item, setItem] = useState<CustomFeedModel | undefined>()
|
||||
useEffect(() => {
|
||||
async function fetchView() {
|
||||
const res = await store.agent.app.bsky.feed.getFeedGenerator({
|
||||
feed: uri,
|
||||
})
|
||||
const view = res.data.view
|
||||
return view
|
||||
}
|
||||
async function buildFeedItem() {
|
||||
const view = await fetchView()
|
||||
if (view) {
|
||||
const temp = new CustomFeedModel(store, view)
|
||||
setItem(temp)
|
||||
}
|
||||
}
|
||||
buildFeedItem()
|
||||
}, [store, uri])
|
||||
|
||||
return item
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import {useEffect, useRef, useMemo, ForwardedRef} from 'react'
|
||||
import {Platform, findNodeHandle} from 'react-native'
|
||||
import type {ScrollView} from 'react-native'
|
||||
import {mergeRefs} from 'lib/merge-refs'
|
||||
|
||||
type Props<Scrollable extends ScrollView = ScrollView> = {
|
||||
cursor?: string
|
||||
outerRef?: ForwardedRef<Scrollable>
|
||||
}
|
||||
|
||||
export function useDraggableScroll<Scrollable extends ScrollView = ScrollView>({
|
||||
outerRef,
|
||||
cursor = 'grab',
|
||||
}: Props<Scrollable> = {}) {
|
||||
const ref = useRef<Scrollable>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== 'web' || !ref.current) {
|
||||
return
|
||||
}
|
||||
const slider = findNodeHandle(ref.current) as unknown as HTMLDivElement
|
||||
if (!slider) {
|
||||
return
|
||||
}
|
||||
let isDragging = false
|
||||
let isMouseDown = false
|
||||
let startX = 0
|
||||
let scrollLeft = 0
|
||||
|
||||
const mouseDown = (e: MouseEvent) => {
|
||||
isMouseDown = true
|
||||
startX = e.pageX - slider.offsetLeft
|
||||
scrollLeft = slider.scrollLeft
|
||||
|
||||
slider.style.cursor = cursor
|
||||
}
|
||||
|
||||
const mouseUp = () => {
|
||||
if (isDragging) {
|
||||
slider.addEventListener('click', e => e.stopPropagation(), {once: true})
|
||||
}
|
||||
|
||||
isMouseDown = false
|
||||
isDragging = false
|
||||
slider.style.cursor = 'default'
|
||||
}
|
||||
|
||||
const mouseMove = (e: MouseEvent) => {
|
||||
if (!isMouseDown) {
|
||||
return
|
||||
}
|
||||
|
||||
// Require n pixels momement before start of drag (3 in this case )
|
||||
const x = e.pageX - slider.offsetLeft
|
||||
if (Math.abs(x - startX) < 3) {
|
||||
return
|
||||
}
|
||||
|
||||
isDragging = true
|
||||
e.preventDefault()
|
||||
const walk = x - startX
|
||||
slider.scrollLeft = scrollLeft - walk
|
||||
}
|
||||
|
||||
slider.addEventListener('mousedown', mouseDown)
|
||||
window.addEventListener('mouseup', mouseUp)
|
||||
window.addEventListener('mousemove', mouseMove)
|
||||
|
||||
return () => {
|
||||
slider.removeEventListener('mousedown', mouseDown)
|
||||
window.removeEventListener('mouseup', mouseUp)
|
||||
window.removeEventListener('mousemove', mouseMove)
|
||||
}
|
||||
}, [cursor])
|
||||
|
||||
const refs = useMemo(
|
||||
() => mergeRefs(outerRef ? [ref, outerRef] : [ref]),
|
||||
[ref, outerRef],
|
||||
)
|
||||
|
||||
return {
|
||||
refs,
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,16 @@ export function useNavigationTabState() {
|
||||
const res = {
|
||||
isAtHome: getTabState(state, 'Home') !== TabState.Outside,
|
||||
isAtSearch: getTabState(state, 'Search') !== TabState.Outside,
|
||||
isAtFeeds: getTabState(state, 'Feeds') !== TabState.Outside,
|
||||
isAtNotifications:
|
||||
getTabState(state, 'Notifications') !== TabState.Outside,
|
||||
isAtMyProfile: getTabState(state, 'MyProfile') !== TabState.Outside,
|
||||
}
|
||||
if (
|
||||
!res.isAtHome &&
|
||||
!res.isAtNotifications &&
|
||||
!res.isAtSearch &&
|
||||
!res.isAtFeeds &&
|
||||
!res.isAtNotifications &&
|
||||
!res.isAtMyProfile
|
||||
) {
|
||||
// HACK for some reason useNavigationState will give us pre-hydration results
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import * as Updates from 'expo-updates'
|
||||
import {useCallback, useEffect} from 'react'
|
||||
import {AppState} from 'react-native'
|
||||
import {useStores} from 'state/index'
|
||||
|
||||
export function useOTAUpdate() {
|
||||
const store = useStores()
|
||||
|
||||
// HELPER FUNCTIONS
|
||||
const showUpdatePopup = useCallback(() => {
|
||||
store.shell.openModal({
|
||||
name: 'confirm',
|
||||
title: 'Update Available',
|
||||
message:
|
||||
'A new version of the app is available. Please update to continue using the app.',
|
||||
onPressConfirm: async () => {
|
||||
Updates.reloadAsync().catch(err => {
|
||||
throw err
|
||||
})
|
||||
},
|
||||
})
|
||||
}, [store.shell])
|
||||
const checkForUpdate = useCallback(async () => {
|
||||
store.log.debug('useOTAUpdate: Checking for update...')
|
||||
try {
|
||||
// Check if new OTA update is available
|
||||
const update = await Updates.checkForUpdateAsync()
|
||||
// If updates aren't available stop the function execution
|
||||
if (!update.isAvailable) {
|
||||
return
|
||||
}
|
||||
// Otherwise fetch the update in the background, so even if the user rejects switching to latest version it will be done automatically on next relaunch.
|
||||
await Updates.fetchUpdateAsync()
|
||||
// show a popup modal
|
||||
showUpdatePopup()
|
||||
} catch (e) {
|
||||
console.error('useOTAUpdate: Error while checking for update', e)
|
||||
store.log.error('useOTAUpdate: Error while checking for update', e)
|
||||
}
|
||||
}, [showUpdatePopup, store.log])
|
||||
const updateEventListener = useCallback(
|
||||
(event: Updates.UpdateEvent) => {
|
||||
store.log.debug('useOTAUpdate: Listening for update...')
|
||||
if (event.type === Updates.UpdateEventType.ERROR) {
|
||||
throw new Error(event.message)
|
||||
} else if (event.type === Updates.UpdateEventType.NO_UPDATE_AVAILABLE) {
|
||||
// Handle no update available
|
||||
// do nothing
|
||||
} else if (event.type === Updates.UpdateEventType.UPDATE_AVAILABLE) {
|
||||
// Handle update available
|
||||
// open modal, ask for user confirmation, and reload the app
|
||||
showUpdatePopup()
|
||||
}
|
||||
},
|
||||
[showUpdatePopup, store.log],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// ADD EVENT LISTENERS
|
||||
const updateEventSubscription = Updates.addListener(updateEventListener)
|
||||
const appStateSubscription = AppState.addEventListener('change', state => {
|
||||
if (state === 'active' && !__DEV__) {
|
||||
checkForUpdate()
|
||||
}
|
||||
})
|
||||
|
||||
// REMOVE EVENT LISTENERS (CLEANUP)
|
||||
return () => {
|
||||
updateEventSubscription.remove()
|
||||
appStateSubscription.remove()
|
||||
}
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
// disable exhaustive deps because we don't want to run this effect again
|
||||
}
|
||||
@@ -1,25 +1,56 @@
|
||||
import {useState} from 'react'
|
||||
import {useState, useCallback, useRef} from 'react'
|
||||
import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {s} from 'lib/styles'
|
||||
import {isDesktopWeb} from 'platform/detection'
|
||||
|
||||
const DY_LIMIT = isDesktopWeb ? 30 : 10
|
||||
|
||||
export type OnScrollCb = (
|
||||
event: NativeSyntheticEvent<NativeScrollEvent>,
|
||||
) => void
|
||||
export type ResetCb = () => void
|
||||
|
||||
export function useOnMainScroll(store: RootStoreModel) {
|
||||
let [lastY, setLastY] = useState(0)
|
||||
let isMinimal = store.shell.minimalShellMode
|
||||
return function onMainScroll(event: NativeSyntheticEvent<NativeScrollEvent>) {
|
||||
const y = event.nativeEvent.contentOffset.y
|
||||
const dy = y - (lastY || 0)
|
||||
setLastY(y)
|
||||
export function useOnMainScroll(
|
||||
store: RootStoreModel,
|
||||
): [OnScrollCb, boolean, ResetCb] {
|
||||
let lastY = useRef(0)
|
||||
let [isScrolledDown, setIsScrolledDown] = useState(false)
|
||||
return [
|
||||
useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const y = event.nativeEvent.contentOffset.y
|
||||
const dy = y - (lastY.current || 0)
|
||||
lastY.current = y
|
||||
|
||||
if (!isMinimal && y > 10 && dy > 10) {
|
||||
store.shell.setMinimalShellMode(true)
|
||||
isMinimal = true
|
||||
} else if (isMinimal && (y <= 10 || dy < -10)) {
|
||||
if (!store.shell.minimalShellMode && y > 10 && dy > DY_LIMIT) {
|
||||
store.shell.setMinimalShellMode(true)
|
||||
} else if (
|
||||
store.shell.minimalShellMode &&
|
||||
(y <= 10 || dy < DY_LIMIT * -1)
|
||||
) {
|
||||
store.shell.setMinimalShellMode(false)
|
||||
}
|
||||
|
||||
if (
|
||||
!isScrolledDown &&
|
||||
event.nativeEvent.contentOffset.y > s.window.height
|
||||
) {
|
||||
setIsScrolledDown(true)
|
||||
} else if (
|
||||
isScrolledDown &&
|
||||
event.nativeEvent.contentOffset.y < s.window.height
|
||||
) {
|
||||
setIsScrolledDown(false)
|
||||
}
|
||||
},
|
||||
[store, isScrolledDown],
|
||||
),
|
||||
isScrolledDown,
|
||||
useCallback(() => {
|
||||
setIsScrolledDown(false)
|
||||
store.shell.setMinimalShellMode(false)
|
||||
isMinimal = false
|
||||
}
|
||||
}
|
||||
lastY.current = 1e8 // NOTE we set this very high so that the onScroll logic works right -prf
|
||||
}, [store, setIsScrolledDown]),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import {Alert} from 'react-native'
|
||||
import {Camera} from 'expo-camera'
|
||||
import * as MediaLibrary from 'expo-media-library'
|
||||
import {Linking} from 'react-native'
|
||||
import {isWeb} from 'platform/detection'
|
||||
|
||||
const openSettings = () => {
|
||||
Linking.openURL('app-settings:')
|
||||
}
|
||||
import {Alert} from 'view/com/util/Alert'
|
||||
|
||||
const openPermissionAlert = (perm: string) => {
|
||||
Alert.alert(
|
||||
@@ -17,7 +13,7 @@ const openPermissionAlert = (perm: string) => {
|
||||
text: 'Cancel',
|
||||
style: 'cancel',
|
||||
},
|
||||
{text: 'Open Settings', onPress: () => openSettings()},
|
||||
{text: 'Open Settings', onPress: () => Linking.openSettings()},
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {getTabState, TabState} from 'lib/routes/helpers'
|
||||
|
||||
export function useTabFocusEffect(
|
||||
tabName: string,
|
||||
cb: (isInside: boolean) => void,
|
||||
) {
|
||||
const [isInside, setIsInside] = useState(false)
|
||||
|
||||
// get root navigator state
|
||||
let nav = useNavigation()
|
||||
while (nav.getParent()) {
|
||||
nav = nav.getParent()
|
||||
}
|
||||
const state = nav.getState()
|
||||
|
||||
useEffect(() => {
|
||||
// check if inside
|
||||
let v = getTabState(state, tabName) !== TabState.Outside
|
||||
if (v !== isInside) {
|
||||
// fire
|
||||
setIsInside(v)
|
||||
cb(v)
|
||||
}
|
||||
}, [state, isInside, setIsInside, tabName, cb])
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import * as React from 'react'
|
||||
|
||||
/**
|
||||
* Helper hook to run persistent timers on views
|
||||
*/
|
||||
export function useTimer(time: number, handler: () => void) {
|
||||
const timer = React.useRef<undefined | NodeJS.Timeout>(undefined)
|
||||
|
||||
// function to restart the timer
|
||||
const reset = React.useCallback(() => {
|
||||
if (timer.current) {
|
||||
clearTimeout(timer.current)
|
||||
}
|
||||
timer.current = setTimeout(handler, time)
|
||||
}, [time, timer, handler])
|
||||
|
||||
// function to cancel the timer
|
||||
const cancel = React.useCallback(() => {
|
||||
if (timer.current) {
|
||||
clearTimeout(timer.current)
|
||||
timer.current = undefined
|
||||
}
|
||||
}, [timer])
|
||||
|
||||
// start the timer immediately
|
||||
React.useEffect(() => {
|
||||
reset()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
return [reset, cancel]
|
||||
}
|
||||
+138
-9
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, TextStyle, ViewStyle} from 'react-native'
|
||||
import Svg, {Path, Rect, Line, Ellipse} from 'react-native-svg'
|
||||
import Svg, {Path, Rect, Line, Ellipse, Circle} from 'react-native-svg'
|
||||
|
||||
export function GridIcon({
|
||||
style,
|
||||
@@ -88,7 +88,7 @@ export function HomeIconSolid({
|
||||
<Path
|
||||
fill="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
d="M 23.951 2 C 23.631 2.011 23.323 2.124 23.072 2.322 L 8.859 13.52 C 7.055 14.941 6 17.114 6 19.41 L 6 38.5 C 6 39.864 7.136 41 8.5 41 L 18.5 41 C 19.864 41 21 39.864 21 38.5 L 21 28.5 C 21 28.205 21.205 28 21.5 28 L 26.5 28 C 26.795 28 27 28.205 27 28.5 L 27 38.5 C 27 39.864 28.136 41 29.5 41 L 39.5 41 C 40.864 41 42 39.864 42 38.5 L 42 19.41 C 42 17.114 40.945 14.941 39.141 13.52 L 24.928 2.322 C 24.65 2.103 24.304 1.989 23.951 2 Z"
|
||||
d="m 23.951,2 c -0.32,0.011 -0.628,0.124 -0.879,0.322 L 8.859,13.52 C 7.055,14.941 6,17.114 6,19.41 V 38.5 C 6,39.864 7.136,41 8.5,41 h 8 c 1.364,0 2.5,-1.136 2.5,-2.5 v -12 C 19,26.205 19.205,26 19.5,26 h 9 c 0.295,0 0.5,0.205 0.5,0.5 v 12 c 0,1.364 1.136,2.5 2.5,2.5 h 8 C 40.864,41 42,39.864 42,38.5 V 19.41 c 0,-2.296 -1.055,-4.469 -2.859,-5.89 L 24.928,2.322 C 24.65,2.103 24.304,1.989 23.951,2 Z"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
@@ -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({
|
||||
@@ -431,7 +460,7 @@ export function RepostIcon({
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
d="M 14.437 17.081 L 5.475 17.095 C 4.7 17.095 4.072 16.467 4.072 15.692 L 4.082 5.65 L 1.22 9.854 M 4.082 5.65 L 7.006 9.854 M 9.859 5.65 L 18.625 5.654 C 19.4 5.654 20.028 6.282 20.028 7.057 L 20.031 17.081 L 17.167 12.646 M 20.031 17.081 L 22.866 12.646"
|
||||
d="M 14.437 18.081 L 5.475 18.095 C 4.7 18.095 4.072 17.467 4.072 16.692 L 4.082 6.65 L 1.22 10.854 M 4.082 6.65 L 7.006 10.854 M 9.859 6.65 L 18.625 6.654 C 19.4 6.654 20.028 7.282 20.028 8.057 L 20.031 18.081 L 17.167 13.646 M 20.031 18.081 L 22.866 13.646"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
@@ -443,7 +472,7 @@ export function HeartIcon({
|
||||
size = 24,
|
||||
strokeWidth = 1.5,
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>
|
||||
style?: StyleProp<TextStyle>
|
||||
size?: string | number
|
||||
strokeWidth: number
|
||||
}) {
|
||||
@@ -464,7 +493,7 @@ export function HeartIconSolid({
|
||||
style,
|
||||
size = 24,
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>
|
||||
style?: StyleProp<TextStyle>
|
||||
size?: string | number
|
||||
}) {
|
||||
return (
|
||||
@@ -772,8 +801,8 @@ export function SquarePlusIcon({
|
||||
height={size || 24}
|
||||
style={style}>
|
||||
<Line
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
x1="12"
|
||||
y1="5.5"
|
||||
x2="12"
|
||||
@@ -781,8 +810,8 @@ export function SquarePlusIcon({
|
||||
strokeWidth={strokeWidth * 1.5}
|
||||
/>
|
||||
<Line
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
x1="5.5"
|
||||
y1="12"
|
||||
x2="18.5"
|
||||
@@ -828,3 +857,103 @@ 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>
|
||||
)
|
||||
}
|
||||
|
||||
export function SatelliteDishIconSolid({
|
||||
style,
|
||||
size,
|
||||
strokeWidth = 1.5,
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>
|
||||
size?: string | number
|
||||
strokeWidth?: number
|
||||
}) {
|
||||
return (
|
||||
<Svg
|
||||
width={size || 24}
|
||||
height={size || 24}
|
||||
viewBox="0 0 22 22"
|
||||
style={style}
|
||||
fill="none"
|
||||
stroke="none">
|
||||
<Path
|
||||
d="M16 19.6622C14.5291 20.513 12.8214 21 11 21C5.47715 21 1 16.5229 1 11C1 9.17858 1.48697 7.47088 2.33782 6.00002C3.18867 4.52915 6 7.66219 6 7.66219L14.5 16.1622C14.5 16.1622 17.4709 18.8113 16 19.6622Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<Path
|
||||
d="M8 1.62961C9.04899 1.22255 10.1847 1 11.3704 1C16.6887 1 21 5.47715 21 11C21 12.0452 20.8456 13.053 20.5592 14"
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<Path
|
||||
d="M9 5.38745C9.64553 5.13695 10.3444 5 11.0741 5C14.3469 5 17 7.75517 17 11.1538C17 11.797 16.905 12.4172 16.7287 13"
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<Circle cx="10" cy="12" r="2" fill="currentColor" />
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function SatelliteDishIcon({
|
||||
style,
|
||||
size,
|
||||
strokeWidth = 1.5,
|
||||
}: {
|
||||
style?: StyleProp<TextStyle>
|
||||
size?: string | number
|
||||
strokeWidth?: number
|
||||
}) {
|
||||
return (
|
||||
<Svg
|
||||
fill="none"
|
||||
viewBox="0 0 22 22"
|
||||
strokeWidth={strokeWidth}
|
||||
stroke="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
style={style}>
|
||||
<Path d="M5.25593 8.3303L5.25609 8.33047L5.25616 8.33056L5.25621 8.33061L5.27377 8.35018L5.29289 8.3693L13.7929 16.8693L13.8131 16.8895L13.8338 16.908L13.834 16.9081L13.8342 16.9083L13.8342 16.9083L13.8345 16.9086L13.8381 16.9118L13.8574 16.9294C13.8752 16.9458 13.9026 16.9711 13.9377 17.0043C14.0081 17.0708 14.1088 17.1683 14.2258 17.2881C14.4635 17.5315 14.7526 17.8509 14.9928 18.1812C15.2067 18.4755 15.3299 18.7087 15.3817 18.8634C14.0859 19.5872 12.5926 20 11 20C6.02944 20 2 15.9706 2 11C2 9.4151 2.40883 7.9285 3.12619 6.63699C3.304 6.69748 3.56745 6.84213 3.89275 7.08309C4.24679 7.34534 4.58866 7.65673 4.84827 7.9106C4.97633 8.03583 5.08062 8.14337 5.152 8.21863C5.18763 8.25619 5.21487 8.28551 5.23257 8.30473L5.25178 8.32572L5.25571 8.33006L5.25593 8.3303ZM3.00217 6.60712C3.00217 6.6071 3.00267 6.6071 3.00372 6.60715C3.00271 6.60716 3.00218 6.60714 3.00217 6.60712Z" />
|
||||
<Path
|
||||
d="M8 1.62961C9.04899 1.22255 10.1847 1 11.3704 1C16.6887 1 21 5.47715 21 11C21 12.0452 20.8456 13.053 20.5592 14"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<Path
|
||||
d="M9 5.38745C9.64553 5.13695 10.3444 5 11.0741 5C14.3469 5 17 7.75517 17 11.1538C17 11.797 16.905 12.4172 16.7287 13"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<Path
|
||||
d="M12 12C12 12.7403 11.5978 13.3866 11 13.7324L8.26756 11C8.61337 10.4022 9.25972 10 10 10C11.1046 10 12 10.8954 12 12Z"
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
+27
-14
@@ -1,22 +1,31 @@
|
||||
import {LabelPreferencesModel} from 'state/models/ui/preferences'
|
||||
|
||||
export interface LabelValGroup {
|
||||
id: keyof LabelPreferencesModel | 'illegal' | 'unknown'
|
||||
title: string
|
||||
subtitle?: string
|
||||
warning?: string
|
||||
values: string[]
|
||||
}
|
||||
import {LabelValGroup} from './types'
|
||||
|
||||
export const ILLEGAL_LABEL_GROUP: LabelValGroup = {
|
||||
id: 'illegal',
|
||||
title: 'Illegal Content',
|
||||
values: ['csam', 'dmca-violation', 'nudity-nonconsentual'],
|
||||
warning: 'Illegal Content',
|
||||
values: ['csam', 'dmca-violation', 'nudity-nonconsensual'],
|
||||
}
|
||||
|
||||
export const ALWAYS_FILTER_LABEL_GROUP: LabelValGroup = {
|
||||
id: 'always-filter',
|
||||
title: 'Content Warning',
|
||||
warning: 'Content Warning',
|
||||
values: ['!filter'],
|
||||
}
|
||||
|
||||
export const ALWAYS_WARN_LABEL_GROUP: LabelValGroup = {
|
||||
id: 'always-warn',
|
||||
title: 'Content Warning',
|
||||
warning: 'Content Warning',
|
||||
values: ['!warn', 'account-security'],
|
||||
}
|
||||
|
||||
export const UNKNOWN_LABEL_GROUP: LabelValGroup = {
|
||||
id: 'unknown',
|
||||
title: 'Unknown Label',
|
||||
warning: 'Content Warning',
|
||||
values: [],
|
||||
}
|
||||
|
||||
@@ -27,9 +36,10 @@ export const CONFIGURABLE_LABEL_GROUPS: Record<
|
||||
nsfw: {
|
||||
id: 'nsfw',
|
||||
title: 'Explicit Sexual Images',
|
||||
subtitle: 'i.e. Pornography',
|
||||
subtitle: 'i.e. pornography',
|
||||
warning: 'Sexually Explicit',
|
||||
values: ['porn'],
|
||||
values: ['porn', 'nsfl'],
|
||||
isAdultImagery: true,
|
||||
},
|
||||
nudity: {
|
||||
id: 'nudity',
|
||||
@@ -37,6 +47,7 @@ export const CONFIGURABLE_LABEL_GROUPS: Record<
|
||||
subtitle: 'Including non-sexual and artistic',
|
||||
warning: 'Nudity',
|
||||
values: ['nudity'],
|
||||
isAdultImagery: true,
|
||||
},
|
||||
suggestive: {
|
||||
id: 'suggestive',
|
||||
@@ -44,24 +55,26 @@ export const CONFIGURABLE_LABEL_GROUPS: Record<
|
||||
subtitle: 'Does not include nudity',
|
||||
warning: 'Sexually Suggestive',
|
||||
values: ['sexual'],
|
||||
isAdultImagery: true,
|
||||
},
|
||||
gore: {
|
||||
id: 'gore',
|
||||
title: 'Violent / Bloody',
|
||||
subtitle: 'Gore, self-harm, torture',
|
||||
warning: 'Violence',
|
||||
values: ['gore', 'self-harm', 'torture'],
|
||||
values: ['gore', 'self-harm', 'torture', 'nsfl', 'corpse'],
|
||||
isAdultImagery: true,
|
||||
},
|
||||
hate: {
|
||||
id: 'hate',
|
||||
title: 'Political Hate-Groups',
|
||||
warning: 'Hate',
|
||||
values: ['icon-kkk', 'icon-nazi'],
|
||||
values: ['icon-kkk', 'icon-nazi', 'icon-intolerant', 'behavior-intolerant'],
|
||||
},
|
||||
spam: {
|
||||
id: 'spam',
|
||||
title: 'Spam',
|
||||
subtitle: 'Excessive low-quality posts',
|
||||
subtitle: 'Excessive unwanted interactions',
|
||||
warning: 'Spam',
|
||||
values: ['spam'],
|
||||
},
|
||||
|
||||
+418
-1
@@ -1,9 +1,36 @@
|
||||
import {
|
||||
LabelValGroup,
|
||||
AppBskyActorDefs,
|
||||
AppBskyGraphDefs,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyEmbedImages,
|
||||
AppBskyEmbedExternal,
|
||||
} from '@atproto/api'
|
||||
import {
|
||||
CONFIGURABLE_LABEL_GROUPS,
|
||||
ILLEGAL_LABEL_GROUP,
|
||||
ALWAYS_FILTER_LABEL_GROUP,
|
||||
ALWAYS_WARN_LABEL_GROUP,
|
||||
UNKNOWN_LABEL_GROUP,
|
||||
} from './const'
|
||||
import {
|
||||
Label,
|
||||
LabelValGroup,
|
||||
ModerationBehaviorCode,
|
||||
ModerationBehavior,
|
||||
PostModeration,
|
||||
ProfileModeration,
|
||||
PostLabelInfo,
|
||||
ProfileLabelInfo,
|
||||
} from './types'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
|
||||
type Embed =
|
||||
| AppBskyEmbedRecord.View
|
||||
| AppBskyEmbedImages.View
|
||||
| AppBskyEmbedExternal.View
|
||||
| AppBskyEmbedRecordWithMedia.View
|
||||
| {$type: string; [k: string]: unknown}
|
||||
|
||||
export function getLabelValueGroup(labelVal: string): LabelValGroup {
|
||||
let id: keyof typeof CONFIGURABLE_LABEL_GROUPS
|
||||
@@ -11,9 +38,399 @@ export function getLabelValueGroup(labelVal: string): LabelValGroup {
|
||||
if (ILLEGAL_LABEL_GROUP.values.includes(labelVal)) {
|
||||
return ILLEGAL_LABEL_GROUP
|
||||
}
|
||||
if (ALWAYS_FILTER_LABEL_GROUP.values.includes(labelVal)) {
|
||||
return ALWAYS_FILTER_LABEL_GROUP
|
||||
}
|
||||
if (ALWAYS_WARN_LABEL_GROUP.values.includes(labelVal)) {
|
||||
return ALWAYS_WARN_LABEL_GROUP
|
||||
}
|
||||
if (CONFIGURABLE_LABEL_GROUPS[id].values.includes(labelVal)) {
|
||||
return CONFIGURABLE_LABEL_GROUPS[id]
|
||||
}
|
||||
}
|
||||
return UNKNOWN_LABEL_GROUP
|
||||
}
|
||||
|
||||
export function getPostModeration(
|
||||
store: RootStoreModel,
|
||||
postInfo: PostLabelInfo,
|
||||
): PostModeration {
|
||||
const accountPref = store.preferences.getLabelPreference(
|
||||
postInfo.accountLabels,
|
||||
)
|
||||
const profilePref = store.preferences.getLabelPreference(
|
||||
postInfo.profileLabels,
|
||||
)
|
||||
const postPref = store.preferences.getLabelPreference(postInfo.postLabels)
|
||||
|
||||
// avatar
|
||||
let avatar = {
|
||||
warn: accountPref.pref === 'hide' || accountPref.pref === 'warn',
|
||||
blur:
|
||||
postInfo.isBlocking ||
|
||||
accountPref.pref === 'hide' ||
|
||||
accountPref.pref === 'warn' ||
|
||||
profilePref.pref === 'hide' ||
|
||||
profilePref.pref === 'warn',
|
||||
}
|
||||
|
||||
// hide no-override cases
|
||||
if (accountPref.pref === 'hide' && accountPref.desc.id === 'illegal') {
|
||||
return hidePostNoOverride(accountPref.desc.warning)
|
||||
}
|
||||
if (profilePref.pref === 'hide' && profilePref.desc.id === 'illegal') {
|
||||
return hidePostNoOverride(profilePref.desc.warning)
|
||||
}
|
||||
if (postPref.pref === 'hide' && postPref.desc.id === 'illegal') {
|
||||
return hidePostNoOverride(postPref.desc.warning)
|
||||
}
|
||||
|
||||
// hide cases
|
||||
if (postInfo.isBlocking) {
|
||||
return {
|
||||
avatar,
|
||||
list: hide('Post from an account you blocked.'),
|
||||
thread: hide('Post from an account you blocked.'),
|
||||
view: warn('Post from an account you blocked.'),
|
||||
}
|
||||
}
|
||||
if (postInfo.isBlockedBy) {
|
||||
return {
|
||||
avatar,
|
||||
list: hide('Post from an account that has blocked you.'),
|
||||
thread: hide('Post from an account that has blocked you.'),
|
||||
view: warn('Post from an account that has blocked you.'),
|
||||
}
|
||||
}
|
||||
if (accountPref.pref === 'hide') {
|
||||
return {
|
||||
avatar,
|
||||
list: hide(accountPref.desc.warning),
|
||||
thread: hide(accountPref.desc.warning),
|
||||
view: warn(accountPref.desc.warning),
|
||||
}
|
||||
}
|
||||
if (profilePref.pref === 'hide') {
|
||||
return {
|
||||
avatar,
|
||||
list: hide(profilePref.desc.warning),
|
||||
thread: hide(profilePref.desc.warning),
|
||||
view: warn(profilePref.desc.warning),
|
||||
}
|
||||
}
|
||||
if (postPref.pref === 'hide') {
|
||||
return {
|
||||
avatar,
|
||||
list: hide(postPref.desc.warning),
|
||||
thread: hide(postPref.desc.warning),
|
||||
view: warn(postPref.desc.warning),
|
||||
}
|
||||
}
|
||||
|
||||
// muting
|
||||
if (postInfo.isMuted) {
|
||||
let msg = 'Post from an account you muted.'
|
||||
if (postInfo.mutedByList) {
|
||||
msg = `Muted by ${postInfo.mutedByList.name}`
|
||||
}
|
||||
return {
|
||||
avatar,
|
||||
list: isMute(hide(msg)),
|
||||
thread: isMute(warn(msg)),
|
||||
view: isMute(warn(msg)),
|
||||
}
|
||||
}
|
||||
|
||||
// warning cases
|
||||
if (postPref.pref === 'warn') {
|
||||
if (postPref.desc.isAdultImagery) {
|
||||
return {
|
||||
avatar,
|
||||
list: warnImages(postPref.desc.warning),
|
||||
thread: warnImages(postPref.desc.warning),
|
||||
view: warnImages(postPref.desc.warning),
|
||||
}
|
||||
}
|
||||
return {
|
||||
avatar,
|
||||
list: warnContent(postPref.desc.warning),
|
||||
thread: warnContent(postPref.desc.warning),
|
||||
view: warnContent(postPref.desc.warning),
|
||||
}
|
||||
}
|
||||
if (accountPref.pref === 'warn') {
|
||||
return {
|
||||
avatar,
|
||||
list: warnContent(accountPref.desc.warning),
|
||||
thread: warnContent(accountPref.desc.warning),
|
||||
view: warnContent(accountPref.desc.warning),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
avatar,
|
||||
list: show(),
|
||||
thread: show(),
|
||||
view: show(),
|
||||
}
|
||||
}
|
||||
|
||||
export function mergePostModerations(
|
||||
moderations: PostModeration[],
|
||||
): PostModeration {
|
||||
const merged: PostModeration = {
|
||||
avatar: {warn: false, blur: false},
|
||||
list: show(),
|
||||
thread: show(),
|
||||
view: show(),
|
||||
}
|
||||
for (const mod of moderations) {
|
||||
if (mod.list.behavior === ModerationBehaviorCode.Hide) {
|
||||
merged.list = mod.list
|
||||
}
|
||||
if (mod.thread.behavior === ModerationBehaviorCode.Hide) {
|
||||
merged.thread = mod.thread
|
||||
}
|
||||
if (mod.view.behavior === ModerationBehaviorCode.Hide) {
|
||||
merged.view = mod.view
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
export function getProfileModeration(
|
||||
store: RootStoreModel,
|
||||
profileInfo: ProfileLabelInfo,
|
||||
): ProfileModeration {
|
||||
const accountPref = store.preferences.getLabelPreference(
|
||||
profileInfo.accountLabels,
|
||||
)
|
||||
const profilePref = store.preferences.getLabelPreference(
|
||||
profileInfo.profileLabels,
|
||||
)
|
||||
|
||||
// avatar
|
||||
let avatar = {
|
||||
warn: accountPref.pref === 'hide' || accountPref.pref === 'warn',
|
||||
blur:
|
||||
profileInfo.isBlocking ||
|
||||
accountPref.pref === 'hide' ||
|
||||
accountPref.pref === 'warn' ||
|
||||
profilePref.pref === 'hide' ||
|
||||
profilePref.pref === 'warn',
|
||||
}
|
||||
|
||||
// hide no-override cases
|
||||
if (accountPref.pref === 'hide' && accountPref.desc.id === 'illegal') {
|
||||
return hideProfileNoOverride(accountPref.desc.warning)
|
||||
}
|
||||
if (profilePref.pref === 'hide' && profilePref.desc.id === 'illegal') {
|
||||
return hideProfileNoOverride(profilePref.desc.warning)
|
||||
}
|
||||
|
||||
// hide cases
|
||||
if (accountPref.pref === 'hide') {
|
||||
return {
|
||||
avatar,
|
||||
list: hide(accountPref.desc.warning),
|
||||
view: hide(accountPref.desc.warning),
|
||||
}
|
||||
}
|
||||
if (profilePref.pref === 'hide') {
|
||||
return {
|
||||
avatar,
|
||||
list: hide(profilePref.desc.warning),
|
||||
view: hide(profilePref.desc.warning),
|
||||
}
|
||||
}
|
||||
|
||||
// warn cases
|
||||
if (accountPref.pref === 'warn') {
|
||||
return {
|
||||
avatar,
|
||||
list:
|
||||
profileInfo.isBlocking || profileInfo.isBlockedBy
|
||||
? hide('Blocked account')
|
||||
: warn(accountPref.desc.warning),
|
||||
view: warn(accountPref.desc.warning),
|
||||
}
|
||||
}
|
||||
// we don't warn for this
|
||||
// if (profilePref.pref === 'warn') {
|
||||
// return {
|
||||
// avatar,
|
||||
// list: warn(profilePref.desc.warning),
|
||||
// view: warn(profilePref.desc.warning),
|
||||
// }
|
||||
// }
|
||||
|
||||
return {
|
||||
avatar,
|
||||
list: profileInfo.isBlocking ? hide('Blocked account') : show(),
|
||||
view: show(),
|
||||
}
|
||||
}
|
||||
|
||||
export function getProfileViewBasicLabelInfo(
|
||||
profile: AppBskyActorDefs.ProfileViewBasic,
|
||||
): ProfileLabelInfo {
|
||||
return {
|
||||
accountLabels: filterAccountLabels(profile.labels),
|
||||
profileLabels: filterProfileLabels(profile.labels),
|
||||
isMuted: profile.viewer?.muted || false,
|
||||
isBlocking: !!profile.viewer?.blocking || false,
|
||||
isBlockedBy: !!profile.viewer?.blockedBy || false,
|
||||
}
|
||||
}
|
||||
|
||||
export function getEmbedLabels(embed?: Embed): Label[] {
|
||||
if (!embed) {
|
||||
return []
|
||||
}
|
||||
if (
|
||||
AppBskyEmbedRecord.isView(embed) &&
|
||||
AppBskyEmbedRecord.isViewRecord(embed.record)
|
||||
) {
|
||||
return embed.record.labels || []
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function getEmbedMuted(embed?: Embed): boolean {
|
||||
if (!embed) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
AppBskyEmbedRecord.isView(embed) &&
|
||||
AppBskyEmbedRecord.isViewRecord(embed.record)
|
||||
) {
|
||||
return !!embed.record.author.viewer?.muted
|
||||
}
|
||||
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
|
||||
}
|
||||
if (
|
||||
AppBskyEmbedRecord.isView(embed) &&
|
||||
AppBskyEmbedRecord.isViewRecord(embed.record)
|
||||
) {
|
||||
return !!embed.record.author.viewer?.blocking
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function getEmbedBlockedBy(embed?: Embed): boolean {
|
||||
if (!embed) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
AppBskyEmbedRecord.isView(embed) &&
|
||||
AppBskyEmbedRecord.isViewRecord(embed.record)
|
||||
) {
|
||||
return !!embed.record.author.viewer?.blockedBy
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function filterAccountLabels(labels?: Label[]): Label[] {
|
||||
if (!labels) {
|
||||
return []
|
||||
}
|
||||
return labels.filter(
|
||||
label => !label.uri.endsWith('/app.bsky.actor.profile/self'),
|
||||
)
|
||||
}
|
||||
|
||||
export function filterProfileLabels(labels?: Label[]): Label[] {
|
||||
if (!labels) {
|
||||
return []
|
||||
}
|
||||
return labels.filter(label =>
|
||||
label.uri.endsWith('/app.bsky.actor.profile/self'),
|
||||
)
|
||||
}
|
||||
|
||||
// internal methods
|
||||
// =
|
||||
|
||||
function show() {
|
||||
return {
|
||||
behavior: ModerationBehaviorCode.Show,
|
||||
}
|
||||
}
|
||||
|
||||
function hidePostNoOverride(reason: string) {
|
||||
return {
|
||||
avatar: {warn: true, blur: true},
|
||||
list: hideNoOverride(reason),
|
||||
thread: hideNoOverride(reason),
|
||||
view: hideNoOverride(reason),
|
||||
}
|
||||
}
|
||||
|
||||
function hideProfileNoOverride(reason: string) {
|
||||
return {
|
||||
avatar: {warn: true, blur: true},
|
||||
list: hideNoOverride(reason),
|
||||
view: hideNoOverride(reason),
|
||||
}
|
||||
}
|
||||
|
||||
function hideNoOverride(reason: string) {
|
||||
return {
|
||||
behavior: ModerationBehaviorCode.Hide,
|
||||
reason,
|
||||
noOverride: true,
|
||||
}
|
||||
}
|
||||
|
||||
function hide(reason: string) {
|
||||
return {
|
||||
behavior: ModerationBehaviorCode.Hide,
|
||||
reason,
|
||||
}
|
||||
}
|
||||
|
||||
function warn(reason: string) {
|
||||
return {
|
||||
behavior: ModerationBehaviorCode.Warn,
|
||||
reason,
|
||||
}
|
||||
}
|
||||
|
||||
function warnContent(reason: string) {
|
||||
return {
|
||||
behavior: ModerationBehaviorCode.WarnContent,
|
||||
reason,
|
||||
}
|
||||
}
|
||||
|
||||
function isMute(behavior: ModerationBehavior): ModerationBehavior {
|
||||
behavior.isMute = true
|
||||
return behavior
|
||||
}
|
||||
|
||||
function warnImages(reason: string) {
|
||||
return {
|
||||
behavior: ModerationBehaviorCode.WarnImages,
|
||||
reason,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import {ComAtprotoLabelDefs, AppBskyGraphDefs} from '@atproto/api'
|
||||
import {LabelPreferencesModel} from 'state/models/ui/preferences'
|
||||
|
||||
export type Label = ComAtprotoLabelDefs.Label
|
||||
|
||||
export interface LabelValGroup {
|
||||
id:
|
||||
| keyof LabelPreferencesModel
|
||||
| 'illegal'
|
||||
| 'always-filter'
|
||||
| 'always-warn'
|
||||
| 'unknown'
|
||||
title: string
|
||||
isAdultImagery?: boolean
|
||||
subtitle?: string
|
||||
warning: string
|
||||
values: string[]
|
||||
}
|
||||
|
||||
export interface PostLabelInfo {
|
||||
postLabels: Label[]
|
||||
accountLabels: Label[]
|
||||
profileLabels: Label[]
|
||||
isMuted: boolean
|
||||
mutedByList?: AppBskyGraphDefs.ListViewBasic
|
||||
isBlocking: boolean
|
||||
isBlockedBy: boolean
|
||||
}
|
||||
|
||||
export interface ProfileLabelInfo {
|
||||
accountLabels: Label[]
|
||||
profileLabels: Label[]
|
||||
isMuted: boolean
|
||||
isBlocking: boolean
|
||||
isBlockedBy: boolean
|
||||
}
|
||||
|
||||
export enum ModerationBehaviorCode {
|
||||
Show,
|
||||
Hide,
|
||||
Warn,
|
||||
WarnContent,
|
||||
WarnImages,
|
||||
}
|
||||
|
||||
export interface ModerationBehavior {
|
||||
behavior: ModerationBehaviorCode
|
||||
isMute?: boolean
|
||||
noOverride?: boolean
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export interface AvatarModeration {
|
||||
warn: boolean
|
||||
blur: boolean
|
||||
}
|
||||
|
||||
export interface PostModeration {
|
||||
avatar: AvatarModeration
|
||||
list: ModerationBehavior
|
||||
thread: ModerationBehavior
|
||||
view: ModerationBehavior
|
||||
}
|
||||
|
||||
export interface ProfileModeration {
|
||||
avatar: AvatarModeration
|
||||
list: ModerationBehavior
|
||||
view: ModerationBehavior
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as apilib from 'lib/api/index'
|
||||
import {LikelyType, LinkMeta} from './link-meta'
|
||||
// import {match as matchRoute} from 'view/routes'
|
||||
import {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers'
|
||||
@@ -128,3 +129,29 @@ export async function getPostAsQuote(
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFeedAsEmbed(
|
||||
store: RootStoreModel,
|
||||
url: string,
|
||||
): Promise<apilib.ExternalEmbedDraft> {
|
||||
url = convertBskyAppUrlIfNeeded(url)
|
||||
const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
|
||||
const feed = makeRecordUri(user, 'app.bsky.feed.generator', rkey)
|
||||
const res = await store.agent.app.bsky.feed.getFeedGenerator({feed})
|
||||
return {
|
||||
isLoading: false,
|
||||
uri: feed,
|
||||
meta: {
|
||||
url: feed,
|
||||
likelyType: LikelyType.AtpData,
|
||||
title: res.data.view.displayName,
|
||||
},
|
||||
embed: {
|
||||
$type: 'app.bsky.embed.record',
|
||||
record: {
|
||||
uri: res.data.view.uri,
|
||||
cid: res.data.view.cid,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import he from 'he'
|
||||
import {isBskyAppUrl} from '../strings/url-helpers'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {extractBskyMeta} from './bsky'
|
||||
import {extractHtmlMeta} from './html'
|
||||
import {LINK_META_PROXY} from 'lib/constants'
|
||||
|
||||
export enum LikelyType {
|
||||
HTML,
|
||||
@@ -54,26 +53,29 @@ export async function getLinkMeta(
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const to = setTimeout(() => controller.abort(), timeout || 5e3)
|
||||
const httpRes = await fetch(url, {
|
||||
headers: {accept: 'text/html'},
|
||||
signal: controller.signal,
|
||||
})
|
||||
const httpResBody = await httpRes.text()
|
||||
|
||||
const response = await fetch(
|
||||
`${LINK_META_PROXY(
|
||||
store.session.currentSession?.service || '',
|
||||
)}${encodeURIComponent(url)}`,
|
||||
)
|
||||
|
||||
const body = await response.json()
|
||||
clearTimeout(to)
|
||||
const httpResMeta = extractHtmlMeta({
|
||||
html: httpResBody,
|
||||
hostname: urlp?.hostname,
|
||||
pathname: urlp?.pathname,
|
||||
})
|
||||
meta.title = httpResMeta.title ? he.decode(httpResMeta.title) : undefined
|
||||
meta.description = httpResMeta.description
|
||||
? he.decode(httpResMeta.description)
|
||||
: undefined
|
||||
meta.image = httpResMeta.image
|
||||
|
||||
const {description, error, image, title} = body
|
||||
|
||||
if (error !== '') {
|
||||
throw new Error(error)
|
||||
}
|
||||
|
||||
meta.description = description
|
||||
meta.image = image
|
||||
meta.title = title
|
||||
} catch (e) {
|
||||
// failed
|
||||
console.error(e)
|
||||
meta.error = 'Failed to fetch link'
|
||||
meta.error = e instanceof Error ? e.toString() : 'Failed to fetch link'
|
||||
}
|
||||
|
||||
return meta
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {ImageModel} from 'state/models/media/image'
|
||||
|
||||
export async function openAltTextModal(
|
||||
store: RootStoreModel,
|
||||
image: ImageModel,
|
||||
) {
|
||||
store.shell.openModal({
|
||||
name: 'alt-text-image',
|
||||
image,
|
||||
})
|
||||
}
|
||||
+46
-61
@@ -1,56 +1,12 @@
|
||||
import RNFetchBlob from 'rn-fetch-blob'
|
||||
import ImageResizer from '@bam.tech/react-native-image-resizer'
|
||||
import {Image as RNImage, Share} from 'react-native'
|
||||
import {Image as RNImage, Share as RNShare} from 'react-native'
|
||||
import {Image} from 'react-native-image-crop-picker'
|
||||
import RNFS from 'react-native-fs'
|
||||
import * as RNFS from 'react-native-fs'
|
||||
import uuid from 'react-native-uuid'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
import * as Sharing from 'expo-sharing'
|
||||
import {Dimensions} from './types'
|
||||
import {POST_IMG_MAX} from 'lib/constants'
|
||||
import {isAndroid} from 'platform/detection'
|
||||
|
||||
export async function compressAndResizeImageForPost(
|
||||
image: Image,
|
||||
): Promise<Image> {
|
||||
const uri = `file://${image.path}`
|
||||
let resized: Omit<Image, 'mime'>
|
||||
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const quality = 100 - i * 10
|
||||
|
||||
try {
|
||||
resized = await ImageResizer.createResizedImage(
|
||||
uri,
|
||||
POST_IMG_MAX.width,
|
||||
POST_IMG_MAX.height,
|
||||
'JPEG',
|
||||
quality,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{mode: 'cover'},
|
||||
)
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to resize: ${err}`)
|
||||
}
|
||||
|
||||
if (resized.size < POST_IMG_MAX.size) {
|
||||
const path = await moveToPermanentPath(resized.path)
|
||||
|
||||
return {
|
||||
path,
|
||||
mime: 'image/jpeg',
|
||||
size: resized.size,
|
||||
height: resized.height,
|
||||
width: resized.width,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`This image is too big! We couldn't compress it down to ${POST_IMG_MAX.size} bytes`,
|
||||
)
|
||||
}
|
||||
import {isAndroid, isIOS} from 'platform/detection'
|
||||
|
||||
export async function compressIfNeeded(
|
||||
img: Image,
|
||||
@@ -120,19 +76,33 @@ export async function downloadAndResize(opts: DownloadAndResizeOpts) {
|
||||
}
|
||||
|
||||
export async function saveImageModal({uri}: {uri: string}) {
|
||||
if (!(await Sharing.isAvailableAsync())) {
|
||||
// TODO might need to give an error to the user in this case -prf
|
||||
return
|
||||
}
|
||||
const downloadResponse = await RNFetchBlob.config({
|
||||
fileCache: true,
|
||||
}).fetch('GET', uri)
|
||||
|
||||
const imagePath = downloadResponse.path()
|
||||
const base64Data = await downloadResponse.readFile('base64')
|
||||
const result = await Share.share({
|
||||
url: 'data:image/png;base64,' + base64Data,
|
||||
})
|
||||
if (result.action === Share.sharedAction) {
|
||||
Toast.show('Image saved to gallery')
|
||||
} else if (result.action === Share.dismissedAction) {
|
||||
// dismissed
|
||||
// NOTE
|
||||
// assuming PNG
|
||||
// we're currently relying on the fact our CDN only serves pngs
|
||||
// -prf
|
||||
|
||||
let imagePath = downloadResponse.path()
|
||||
imagePath = normalizePath(await moveToPermanentPath(imagePath, '.png'), true)
|
||||
|
||||
// NOTE
|
||||
// for some reason expo-sharing refuses to work on iOS
|
||||
// ...and visa versa
|
||||
// -prf
|
||||
if (isIOS) {
|
||||
await RNShare.share({url: imagePath})
|
||||
} else {
|
||||
await Sharing.shareAsync(imagePath, {
|
||||
mimeType: 'image/png',
|
||||
UTI: 'image/png',
|
||||
})
|
||||
}
|
||||
RNFS.unlink(imagePath)
|
||||
}
|
||||
@@ -188,7 +158,7 @@ async function doResize(localUri: string, opts: DoResizeOpts): Promise<Image> {
|
||||
)
|
||||
}
|
||||
|
||||
async function moveToPermanentPath(path: string): Promise<string> {
|
||||
async function moveToPermanentPath(path: string, ext = ''): Promise<string> {
|
||||
/*
|
||||
Since this package stores images in a temp directory, we need to move the file to a permanent location.
|
||||
Relevant: IOS bug when trying to open a second time:
|
||||
@@ -196,13 +166,28 @@ async function moveToPermanentPath(path: string): Promise<string> {
|
||||
*/
|
||||
const filename = uuid.v4()
|
||||
|
||||
const destinationPath = `${RNFS.TemporaryDirectoryPath}/${filename}`
|
||||
const destinationPath = joinPath(
|
||||
RNFS.TemporaryDirectoryPath,
|
||||
`${filename}${ext}`,
|
||||
)
|
||||
await RNFS.moveFile(path, destinationPath)
|
||||
return normalizePath(destinationPath)
|
||||
}
|
||||
|
||||
function normalizePath(str: string): string {
|
||||
if (isAndroid) {
|
||||
function joinPath(a: string, b: string) {
|
||||
if (a.endsWith('/')) {
|
||||
if (b.startsWith('/')) {
|
||||
return a.slice(0, -1) + b
|
||||
}
|
||||
return a + b
|
||||
} else if (b.startsWith('/')) {
|
||||
return a + b
|
||||
}
|
||||
return a + '/' + b
|
||||
}
|
||||
|
||||
function normalizePath(str: string, allPlatforms = false): string {
|
||||
if (isAndroid || allPlatforms) {
|
||||
if (!str.startsWith('file://')) {
|
||||
return `file://${str}`
|
||||
}
|
||||
|
||||
@@ -1,25 +1,6 @@
|
||||
import {Dimensions} from './types'
|
||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||
import {getDataUriSize, blobToDataUri} from './util'
|
||||
import {POST_IMG_MAX} from 'lib/constants'
|
||||
|
||||
export async function compressAndResizeImageForPost({
|
||||
path,
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
path: string
|
||||
width: number
|
||||
height: number
|
||||
}): Promise<RNImage> {
|
||||
// Compression is handled in `doResize` via `quality`
|
||||
return await doResize(path, {
|
||||
width,
|
||||
height,
|
||||
maxSize: POST_IMG_MAX.size,
|
||||
mode: 'stretch',
|
||||
})
|
||||
}
|
||||
|
||||
export async function compressIfNeeded(
|
||||
img: RNImage,
|
||||
|
||||
@@ -2,7 +2,7 @@ import {RootStoreModel} from 'state/index'
|
||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||
import RNFS from 'react-native-fs'
|
||||
import {CropperOptions} from './types'
|
||||
import {compressAndResizeImageForPost} from './manip'
|
||||
import {compressIfNeeded} from './manip'
|
||||
|
||||
let _imageCounter = 0
|
||||
async function getFile() {
|
||||
@@ -13,7 +13,7 @@ async function getFile() {
|
||||
.join('/'),
|
||||
)
|
||||
const file = files[_imageCounter++ % files.length]
|
||||
return await compressAndResizeImageForPost({
|
||||
return await compressIfNeeded({
|
||||
path: file.path,
|
||||
mime: 'image/jpeg',
|
||||
size: file.size,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
ImagePickerOptions,
|
||||
launchImageLibraryAsync,
|
||||
MediaTypeOptions,
|
||||
} from 'expo-image-picker'
|
||||
import {getDataUriSize} from './util'
|
||||
|
||||
export async function openPicker(opts?: ImagePickerOptions) {
|
||||
const response = await launchImageLibraryAsync({
|
||||
exif: false,
|
||||
mediaTypes: MediaTypeOptions.Images,
|
||||
quality: 1,
|
||||
...opts,
|
||||
})
|
||||
|
||||
return (response.assets ?? []).map(image => ({
|
||||
mime: 'image/jpeg',
|
||||
height: image.height,
|
||||
width: image.width,
|
||||
path: image.uri,
|
||||
size: getDataUriSize(image.uri),
|
||||
}))
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
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'
|
||||
export {openPicker} from './picker.shared'
|
||||
|
||||
/**
|
||||
* NOTE
|
||||
@@ -17,31 +16,6 @@ import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||
* -prf
|
||||
*/
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
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)]
|
||||
}
|
||||
|
||||
export async function openCamera(
|
||||
_store: RootStoreModel,
|
||||
opts: CameraOpts,
|
||||
@@ -55,6 +29,7 @@ export async function openCamera(
|
||||
forceJpg: true, // ios only
|
||||
compressImageQuality: 0.8,
|
||||
})
|
||||
|
||||
return {
|
||||
path: item.path,
|
||||
mime: item.mime,
|
||||
@@ -67,11 +42,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 {
|
||||
|
||||
@@ -1,34 +1,9 @@
|
||||
/// <reference lib="dom" />
|
||||
|
||||
import {PickerOpts, CameraOpts, CropperOptions} from './types'
|
||||
import {CameraOpts, CropperOptions} from './types'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {getImageDim} from 'lib/media/manip'
|
||||
import {extractDataUriMime} from './util'
|
||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||
|
||||
interface PickedFile {
|
||||
uri: string
|
||||
path: string
|
||||
size: number
|
||||
}
|
||||
|
||||
export async function openPicker(
|
||||
_store: RootStoreModel,
|
||||
opts?: PickerOpts,
|
||||
): Promise<RNImage[]> {
|
||||
const res = await selectFile(opts)
|
||||
const dim = await getImageDim(res.uri)
|
||||
const mime = extractDataUriMime(res.uri)
|
||||
return [
|
||||
{
|
||||
path: res.uri,
|
||||
mime,
|
||||
size: res.size,
|
||||
width: dim.width,
|
||||
height: dim.height,
|
||||
},
|
||||
]
|
||||
}
|
||||
export {openPicker} from './picker.shared'
|
||||
|
||||
export async function openCamera(
|
||||
_store: RootStoreModel,
|
||||
@@ -57,44 +32,3 @@ export async function openCropper(
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the select file dialog in the browser.
|
||||
* NOTE:
|
||||
* If in the future someone updates this method to use:
|
||||
* https://developer.mozilla.org/en-US/docs/Web/API/window/showOpenFilePicker
|
||||
* Check that the `showOpenFilePicker` API does not require any permissions
|
||||
* granted to use. As of this writing, it does not, but that could change
|
||||
* in the future. If the user does need to go through a permissions granting
|
||||
* flow, then checkout the usePhotoLibraryPermission() hook in
|
||||
* src/lib/hooks/usePermissions.ts
|
||||
* so that it gets appropriately updated.
|
||||
*/
|
||||
function selectFile(opts?: PickerOpts): Promise<PickedFile> {
|
||||
return new Promise((resolve, reject) => {
|
||||
var input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = opts?.mediaType === 'photo' ? 'image/*' : '*/*'
|
||||
input.onchange = e => {
|
||||
const target = e.target as HTMLInputElement
|
||||
const file = target?.files?.[0]
|
||||
if (!file) {
|
||||
return reject(new Error('Canceled'))
|
||||
}
|
||||
|
||||
var reader = new FileReader()
|
||||
reader.readAsDataURL(file)
|
||||
reader.onload = readerEvent => {
|
||||
if (!readerEvent.target) {
|
||||
return reject(new Error('Canceled'))
|
||||
}
|
||||
resolve({
|
||||
uri: readerEvent.target.result as string,
|
||||
path: file.name,
|
||||
size: file.size,
|
||||
})
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import {Dimensions} from './types'
|
||||
|
||||
export function extractDataUriMime(uri: string): string {
|
||||
return uri.substring(uri.indexOf(':') + 1, uri.indexOf(';'))
|
||||
}
|
||||
@@ -10,21 +8,6 @@ export function getDataUriSize(uri: string): number {
|
||||
return Math.round((uri.length * 3) / 4)
|
||||
}
|
||||
|
||||
export function scaleDownDimensions(
|
||||
dim: Dimensions,
|
||||
max: Dimensions,
|
||||
): Dimensions {
|
||||
if (dim.width < max.width && dim.height < max.height) {
|
||||
return dim
|
||||
}
|
||||
const wScale = dim.width > max.width ? max.width / dim.width : 1
|
||||
const hScale = dim.height > max.height ? max.height / dim.height : 1
|
||||
if (wScale < hScale) {
|
||||
return {width: dim.width * wScale, height: dim.height * wScale}
|
||||
}
|
||||
return {width: dim.width * hScale, height: dim.height * hScale}
|
||||
}
|
||||
|
||||
export function isUriImage(uri: string) {
|
||||
return /\.(jpg|jpeg|png).*$/.test(uri)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* This TypeScript function merges multiple React refs into a single ref callback.
|
||||
* When developing low level UI components, it is common to have to use a local ref
|
||||
* but also support an external one using React.forwardRef.
|
||||
* Natively, React does not offer a way to set two refs inside the ref property. This is the goal of this small utility.
|
||||
* Today a ref can be a function or an object, tomorrow it could be another thing, who knows.
|
||||
* This utility handles compatibility for you.
|
||||
* This function is inspired by https://github.com/gregberge/react-merge-refs
|
||||
* @param refs - An array of React refs, which can be either `React.MutableRefObject<T>` or
|
||||
* `React.LegacyRef<T>`. These refs are used to store references to DOM elements or React components.
|
||||
* The `mergeRefs` function takes in an array of these refs and returns a callback function that
|
||||
* @returns The function `mergeRefs` is being returned. It takes an array of mutable or legacy refs and
|
||||
* returns a ref callback function that can be used to merge multiple refs into a single ref.
|
||||
*/
|
||||
export function mergeRefs<T = any>(
|
||||
refs: Array<React.MutableRefObject<T> | React.LegacyRef<T>>,
|
||||
): React.RefCallback<T> {
|
||||
return value => {
|
||||
refs.forEach(ref => {
|
||||
if (typeof ref === 'function') {
|
||||
ref(value)
|
||||
} else if (ref != null) {
|
||||
;(ref as React.MutableRefObject<T | null>).current = value
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+16
-14
@@ -41,26 +41,26 @@ export function displayNotification(
|
||||
}
|
||||
|
||||
export function displayNotificationFromModel(
|
||||
notif: NotificationsFeedItemModel,
|
||||
notification: NotificationsFeedItemModel,
|
||||
) {
|
||||
let author = sanitizeDisplayName(
|
||||
notif.author.displayName || notif.author.handle,
|
||||
notification.author.displayName || notification.author.handle,
|
||||
)
|
||||
let title: string
|
||||
let body: string = ''
|
||||
if (notif.isLike) {
|
||||
if (notification.isLike) {
|
||||
title = `${author} liked your post`
|
||||
body = notif.additionalPost?.thread?.postRecord?.text || ''
|
||||
} else if (notif.isRepost) {
|
||||
body = notification.additionalPost?.thread?.postRecord?.text || ''
|
||||
} else if (notification.isRepost) {
|
||||
title = `${author} reposted your post`
|
||||
body = notif.additionalPost?.thread?.postRecord?.text || ''
|
||||
} else if (notif.isMention) {
|
||||
body = notification.additionalPost?.thread?.postRecord?.text || ''
|
||||
} else if (notification.isMention) {
|
||||
title = `${author} mentioned you`
|
||||
body = notif.additionalPost?.thread?.postRecord?.text || ''
|
||||
} else if (notif.isReply) {
|
||||
body = notification.additionalPost?.thread?.postRecord?.text || ''
|
||||
} else if (notification.isReply) {
|
||||
title = `${author} replied to your post`
|
||||
body = notif.additionalPost?.thread?.postRecord?.text || ''
|
||||
} else if (notif.isFollow) {
|
||||
body = notification.additionalPost?.thread?.postRecord?.text || ''
|
||||
} else if (notification.isFollow) {
|
||||
title = 'New follower!'
|
||||
body = `${author} has followed you`
|
||||
} else {
|
||||
@@ -68,10 +68,12 @@ export function displayNotificationFromModel(
|
||||
}
|
||||
let image
|
||||
if (
|
||||
AppBskyEmbedImages.isView(notif.additionalPost?.thread?.post.embed) &&
|
||||
notif.additionalPost?.thread?.post.embed.images[0]?.thumb
|
||||
AppBskyEmbedImages.isView(
|
||||
notification.additionalPost?.thread?.post.embed,
|
||||
) &&
|
||||
notification.additionalPost?.thread?.post.embed.images[0]?.thumb
|
||||
) {
|
||||
image = notif.additionalPost.thread.post.embed.images[0].thumb
|
||||
image = notification.additionalPost.thread.post.embed.images[0].thumb
|
||||
}
|
||||
return displayNotification(title, body, image)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import {BackHandler} from 'react-native'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
|
||||
export function init(store: RootStoreModel) {
|
||||
BackHandler.addEventListener('hardwareBackPress', () => {
|
||||
return store.shell.closeAnyActiveElement()
|
||||
})
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export function getCurrentRoute(state: State) {
|
||||
export function isStateAtTabRoot(state: State | undefined) {
|
||||
if (!state) {
|
||||
// NOTE
|
||||
// if state is not defined it's because init is occuring
|
||||
// if state is not defined it's because init is occurring
|
||||
// and therefore we can safely assume we're at root
|
||||
// -prf
|
||||
return true
|
||||
@@ -20,6 +20,7 @@ export function isStateAtTabRoot(state: State | undefined) {
|
||||
return (
|
||||
isTab(currentRoute.name, 'Home') ||
|
||||
isTab(currentRoute.name, 'Search') ||
|
||||
isTab(currentRoute.name, 'Feeds') ||
|
||||
isTab(currentRoute.name, 'Notifications') ||
|
||||
isTab(currentRoute.name, 'MyProfile')
|
||||
)
|
||||
@@ -55,10 +56,15 @@ export function getTabState(state: State | undefined, tab: string): TabState {
|
||||
return TabState.Outside
|
||||
}
|
||||
|
||||
type ExistingState = {
|
||||
name: string
|
||||
params?: RouteParams
|
||||
}
|
||||
export function buildStateObject(
|
||||
stack: string,
|
||||
route: string,
|
||||
params: RouteParams,
|
||||
state: ExistingState[] = [],
|
||||
) {
|
||||
if (stack === 'Flat') {
|
||||
return {
|
||||
@@ -70,7 +76,7 @@ export function buildStateObject(
|
||||
{
|
||||
name: stack,
|
||||
state: {
|
||||
routes: [{name: route, params}],
|
||||
routes: [...state, {name: route, params}],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -5,13 +5,21 @@ export type {NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||
|
||||
export type CommonNavigatorParams = {
|
||||
NotFound: undefined
|
||||
Moderation: undefined
|
||||
ModerationMuteLists: undefined
|
||||
ModerationMutedAccounts: undefined
|
||||
ModerationBlockedAccounts: undefined
|
||||
DiscoverFeeds: 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}
|
||||
CustomFeedLikedBy: {name: string; rkey: string}
|
||||
Debug: undefined
|
||||
Log: undefined
|
||||
Support: undefined
|
||||
@@ -19,11 +27,14 @@ export type CommonNavigatorParams = {
|
||||
TermsOfService: undefined
|
||||
CommunityGuidelines: undefined
|
||||
CopyrightPolicy: undefined
|
||||
AppPasswords: undefined
|
||||
SavedFeeds: undefined
|
||||
}
|
||||
|
||||
export type BottomTabNavigatorParams = CommonNavigatorParams & {
|
||||
HomeTab: undefined
|
||||
SearchTab: undefined
|
||||
FeedsTab: undefined
|
||||
NotificationsTab: undefined
|
||||
MyProfileTab: undefined
|
||||
}
|
||||
@@ -36,6 +47,10 @@ export type SearchTabNavigatorParams = CommonNavigatorParams & {
|
||||
Search: {q?: string}
|
||||
}
|
||||
|
||||
export type FeedsTabNavigatorParams = CommonNavigatorParams & {
|
||||
Feeds: undefined
|
||||
}
|
||||
|
||||
export type NotificationsTabNavigatorParams = CommonNavigatorParams & {
|
||||
Notifications: undefined
|
||||
}
|
||||
@@ -47,6 +62,7 @@ export type MyProfileTabNavigatorParams = CommonNavigatorParams & {
|
||||
export type FlatNavigatorParams = CommonNavigatorParams & {
|
||||
Home: undefined
|
||||
Search: {q?: string}
|
||||
Feeds: undefined
|
||||
Notifications: undefined
|
||||
}
|
||||
|
||||
@@ -55,6 +71,8 @@ export type AllNavigatorParams = CommonNavigatorParams & {
|
||||
Home: undefined
|
||||
SearchTab: undefined
|
||||
Search: {q?: string}
|
||||
FeedsTab: undefined
|
||||
Feeds: undefined
|
||||
NotificationsTab: undefined
|
||||
Notifications: undefined
|
||||
MyProfileTab: undefined
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import {isNative, isWeb} from 'platform/detection'
|
||||
import {FC} from 'react'
|
||||
import * as Sentry from 'sentry-expo'
|
||||
|
||||
// Sentry Initialization
|
||||
|
||||
export const getRoutingInstrumentation = () => {
|
||||
return new Sentry.Native.ReactNavigationInstrumentation() // initialize this in `onReady` prop of NavigationContainer
|
||||
}
|
||||
|
||||
Sentry.init({
|
||||
dsn: 'https://05bc3789bf994b81bd7ce20c86ccd3ae@o4505071687041024.ingest.sentry.io/4505071690514432',
|
||||
enableInExpoDevelopment: false, // if true, Sentry will try to send events/errors in development mode.
|
||||
debug: false, // If `true`, Sentry will try to print out useful debugging information if something goes wrong with sending the event. Set it to `false` in production
|
||||
environment: __DEV__ ? 'development' : 'production', // Set the environment
|
||||
enableAutoPerformanceTracking: true, // Enable auto performance tracking
|
||||
tracesSampleRate: 0.5, // Set tracesSampleRate to 1.0 to capture 100% of transactions for performance monitoring. // TODO: this might be too much in production
|
||||
integrations: isNative
|
||||
? [
|
||||
new Sentry.Native.ReactNativeTracing({
|
||||
shouldCreateSpanForRequest: url => {
|
||||
// Do not create spans for outgoing requests to a `/logs` endpoint as it is too noisy due to expo
|
||||
return !url.match(/\/logs$/)
|
||||
},
|
||||
routingInstrumentation: getRoutingInstrumentation(),
|
||||
}),
|
||||
]
|
||||
: [], // no integrations for web, yet
|
||||
})
|
||||
|
||||
// if web, use Browser client, otherwise use Native client
|
||||
export function getSentryClient() {
|
||||
if (isWeb) {
|
||||
return Sentry.Browser
|
||||
}
|
||||
return Sentry.Native
|
||||
}
|
||||
|
||||
// wrap root App component with Sentry for automatic touch event tracking and performance monitoring
|
||||
export function withSentry(Component: FC) {
|
||||
if (isWeb) {
|
||||
return Component // .wrap is not required or available for web
|
||||
}
|
||||
const sentryClient = getSentryClient()
|
||||
return sentryClient.wrap(Component)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import {isIOS, isAndroid} from 'platform/detection'
|
||||
// import * as Sharing from 'expo-sharing'
|
||||
import Clipboard from '@react-native-clipboard/clipboard'
|
||||
import * as Toast from '../view/com/util/Toast'
|
||||
import {Share} from 'react-native'
|
||||
|
||||
/**
|
||||
* This function shares a URL using the native Share API if available, or copies it to the clipboard
|
||||
* and displays a toast message if not (mostly on web)
|
||||
* @param {string} url - A string representing the URL that needs to be shared or copied to the
|
||||
* clipboard.
|
||||
*/
|
||||
export async function shareUrl(url: string) {
|
||||
if (isAndroid) {
|
||||
Share.share({message: url})
|
||||
} else if (isIOS) {
|
||||
Share.share({url})
|
||||
} else {
|
||||
// React Native Share is not supported by web. Web Share API
|
||||
// has increasing but not full support, so default to clipboard
|
||||
Clipboard.setString(url)
|
||||
Toast.show('Copied to clipboard')
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,22 @@ const CHECK_MARKS_RE = /[\u2705\u2713\u2714\u2611]/gu
|
||||
|
||||
export function sanitizeDisplayName(str: string): string {
|
||||
if (typeof str === 'string') {
|
||||
return str.replace(CHECK_MARKS_RE, '')
|
||||
return str.replace(CHECK_MARKS_RE, '').trim()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function combinedDisplayName({
|
||||
handle,
|
||||
displayName,
|
||||
}: {
|
||||
handle?: string
|
||||
displayName?: string
|
||||
}): string {
|
||||
if (!handle) {
|
||||
return ''
|
||||
}
|
||||
return displayName
|
||||
? `${sanitizeDisplayName(displayName)} (@${handle})`
|
||||
: `@${handle}`
|
||||
}
|
||||
|
||||
@@ -19,5 +19,9 @@ export function cleanError(str: any): string {
|
||||
|
||||
export function isNetworkError(e: unknown) {
|
||||
const str = String(e)
|
||||
return str.includes('Abort') || str.includes('Network request failed')
|
||||
return (
|
||||
str.includes('Abort') ||
|
||||
str.includes('Network request failed') ||
|
||||
str.includes('Failed to fetch')
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export function bskyTitle(page: string, unreadCountLabel?: string) {
|
||||
const unreadPrefix = unreadCountLabel ? `(${unreadCountLabel}) ` : ''
|
||||
return `${unreadPrefix}${page} - Bluesky`
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export function detectLinkables(text: string): DetectedLinkable[] {
|
||||
matchValue = matchValue.slice(1)
|
||||
}
|
||||
|
||||
// strip ending puncuation
|
||||
// strip ending punctuation
|
||||
if (/[.,;!?]$/.test(matchValue)) {
|
||||
matchValue = matchValue.slice(0, -1)
|
||||
}
|
||||
|
||||
@@ -27,3 +27,25 @@ export function ago(date: number | string | Date): string {
|
||||
return new Date(ts).toLocaleDateString()
|
||||
}
|
||||
}
|
||||
|
||||
export function niceDate(date: number | string | Date) {
|
||||
const d = new Date(date)
|
||||
return `${d.toLocaleDateString('en-us', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})} at ${d.toLocaleTimeString(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})}`
|
||||
}
|
||||
|
||||
export function getAge(birthDate: Date): number {
|
||||
var today = new Date()
|
||||
var age = today.getFullYear() - birthDate.getFullYear()
|
||||
var m = today.getMonth() - birthDate.getMonth()
|
||||
if (m < 0 || (m === 0 && today.getDate() < birthDate.getDate())) {
|
||||
age--
|
||||
}
|
||||
return age
|
||||
}
|
||||
|
||||
@@ -66,6 +66,10 @@ export function isBskyAppUrl(url: string): boolean {
|
||||
return url.startsWith('https://bsky.app/')
|
||||
}
|
||||
|
||||
export function isExternalUrl(url: string): boolean {
|
||||
return !isBskyAppUrl(url) && url.startsWith('http')
|
||||
}
|
||||
|
||||
export function isBskyPostUrl(url: string): boolean {
|
||||
if (isBskyAppUrl(url)) {
|
||||
try {
|
||||
@@ -78,6 +82,18 @@ export function isBskyPostUrl(url: string): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
export function isBskyCustomFeedUrl(url: string): boolean {
|
||||
if (isBskyAppUrl(url)) {
|
||||
try {
|
||||
const urlp = new URL(url)
|
||||
return /profile\/(?<name>[^/]+)\/feed\/(?<rkey>[^/]+)/i.test(
|
||||
urlp.pathname,
|
||||
)
|
||||
} catch {}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function convertBskyAppUrlIfNeeded(url: string): string {
|
||||
if (isBskyAppUrl(url)) {
|
||||
try {
|
||||
@@ -90,6 +106,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 {
|
||||
|
||||
+13
-1
@@ -1,4 +1,4 @@
|
||||
import {StyleProp, StyleSheet, TextStyle} from 'react-native'
|
||||
import {Dimensions, StyleProp, StyleSheet, TextStyle} from 'react-native'
|
||||
import {Theme, TypographyVariant} from './ThemeContext'
|
||||
import {isMobileWeb} from 'platform/detection'
|
||||
|
||||
@@ -52,6 +52,7 @@ export const colors = {
|
||||
green5: '#082b03',
|
||||
|
||||
unreadNotifBg: '#ebf6ff',
|
||||
brandBlue: '#0066FF',
|
||||
}
|
||||
|
||||
export const gradients = {
|
||||
@@ -118,15 +119,19 @@ export const s = StyleSheet.create({
|
||||
mr2: {marginRight: 2},
|
||||
mr5: {marginRight: 5},
|
||||
mr10: {marginRight: 10},
|
||||
mr20: {marginRight: 20},
|
||||
ml2: {marginLeft: 2},
|
||||
ml5: {marginLeft: 5},
|
||||
ml10: {marginLeft: 10},
|
||||
ml20: {marginLeft: 20},
|
||||
mt2: {marginTop: 2},
|
||||
mt5: {marginTop: 5},
|
||||
mt10: {marginTop: 10},
|
||||
mt20: {marginTop: 20},
|
||||
mb2: {marginBottom: 2},
|
||||
mb5: {marginBottom: 5},
|
||||
mb10: {marginBottom: 10},
|
||||
mb20: {marginBottom: 20},
|
||||
|
||||
// paddings
|
||||
p2: {padding: 2},
|
||||
@@ -149,6 +154,7 @@ export const s = StyleSheet.create({
|
||||
pb5: {paddingBottom: 5},
|
||||
pb10: {paddingBottom: 10},
|
||||
pb20: {paddingBottom: 20},
|
||||
px5: {paddingHorizontal: 5},
|
||||
|
||||
// flex
|
||||
flexRow: {flexDirection: 'row'},
|
||||
@@ -164,6 +170,10 @@ export const s = StyleSheet.create({
|
||||
w100pct: {width: '100%'},
|
||||
h100pct: {height: '100%'},
|
||||
hContentRegion: isMobileWeb ? {flex: 1} : {height: '100%'},
|
||||
window: {
|
||||
width: Dimensions.get('window').width,
|
||||
height: Dimensions.get('window').height,
|
||||
},
|
||||
|
||||
// text align
|
||||
textLeft: {textAlign: 'left'},
|
||||
@@ -209,6 +219,8 @@ export const s = StyleSheet.create({
|
||||
green3: {color: colors.green3},
|
||||
green4: {color: colors.green4},
|
||||
green5: {color: colors.green5},
|
||||
|
||||
brandBlue: {color: colors.brandBlue},
|
||||
})
|
||||
|
||||
export function lh(
|
||||
|
||||
+3
-3
@@ -291,8 +291,8 @@ export const darkTheme: Theme = {
|
||||
palette: {
|
||||
...defaultTheme.palette,
|
||||
default: {
|
||||
background: colors.gray8,
|
||||
backgroundLight: colors.gray6,
|
||||
background: colors.black,
|
||||
backgroundLight: colors.gray7,
|
||||
text: colors.white,
|
||||
textLight: colors.gray3,
|
||||
textInverted: colors.black,
|
||||
@@ -307,7 +307,7 @@ export const darkTheme: Theme = {
|
||||
replyLineDot: colors.gray6,
|
||||
unreadNotifBg: colors.blue7,
|
||||
unreadNotifBorder: colors.blue6,
|
||||
postCtrl: '#61657A',
|
||||
postCtrl: '#707489',
|
||||
brandText: '#0085ff',
|
||||
emptyStateIcon: colors.gray4,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const getKeys = Object.keys as <T extends object>(
|
||||
obj: T,
|
||||
) => Array<keyof T>
|
||||
@@ -1,154 +0,0 @@
|
||||
import React from 'react'
|
||||
import {H3, H4, P, UL, LI, A, EM, OL} from 'view/com/util/Html'
|
||||
|
||||
export default function () {
|
||||
return (
|
||||
<>
|
||||
<H4>Last Updated: 2023/04/06</H4>
|
||||
<P>
|
||||
The Bluesky app is built on a decentralized social networking protocol,
|
||||
the AT Protocol (atproto). Atproto is an open protocol that supports
|
||||
many different kinds of services. Our mission for the Bluesky app is to
|
||||
foster a vibrant and evolving community that respects individual
|
||||
preferences and adapts to the changing needs of our users. With this in
|
||||
mind, we have established the following goals for our community
|
||||
guidelines:
|
||||
</P>
|
||||
<OL>
|
||||
<LI>
|
||||
Empower user choice: We strive to provide users with the ability to
|
||||
select self-governing services on the AT protocol that align with
|
||||
their personal preferences and values. This includes making it easy
|
||||
for others to run their own services, and for users to migrate between
|
||||
services.
|
||||
</LI>
|
||||
<LI>
|
||||
Cultivate a welcoming environment: Our aim is to create a safe and
|
||||
friendly space on bsky.social, the server we run, where new users feel
|
||||
welcome and supported, and where we ourselves enjoy participating. To
|
||||
help achieve this vision, we have implemented moderation systems
|
||||
guided by the following policies.
|
||||
</LI>
|
||||
<LI>
|
||||
Maintain up-to-date guidelines: As our user base grows and changes,
|
||||
our community guidelines must evolve as well. We will regularly review
|
||||
and update these guidelines in response to feedback from our users,
|
||||
emerging trends, and changing circumstances, and will strive to
|
||||
maintain transparency about any changes to our policies.
|
||||
</LI>
|
||||
</OL>
|
||||
<P>
|
||||
In the following sections, we will dive into the specific policies that
|
||||
make up our server and community guidelines.
|
||||
</P>
|
||||
<H3>Server Guidelines</H3>
|
||||
<P>
|
||||
Our server guidelines outline the content policies we have established
|
||||
for the material hosted on our infrastructure at bsky.social. These
|
||||
policies have been selected to minimize potential risks and costs
|
||||
associated with hosting certain types of content, since we have limited
|
||||
developer resources and want to focus them on improving the AT Protocol.
|
||||
</P>
|
||||
<P>
|
||||
It is important to note that other servers within the atproto network
|
||||
may have different server-level rules. If you find that our policies do
|
||||
not align with your preferences, we encourage you to explore alternative
|
||||
servers or create your own. If you initially choose to use bsky.social
|
||||
but later decide that our policies do not suit your needs, you will soon
|
||||
be able to seamlessly migrate your account between servers.
|
||||
</P>
|
||||
<P>
|
||||
<EM>No illegal content or transactions</EM>
|
||||
</P>
|
||||
<UL>
|
||||
<LI>
|
||||
Don’t share, promote, or engage in any illegal activities or
|
||||
transactions on our platform. This includes, but is not limited to,
|
||||
sharing copyrighted material without permission, distributing illicit
|
||||
substances, or participating in any form of illegal trade.
|
||||
</LI>
|
||||
</UL>
|
||||
<P>
|
||||
<EM>Don’t break our infrastructure</EM>
|
||||
</P>
|
||||
<UL>
|
||||
<LI>
|
||||
If you find a vulnerability, please report it to us at
|
||||
support@bsky.app, and don’t use it to exploit or take down our
|
||||
infrastructure.
|
||||
</LI>
|
||||
</UL>
|
||||
<H3>Community Guidelines</H3>
|
||||
<P>
|
||||
Our community guidelines are designed to promote a safe and enjoyable
|
||||
experience for all users on our server. These policies serve as an
|
||||
additional layer on top of our server-level guidelines and are intended
|
||||
to foster a positive and respectful environment. For some community
|
||||
guidelines, we plan to offer content filters that you can adjust
|
||||
according to your preferences, allowing you to view content that has
|
||||
been initially filtered out. Please be aware that these rules will
|
||||
evolve over time as we continually work to cultivate a healthy and
|
||||
thriving community.
|
||||
</P>
|
||||
<P>
|
||||
<EM>Be polite and respectful</EM>
|
||||
</P>
|
||||
<UL>
|
||||
<LI>
|
||||
Don’t harass, use slurs, threaten violence, or attack people
|
||||
</LI>
|
||||
</UL>
|
||||
<P>
|
||||
<EM>Don’t spam</EM>
|
||||
</P>
|
||||
<UL>
|
||||
<LI>
|
||||
Don’t repeatedly post the same message, or excessively promote
|
||||
anything
|
||||
</LI>
|
||||
</UL>
|
||||
<P>
|
||||
<EM>Don’t abuse the reporting system</EM>
|
||||
</P>
|
||||
<UL>
|
||||
<LI>
|
||||
Don’t use the reporting tool to spam, harass users, or submit
|
||||
unfounded or trivial complaints. The reporting system is in place to
|
||||
address genuine concerns and maintain the safety and integrity of our
|
||||
community, so please use it responsibly.
|
||||
</LI>
|
||||
</UL>
|
||||
<H3>Enforcement</H3>
|
||||
<P>
|
||||
Our goal is to provide a flexible environment that balances the freedom
|
||||
and safety of our users. Violations of server or community guidelines
|
||||
may result in a flag, a warning, an account suspension until you migrate
|
||||
away from our service, or a permanent account suspension and ban from
|
||||
our services.{' '}
|
||||
</P>
|
||||
<P>References:</P>
|
||||
<P>
|
||||
Twitter:{' '}
|
||||
<A href="https://help.twitter.com/en/rules-and-policies/twitter-rules">
|
||||
https://help.twitter.com/en/rules-and-policies/twitter-rules
|
||||
</A>
|
||||
</P>
|
||||
<P>
|
||||
Reddit:{' '}
|
||||
<A href="https://www.redditinc.com/policies/content-policy">
|
||||
https://www.redditinc.com/policies/content-policy
|
||||
</A>
|
||||
</P>
|
||||
<P>
|
||||
Discord:{' '}
|
||||
<A href="https://discord.com/guidelines">
|
||||
https://discord.com/guidelines
|
||||
</A>
|
||||
</P>
|
||||
<P>
|
||||
Discord TOS:{' '}
|
||||
<A href="https://discord.com/terms">https://discord.com/terms</A>
|
||||
</P>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import React from 'react'
|
||||
import {H3, H4, P, UL, LI, A} from 'view/com/util/Html'
|
||||
|
||||
export default function () {
|
||||
return (
|
||||
<>
|
||||
<H4>Last Updated: 2023/04/06</H4>
|
||||
<P>Notification of Copyright Infringement</P>
|
||||
<P>
|
||||
Bluesky, PBLLC d.b.a. Bluesky (“Bluesky”) respects the
|
||||
intellectual property rights of others and expects its users to do the
|
||||
same.
|
||||
</P>
|
||||
<P>
|
||||
It is Bluesky’s policy, in appropriate circumstances and at its
|
||||
discretion, to disable the accounts of users who repeatedly infringe the
|
||||
copyrights of others.
|
||||
</P>
|
||||
<P>
|
||||
In accordance with the Digital Millennium Copyright Act of 1998, the
|
||||
text of which may be found on the U.S. Copyright Office website at{' '}
|
||||
<A href="http://www.copyright.gov/legislation/dmca.pdf">
|
||||
http://www.copyright.gov/legislation/dmca.pdf
|
||||
</A>
|
||||
, Bluesky will respond expeditiously to claims of copyright infringement
|
||||
committed using the Bluesky website, app, or other Bluesky owned or
|
||||
controlled online networ k services accessible through a mobile
|
||||
device or other type of device (the “Sites”) that are
|
||||
reported to Bluesky’s Designated Copyright Agent, identified in
|
||||
the sample notice below.
|
||||
</P>
|
||||
<P>
|
||||
If you are a copyright owner, or are authorized to act on behalf of one,
|
||||
or authorized to act under any exclusive right under copyright, please
|
||||
report alleged copyright infringements taking place on or through the
|
||||
Sites by completing the following DMCA Notice of Alleged Infringement
|
||||
and delivering it to Bluesky’s Designated Copyright Agent. Upon
|
||||
receipt of the Notice as described below, Bluesky will take whatever
|
||||
action, in its sole discretion, it deems appropriate, including removal
|
||||
of the challenged material from the Sites.
|
||||
</P>
|
||||
<H3>DMCA Notice of Alleged Infringement (“Notice”)</H3>
|
||||
<P>
|
||||
1. Identify the copyrighted work that you claim has been infringed, or
|
||||
– if multiple copyrighted works are covered by this Notice –
|
||||
you may provide a representative list of the copyrighted works that you
|
||||
claim have been infringed.
|
||||
</P>
|
||||
<P>
|
||||
2. Identify the material that you claim is infringing (or to be the
|
||||
subject of infringing activity) and that is to be removed or access to
|
||||
which is to be disabled, and information reasonably sufficient to permit
|
||||
us to locate the material, including at a minimum, if applicable, the
|
||||
URL of the link shown on the Site(s) where such material may be found.
|
||||
</P>
|
||||
<P>
|
||||
3. Provide your mailing address, telephone number, and email address.
|
||||
</P>
|
||||
<P>
|
||||
4. Include both of the following statements in the body of the Notice:
|
||||
</P>
|
||||
<UL>
|
||||
<LI>
|
||||
“I hereby state that I have a good faith belief that the
|
||||
disputed use of the copyrighted material is not authorized by the
|
||||
copyright owner, its agent, or the law (e.g., as a fair use).”
|
||||
</LI>
|
||||
<LI>
|
||||
“I hereby state that the information in this Notice is accurate
|
||||
and, under penalty of perjury, that I am the owner, or authorized to
|
||||
act on behalf of the owner, of the copyright or of an exclusive right
|
||||
under the copyright that is allegedly infringed.”
|
||||
</LI>
|
||||
</UL>
|
||||
<P>
|
||||
5. Provide your full legal name and your electronic or physical
|
||||
signature.
|
||||
</P>
|
||||
<P>
|
||||
Deliver this Notice, with all items completed, to Bluesky’s
|
||||
Designated Copyright Agent:
|
||||
</P>
|
||||
<P>Copyright Agent</P>
|
||||
<P>c/o Bluesky, PBLLC</P>
|
||||
<P>support@bsky.app</P>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,606 +0,0 @@
|
||||
import React from 'react'
|
||||
import {H2, H4, P, UL, LI, A} from 'view/com/util/Html'
|
||||
|
||||
export default function () {
|
||||
return (
|
||||
<>
|
||||
<H4>Last Updated: 2023/02/02</H4>
|
||||
<P>
|
||||
This Privacy Policy is designed to help you understand how Bluesky,
|
||||
PBLLC d.b.a. Bluesky (“Bluesky,” “we,” “us,” or “our”)
|
||||
collects, uses, processes, and shares your personal information, and to
|
||||
help you understand and exercise your privacy rights.{' '}
|
||||
</P>
|
||||
<P>1. SCOPE AND UPDATES TO THIS PRIVACY POLICY</P>
|
||||
<P>2. PERSONAL INFORMATION WE COLLECT</P>
|
||||
<P>3. HOW WE USE YOUR PERSONAL INFORMATION</P>
|
||||
<P>4. HOW WE DISCLOSE YOUR PERSONAL INFORMATION</P>
|
||||
<P>5. YOUR PRIVACY CHOICES AND RIGHTS</P>
|
||||
<P>6. SECURITY OF YOUR INFORMATION</P>
|
||||
<P>7. INTERNATIONAL DATA TRANSFERS</P>
|
||||
<P>8. RETENTION OF PERSONAL INFORMATION</P>
|
||||
<P>9. SUPPLEMENTAL NOTICE FOR NEVADA RESIDENTS</P>
|
||||
<P>10. CHILDREN’S INFORMATION</P>
|
||||
<P>11. OTHER PROVISIONS</P>
|
||||
<P>12. CONTACT US</P>
|
||||
|
||||
<H2>1. SCOPE AND UPDATES TO THIS PRIVACY POLICY</H2>
|
||||
<P>
|
||||
This Privacy Policy applies to personal information processed by us,
|
||||
including on our websites, mobile applications, and other online or
|
||||
offline offerings. To make this Privacy Policy easier to read, our
|
||||
websites, mobile applications, and other offerings are collectively
|
||||
called the “Services.”
|
||||
</P>
|
||||
<P>
|
||||
Changes to our Privacy Policy. We may revise this Privacy Policy from
|
||||
time to time in our sole discretion. If there are any material changes
|
||||
to this Privacy Policy, we will notify you as required by applicable
|
||||
law. You understand and agree that you will be deemed to have accepted
|
||||
the updated Privacy Policy if you continue to use our Services after the
|
||||
new Privacy Policy takes effect.
|
||||
</P>
|
||||
<H2>2. PERSONAL INFORMATION WE COLLECT</H2>
|
||||
<P>
|
||||
The categories of personal information we collect depend on how you
|
||||
interact with us, our Services, and the requirements of applicable law.
|
||||
We collect information that you provide to us, information we obtain
|
||||
automatically when you use our Services, and information from other
|
||||
sources such as third-party services and organizations, as described
|
||||
below.
|
||||
</P>
|
||||
<H4>1. Personal Information You Provide to Us Directly</H4>
|
||||
<P>We may collect personal information that you provide to us.</P>
|
||||
<UL>
|
||||
<LI>
|
||||
Account Creation. We may collect personal information when you create
|
||||
an account with us, such as a username and password.
|
||||
</LI>
|
||||
</UL>
|
||||
<UL>
|
||||
<LI>
|
||||
Your Communications with Us. We may collect personal information, such
|
||||
as email address, phone number, or full name when you request
|
||||
information about our Services, request customer or technical support,
|
||||
or otherwise communicate with us.
|
||||
</LI>
|
||||
</UL>
|
||||
<UL>
|
||||
<LI>
|
||||
Interactive Features. We and others who use our Services may collect
|
||||
personal information that you submit or make available through our
|
||||
interactive features (e.g., messaging and chat features, commenting
|
||||
functionalities, forums, blogs, and social media pages). Any
|
||||
information you provide using the public sharing features of the
|
||||
Services will be considered “public,” unless otherwise required by
|
||||
applicable law, and is not subject to the privacy protections
|
||||
referenced herein. Please exercise caution before revealing any
|
||||
information that may identify you in the real world to other users.
|
||||
</LI>
|
||||
</UL>
|
||||
<H4>2. Personal Information Collected Automatically</H4>
|
||||
<P>
|
||||
We may collect personal information automatically when you use our
|
||||
Services.
|
||||
</P>
|
||||
<UL>
|
||||
<LI>
|
||||
Automatic Collection of Personal Information. We may collect certain
|
||||
information automatically when you use our Services, such as your
|
||||
Internet protocol (IP) address, user settings, cookie identifiers,
|
||||
mobile carrier, mobile advertising and other unique identifiers,
|
||||
browser or device information, and Internet service provide. We may
|
||||
also automatically collect information regarding your use of our
|
||||
Services, such as pages that you visit before, during and after using
|
||||
our Services, information about the links you click, the types of
|
||||
content you interact with, the frequency and duration of your
|
||||
activities, and other information about how you use our Services.
|
||||
</LI>
|
||||
<UL>
|
||||
<LI>
|
||||
Crash Reports. If you provide crash reports, we may collect personal
|
||||
information related to such crash reports, including detailed
|
||||
diagnostic information about your device and the activities that led
|
||||
to the crash.
|
||||
</LI>
|
||||
</UL>
|
||||
</UL>
|
||||
<UL>
|
||||
<LI>
|
||||
Cookie Policy (and Other Technologies). We, as well as third
|
||||
parties that provide content, or other functionality on our Services,
|
||||
may use cookies, pixel tags, and other technologies (“Technologies”)
|
||||
to automatically collect information through your use of our Services.
|
||||
</LI>
|
||||
<UL>
|
||||
<LI>
|
||||
Cookies. Cookies are small text files placed in device browsers that
|
||||
store preferences and facilitate and enhance your experience.
|
||||
</LI>
|
||||
</UL>
|
||||
<UL>
|
||||
<LI>
|
||||
Pixel Tags/Web Beacons. A pixel tag (also known as a web beacon) is
|
||||
a piece of code embedded in our Services that collects information
|
||||
about engagement on our Services. The use of a pixel tag allows us
|
||||
to record, for example, that a user has visited a particular web
|
||||
page or clicked on a particular advertisement. We may also include
|
||||
web beacons in e-mails to understand whether messages have been
|
||||
opened, acted on, or forwarded.
|
||||
</LI>
|
||||
</UL>
|
||||
</UL>
|
||||
<P>
|
||||
Our uses of these Technologies fall into the following general
|
||||
categories:
|
||||
</P>
|
||||
<UL>
|
||||
<LI>
|
||||
Operationally Necessary. This includes Technologies that allow you
|
||||
access to our Services, applications, and tools that are required to
|
||||
identify irregular website behavior, prevent fraudulent activity,
|
||||
improve security, or allow you to make use of our functionality;
|
||||
</LI>
|
||||
</UL>
|
||||
<UL>
|
||||
<LI>
|
||||
Performance-Related. We may use Technologies to assess the performance
|
||||
of our Services, including as part of our analytic practices to help
|
||||
us understand how individuals use our Services (see Analytics below);
|
||||
</LI>
|
||||
</UL>
|
||||
<UL>
|
||||
<LI>
|
||||
Functionality-Related. We may use Technologies that allow us to offer
|
||||
you enhanced functionality when accessing or using our Services. This
|
||||
may include identifying you when you sign into our Services or keeping
|
||||
track of your specified preferences, interests, or past items viewed;
|
||||
</LI>
|
||||
</UL>
|
||||
<P>
|
||||
See “Your Privacy Choices and Rights” below to understand your choices
|
||||
regarding these Technologies.
|
||||
</P>
|
||||
<UL>
|
||||
<LI>
|
||||
Analytics. We may use Technologies and other third-party tools to
|
||||
process analytics information on our Services. These Technologies
|
||||
allow us to better understand how our digital Services are used and to
|
||||
continually improve and personalize our Services. Some of our
|
||||
analytics partners include:
|
||||
</LI>
|
||||
<UL>
|
||||
<LI>
|
||||
Segment.io. We use Segment's event tracking services to
|
||||
aggregate, archive, and distribute website and application usage
|
||||
information. For more information about how Segment uses your
|
||||
personal information, please visit{' '}
|
||||
<A href="https://www.twilio.com/legal/privacy">
|
||||
https://www.twilio.com/legal/privacy
|
||||
</A>
|
||||
.
|
||||
</LI>
|
||||
<LI>
|
||||
Mixpanel. We use Mixpanel's analytics and event tracking
|
||||
services to record and analyze website and application usage. For
|
||||
more information about how Mixpanel uses your personal information,
|
||||
please visit{' '}
|
||||
<A href="https://mixpanel.com/legal/privacy-policy">
|
||||
https://mixpanel.com/legal/privacy-policy
|
||||
</A>
|
||||
.
|
||||
</LI>
|
||||
<LI>
|
||||
DataDog. We use DataDog's monitoring, tracing, and logging
|
||||
services to record application and server metrics, logs, and related
|
||||
debugging information. For more information about how DataDog uses
|
||||
your personal information, please visit{' '}
|
||||
<A href="https://www.datadoghq.com/legal/privacy/">
|
||||
https://www.datadoghq.com/legal/privacy/
|
||||
</A>
|
||||
.
|
||||
</LI>
|
||||
</UL>
|
||||
</UL>
|
||||
<UL>
|
||||
<LI>
|
||||
Social Media Platforms. Our Services may contain social media
|
||||
buttons, such as Twitter, GitHub, Matrix, which might include widgets
|
||||
such as the “share this” button or other interactive mini programs).
|
||||
These features may collect personal information such as your IP
|
||||
address and which page you are visiting on our Services, and may set a
|
||||
cookie to enable the feature to function properly. Your interactions
|
||||
with these platforms are governed by the privacy policy of the company
|
||||
providing it.
|
||||
</LI>
|
||||
</UL>
|
||||
<H4>3. Personal Information Collected from Other Sources</H4>
|
||||
<P>
|
||||
Third-Party Services and Sources. We may obtain personal information
|
||||
about you from other sources, including through third-party services and
|
||||
organizations. For example, if you access our Services through a
|
||||
third-party application, such as an app store, a third-party login
|
||||
service, or a social networking site, we may collect personal
|
||||
information about you from that third-party application that you have
|
||||
made available via your privacy settings.
|
||||
</P>
|
||||
<P>
|
||||
Referrals and Sharing Features. Our Services may offer various
|
||||
tools and functionalities that allow you to provide personal information
|
||||
about your friends through our referral service. Third parties may also
|
||||
use the Services to upload personal information about you, including
|
||||
when they tag you. Our referral services may also allow you to forward
|
||||
or share certain content with a friend or colleague, such as an email
|
||||
inviting your friend to use our Services. Please only share with us
|
||||
contact information of people with whom you have a relationship (e.g.,
|
||||
relative, friend, neighbor, or co-worker).
|
||||
</P>
|
||||
<H2>3. HOW WE USE YOUR PERSONAL INFORMATION</H2>
|
||||
<P>
|
||||
We use your personal information for a variety of business purposes,
|
||||
including to provide our Services, for administrative purposes, and to
|
||||
market our products and Services, as described below.
|
||||
</P>
|
||||
<H4>1. Provide Our Services</H4>
|
||||
<P>
|
||||
We use your information to fulfill our contract with you and provide you
|
||||
with our Services, such as:
|
||||
</P>
|
||||
<UL>
|
||||
<LI>Managing your information and accounts;</LI>
|
||||
<LI>
|
||||
Providing access to certain areas, functionalities, and features of
|
||||
our Services;
|
||||
</LI>
|
||||
<LI>Answering requests for customer or technical support;</LI>
|
||||
<LI>
|
||||
Communicating with you about your account, activities on our Services,
|
||||
and policy changes;
|
||||
</LI>
|
||||
<LI>
|
||||
Processing your financial information and other payment methods for
|
||||
products or Services purchased;
|
||||
</LI>
|
||||
<LI>Allowing you to register for events.</LI>
|
||||
</UL>
|
||||
<H4>2. Administrative Purposes</H4>
|
||||
<P>
|
||||
We use your information for various administrative purposes, such as:
|
||||
</P>
|
||||
<UL>
|
||||
<LI>
|
||||
Pursuing our legitimate interests such as direct marketing, research
|
||||
and development (including marketing research), network and
|
||||
information security, and fraud prevention;
|
||||
</LI>
|
||||
<LI>
|
||||
Detecting security incidents, protecting against malicious, deceptive,
|
||||
fraudulent or illegal activity, and prosecuting those responsible for
|
||||
that activity;
|
||||
</LI>
|
||||
<LI>Measuring interest and engagement in our Services;</LI>
|
||||
<LI>
|
||||
Short-term, transient use, such as contextual customization of ads;
|
||||
</LI>
|
||||
<LI>Improving, upgrading, or enhancing our Services;</LI>
|
||||
<LI>Developing new products and services;</LI>
|
||||
<LI>Ensuring internal quality control and safety;</LI>
|
||||
</UL>
|
||||
<UL>
|
||||
<LI>
|
||||
Authenticating and verifying individual identities, including requests
|
||||
to exercise your rights under this Privacy Policy;
|
||||
</LI>
|
||||
<LI>Debugging to identify and repair errors with our Services;</LI>
|
||||
<LI>
|
||||
Auditing relating to interactions, transactions, and other compliance
|
||||
activities;
|
||||
</LI>
|
||||
<LI>
|
||||
Sharing personal information with third parties as needed to provide
|
||||
the Services;
|
||||
</LI>
|
||||
<LI>Enforcing our agreements and policies; and</LI>
|
||||
<LI>
|
||||
Carrying out activities that are required to comply with our legal
|
||||
obligations.
|
||||
</LI>
|
||||
</UL>
|
||||
<H4>3. With Your Consent</H4>
|
||||
<P>
|
||||
We may use personal information for other purposes that are clearly
|
||||
disclosed to you at the time you provide personal information or with
|
||||
your consent.
|
||||
</P>
|
||||
<H4>4. Other Purposes</H4>
|
||||
<P>
|
||||
We also use your personal information for other purposes as requested by
|
||||
you or as permitted by applicable law.
|
||||
</P>
|
||||
<UL>
|
||||
<LI>
|
||||
De-identified and Aggregated Information. We may use personal
|
||||
information to create de-identified and/or aggregated information,
|
||||
such as demographic information, information about the device from
|
||||
which you access our Services, or other analyses we create.{' '}
|
||||
</LI>
|
||||
</UL>
|
||||
<H2>4. HOW WE DISCLOSE YOUR PERSONAL INFORMATION</H2>
|
||||
<P>
|
||||
We disclose your personal information to third parties for a variety of
|
||||
business purposes, including to provide our Services, to protect us or
|
||||
others, or in the event of a major business transaction such as a
|
||||
merger, sale, or asset transfer, as described below.
|
||||
</P>
|
||||
<H4>1. Disclosures to Provide our Services</H4>
|
||||
<P>
|
||||
The categories of third parties with whom we may share your personal
|
||||
information are described below.
|
||||
</P>
|
||||
<UL>
|
||||
<LI>
|
||||
Service Providers. We may share your personal information with our
|
||||
third-party service providers and vendors that assist us with the
|
||||
provision of our Services. This includes service providers and vendors
|
||||
that provide us with IT support, hosting, payment processing, customer
|
||||
service, and related services.
|
||||
</LI>
|
||||
</UL>
|
||||
<UL>
|
||||
<LI>
|
||||
Business Partners. We may share your personal information with
|
||||
business partners to provide you with a product or service you have
|
||||
requested. We may also share your personal information with business
|
||||
partners with whom we jointly offer products or services.
|
||||
</LI>
|
||||
</UL>
|
||||
<UL>
|
||||
<LI>
|
||||
Other Users or Third Parties You Share or Interact With. As described
|
||||
above in “Personal Information We Collect,” our Services may allow you
|
||||
to share personal information or interact with other users and third
|
||||
parties (including individuals and third parties who do not use our
|
||||
Services and the general public).
|
||||
</LI>
|
||||
</UL>
|
||||
<UL>
|
||||
<LI>
|
||||
APIs/SDKs. We may use third-party application program interfaces
|
||||
(“APIs”) and software development kits (“SDKs”) as part of the
|
||||
functionality of our Services. For more information about our use of
|
||||
APIs and SDKs, please contact us as set forth in “ Contact
|
||||
Us” below.
|
||||
</LI>
|
||||
</UL>
|
||||
<H4>2. Disclosures to Protect Us or Others</H4>
|
||||
<P>
|
||||
We may access, preserve, and disclose any information we store
|
||||
associated with you to external parties if we, in good faith, believe
|
||||
doing so is required or appropriate to: comply with law enforcement or
|
||||
national security requests and legal process, such as a court order or
|
||||
subpoena; protect your, our, or others’ rights, property, or
|
||||
safety; enforce our policies or contracts; collect amounts owed to us;
|
||||
or assist with an investigation or prosecution of suspected or actual
|
||||
illegal activity.
|
||||
</P>
|
||||
<H4>
|
||||
3. Disclosure in the Event of Merger, Sale, or Other Asset Transfers
|
||||
</H4>
|
||||
<P>
|
||||
If we are involved in a merger, acquisition, financing due diligence,
|
||||
reorganization, bankruptcy, receivership, purchase or sale of assets, or
|
||||
transition of service to another provider, your information may be sold
|
||||
or transferred as part of such a transaction, as permitted by law and/or
|
||||
contract.
|
||||
</P>
|
||||
<H2>5. YOUR PRIVACY CHOICES AND RIGHTS</H2>
|
||||
<P>
|
||||
Your Privacy Choices. The privacy choices you may have about your
|
||||
personal information are determined by applicable law and are described
|
||||
below.
|
||||
</P>
|
||||
<UL>
|
||||
<LI>
|
||||
Email Communications. If you receive an unwanted email from us, you
|
||||
can use the unsubscribe link found at the bottom of the email to opt
|
||||
out of receiving future emails. Note that you will continue to receive
|
||||
transaction-related emails regarding products or Services you have
|
||||
requested. We may also send you certain non-promotional communications
|
||||
regarding us and our Services, and you will not be able to opt out of
|
||||
those communications (e.g., communications regarding our Services or
|
||||
updates to our Terms or this Privacy Policy).
|
||||
</LI>
|
||||
</UL>
|
||||
<UL>
|
||||
<LI>
|
||||
Mobile Devices. We may send you push notifications through our mobile
|
||||
application. You may opt out from receiving these push notifications
|
||||
by changing the settings on your mobile device. With your consent, we
|
||||
may also collect precise location-based information via our mobile
|
||||
application. You may opt out of this collection by changing the
|
||||
settings on your mobile device.
|
||||
</LI>
|
||||
</UL>
|
||||
<UL>
|
||||
<LI>
|
||||
“Do Not Track.” Do Not Track (“DNT”) is a privacy preference that
|
||||
users can set in certain web browsers. Please note that we do not
|
||||
respond to or honor DNT signals or similar mechanisms transmitted by
|
||||
web browsers.
|
||||
</LI>
|
||||
</UL>
|
||||
<P>
|
||||
Your Privacy Rights. In accordance with applicable law, you may have the
|
||||
right to:
|
||||
</P>
|
||||
<UL>
|
||||
<LI>
|
||||
Access to and Portability of Your Personal Information, including: (i)
|
||||
confirming whether we are processing your personal information; (ii)
|
||||
obtaining access to or a copy of your personal information; and (iii)
|
||||
receiving an electronic copy of personal information that you have
|
||||
provided to us, or asking us to send that information to another
|
||||
company in a structured, commonly used, and machine readable format
|
||||
(also known as the “right of data portability”);
|
||||
</LI>
|
||||
</UL>
|
||||
<UL>
|
||||
<LI>
|
||||
Request Correction of your personal information where it is inaccurate
|
||||
or incomplete. In some cases, we may provide self-service tools that
|
||||
enable you to update your personal information;
|
||||
</LI>
|
||||
</UL>
|
||||
<UL>
|
||||
<LI>Request Deletion of your personal information;</LI>
|
||||
</UL>
|
||||
<UL>
|
||||
<LI>
|
||||
Request Restriction of or Object to our processing of your
|
||||
personal information where the processing of your personal information
|
||||
is based on our legitimate interest or for direct marketing purposes;
|
||||
and
|
||||
</LI>
|
||||
</UL>
|
||||
<UL>
|
||||
<LI>
|
||||
Withdraw your Consent to our processing of your personal
|
||||
information. Please note that your withdrawal will only take effect
|
||||
for future processing, and will not affect the lawfulness of
|
||||
processing before the withdrawal.
|
||||
</LI>
|
||||
</UL>
|
||||
<P>
|
||||
If you would like to exercise any of these rights, please contact us as
|
||||
set forth in “Contact Us” below. We will process such requests in
|
||||
accordance with applicable laws.
|
||||
</P>
|
||||
<H2>6. SECURITY OF YOUR INFORMATION</H2>
|
||||
<P>
|
||||
We take steps to ensure that your information is treated securely and in
|
||||
accordance with this Privacy Policy. Unfortunately, no system is
|
||||
100% secure, and we cannot ensure or warrant the security of any
|
||||
information you provide to us. To the fullest extent permitted by
|
||||
applicable law, we do not accept liability for unauthorized access, use,
|
||||
disclosure, or loss of personal information.
|
||||
</P>
|
||||
<P>
|
||||
By using our Services or providing personal information to us, you agree
|
||||
that we may communicate with you electronically regarding security,
|
||||
privacy, and administrative issues relating to your use of our Services.
|
||||
If we learn of a security system’s breach, we may attempt to
|
||||
notify you electronically by posting a notice on our Services, by mail,
|
||||
or by sending an email to you.
|
||||
</P>
|
||||
<H2>7. INTERNATIONAL DATA TRANSFERS</H2>
|
||||
<P>
|
||||
All information processed by us may be transferred, processed, and
|
||||
stored anywhere in the world, including, but not limited to, the United
|
||||
States or other countries, which may have data protection laws that are
|
||||
different from the laws where you live. We endeavor to safeguard your
|
||||
information consistent with the requirements of applicable laws.
|
||||
</P>
|
||||
<P>
|
||||
If we transfer personal information which originates in the European
|
||||
Economic Area, Switzerland, and/or the United Kingdom to a country that
|
||||
has not been found to provide an adequate level of protection under
|
||||
applicable data protection laws, one of the safeguards we may use to
|
||||
support such transfer is the{' '}
|
||||
<A href="https://ec.europa.eu/info/law/law-topic/data-protection/international-dimension-data-protection/standard-contractual-clauses-scc/standard-contractual-clauses-international-transfers_en">
|
||||
EU Standard Contractual Clauses
|
||||
</A>
|
||||
.
|
||||
</P>
|
||||
<P>
|
||||
For more information about the safeguards we use for international
|
||||
transfers of your personal information, please contact us as set forth
|
||||
below.
|
||||
</P>
|
||||
<H2>8. RETENTION OF PERSONAL INFORMATION</H2>
|
||||
<P>
|
||||
We store the personal information we collect as described in this
|
||||
Privacy Policy for as long as you use our Services, or as necessary to
|
||||
fulfill the purpose(s) for which it was collected, provide our Services,
|
||||
resolve disputes, establish legal defenses, conduct audits, pursue
|
||||
legitimate business purposes, enforce our agreements, and comply with
|
||||
applicable laws.
|
||||
</P>
|
||||
<H2>9. SUPPLEMENTAL NOTICE FOR NEVADA RESIDENTS</H2>
|
||||
<P>
|
||||
If you are a resident of Nevada, you have the right to opt-out of the
|
||||
sale of certain personal information to third parties who intend to
|
||||
license or sell that personal information. You can exercise this right
|
||||
by contacting us at{' '}
|
||||
<A href="mailto:support@bsky.app">support@bsky.app</A> with the subject
|
||||
line “Nevada Do Not Sell Request” and providing us with your name and
|
||||
the email address associated with your account. Please note that we do
|
||||
not currently sell your personal information as sales are defined in
|
||||
Nevada Revised Statutes Chapter 603A. If you have any questions, please
|
||||
contact us as set forth in Contact Us below.
|
||||
</P>
|
||||
<H2>10. CHILDREN’S INFORMATION</H2>
|
||||
<P>
|
||||
The Services are not directed to children under 13 (or other age as
|
||||
required by local law), and we do not knowingly collect personal
|
||||
information from children.
|
||||
</P>
|
||||
<P>
|
||||
If you are a parent or guardian and believe your child has uploaded
|
||||
personal information to our site without your consent, you may contact
|
||||
us as described in “Contact Us” below. If we become aware that a child
|
||||
has provided us with personal information in violation of applicable
|
||||
law, we will delete any personal information we have collected, unless
|
||||
we have a legal obligation to keep it, and terminate the child’s
|
||||
account.
|
||||
</P>
|
||||
<H2>11. OTHER PROVISIONS</H2>
|
||||
<P>
|
||||
Third-Party Websites/Applications. The Services may contain links to
|
||||
other websites/applications and other websites/applications may
|
||||
reference or link to our Services. These third-party services are not
|
||||
controlled by us. We encourage our users to read the privacy policies of
|
||||
each website and application with which they interact. We do not
|
||||
endorse, screen, or approve, and are not responsible for, the privacy
|
||||
practices or content of such other websites or applications. Providing
|
||||
personal information to third-party websites or applications is at your
|
||||
own risk.
|
||||
</P>
|
||||
<P>
|
||||
Supervisory Authority. If your personal information is subject to
|
||||
the applicable data protection laws of the European Economic Area,
|
||||
Switzerland, or the United Kingdom, you have the right to lodge a
|
||||
complaint with the competent supervisory authority or attorney general
|
||||
if you believe our processing of your personal information violates
|
||||
applicable law.
|
||||
</P>
|
||||
<UL>
|
||||
<LI>
|
||||
<A href="https://edpb.europa.eu/about-edpb/board/members_en">
|
||||
EEA Data Protection Authorities (DPAs)
|
||||
</A>
|
||||
</LI>
|
||||
<LI>
|
||||
<A href="https://www.edoeb.admin.ch/edoeb/en/home/the-fdpic/contact.html">
|
||||
Swiss Federal Data Protection and Information Commissioner (FDPIC)
|
||||
</A>
|
||||
</LI>
|
||||
<LI>
|
||||
<A href="https://ico.org.uk/global/contact-us/">
|
||||
UK Information Commissioner’s Office (ICO)
|
||||
</A>
|
||||
</LI>
|
||||
</UL>
|
||||
<H2>12. CONTACT US </H2>
|
||||
<P>
|
||||
Bluesky is the controller of the personal information we process under
|
||||
this Privacy Policy.
|
||||
</P>
|
||||
<P>
|
||||
If you have any questions about our privacy practices or this Privacy
|
||||
Policy, or to exercise your rights as detailed in this Privacy Policy,
|
||||
please contact us at:{' '}
|
||||
<A href="mailto:support@bsky.app">support@bsky.app</A>
|
||||
</P>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,723 +0,0 @@
|
||||
import React from 'react'
|
||||
import {H4, P, OL, LI, A, STRONG, EM, UL} from 'view/com/util/Html'
|
||||
|
||||
export default function () {
|
||||
return (
|
||||
<>
|
||||
<H4>Last Updated: 2023/04/06</H4>
|
||||
<P>
|
||||
Welcome to the Bluesky, PBLLC d.b.a. Bluesky (“Bluesky”,
|
||||
“we”, or “us”) website located at{' '}
|
||||
<A href="https://bsky.app/">bsky.social</A> (“Site”), the
|
||||
Authenticated Transfer social protocol (“Protocol”) and our
|
||||
mobile application (“App”). Please read these Terms of
|
||||
Service (the “Terms”) and our{' '}
|
||||
<A href="https://bsky.app/support/privacy">Privacy Policy</A>{' '}
|
||||
(“Privacy Policy”) carefully because they govern your use of
|
||||
our Site, Protocol, App, and our content accessible therein. In
|
||||
addition, please read the{' '}
|
||||
<A href="https://bsky.app/support/community-guidelines">
|
||||
Bluesky Community Guidelines
|
||||
</A>{' '}
|
||||
(the “Bluesky Community Guidelines”), which are incorporated
|
||||
by reference and included in the Terms. To make these Terms easier to
|
||||
read, our Site, Protocol, and App, and our content and services provided
|
||||
therein, are collectively called the “Services.” The
|
||||
Services are not official products and have not been commercially or
|
||||
publicly released by Bluesky.
|
||||
</P>
|
||||
<P>
|
||||
IMPORTANT NOTICE REGARDING ARBITRATION: WHEN YOU AGREE TO THESE TERMS
|
||||
YOU ARE AGREEING (WITH LIMITED EXCEPTION) TO RESOLVE ANY DISPUTE BETWEEN
|
||||
YOU AND BLUESKY THROUGH BINDING, INDIVIDUAL ARBITRATION RATHER THAN IN
|
||||
COURT. PLEASE REVIEW CAREFULLY SECTION 18 “DISPUTE
|
||||
RESOLUTION” BELOW FOR DETAILS REGARDING ARBITRATION. HOWEVER, IF
|
||||
YOU ARE A RESIDENT OF A JURISDICTION WHERE APPLICABLE LAW PROHIBITS
|
||||
ARBITRATION OF DISPUTES, THE AGREEMENT TO ARBITRATE IN SECTION ‎18
|
||||
WILL NOT APPLY TO YOU BUT THE PROVISIONS OF SECTION ‎17
|
||||
“GOVERNING LAW AND FORUM CHOICE” WILL APPLY INSTEAD.
|
||||
</P>
|
||||
<OL>
|
||||
<LI>
|
||||
<STRONG>Agreement to Terms.</STRONG> By using our Services, you agree
|
||||
to be bound by these Terms. If you don’t agree to be bound by
|
||||
these Terms, do not use the Services. If you are accessing and using
|
||||
the Services on behalf of a company (such as your employer) or other
|
||||
legal entity, you represent and warrant that you have the authority to
|
||||
bind that company or other legal entity to these Terms. In that case,
|
||||
“you” and “your” will refer to that company or
|
||||
other legal entity.
|
||||
</LI>
|
||||
<LI>
|
||||
<STRONG>Acknowledgment of Beta Services.</STRONG> You acknowledge and
|
||||
agree that: (a) the Services are not official products and have not
|
||||
been commercially or publicly released by Bluesky; (b) the Services
|
||||
may not operate properly, be in final form or be fully functional; (c)
|
||||
the Services may contain errors, design flaws or other problems; (d)
|
||||
it may not be possible to make the Services fully functional; (e) the
|
||||
information obtained using the Services may not be accurate; (f) use
|
||||
of the Services may result in unexpected results, loss of data or
|
||||
communications, project delays or other unpredictable damage or loss;
|
||||
(g) Bluesky is under no obligation to release a commercial or public
|
||||
version of the Services; and (h) Bluesky has the right to unilaterally
|
||||
to abandon development of the Services, at any time and without any
|
||||
obligation or liability to you.
|
||||
</LI>
|
||||
<LI>
|
||||
<STRONG>Privacy Policy.</STRONG> Please refer to our{' '}
|
||||
<A href="https://bsky.app/support/privacy">Privacy Policy</A> for
|
||||
information on how we collect, use and share your information. You
|
||||
acknowledge and agree that your use of the Services is subject to our
|
||||
Privacy Policy.
|
||||
</LI>
|
||||
<LI>
|
||||
<STRONG>Changes to these Terms or the Services.</STRONG> We may update
|
||||
these Terms from time to time at our sole discretion. If we do,
|
||||
we’ll let you know by posting the updated Terms on the Site,
|
||||
Protocol or the App and may also send other communications. It’s
|
||||
important that you review these Terms whenever we update them or you
|
||||
use the Services. If you continue to use the Services after we have
|
||||
posted updated Terms it means that you accept and agree to the
|
||||
changes. If you don’t agree to be bound by the changes, you may
|
||||
not use the Services anymore. Because our Services are evolving over
|
||||
time we may change or discontinue all or any part of the Services, at
|
||||
any time and without notice, at our sole discretion. We may add,
|
||||
remove, suspend or alter access to any content available through or on
|
||||
the Services at any time and make no guarantee as to the availability
|
||||
or minimum amount of specific content.{' '}
|
||||
</LI>
|
||||
<LI>
|
||||
<STRONG>Who May Use the Services?</STRONG>
|
||||
<OL>
|
||||
<LI>
|
||||
<EM>Eligibility</EM>. To use the Services, you must be at least 13
|
||||
years of age and not otherwise barred from using the Services
|
||||
under applicable law. If you are over 13 years of age but under
|
||||
the age of majority in your respective jurisdiction, you hereby
|
||||
represent and warrant that your parent or legal guardian has read
|
||||
these Terms and accepts them on your behalf. Parents and legal
|
||||
guardians are responsible for the acts of their minor children
|
||||
when using the Services, whether or not the parent or guardian has
|
||||
authorized such acts.
|
||||
</LI>
|
||||
<LI>
|
||||
<EM>Registration and Your Information</EM>. If you want to use
|
||||
certain features of the Services you’ll have to create an
|
||||
account (“Account”) via the Services.
|
||||
</LI>
|
||||
<LI>
|
||||
<EM>Accuracy of Account Information</EM>. It’s important
|
||||
that you provide us with accurate, complete and current
|
||||
information for your Account and you agree to keep this
|
||||
information up-to-date. If you don’t, we might have to
|
||||
suspend or terminate your Account. You agree that you won’t
|
||||
disclose your Account password to anyone and you’ll notify
|
||||
us immediately of any unauthorized use of your Account.
|
||||
You’re responsible for all activities that occur under your
|
||||
Account, including, without limitation, the posting of User
|
||||
Content (as defined below), and any communications or other
|
||||
contact you have with other users of the Services, whether or not
|
||||
you know about them. We may take actions we deem reasonably
|
||||
necessary to prevent fraud and abuse, including placing
|
||||
restrictions on user accounts or on the amount of content that can
|
||||
be accessed from the Services at any one time.
|
||||
</LI>
|
||||
</OL>
|
||||
</LI>
|
||||
<LI>
|
||||
<STRONG>Feedback.</STRONG> We welcome feedback, comments and
|
||||
suggestions for improvements to the Services (“Feedback”).
|
||||
You can submit Feedback by posting in the App or emailing us at
|
||||
support@bsky.app. You grant us a non-exclusive, transferable,
|
||||
worldwide, perpetual, irrevocable, fully-paid, royalty-free license,
|
||||
with the right to sublicense, under any and all intellectual property
|
||||
rights that you own or control, to use, copy, modify, create
|
||||
derivative works based upon and otherwise exploit the Feedback for any
|
||||
purpose.
|
||||
</LI>
|
||||
<LI>
|
||||
<STRONG>Content Ownership, Responsibility and Removal.</STRONG>
|
||||
<OL>
|
||||
<LI>
|
||||
<EM>Definitions</EM>. For purposes of these Terms: (i){' '}
|
||||
<STRONG>“Content”</STRONG> means text, graphics,
|
||||
images, music, software, audio, video, works of authorship of any
|
||||
kind, and information or other materials that are posted,
|
||||
generated, provided or otherwise made available through the
|
||||
Services; and (ii) <STRONG>“User Content”</STRONG>{' '}
|
||||
means any Content that Account holders (including you) provide or
|
||||
make available through the Services. User Content also includes,
|
||||
without limitation, any communications or content that you share
|
||||
with another user of the Services such as comments on other
|
||||
users’ User Content or information you provide or make
|
||||
available through the Services. Content includes, without
|
||||
limitation, User Content.
|
||||
</LI>
|
||||
<LI>
|
||||
<EM>Our Content Ownership</EM>. Except for any licensed rights
|
||||
granted under these Terms, Bluesky does not claim any ownership
|
||||
rights in any User Content and nothing in these Terms will be
|
||||
deemed to restrict any rights that you may have to use and exploit
|
||||
your User Content. Subject to the foregoing, Bluesky and its
|
||||
licensors exclusively own all right, title and interest in and to
|
||||
the Services and Content, including all associated intellectual
|
||||
property rights and all features, trademarks, trade names, service
|
||||
marks, trade dress, and the look and feel of the Services. You
|
||||
acknowledge that the Services and Content are protected by
|
||||
copyright, trademark, and other laws of the United States and
|
||||
foreign countries. You agree not to remove, alter or obscure any
|
||||
copyright, trademark, service mark or other proprietary rights
|
||||
notices incorporated in or accompanying the Services or Content.
|
||||
</LI>
|
||||
<LI>
|
||||
<EM>Rights in Content Granted by Bluesky</EM>. Subject to your
|
||||
compliance with these Terms, Bluesky grants to you a limited,
|
||||
non-exclusive, non-transferable license, with no right to
|
||||
sublicense, to download, view, copy, display and print the Content
|
||||
solely in connection with your permitted use of the Services and
|
||||
solely for your personal and non-commercial purposes. Other than
|
||||
the right to use the Services as explicitly described in these
|
||||
Terms for your personal, limited use, no other rights are granted
|
||||
to you under these Terms.{' '}
|
||||
</LI>
|
||||
<LI>
|
||||
<EM>Rights in User Content Granted by You to Us</EM>. By making
|
||||
any User Content available through the Services, you hereby grant
|
||||
to Bluesky and its subsidiaries, affiliates, licensee, successors,
|
||||
and assigns (the “Bluesky Parties”) an irrevocable,
|
||||
non-exclusive, perpetual, transferable, worldwide, royalty-free
|
||||
license, with the right to sublicense (through multiple tiers of
|
||||
sub-licensing), to use, copy, modify, adapt, crop, edit, creative
|
||||
derivative works, distribute, publicly display, publicly perform
|
||||
and otherwise exploit in any media now known or hereafter devised,
|
||||
your User Content, in whole or in part, in connection with (i)
|
||||
providing the Services and Content to you and to others; (ii)
|
||||
promote and market Bluesky and our Services, including without
|
||||
limitation through Bluesky’s owned, operated, and/or branded
|
||||
social media channels. For example, Bluesky may create
|
||||
compilations of Content made available by Account holders
|
||||
(including your User Content), and/or use User Content or such
|
||||
compilations to promote the App through Bluesky’s operated,
|
||||
and/or branded social media channels, without further payment or
|
||||
consideration by Bluesky. However, without your prior consent,
|
||||
Bluesky will not use your User Content in any Content that is
|
||||
sponsored by a third party.
|
||||
</LI>
|
||||
<LI>
|
||||
<EM>Name, Likeness, Other Personal Rights</EM>. By submitting User
|
||||
Content in which you may appear, including without limitation your
|
||||
photograph, you hereby grant to (i) Bluesky and the Bluesky
|
||||
Parties and (ii) other Account holders, an irrevocable,
|
||||
non-exclusive, perpetual, transferable, worldwide, royalty-free,
|
||||
unlimited license to use your name, image, likeness, or other
|
||||
information or materials supplied by you, including any third
|
||||
party materials as they appear in such User Content, consistent
|
||||
with the rights granted by you in this Section 7.
|
||||
</LI>
|
||||
<LI>
|
||||
<EM>Your Responsibility for User Content</EM>. You are solely
|
||||
responsible for all of your User Content. You represent and
|
||||
warrant that your User Content is original, that you own your User
|
||||
Content or you have all rights that are necessary to grant us and
|
||||
the Bluesky Parties the license rights in your User Content under
|
||||
these Terms. This includes the rights to the name, likeness or
|
||||
other publicity rights of any other party appearing in your User
|
||||
Content. You also represent and warrant that neither your User
|
||||
Content, nor your use and provision of your User Content to be
|
||||
made available through the Services, nor any exercise of any
|
||||
rights granted by you in such User Content will (i) conflict with
|
||||
any rights or commitments granted by you to any other party; (ii)
|
||||
infringe, misappropriate or violate a third party’s
|
||||
intellectual property rights, or rights of publicity or privacy,
|
||||
or (iii) result in the violation of any applicable law or
|
||||
regulation. You represent and warrant that all of your User
|
||||
Content and your activities in connection with the Services will,
|
||||
at all times, comply with (i) these Terms; (ii) all applicable
|
||||
laws, rules, and regulations; and (iii) any other guidelines or
|
||||
requirements that we may make available to you from time to time.
|
||||
</LI>
|
||||
<LI>
|
||||
<EM>Removal of User Content</EM>. We have the right, in our sole
|
||||
discretion, to remove any User Content. You can remove your User
|
||||
Content by specifically deleting it. However, in certain
|
||||
instances, some of your User Content (such as posts or comments
|
||||
you make) may not be completely removed and copies of your User
|
||||
Content may continue to exist on the Services. We are not
|
||||
responsible or liable for the removal or deletion of (or the
|
||||
failure to remove or delete) any of your User Content. Except as
|
||||
expressly stated herein, you acknowledge and agree that we have no
|
||||
obligation to provide, monitor, edit, upload, or remove any of
|
||||
your User Content, although we have the right to do so.
|
||||
</LI>
|
||||
</OL>
|
||||
</LI>
|
||||
<LI>
|
||||
<STRONG>Respecting Others’ User Content</STRONG>. Subject to our
|
||||
rights under Section 7, you acknowledge that all User Content and any
|
||||
content related thereto are the property of the respective user that
|
||||
makes the User Content available through the Services or, if made
|
||||
explicit on the Services, Bluesky. You acknowledge and agree that
|
||||
Bluesky is not responsible or liable for your User Content or any
|
||||
direct message through the Services between any user of the Services
|
||||
and another user of the Services (“Communications”),
|
||||
including you.
|
||||
</LI>
|
||||
<LI>
|
||||
<STRONG>Rights and Terms for Apps.</STRONG>
|
||||
<OL>
|
||||
<LI>
|
||||
<EM>Rights in App Granted by Bluesky</EM>. Subject to your
|
||||
compliance with these Terms, Bluesky grants to you a limited,
|
||||
non-exclusive, non-transferable license, with no right to
|
||||
sublicense, to download and install the App on a mobile device
|
||||
that you own or control and to run the App solely for your own
|
||||
personal non-commercial purposes. Except as expressly permitted in
|
||||
these Terms, you may not: (i) copy, modify or create
|
||||
derivative works based on the App; (ii) sublicense, lease,
|
||||
lend or rent the App to any third party; (iii) decompile or
|
||||
disassemble the App; or (iv) make the functionality of the
|
||||
App available to multiple users through any means. Bluesky
|
||||
reserves all rights in and to the App not expressly granted to you
|
||||
under these Terms. Certain portions of the App may be subject to
|
||||
an open source license agreement, as expressly designated within
|
||||
the App or on the Site. Such license will govern the use of such
|
||||
portions of the App to the extent that such license agreement
|
||||
conflicts with or is inconsistent with this Section 9(a) (for
|
||||
example, if the license grants broader use rights).
|
||||
</LI>
|
||||
<LI>
|
||||
<EM>Accessing App from App Store</EM>. The following terms apply
|
||||
to any App accessed through or downloaded from any app store or
|
||||
distribution platform (like the Apple App Store) where the App may
|
||||
now or in the future be made available (each, an “App
|
||||
Provider”). You acknowledge and agree that:
|
||||
<UL>
|
||||
<LI>
|
||||
These Terms are concluded between you and Bluesky, and not
|
||||
with the App Provider, and Bluesky (not the App Provider), is
|
||||
solely responsible for the App.
|
||||
</LI>
|
||||
<LI>
|
||||
The App Provider has no obligation to furnish any maintenance
|
||||
and support services with respect to the App.
|
||||
</LI>
|
||||
<LI>
|
||||
In the event of any failure of the App to conform to any
|
||||
applicable warranty, you may notify the App Provider and, to
|
||||
the maximum extent permitted by applicable law, the App
|
||||
Provider will have no other warranty obligation whatsoever
|
||||
with respect to the App. Any other claims, losses,
|
||||
liabilities, damages, costs or expenses attributable to any
|
||||
failure to conform to any warranty will be the sole
|
||||
responsibility of Bluesky.
|
||||
</LI>
|
||||
<LI>
|
||||
The App Provider is not responsible for addressing any claims
|
||||
you have or any claims of any third party relating to the App
|
||||
or your possession and use of the App, including, but not
|
||||
limited to: (i) product liability claims; (ii) any claim
|
||||
that the App fails to conform to any applicable legal or
|
||||
regulatory requirement; and (iii) claims arising under
|
||||
consumer protection or similar legislation.
|
||||
</LI>
|
||||
<LI>
|
||||
In the event of any third party claim that the App or your
|
||||
possession and use of that App infringes that third
|
||||
party’s intellectual property rights, Bluesky will be
|
||||
solely responsible for the investigation, defense, settlement
|
||||
and discharge of any such intellectual property infringement
|
||||
claim to the extent required by these Terms.
|
||||
</LI>
|
||||
<LI>
|
||||
The App Provider, and its subsidiaries, are third-party
|
||||
beneficiaries of these Terms as related to your license to the
|
||||
App, and upon your acceptance of these Terms, the App Provider
|
||||
will have the right (and will be deemed to have accepted the
|
||||
right) to enforce these Terms against you as a third-party
|
||||
beneficiary thereof.
|
||||
</LI>
|
||||
<LI>
|
||||
You represent and warrant that (i) you are not located in a
|
||||
country that is subject to a U.S. Government embargo, or that
|
||||
has been designated by the U.S. Government as a
|
||||
terrorist-supporting country; and (ii) you are not listed on
|
||||
any U.S. Government list of prohibited or restricted parties.
|
||||
</LI>
|
||||
<LI>
|
||||
You must also comply with all applicable third-party terms of
|
||||
service when using the App.
|
||||
</LI>
|
||||
</UL>
|
||||
</LI>
|
||||
</OL>
|
||||
</LI>
|
||||
<LI>
|
||||
<STRONG>
|
||||
General Prohibitions and Bluesky’s Enforcement Rights
|
||||
</STRONG>
|
||||
. You agree not to do any of the following:
|
||||
<OL>
|
||||
<LI>
|
||||
Post, upload, publish, submit or transmit any content that:
|
||||
(i) infringes, misappropriates or violates a third
|
||||
party’s patent, copyright, trademark, trade secret, moral
|
||||
rights or other intellectual property rights, or rights of
|
||||
publicity or privacy; or (ii) violates any applicable law or
|
||||
regulation or would give rise to civil liability;
|
||||
</LI>
|
||||
<LI>
|
||||
Use, display, mirror or frame the Services, Bluesky’s name,
|
||||
any Bluesky trademark, logo or other proprietary information,
|
||||
without Bluesky’s express written consent;
|
||||
</LI>
|
||||
<LI>
|
||||
Access, tamper with, or use non-public areas of the Services;
|
||||
</LI>
|
||||
<LI>
|
||||
Attempt to probe, scan or test the vulnerability of any Bluesky
|
||||
system or network or breach any security or authentication
|
||||
measures without reporting such vulnerability or breach to
|
||||
support@bsky.app, except as part of community testing initiatives
|
||||
authorized by Bluesky;
|
||||
</LI>
|
||||
<LI>
|
||||
Avoid, bypass, remove, deactivate, impair, descramble or otherwise
|
||||
circumvent any technological measure implemented by Bluesky or any
|
||||
of Bluesky’s providers or any other third party (including
|
||||
another user) to protect the Services, including any service
|
||||
protection or usage limits;
|
||||
</LI>
|
||||
<LI>
|
||||
Attempt to access or search the Services or download content from
|
||||
the Services using any engine, software, tool, agent, device or
|
||||
mechanism (including spiders, robots, crawlers, data mining tools
|
||||
or the like) that imposes unreasonable burdens on the Services or
|
||||
that we otherwise deem abusive or harmful;
|
||||
</LI>
|
||||
<LI>Send any junk mail or spam;</LI>
|
||||
<LI>
|
||||
Use any meta tags or other hidden text or metadata utilizing a
|
||||
Bluesky trademark, logo URL or product name without
|
||||
Bluesky’s express written consent;
|
||||
</LI>
|
||||
<LI>
|
||||
Use the Services, or any portion thereof, in any manner not
|
||||
permitted by these Terms;
|
||||
</LI>
|
||||
<LI>
|
||||
Interfere with, or attempt to interfere with, the access of any
|
||||
user, host or network, including, without limitation, sending a
|
||||
virus, overloading, flooding, spamming, or mail-bombing the
|
||||
Services;
|
||||
</LI>
|
||||
<LI>
|
||||
Collect or store any personally identifiable information from the
|
||||
Services from other users of the Services without their
|
||||
express permission;
|
||||
</LI>
|
||||
<LI>
|
||||
Impersonate or misrepresent your affiliation with any person or
|
||||
entity, claim a false affiliation, or misrepresent the source or
|
||||
identity of content used through the Services;
|
||||
</LI>
|
||||
<LI>
|
||||
You agree not to provide any information that is intended to
|
||||
misinform, misdirect, mislead, or otherwise deceive any users of
|
||||
the Services or any other third party;
|
||||
</LI>
|
||||
<LI>Violate any applicable law or regulation;</LI>
|
||||
<LI>
|
||||
Commercialize any User Content not in accordance with these Terms;
|
||||
or
|
||||
</LI>
|
||||
<LI>
|
||||
Directly or indirectly induce others to do any of the above.
|
||||
</LI>
|
||||
</OL>
|
||||
<P>
|
||||
Bluesky is not obligated to monitor access to or use of the Services
|
||||
or to review or edit any content. However, we have the right to do
|
||||
so for the purpose of operating the Services, to ensure compliance
|
||||
with these Terms and to comply with applicable law or other legal
|
||||
requirements. We reserve the right, but are not obligated, to remove
|
||||
or disable access to any content, including User Content, at any
|
||||
time and without notice, including, but not limited to, if we, at
|
||||
our sole discretion, consider it objectionable or in violation of
|
||||
these Terms. We have the right to investigate violations of these
|
||||
Terms or conduct that affects the Services. We may also consult and
|
||||
cooperate with law enforcement authorities to prosecute users who
|
||||
violate the law.
|
||||
</P>
|
||||
</LI>
|
||||
<LI>
|
||||
<STRONG>DMCA/Copyright Policy.</STRONG> Bluesky respects copyright law
|
||||
and expects its users to do the same. It is Bluesky’s policy to
|
||||
terminate in appropriate circumstances account holders who repeatedly
|
||||
infringe or are believed to be repeatedly infringing the rights of
|
||||
copyright holders. Please see Bluesky’s Copyright Policy at{' '}
|
||||
<A href="/support/copyright">bsky.app/support/copyright</A> for
|
||||
further information.
|
||||
</LI>
|
||||
<LI>
|
||||
<STRONG>Links to Third Party Websites or Resources.</STRONG> The
|
||||
Services (including the Site, Protocol and App) may allow you to
|
||||
access third-party websites or other resources. To the extent provided
|
||||
by us, we provide access only as a convenience and are not responsible
|
||||
for the content, products or services on or available from those
|
||||
resources or links displayed on such websites. Users of the Services
|
||||
may provide access through User Content to third-party websites or
|
||||
other resources. To the extent provided by you, you hereby acknowledge
|
||||
and agree that, as between you and Bluesky, you bear full
|
||||
responsibility and liability in connection with such access by users
|
||||
of the Services. You acknowledge sole responsibility for and assume
|
||||
all risk arising from, your use of any third-party resources. You
|
||||
further acknowledge that we have no responsibility to remove, add,
|
||||
modify, or monitor User Content, including any access to third-party
|
||||
websites or other resources contained in User Content.
|
||||
</LI>
|
||||
<LI>
|
||||
<STRONG>Termination.</STRONG> We may terminate or suspend, in whole or
|
||||
in part, your access to and use of the Services, including suspending
|
||||
access to or terminating your account, at our sole discretion, at any
|
||||
time and without notice to you. You may request deletion of your
|
||||
account, which we will consider on a case-by-case basis, by emailing
|
||||
support@bsky.app. Upon any termination, discontinuation or
|
||||
cancellation of the Services or your account, the following Sections
|
||||
will survive: 2, 7(a), 7(b), 7(d), 7(e), 7(f), 8, 10, 13, 14, 15, 16,
|
||||
17, 18 and 19.
|
||||
</LI>
|
||||
<LI>
|
||||
<P>
|
||||
<STRONG>Warranty Disclaimers.</STRONG> THE SERVICES ARE PROVIDED
|
||||
“AS IS,” WITHOUT WARRANTY OF ANY KIND. WITHOUT LIMITING
|
||||
THE FOREGOING, WE EXPLICITLY DISCLAIM ANY IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, QUIET ENJOYMENT
|
||||
AND NON-INFRINGEMENT, AND ANY WARRANTIES ARISING OUT OF COURSE OF
|
||||
DEALING OR USAGE OF TRADE. WE MAKE NO WARRANTY THAT THE SERVICES
|
||||
WILL MEET YOUR REQUIREMENTS OR BE AVAILABLE ON AN UNINTERRUPTED,
|
||||
SECURE, OR ERROR-FREE BASIS. WE MAKE NO WARRANTY REGARDING THE
|
||||
QUALITY, ACCURACY, TIMELINESS, TRUTHFULNESS, COMPLETENESS OR
|
||||
RELIABILITY OF ANY INFORMATION OR CONTENT ON THE SERVICES. WE
|
||||
ARE NOT RESPONSIBLE OR LIABLE FOR USER CONTENT, OR ANY
|
||||
COMMUNICATIONS, AND WE MAKE NO WARRANTY OR REPRESENTATION OF ANY
|
||||
KIND IN REGARD TO USER CONTENT.
|
||||
</P>
|
||||
<P>
|
||||
BLUESKY ASSUMES NO RESPONSIBILITY FOR ANY USER’S OR THIRD
|
||||
PARTY’S FAILURE TO COMPLY WITH ANY APPLICABLE LAWS AND
|
||||
REGULATIONS. WE EXPLICITLY DISCLAIM ALL LIABILITY FOR ANY ACT OR
|
||||
OMISSION OF ANY USER OR OTHER THIRD PARTY. WE DO NOT AND CANNOT
|
||||
CONTROL YOUR INTERACTION WITH ANY USER OR OTHER THIRD PARTY, AND WE
|
||||
EXPRESSLY DISCLAIM ANY LIABILITY ARISING FROM SUCH INTERACTION.
|
||||
</P>
|
||||
</LI>
|
||||
<LI>
|
||||
<STRONG>Indemnity.</STRONG> You will indemnify and hold harmless
|
||||
Bluesky and its officers, directors, employees and agents, from and
|
||||
against any claims, disputes, demands, liabilities, damages, losses,
|
||||
and costs and expenses, including, without limitation, reasonable
|
||||
legal and accounting fees arising out of or in any way connected with
|
||||
(a) your access to or use of the Services, (b) your User Content, or
|
||||
(c) your violation of these Terms.
|
||||
</LI>
|
||||
<LI>
|
||||
<STRONG>Limitation of Liability.</STRONG>
|
||||
<OL>
|
||||
<LI>
|
||||
For the purposes of this Section 16, “Bluesky”,
|
||||
“we”, or “us” shall include Bluesky, its
|
||||
subsidiaries, affiliates, investors, agents, and successors and
|
||||
assigns.
|
||||
</LI>
|
||||
<LI>
|
||||
TO THE MAXIMUM EXTENT PERMITTED BY LAW, NEITHER BLUESKY NOR ANY
|
||||
OTHER PARTY INVOLVED IN CREATING, PRODUCING, OR DELIVERING THE
|
||||
SERVICES WILL BE LIABLE FOR ANY INCIDENTAL, SPECIAL, EXEMPLARY OR
|
||||
CONSEQUENTIAL DAMAGES, OR DAMAGES FOR LOST PROFITS, LOST REVENUES,
|
||||
LOST SAVINGS, LOST BUSINESS OPPORTUNITY, LOSS OF DATA OR GOODWILL,
|
||||
SERVICE INTERRUPTION, COMPUTER DAMAGE OR SYSTEM FAILURE OR THE
|
||||
COST OF SUBSTITUTE SERVICES OF ANY KIND ARISING OUT OF OR IN
|
||||
CONNECTION WITH THESE TERMS OR FROM THE USE OF OR INABILITY TO USE
|
||||
THE SERVICES OR FOR ANY ERROR OR DEFECT IN THE SERVICES, WHETHER
|
||||
BASED ON WARRANTY, CONTRACT, TORT (INCLUDING NEGLIGENCE), PRODUCT
|
||||
LIABILITY OR ANY OTHER LEGAL THEORY, AND WHETHER OR NOT BLUESKY OR
|
||||
ANY OTHER PARTY HAS BEEN INFORMED OF THE POSSIBILITY OF SUCH
|
||||
DAMAGE, EVEN IF A LIMITED REMEDY SET FORTH HEREIN IS FOUND TO HAVE
|
||||
FAILED OF ITS ESSENTIAL PURPOSE. YOU ACKNOWLEDGE THAT BLUESKY
|
||||
SHALL NOT BE RESPONSIBLE OR LIABLE FOR CONTENT OR THE DEFAMATORY,
|
||||
OFFENSIVE, OR ILLEGAL CONDUCT OF ANY THIRD PARTY OR ANY THIRD
|
||||
PARTY’S CONTENT (INCLUDING ANY USER CONTENT), WHETHER OR NOT
|
||||
SUCH CONTENT IS ACCESSED THROUGH THE SERVICES, AND THAT ANY RISK
|
||||
OF HARM OR DAMAGE FROM THE FOREGOING RESTS ENTIRELY WITH YOU.
|
||||
</LI>
|
||||
<LI>
|
||||
TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT
|
||||
WILL BLUESKY’S TOTAL LIABILITY ARISING OUT OF OR IN
|
||||
CONNECTION WITH THESE TERMS OR FROM THE USE OF OR INABILITY TO USE
|
||||
THE SERVICES EXCEED TWENTY DOLLARS ($20).
|
||||
</LI>
|
||||
<LI>
|
||||
THE EXCLUSIONS AND LIMITATIONS OF DAMAGES SET FORTH ABOVE ARE
|
||||
FUNDAMENTAL ELEMENTS OF THE BASIS OF THE BARGAIN BETWEEN BLUESKY
|
||||
AND YOU.
|
||||
</LI>
|
||||
</OL>
|
||||
</LI>
|
||||
<LI>
|
||||
<STRONG>Governing Law and Forum Choice.</STRONG> These Terms and any
|
||||
action related thereto will be governed by the Federal Arbitration
|
||||
Act, federal arbitration law, and the laws of the State of Delaware,
|
||||
without regard to its conflict of laws provisions. Except as otherwise
|
||||
expressly set forth in Section 17 “Dispute Resolution,”
|
||||
the exclusive jurisdiction for all Disputes (defined below) that you
|
||||
and Bluesky are not required to arbitrate will be the state and
|
||||
federal courts located in Delaware, and you and Bluesky each waive any
|
||||
objection to jurisdiction and venue in such courts.
|
||||
</LI>
|
||||
<LI>
|
||||
<STRONG>Dispute Resolution.</STRONG>
|
||||
<OL>
|
||||
<LI>
|
||||
<EM>Mandatory Arbitration of Disputes</EM>. We each agree that any
|
||||
dispute, claim or controversy arising out of or relating to these
|
||||
Terms or the breach, termination, enforcement, interpretation or
|
||||
validity thereof or the use of the Services (collectively,
|
||||
“Disputes”) will be resolved solely by binding,
|
||||
individual arbitration and not in a class, representative or
|
||||
consolidated action or proceeding . You and Bluesky agree that the
|
||||
U.S. Federal Arbitration Act governs the interpretation and
|
||||
enforcement of these Terms, and that you and Bluesky are each
|
||||
waiving the right to a trial by jury or to participate in a class
|
||||
action. This arbitration provision shall survive termination of
|
||||
these Terms.
|
||||
</LI>
|
||||
<LI>
|
||||
<EM>Exceptions</EM>. As limited exceptions to Section 18 (a)
|
||||
above: (i) we both may seek to resolve a Dispute in small
|
||||
claims court if it qualifies; and (ii) we each retain the right to
|
||||
seek injunctive or other equitable relief from a court to prevent
|
||||
(or enjoin) the infringement or misappropriation of our
|
||||
intellectual property rights.
|
||||
</LI>
|
||||
<LI>
|
||||
<P>
|
||||
<EM>Conducting Arbitration and Arbitration Rules</EM>. The
|
||||
arbitration will be conducted by the American Arbitration
|
||||
Association (“AAA”) under its Consumer Arbitration
|
||||
Rules (the “AAA Rules”) then in effect, except as
|
||||
modified by these Terms. The AAA Rules are available at{' '}
|
||||
<A href="https://www.adr.org">www.adr.org</A> or by calling
|
||||
1-800-778-7879. A party who wishes to start arbitration must
|
||||
submit a written Demand for Arbitration to AAA and give notice
|
||||
to the other party as specified in the AAA Rules. The AAA
|
||||
provides a form Demand for Arbitration at{' '}
|
||||
<A href="http://www.adr.org/aaa/ShowPDF?doc=ADRSTG_004175">
|
||||
www.adr.org
|
||||
</A>
|
||||
.
|
||||
</P>
|
||||
<P>
|
||||
Any arbitration hearings will take place in the county (or
|
||||
parish) where you live, unless we both agree to a different
|
||||
location. The parties agree that the arbitrator shall have
|
||||
exclusive authority to decide all issues relating to the
|
||||
interpretation, applicability, enforceability and scope of this
|
||||
arbitration agreement.
|
||||
</P>
|
||||
</LI>
|
||||
<LI>
|
||||
<EM>Arbitration Costs</EM>. Payment of all filing, administration
|
||||
and arbitrator fees will be governed by the AAA Rules. If we
|
||||
prevail in arbitration we’ll pay all of our attorneys’
|
||||
fees and costs and won’t seek to recover them from you. If
|
||||
you prevail in arbitration you will be entitled to an award of
|
||||
attorneys’ fees and expenses to the extent provided under
|
||||
applicable law.
|
||||
</LI>
|
||||
<LI>
|
||||
<EM>Injunctive and Declaratory Relief</EM>. Except as provided in
|
||||
Section 18(b) above, the arbitrator shall determine all issues of
|
||||
liability on the merits of any claim asserted by either party and
|
||||
may award declaratory or injunctive relief only in favor of the
|
||||
individual party seeking relief and only to the extent necessary
|
||||
to provide relief warranted by that party’s individual
|
||||
claim. To the extent that you or we prevail on a claim and seek
|
||||
public injunctive relief (that is, injunctive relief that has the
|
||||
primary purpose and effect of prohibiting unlawful acts that
|
||||
threaten future injury to the public), the entitlement to and
|
||||
extent of such relief must be litigated in a civil court of
|
||||
competent jurisdiction and not in arbitration. The parties agree
|
||||
that litigation of any issues of public injunctive relief shall be
|
||||
stayed pending the outcome of the merits of any individual claims
|
||||
in arbitration.
|
||||
</LI>
|
||||
<LI>
|
||||
<EM>Class Action Waiver</EM>. YOU AND BLUESKY AGREE THAT EACH MAY
|
||||
BRING CLAIMS AGAINST THE OTHER ONLY IN YOUR OR ITS INDIVIDUAL
|
||||
CAPACITY, AND NOT AS A PLAINTIFF OR CLASS MEMBER IN ANY PURPORTED
|
||||
CLASS OR REPRESENTATIVE PROCEEDING. Further, if the parties’
|
||||
Dispute is resolved through arbitration, the arbitrator may not
|
||||
consolidate another person’s claims with your claims, and
|
||||
may not otherwise preside over any form of a representative or
|
||||
class proceeding. If this specific provision is found to be
|
||||
unenforceable, then the entirety of this Dispute Resolution
|
||||
section shall be null and void.
|
||||
</LI>
|
||||
<LI>
|
||||
<EM>Severability</EM>. With the exception of any of the provisions
|
||||
in Section 18(f) of these Terms (“Class Action
|
||||
Waiver”), if an arbitrator or court of competent
|
||||
jurisdiction decides that any part of these Terms is invalid or
|
||||
unenforceable, the other parts of these Terms will still apply.
|
||||
</LI>
|
||||
</OL>
|
||||
</LI>
|
||||
<LI>
|
||||
<STRONG>General Terms.</STRONG>
|
||||
<OL>
|
||||
<LI>
|
||||
<EM>Entire Agreement</EM>. These Terms constitute the entire and
|
||||
exclusive understanding and agreement between Bluesky and you
|
||||
regarding the Services and Content, and these Terms supersede and
|
||||
replace all prior oral or written understandings or agreements
|
||||
between Bluesky and you regarding the Services and Content. If any
|
||||
provision of these Terms is held invalid or unenforceable by an
|
||||
arbitrator or a court of competent jurisdiction, that provision
|
||||
will be enforced to the maximum extent permissible and the other
|
||||
provisions of these Terms will remain in full force and effect.
|
||||
You may not assign or transfer these Terms, by operation of law or
|
||||
otherwise, without Bluesky’s prior written consent. Any
|
||||
attempt by you to assign or transfer these Terms, without such
|
||||
consent, will be null. Bluesky may freely assign or transfer these
|
||||
Terms without restriction. Subject to the foregoing, these Terms
|
||||
will bind and inure to the benefit of the parties, their
|
||||
successors and permitted assigns.
|
||||
</LI>
|
||||
<LI>
|
||||
<EM>Notices</EM>. Any notices or other communications provided by
|
||||
Bluesky under these Terms will be given: (i) via email; or
|
||||
(ii) by posting to the Services. For notices made by email,
|
||||
the date of receipt will be deemed the date on which such notice
|
||||
is transmitted.
|
||||
</LI>
|
||||
<LI>
|
||||
<EM>Waiver of Rights</EM>. Bluesky’s failure to enforce any
|
||||
right or provision of these Terms will not be considered a waiver
|
||||
of such right or provision. The waiver of any such right or
|
||||
provision will be effective only if in writing and signed by a
|
||||
duly authorized representative of Bluesky. Except as expressly set
|
||||
forth in these Terms, the exercise by either party of any of its
|
||||
remedies under these Terms will be without prejudice to its other
|
||||
remedies under these Terms or otherwise.
|
||||
</LI>
|
||||
</OL>
|
||||
</LI>
|
||||
<LI>
|
||||
<STRONG>Contact Information.</STRONG> If you have any questions about
|
||||
these Terms or the Services, please contact Bluesky at:
|
||||
support@bsky.app.
|
||||
</LI>
|
||||
</OL>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user