diff --git a/.github/workflows/build-and-push-bskyweb-aws.yaml b/.github/workflows/build-and-push-bskyweb-aws.yaml
index fef24f952b..3f60705792 100644
--- a/.github/workflows/build-and-push-bskyweb-aws.yaml
+++ b/.github/workflows/build-and-push-bskyweb-aws.yaml
@@ -3,8 +3,8 @@ on:
push:
branches:
- main
- - traffic-reduction
- - respect-optout-for-embeds
+ - 3p-moderators
+
env:
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }}
diff --git a/.github/workflows/pull-request-commit.yml b/.github/workflows/pull-request-commit.yml
new file mode 100644
index 0000000000..85ebae4dba
--- /dev/null
+++ b/.github/workflows/pull-request-commit.yml
@@ -0,0 +1,185 @@
+# Credit https://github.com/expo/expo
+# https://github.com/expo/expo/blob/main/.github/workflows/pr-labeler.yml
+---
+name: PR labeler
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ types: [opened, synchronize]
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ test-suite-fingerprint:
+ runs-on: ubuntu-22.04
+ if: ${{ github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'push' }}
+ # REQUIRED: limit concurrency when pushing main(default) branch to prevent conflict for this action to update its fingerprint database
+ concurrency: fingerprint-${{ github.event_name != 'pull_request' && 'main' || github.run_id }}
+ permissions:
+ # REQUIRED: Allow comments of PRs
+ pull-requests: write
+ # REQUIRED: Allow updating fingerprint in acton caches
+ actions: write
+ steps:
+ - name: ⬇️ Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 100
+
+ - name: ⬇️ Fetch commits from base branch
+ run: git fetch origin main:main --depth 100
+ if: github.event_name == 'pull_request'
+
+ - name: 🔧 Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version-file: .nvmrc
+ cache: yarn
+
+ - name: ⚙️ Install Dependencies
+ run: yarn install
+
+ - name: Get the base commit
+ id: base-commit
+ run: |
+ # Since we limit this pr-labeler workflow only triggered from limited paths, we should use custom base commit
+ echo base-commit=$(git log -n 1 main --pretty=format:'%H') >> "$GITHUB_OUTPUT"
+
+ - name: 📷 Check fingerprint
+ id: fingerprint
+ uses: expo/expo-github-action/fingerprint@main
+ with:
+ previous-git-commit: ${{ steps.base-commit.outputs.base-commit }}
+
+ - name: 👀 Debug fingerprint
+ run: |
+ echo "previousGitCommit=${{ steps.fingerprint.outputs.previous-git-commit }} currentGitCommit=${{ steps.fingerprint.outputs.current-git-commit }}"
+ echo "isPreviousFingerprintEmpty=${{ steps.fingerprint.outputs.previous-fingerprint == '' }}"
+
+ - name: 🏷️ Labeling PR
+ uses: actions/github-script@v6
+ if: ${{ github.event_name == 'pull_request' && steps.fingerprint.outputs.fingerprint-diff == '[]' }}
+ with:
+ script: |
+ try {
+ await github.rest.issues.removeLabel({
+ issue_number: context.issue.number,
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ name: ['bot: fingerprint changed']
+ })
+ } catch (e) {
+ if (e.status != 404) {
+ throw e;
+ }
+ }
+ github.rest.issues.addLabels({
+ issue_number: context.issue.number,
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ labels: ['bot: fingerprint compatible']
+ })
+
+ - name: 🏷️ Labeling PR
+ uses: actions/github-script@v6
+ if: ${{ github.event_name == 'pull_request' && steps.fingerprint.outputs.fingerprint-diff != '[]' }}
+ with:
+ script: |
+ try {
+ await github.rest.issues.removeLabel({
+ issue_number: context.issue.number,
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ name: ['bot: fingerprint compatible']
+ })
+ } catch (e) {
+ if (e.status != 404) {
+ throw e;
+ }
+ }
+ github.rest.issues.addLabels({
+ issue_number: context.issue.number,
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ labels: ['bot: fingerprint changed']
+ })
+
+ - name: 🔍 Find old comment if it exists
+ uses: peter-evans/find-comment@v2
+ if: ${{ github.event_name == 'pull_request' }}
+ id: old_comment
+ with:
+ issue-number: ${{ github.event.pull_request.number }}
+ comment-author: 'github-actions[bot]'
+ body-includes:
+
+ - name: 💬 Add comment with fingerprint
+ if: ${{ github.event_name == 'pull_request' && steps.fingerprint.outputs.fingerprint-diff != '[]' && steps.old_comment.outputs.comment-id == '' }}
+ uses: actions/github-script@v6
+ with:
+ script: |
+ const diff = JSON.stringify(${{ steps.fingerprint.outputs.fingerprint-diff}}, null, 2);
+ const body = `
+ The Pull Request introduced fingerprint changes against the base commit: ${{ steps.fingerprint.outputs.previous-git-commit }}
+ Fingerprint diff
+
+ \`\`\`json
+ ${diff}
+ \`\`\`
+
+
+
+ ---
+ *Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
+ `;
+
+ github.rest.issues.createComment({
+ issue_number: context.issue.number,
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ body: body,
+ });
+
+ - name: 💬 Update comment with fingerprint
+ if: ${{ github.event_name == 'pull_request' && steps.fingerprint.outputs.fingerprint-diff != '[]' && steps.old_comment.outputs.comment-id != '' }}
+ uses: actions/github-script@v6
+ with:
+ script: |
+ const diff = JSON.stringify(${{ steps.fingerprint.outputs.fingerprint-diff}}, null, 2);
+ const body = `
+ The Pull Request introduced fingerprint changes against the base commit: ${{ steps.fingerprint.outputs.previous-git-commit }}
+ Fingerprint diff
+
+ \`\`\`json
+ ${diff}
+ \`\`\`
+
+
+
+ ---
+ *Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
+ `;
+
+ github.rest.issues.updateComment({
+ issue_number: context.issue.number,
+ comment_id: '${{ steps.old_comment.outputs.comment-id }}',
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ body: body,
+ });
+
+ - name: 💬 Delete comment with fingerprint
+ if: ${{ github.event_name == 'pull_request' && steps.fingerprint.outputs.fingerprint-diff == '[]' && steps.old_comment.outputs.comment-id != '' }}
+ uses: actions/github-script@v6
+ with:
+ script: |
+ github.rest.issues.deleteComment({
+ issue_number: context.issue.number,
+ comment_id: '${{ steps.old_comment.outputs.comment-id }}',
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ });
diff --git a/assets/icons/arrowTriangleBottom_stroke2_corner1_rounded.svg b/assets/icons/arrowTriangleBottom_stroke2_corner1_rounded.svg
new file mode 100644
index 0000000000..f40546f7cd
--- /dev/null
+++ b/assets/icons/arrowTriangleBottom_stroke2_corner1_rounded.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/icons/bars3_stroke2_corner0_rounded.svg b/assets/icons/bars3_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..cbcb531a6e
--- /dev/null
+++ b/assets/icons/bars3_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/chevronBottom_stroke2_corner0_rounded.svg b/assets/icons/chevronBottom_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..705c1c5139
--- /dev/null
+++ b/assets/icons/chevronBottom_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/icons/chevronTop_stroke2_corner0_rounded.svg b/assets/icons/chevronTop_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..da94ba911f
--- /dev/null
+++ b/assets/icons/chevronTop_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/icons/circleBanSign_stroke2_corner0_rounded.svg b/assets/icons/circleBanSign_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..73251477fa
--- /dev/null
+++ b/assets/icons/circleBanSign_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/group3_stroke2_corner0_rounded.svg b/assets/icons/group3_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..2a8f43a8a4
--- /dev/null
+++ b/assets/icons/group3_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/heart2_filled_stroke2_corner0_rounded.svg b/assets/icons/heart2_filled_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..1dfefb4c99
--- /dev/null
+++ b/assets/icons/heart2_filled_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/heart2_stroke2_corner0_rounded.svg b/assets/icons/heart2_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..5b3da8e00e
--- /dev/null
+++ b/assets/icons/heart2_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/person_stroke2_corner0_rounded.svg b/assets/icons/person_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..a23ad76071
--- /dev/null
+++ b/assets/icons/person_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/raisingHand4Finger_stroke2_corner0_rounded.svg b/assets/icons/raisingHand4Finger_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..aed3d9e7ec
--- /dev/null
+++ b/assets/icons/raisingHand4Finger_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/settingsGear2_stroke2_corner0_rounded.svg b/assets/icons/settingsGear2_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..de8a579194
--- /dev/null
+++ b/assets/icons/settingsGear2_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/shield_stroke2_corner0_rounded.svg b/assets/icons/shield_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..c4ef98e5ad
--- /dev/null
+++ b/assets/icons/shield_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/icons/squareArrowTopRight_stroke2_corner0_rounded.svg b/assets/icons/squareArrowTopRight_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..1407a1d6fe
--- /dev/null
+++ b/assets/icons/squareArrowTopRight_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/squareBehindSquare4_stroke2_corner0_rounded.svg b/assets/icons/squareBehindSquare4_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..3fa7e5d390
--- /dev/null
+++ b/assets/icons/squareBehindSquare4_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/triangleExclamation_stroke2_corner2_rounded.svg b/assets/icons/triangleExclamation_stroke2_corner2_rounded.svg
new file mode 100644
index 0000000000..aa56404457
--- /dev/null
+++ b/assets/icons/triangleExclamation_stroke2_corner2_rounded.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go
index e159d780a2..54a3925c6d 100644
--- a/bskyweb/cmd/bskyweb/server.go
+++ b/bskyweb/cmd/bskyweb/server.go
@@ -188,6 +188,7 @@ func serve(cctx *cli.Context) error {
e.GET("/settings/threads", server.WebGeneric)
e.GET("/settings/external-embeds", server.WebGeneric)
e.GET("/sys/debug", server.WebGeneric)
+ e.GET("/sys/debug-mod", server.WebGeneric)
e.GET("/sys/log", server.WebGeneric)
e.GET("/support", server.WebGeneric)
e.GET("/support/privacy", server.WebGeneric)
@@ -203,6 +204,7 @@ func serve(cctx *cli.Context) error {
e.GET("/profile/:handleOrDID/lists/:rkey", server.WebGeneric)
e.GET("/profile/:handleOrDID/feed/:rkey", server.WebGeneric)
e.GET("/profile/:handleOrDID/feed/:rkey/liked-by", server.WebGeneric)
+ e.GET("/profile/:handleOrDID/labeler/liked-by", server.WebGeneric)
// profile RSS feed (DID not handle)
e.GET("/profile/:ident/rss", server.WebProfileRSS)
diff --git a/bskyweb/static/iframe/youtube.html b/bskyweb/static/iframe/youtube.html
index f2ada2ec55..4b74d6fcd9 100644
--- a/bskyweb/static/iframe/youtube.html
+++ b/bskyweb/static/iframe/youtube.html
@@ -5,16 +5,14 @@
}
.container {
position: relative;
- width: 100%;
- height: 0;
- padding-bottom: 56.25%;
+ overflow: hidden;
+ width: 100vw;
+ height: 100vh;
}
.video {
position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
+ width: 100vw;
+ height: 100vh;
}
diff --git a/jest/jestSetup.js b/jest/jestSetup.js
index d8cee9bfda..e690e813a9 100644
--- a/jest/jestSetup.js
+++ b/jest/jestSetup.js
@@ -88,3 +88,5 @@ jest.mock('sentry-expo', () => ({
ReactNavigationInstrumentation: jest.fn(),
},
}))
+
+jest.mock('crypto', () => ({}))
diff --git a/package.json b/package.json
index 8962018208..b526825fbd 100644
--- a/package.json
+++ b/package.json
@@ -44,7 +44,7 @@
"update-extensions": "scripts/updateExtensions.sh"
},
"dependencies": {
- "@atproto/api": "^0.10.5",
+ "@atproto/api": "^0.12.0",
"@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2",
"@emoji-mart/react": "^1.1.1",
@@ -76,7 +76,9 @@
"@segment/sovran-react-native": "^0.4.5",
"@sentry/react-native": "5.5.0",
"@tamagui/focus-scope": "^1.84.1",
+ "@tanstack/query-async-storage-persister": "^5.25.0",
"@tanstack/react-query": "^5.8.1",
+ "@tanstack/react-query-persist-client": "^5.25.0",
"@tiptap/core": "^2.0.0-beta.220",
"@tiptap/extension-document": "^2.0.0-beta.220",
"@tiptap/extension-hard-break": "^2.0.3",
diff --git a/src/App.native.tsx b/src/App.native.tsx
index eff8ab0997..e825ffa00a 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -5,7 +5,7 @@ import React, {useState, useEffect} from 'react'
import {RootSiblingParent} from 'react-native-root-siblings'
import * as SplashScreen from 'expo-splash-screen'
import {GestureHandlerRootView} from 'react-native-gesture-handler'
-import {QueryClientProvider} from '@tanstack/react-query'
+import {PersistQueryClientProvider} from '@tanstack/react-query-persist-client'
import {
SafeAreaProvider,
initialWindowMetrics,
@@ -22,7 +22,11 @@ import {s} from 'lib/styles'
import {Shell} from 'view/shell'
import * as notifications from 'lib/notifications/notifications'
import * as Toast from 'view/com/util/Toast'
-import {queryClient} from 'lib/react-query'
+import {
+ queryClient,
+ asyncStoragePersister,
+ dehydrateOptions,
+} from 'lib/react-query'
import {TestCtrls} from 'view/com/testing/TestCtrls'
import {Provider as ShellStateProvider} from 'state/shell'
import {Provider as ModalStateProvider} from 'state/modals'
@@ -33,6 +37,7 @@ import {Provider as InvitesStateProvider} from 'state/invites'
import {Provider as PrefsStateProvider} from 'state/preferences'
import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed'
+import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
import I18nProvider from './locale/i18nProvider'
import {
Provider as SessionProvider,
@@ -79,21 +84,23 @@ function InnerApp() {
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
-
-
-
-
- {/* All components should be within this provider */}
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+ {/* All components should be within this provider */}
+
+
+
+
+
+
+
+
+
+
+
@@ -118,7 +125,9 @@ function App() {
* that is set up in the InnerApp component above.
*/
return (
-
+
@@ -140,7 +149,7 @@ function App() {
-
+
)
}
diff --git a/src/App.web.tsx b/src/App.web.tsx
index eb2e425930..f47f763da1 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -1,7 +1,7 @@
import 'lib/sentry' // must be near top
import React, {useState, useEffect} from 'react'
-import {QueryClientProvider} from '@tanstack/react-query'
+import {PersistQueryClientProvider} from '@tanstack/react-query-persist-client'
import {SafeAreaProvider} from 'react-native-safe-area-context'
import {RootSiblingParent} from 'react-native-root-siblings'
@@ -13,7 +13,11 @@ import {init as initPersistedState} from '#/state/persisted'
import {Shell} from 'view/shell/index'
import {ToastContainer} from 'view/com/util/Toast.web'
import {ThemeProvider} from 'lib/ThemeContext'
-import {queryClient} from 'lib/react-query'
+import {
+ queryClient,
+ asyncStoragePersister,
+ dehydrateOptions,
+} from 'lib/react-query'
import {Provider as ShellStateProvider} from 'state/shell'
import {Provider as ModalStateProvider} from 'state/modals'
import {Provider as DialogStateProvider} from 'state/dialogs'
@@ -23,6 +27,7 @@ import {Provider as InvitesStateProvider} from 'state/invites'
import {Provider as PrefsStateProvider} from 'state/preferences'
import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed'
+import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
import I18nProvider from './locale/i18nProvider'
import {
Provider as SessionProvider,
@@ -56,21 +61,23 @@ function InnerApp() {
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
-
-
-
-
- {/* All components should be within this provider */}
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+ {/* All components should be within this provider */}
+
+
+
+
+
+
+
+
+
+
+
@@ -93,7 +100,9 @@ function App() {
* that is set up in the InnerApp component above.
*/
return (
-
+
@@ -115,7 +124,7 @@ function App() {
-
+
)
}
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 77706ce34d..3d6a15c4eb 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -46,7 +46,7 @@ import {SearchScreen} from './view/screens/Search'
import {FeedsScreen} from './view/screens/Feeds'
import {NotificationsScreen} from './view/screens/Notifications'
import {ListsScreen} from './view/screens/Lists'
-import {ModerationScreen} from './view/screens/Moderation'
+import {ModerationScreen} from '#/screens/Moderation'
import {ModerationModlistsScreen} from './view/screens/ModerationModlists'
import {NotFoundScreen} from './view/screens/NotFound'
import {SettingsScreen} from './view/screens/Settings'
@@ -61,6 +61,7 @@ import {PostThreadScreen} from './view/screens/PostThread'
import {PostLikedByScreen} from './view/screens/PostLikedBy'
import {PostRepostedByScreen} from './view/screens/PostRepostedBy'
import {Storybook} from './view/screens/Storybook'
+import {DebugModScreen} from './view/screens/DebugMod'
import {LogScreen} from './view/screens/Log'
import {SupportScreen} from './view/screens/Support'
import {PrivacyPolicyScreen} from './view/screens/PrivacyPolicy'
@@ -78,6 +79,7 @@ import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStack
import {msg} from '@lingui/macro'
import {i18n, MessageDescriptor} from '@lingui/core'
import HashtagScreen from '#/screens/Hashtag'
+import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
import {logEvent, attachRouteToLogEvents} from './lib/statsig/statsig'
const navigationRef = createNavigationContainerRef()
@@ -198,11 +200,21 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
getComponent={() => ProfileFeedLikedByScreen}
options={{title: title(msg`Liked by`)}}
/>
+ ProfileLabelerLikedByScreen}
+ options={{title: title(msg`Liked by`)}}
+ />
Storybook}
options={{title: title(msg`Storybook`), requireAuth: true}}
/>
+ DebugModScreen}
+ options={{title: title(msg`Moderation states`), requireAuth: true}}
+ />
LogScreen}
diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts
index 33803b6fb0..ef285c09aa 100644
--- a/src/alf/atoms.ts
+++ b/src/alf/atoms.ts
@@ -50,6 +50,9 @@ export const atoms = {
h_full: {
height: '100%',
},
+ h_full_vh: web({
+ height: '100vh',
+ }),
/*
* Border radius
@@ -249,6 +252,9 @@ export const atoms = {
font_normal: {
fontWeight: tokens.fontWeight.normal,
},
+ font_semibold: {
+ fontWeight: '500',
+ },
font_bold: {
fontWeight: tokens.fontWeight.semibold,
},
diff --git a/src/alf/tokens.ts b/src/alf/tokens.ts
index b1468f461d..4045c831c5 100644
--- a/src/alf/tokens.ts
+++ b/src/alf/tokens.ts
@@ -12,6 +12,9 @@ export const dimScale = generateScale(12, 100)
export const color = {
trueBlack: '#000000',
+ temp_purple: 'rgb(105 0 255)',
+ temp_purple_dark: 'rgb(83 0 202)',
+
gray_0: `hsl(${BLUE_HUE}, 20%, ${scale[14]}%)`,
gray_25: `hsl(${BLUE_HUE}, 20%, ${scale[13]}%)`,
gray_50: `hsl(${BLUE_HUE}, 20%, ${scale[12]}%)`,
diff --git a/src/components/Button.tsx b/src/components/Button.tsx
index d3bf73cc3e..0e22944a33 100644
--- a/src/components/Button.tsx
+++ b/src/components/Button.tsx
@@ -15,6 +15,7 @@ import LinearGradient from 'react-native-linear-gradient'
import {useTheme, atoms as a, tokens, android, flatten} from '#/alf'
import {Props as SVGIconProps} from '#/components/icons/common'
+import {normalizeTextStyles} from '#/components/Typography'
export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'gradient'
export type ButtonColor =
@@ -139,7 +140,7 @@ export function Button({
}))
}, [setState])
- const {baseStyles, hoverStyles, focusStyles} = React.useMemo(() => {
+ const {baseStyles, hoverStyles} = React.useMemo(() => {
const baseStyles: ViewStyle[] = []
const hoverStyles: ViewStyle[] = []
const light = t.name === 'light'
@@ -191,14 +192,14 @@ export function Button({
if (variant === 'solid') {
if (!disabled) {
baseStyles.push({
- backgroundColor: t.palette.contrast_50,
+ backgroundColor: t.palette.contrast_25,
})
hoverStyles.push({
- backgroundColor: t.palette.contrast_100,
+ backgroundColor: t.palette.contrast_50,
})
} else {
baseStyles.push({
- backgroundColor: t.palette.contrast_200,
+ backgroundColor: t.palette.contrast_100,
})
}
} else if (variant === 'outline') {
@@ -308,12 +309,6 @@ export function Button({
return {
baseStyles,
hoverStyles,
- focusStyles: [
- ...hoverStyles,
- {
- outline: 0,
- } as ViewStyle,
- ],
}
}, [t, variant, color, size, shape, disabled])
@@ -376,10 +371,8 @@ export function Button({
a.flex_row,
a.align_center,
a.justify_center,
- a.justify_center,
flattenedBaseStyles,
...(state.hovered || state.pressed ? hoverStyles : []),
- ...(state.focused ? focusStyles : []),
flatten(style),
]}
onPressIn={onPressIn}
@@ -398,7 +391,7 @@ export function Button({
]}>
+
{children}
)
diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx
index 5f6edeac78..0da2919c56 100644
--- a/src/components/Dialog/index.tsx
+++ b/src/components/Dialog/index.tsx
@@ -23,6 +23,7 @@ import {
DialogInnerProps,
} from '#/components/Dialog/types'
import {Context} from '#/components/Dialog/context'
+import {isNative} from 'platform/detection'
export {useDialogControl, useDialogContext} from '#/components/Dialog/context'
export * from '#/components/Dialog/types'
@@ -221,7 +222,8 @@ export function ScrollableInner({children, style}: DialogInnerProps) {
borderTopRightRadius: 40,
},
flatten(style),
- ]}>
+ ]}
+ contentContainerStyle={isNative ? a.pb_4xl : undefined}>
{children}
diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx
index 3a7f73342d..038f6295ae 100644
--- a/src/components/Dialog/index.web.tsx
+++ b/src/components/Dialog/index.web.tsx
@@ -99,7 +99,7 @@ export function Outer({
style={[
web(a.fixed),
a.inset_0,
- {opacity: 0.5, backgroundColor: t.palette.black},
+ {opacity: 0.8, backgroundColor: t.palette.black},
]}
/>
)}
diff --git a/src/components/GradientFill.tsx b/src/components/GradientFill.tsx
new file mode 100644
index 0000000000..dc14aa72b7
--- /dev/null
+++ b/src/components/GradientFill.tsx
@@ -0,0 +1,27 @@
+import React from 'react'
+import LinearGradient from 'react-native-linear-gradient'
+
+import {atoms as a, tokens} from '#/alf'
+
+export function GradientFill({
+ gradient,
+}: {
+ gradient:
+ | typeof tokens.gradients.sky
+ | typeof tokens.gradients.midnight
+ | typeof tokens.gradients.sunrise
+ | typeof tokens.gradients.sunset
+ | typeof tokens.gradients.bonfire
+ | typeof tokens.gradients.summer
+ | typeof tokens.gradients.nordic
+}) {
+ return (
+ c[1])}
+ locations={gradient.values.map(c => c[0])}
+ start={{x: 0, y: 0}}
+ end={{x: 1, y: 1}}
+ style={[a.absolute, a.inset_0]}
+ />
+ )
+}
diff --git a/src/components/LabelingServiceCard/index.tsx b/src/components/LabelingServiceCard/index.tsx
new file mode 100644
index 0000000000..6d06135110
--- /dev/null
+++ b/src/components/LabelingServiceCard/index.tsx
@@ -0,0 +1,182 @@
+import React from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {AppBskyLabelerDefs} from '@atproto/api'
+
+import {getLabelingServiceTitle} from '#/lib/moderation'
+import {Link as InternalLink, LinkProps} from '#/components/Link'
+import {Text} from '#/components/Typography'
+import {useLabelerInfoQuery} from '#/state/queries/labeler'
+import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
+import {RichText} from '#/components/RichText'
+import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '../icons/Chevron'
+import {UserAvatar} from '#/view/com/util/UserAvatar'
+import {sanitizeHandle} from '#/lib/strings/handles'
+import {pluralize} from '#/lib/strings/helpers'
+
+type LabelingServiceProps = {
+ labeler: AppBskyLabelerDefs.LabelerViewDetailed
+}
+
+export function Outer({
+ children,
+ style,
+}: React.PropsWithChildren) {
+ return (
+
+ {children}
+
+ )
+}
+
+export function Avatar({avatar}: {avatar?: string}) {
+ return
+}
+
+export function Title({value}: {value: string}) {
+ return {value}
+}
+
+export function Description({value, handle}: {value?: string; handle: string}) {
+ return value ? (
+
+
+
+ ) : (
+
+ By {sanitizeHandle(handle, '@')}
+
+ )
+}
+
+export function LikeCount({count}: {count: number}) {
+ const t = useTheme()
+ return (
+
+
+ Liked by {count} {pluralize(count, 'user')}
+
+
+ )
+}
+
+export function Content({children}: React.PropsWithChildren<{}>) {
+ const t = useTheme()
+
+ return (
+
+ {children}
+
+
+
+ )
+}
+
+/**
+ * The canonical view for a labeling service. Use this or compose your own.
+ */
+export function Default({
+ labeler,
+ style,
+}: LabelingServiceProps & ViewStyleProp) {
+ return (
+
+
+
+
+
+ {labeler.likeCount ? : null}
+
+
+ )
+}
+
+export function Link({
+ children,
+ labeler,
+}: LabelingServiceProps & Pick) {
+ const {_} = useLingui()
+
+ return (
+
+ {children}
+
+ )
+}
+
+// TODO not finished yet
+export function DefaultSkeleton() {
+ return (
+
+ Loading
+
+ )
+}
+
+export function Loader({
+ did,
+ loading: LoadingComponent = DefaultSkeleton,
+ error: ErrorComponent,
+ component: Component,
+}: {
+ did: string
+ loading?: React.ComponentType<{}>
+ error?: React.ComponentType<{error: string}>
+ component: React.ComponentType<{
+ labeler: AppBskyLabelerDefs.LabelerViewDetailed
+ }>
+}) {
+ const {isLoading, data, error} = useLabelerInfoQuery({did})
+
+ return isLoading ? (
+ LoadingComponent ? (
+
+ ) : null
+ ) : error || !data ? (
+ ErrorComponent ? (
+
+ ) : null
+ ) : (
+
+ )
+}
diff --git a/src/components/LikedByList.tsx b/src/components/LikedByList.tsx
new file mode 100644
index 0000000000..bd12136394
--- /dev/null
+++ b/src/components/LikedByList.tsx
@@ -0,0 +1,109 @@
+import React from 'react'
+import {View} from 'react-native'
+import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
+import {Trans} from '@lingui/macro'
+
+import {logger} from '#/logger'
+import {List} from '#/view/com/util/List'
+import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
+import {useResolveUriQuery} from '#/state/queries/resolve-uri'
+import {useLikedByQuery} from '#/state/queries/post-liked-by'
+import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
+import {ListFooter} from '#/components/Lists'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
+
+export function LikedByList({uri}: {uri: string}) {
+ const t = useTheme()
+ const [isPTRing, setIsPTRing] = React.useState(false)
+ const {
+ data: resolvedUri,
+ error: resolveError,
+ isFetching: isFetchingResolvedUri,
+ } = useResolveUriQuery(uri)
+ const {
+ data,
+ isFetching,
+ isFetched,
+ isRefetching,
+ hasNextPage,
+ fetchNextPage,
+ isError,
+ error: likedByError,
+ refetch,
+ } = useLikedByQuery(resolvedUri?.uri)
+ const likes = React.useMemo(() => {
+ if (data?.pages) {
+ return data.pages.flatMap(page => page.likes)
+ }
+ return []
+ }, [data])
+ const initialNumToRender = useInitialNumToRender()
+ const error = resolveError || likedByError
+
+ const onRefresh = React.useCallback(async () => {
+ setIsPTRing(true)
+ try {
+ await refetch()
+ } catch (err) {
+ logger.error('Failed to refresh likes', {message: err})
+ }
+ setIsPTRing(false)
+ }, [refetch, setIsPTRing])
+
+ const onEndReached = React.useCallback(async () => {
+ if (isFetching || !hasNextPage || isError) return
+ try {
+ await fetchNextPage()
+ } catch (err) {
+ logger.error('Failed to load more likes', {message: err})
+ }
+ }, [isFetching, hasNextPage, isError, fetchNextPage])
+
+ const renderItem = React.useCallback(({item}: {item: GetLikes.Like}) => {
+ return (
+
+ )
+ }, [])
+
+ if (isFetchingResolvedUri || !isFetched) {
+ return (
+
+
+
+ )
+ }
+
+ return likes.length ? (
+ item.actor.did}
+ refreshing={isPTRing}
+ onRefresh={onRefresh}
+ onEndReached={onEndReached}
+ onEndReachedThreshold={3}
+ renderItem={renderItem}
+ initialNumToRender={initialNumToRender}
+ ListFooterComponent={() => (
+
+ )}
+ />
+ ) : (
+
+
+
+
+ Nobody has liked this yet. Maybe you should be the first!
+
+
+
+
+ )
+}
diff --git a/src/components/LikesDialog.tsx b/src/components/LikesDialog.tsx
new file mode 100644
index 0000000000..94a3f27e26
--- /dev/null
+++ b/src/components/LikesDialog.tsx
@@ -0,0 +1,131 @@
+import React, {useMemo, useCallback} from 'react'
+import {ActivityIndicator, FlatList, View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
+
+import {useResolveUriQuery} from '#/state/queries/resolve-uri'
+import {useLikedByQuery} from '#/state/queries/post-liked-by'
+import {cleanError} from '#/lib/strings/errors'
+import {logger} from '#/logger'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Text} from '#/components/Typography'
+import * as Dialog from '#/components/Dialog'
+import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
+import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
+import {Loader} from '#/components/Loader'
+
+interface LikesDialogProps {
+ control: Dialog.DialogOuterProps['control']
+ uri: string
+}
+
+export function LikesDialog(props: LikesDialogProps) {
+ return (
+
+
+
+
+
+ )
+}
+
+export function LikesDialogInner({control, uri}: LikesDialogProps) {
+ const {_} = useLingui()
+ const t = useTheme()
+
+ const {
+ data: resolvedUri,
+ error: resolveError,
+ isFetched: hasFetchedResolvedUri,
+ } = useResolveUriQuery(uri)
+ const {
+ data,
+ isFetching: isFetchingLikedBy,
+ isFetched: hasFetchedLikedBy,
+ isFetchingNextPage,
+ hasNextPage,
+ fetchNextPage,
+ isError,
+ error: likedByError,
+ } = useLikedByQuery(resolvedUri?.uri)
+
+ const isLoading = !hasFetchedResolvedUri || !hasFetchedLikedBy
+ const likes = useMemo(() => {
+ if (data?.pages) {
+ return data.pages.flatMap(page => page.likes)
+ }
+ return []
+ }, [data])
+
+ const onEndReached = useCallback(async () => {
+ if (isFetchingLikedBy || !hasNextPage || isError) return
+ try {
+ await fetchNextPage()
+ } catch (err) {
+ logger.error('Failed to load more likes', {message: err})
+ }
+ }, [isFetchingLikedBy, hasNextPage, isError, fetchNextPage])
+
+ const renderItem = useCallback(
+ ({item}: {item: GetLikes.Like}) => {
+ return (
+ control.close()}
+ />
+ )
+ },
+ [control],
+ )
+
+ return (
+
+
+ Liked by
+
+
+ {isLoading ? (
+
+
+
+ ) : resolveError || likedByError || !data ? (
+
+ ) : likes.length === 0 ? (
+
+
+
+ Nobody has liked this yet. Maybe you should be the first!
+
+
+
+ ) : (
+ item.actor.did}
+ onEndReached={onEndReached}
+ renderItem={renderItem}
+ initialNumToRender={15}
+ ListFooterComponent={
+
+ }
+ />
+ )}
+
+
+
+ )
+}
+
+function ListFooterComponent({isFetching}: {isFetching: boolean}) {
+ if (isFetching) {
+ return (
+
+
+
+ )
+ }
+ return null
+}
diff --git a/src/components/Link.tsx b/src/components/Link.tsx
index 00e6a56f42..7d0e833329 100644
--- a/src/components/Link.tsx
+++ b/src/components/Link.tsx
@@ -251,7 +251,7 @@ export function InlineLink({
onIn: onPressIn,
onOut: onPressOut,
} = useInteractionState()
- const flattenedStyle = flatten(style)
+ const flattenedStyle = flatten(style) || {}
return (
{isFetching ? (
diff --git a/src/components/Menu/index.web.tsx b/src/components/Menu/index.web.tsx
index f4b03f680e..60b2342034 100644
--- a/src/components/Menu/index.web.tsx
+++ b/src/components/Menu/index.web.tsx
@@ -223,7 +223,7 @@ export function Item({children, label, onPress, ...rest}: ItemProps) {
style={flatten([
a.flex_row,
a.align_center,
- a.gap_sm,
+ a.gap_lg,
a.py_sm,
a.rounded_xs,
{minHeight: 32, paddingHorizontal: 10},
diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx
index 1b9348d9c6..b81b207075 100644
--- a/src/components/Prompt.tsx
+++ b/src/components/Prompt.tsx
@@ -3,7 +3,6 @@ import {View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {isNative} from '#/platform/detection'
import {useTheme, atoms as a, useBreakpoints} from '#/alf'
import {Text} from '#/components/Typography'
import {Button, ButtonColor, ButtonText} from '#/components/Button'
@@ -86,7 +85,6 @@ export function Actions({children}: React.PropsWithChildren<{}>) {
gtMobile
? [a.flex_row, a.flex_row_reverse, a.justify_start]
: [a.flex_col],
- isNative && [a.pb_4xl],
]}>
{children}
diff --git a/src/components/ReportDialog/SelectLabelerView.tsx b/src/components/ReportDialog/SelectLabelerView.tsx
new file mode 100644
index 0000000000..300114fc4f
--- /dev/null
+++ b/src/components/ReportDialog/SelectLabelerView.tsx
@@ -0,0 +1,115 @@
+import React from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {AppBskyLabelerDefs} from '@atproto/api'
+
+export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Text} from '#/components/Typography'
+import {Button, useButtonContext} from '#/components/Button'
+import {Divider} from '#/components/Divider'
+import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
+
+import {ReportDialogProps} from './types'
+
+export function SelectLabelerView({
+ ...props
+}: ReportDialogProps & {
+ labelers: AppBskyLabelerDefs.LabelerViewDetailed[]
+ onSelectLabeler: (v: string) => void
+}) {
+ const t = useTheme()
+ const {_} = useLingui()
+
+ return (
+
+
+
+ Select moderation service
+
+
+ Who do you want to send this report to?
+
+
+
+
+
+
+ {props.labelers.map(labeler => {
+ return (
+
+ )
+ })}
+
+
+ )
+}
+
+function LabelerButton({
+ title,
+ description,
+}: {
+ title: string
+ description: string
+}) {
+ const t = useTheme()
+ const {hovered, pressed} = useButtonContext()
+ const interacted = hovered || pressed
+
+ const styles = React.useMemo(() => {
+ return {
+ interacted: {
+ backgroundColor: t.palette.contrast_50,
+ },
+ }
+ }, [t])
+
+ return (
+
+
+
+ {title}
+
+
+ {description}
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/ReportDialog/SelectReportOptionView.tsx b/src/components/ReportDialog/SelectReportOptionView.tsx
new file mode 100644
index 0000000000..037a62fceb
--- /dev/null
+++ b/src/components/ReportDialog/SelectReportOptionView.tsx
@@ -0,0 +1,199 @@
+import React from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {AppBskyLabelerDefs} from '@atproto/api'
+
+import {useReportOptions, ReportOption} from '#/lib/moderation/useReportOptions'
+import {DMCA_LINK} from '#/components/ReportDialog/const'
+import {Link} from '#/components/Link'
+export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Text} from '#/components/Typography'
+import {
+ Button,
+ ButtonIcon,
+ ButtonText,
+ useButtonContext,
+} from '#/components/Button'
+import {Divider} from '#/components/Divider'
+import {
+ ChevronRight_Stroke2_Corner0_Rounded as ChevronRight,
+ ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft,
+} from '#/components/icons/Chevron'
+import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRight} from '#/components/icons/SquareArrowTopRight'
+
+import {ReportDialogProps} from './types'
+
+export function SelectReportOptionView({
+ ...props
+}: ReportDialogProps & {
+ labelers: AppBskyLabelerDefs.LabelerViewDetailed[]
+ onSelectReportOption: (reportOption: ReportOption) => void
+ goBack: () => void
+}) {
+ const t = useTheme()
+ const {_} = useLingui()
+ const allReportOptions = useReportOptions()
+ const reportOptions = allReportOptions[props.params.type]
+
+ const i18n = React.useMemo(() => {
+ let title = _(msg`Report this content`)
+ let description = _(msg`Why should this content be reviewed?`)
+
+ if (props.params.type === 'account') {
+ title = _(msg`Report this user`)
+ description = _(msg`Why should this user be reviewed?`)
+ } else if (props.params.type === 'post') {
+ title = _(msg`Report this post`)
+ description = _(msg`Why should this post be reviewed?`)
+ } else if (props.params.type === 'list') {
+ title = _(msg`Report this list`)
+ description = _(msg`Why should this list be reviewed?`)
+ } else if (props.params.type === 'feedgen') {
+ title = _(msg`Report this feed`)
+ description = _(msg`Why should this feed be reviewed?`)
+ }
+
+ return {
+ title,
+ description,
+ }
+ }, [_, props.params.type])
+
+ return (
+
+ {props.labelers?.length > 1 ? (
+
+ ) : null}
+
+
+ {i18n.title}
+
+ {i18n.description}
+
+
+
+
+
+
+ {reportOptions.map(reportOption => {
+ return (
+
+ )
+ })}
+
+ {(props.params.type === 'post' || props.params.type === 'account') && (
+
+
+
+ Need to report a copyright violation?
+
+
+
+ View details
+
+
+
+
+
+ )}
+
+
+ )
+}
+
+function ReportOptionButton({
+ title,
+ description,
+}: {
+ title: string
+ description: string
+}) {
+ const t = useTheme()
+ const {hovered, pressed} = useButtonContext()
+ const interacted = hovered || pressed
+
+ const styles = React.useMemo(() => {
+ return {
+ interacted: {
+ backgroundColor: t.palette.contrast_50,
+ },
+ }
+ }, [t])
+
+ return (
+
+
+
+ {title}
+
+ {description}
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/ReportDialog/SubmitView.tsx b/src/components/ReportDialog/SubmitView.tsx
new file mode 100644
index 0000000000..d47211c81c
--- /dev/null
+++ b/src/components/ReportDialog/SubmitView.tsx
@@ -0,0 +1,264 @@
+import React from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {AppBskyLabelerDefs} from '@atproto/api'
+
+import {getLabelingServiceTitle} from '#/lib/moderation'
+import {ReportOption} from '#/lib/moderation/useReportOptions'
+
+import {atoms as a, useTheme, native} from '#/alf'
+import {Text} from '#/components/Typography'
+import * as Dialog from '#/components/Dialog'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
+import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
+import * as Toggle from '#/components/forms/Toggle'
+import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
+import {Loader} from '#/components/Loader'
+import * as Toast from '#/view/com/util/Toast'
+
+import {ReportDialogProps} from './types'
+import {getAgent} from '#/state/session'
+
+export function SubmitView({
+ params,
+ labelers,
+ selectedLabeler,
+ selectedReportOption,
+ goBack,
+ onSubmitComplete,
+}: ReportDialogProps & {
+ labelers: AppBskyLabelerDefs.LabelerViewDetailed[]
+ selectedLabeler: string
+ selectedReportOption: ReportOption
+ goBack: () => void
+ onSubmitComplete: () => void
+}) {
+ const t = useTheme()
+ const {_} = useLingui()
+ const [details, setDetails] = React.useState('')
+ const [submitting, setSubmitting] = React.useState(false)
+ const [selectedServices, setSelectedServices] = React.useState([
+ selectedLabeler,
+ ])
+ const [error, setError] = React.useState('')
+
+ const submit = React.useCallback(async () => {
+ setSubmitting(true)
+ setError('')
+
+ const $type =
+ params.type === 'account'
+ ? 'com.atproto.admin.defs#repoRef'
+ : 'com.atproto.repo.strongRef'
+ const report = {
+ reasonType: selectedReportOption.reason,
+ subject: {
+ $type,
+ ...params,
+ },
+ reason: details,
+ }
+ const results = await Promise.all(
+ selectedServices.map(did =>
+ getAgent()
+ .withProxy('atproto_labeler', did)
+ .createModerationReport(report)
+ .then(
+ _ => true,
+ _ => false,
+ ),
+ ),
+ )
+
+ setSubmitting(false)
+
+ if (results.includes(true)) {
+ Toast.show(_(msg`Thank you. Your report has been sent.`))
+ onSubmitComplete()
+ } else {
+ setError(
+ _(
+ msg`There was an issue sending your report. Please check your internet connection.`,
+ ),
+ )
+ }
+ }, [
+ _,
+ params,
+ details,
+ selectedReportOption,
+ selectedServices,
+ onSubmitComplete,
+ setError,
+ ])
+
+ return (
+
+
+
+
+
+
+ {selectedReportOption.title}
+
+
+ {selectedReportOption.description}
+
+
+
+
+
+
+
+
+ Select the moderation service(s) to report to
+
+
+
+
+ {labelers.map(labeler => {
+ const title = getLabelingServiceTitle({
+ displayName: labeler.creator.displayName,
+ handle: labeler.creator.handle,
+ })
+ return (
+
+
+
+ )
+ })}
+
+
+
+
+
+ Optionally provide additional information below:
+
+
+
+
+
+
+
+
+
+
+
+
+ {!selectedServices.length ||
+ (error && (
+
+ {error ? (
+ error
+ ) : (
+ You must select at least one labeler for a report
+ )}
+
+ ))}
+
+
+
+
+ )
+}
+
+function LabelerToggle({title}: {title: string}) {
+ const t = useTheme()
+ const ctx = Toggle.useItemContext()
+
+ return (
+
+
+
+
+ {title}
+
+
+
+ )
+}
diff --git a/src/components/ReportDialog/const.ts b/src/components/ReportDialog/const.ts
new file mode 100644
index 0000000000..30c9aff88d
--- /dev/null
+++ b/src/components/ReportDialog/const.ts
@@ -0,0 +1 @@
+export const DMCA_LINK = 'https://bsky.social/about/support/copyright'
diff --git a/src/components/ReportDialog/index.tsx b/src/components/ReportDialog/index.tsx
new file mode 100644
index 0000000000..f01ff3f3b1
--- /dev/null
+++ b/src/components/ReportDialog/index.tsx
@@ -0,0 +1,95 @@
+import React from 'react'
+import {View, Pressable} from 'react-native'
+import {Trans} from '@lingui/macro'
+
+import {useMyLabelersQuery} from '#/state/queries/preferences'
+import {ReportOption} from '#/lib/moderation/useReportOptions'
+export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
+
+import {atoms as a} from '#/alf'
+import {Loader} from '#/components/Loader'
+import * as Dialog from '#/components/Dialog'
+import {Text} from '#/components/Typography'
+
+import {ReportDialogProps} from './types'
+import {SelectLabelerView} from './SelectLabelerView'
+import {SelectReportOptionView} from './SelectReportOptionView'
+import {SubmitView} from './SubmitView'
+import {useDelayedLoading} from '#/components/hooks/useDelayedLoading'
+import {AppBskyLabelerDefs} from '@atproto/api'
+
+export function ReportDialog(props: ReportDialogProps) {
+ return (
+
+
+
+
+
+ )
+}
+
+function ReportDialogInner(props: ReportDialogProps) {
+ const {
+ isLoading: isLabelerLoading,
+ data: labelers,
+ error,
+ } = useMyLabelersQuery()
+ const isLoading = useDelayedLoading(500, isLabelerLoading)
+
+ return (
+
+ {isLoading ? (
+
+
+ {/* Here to capture focus for a hot sec to prevent flash */}
+
+
+ ) : error || !labelers ? (
+
+
+ Something went wrong, please try again.
+
+
+ ) : (
+
+ )}
+
+
+
+ )
+}
+
+function ReportDialogLoaded(
+ props: ReportDialogProps & {
+ labelers: AppBskyLabelerDefs.LabelerViewDetailed[]
+ },
+) {
+ const [selectedLabeler, setSelectedLabeler] = React.useState<
+ string | undefined
+ >(props.labelers.length === 1 ? props.labelers[0].creator.did : undefined)
+ const [selectedReportOption, setSelectedReportOption] = React.useState<
+ ReportOption | undefined
+ >()
+
+ if (selectedReportOption && selectedLabeler) {
+ return (
+ setSelectedReportOption(undefined)}
+ onSubmitComplete={() => props.control.close()}
+ />
+ )
+ }
+ if (selectedLabeler) {
+ return (
+ setSelectedLabeler(undefined)}
+ onSelectReportOption={setSelectedReportOption}
+ />
+ )
+ }
+ return
+}
diff --git a/src/components/ReportDialog/types.ts b/src/components/ReportDialog/types.ts
new file mode 100644
index 0000000000..0c8a1e0778
--- /dev/null
+++ b/src/components/ReportDialog/types.ts
@@ -0,0 +1,15 @@
+import * as Dialog from '#/components/Dialog'
+
+export type ReportDialogProps = {
+ control: Dialog.DialogOuterProps['control']
+ params:
+ | {
+ type: 'post' | 'list' | 'feedgen' | 'other'
+ uri: string
+ cid: string
+ }
+ | {
+ type: 'account'
+ did: string
+ }
+}
diff --git a/src/components/TagMenu/index.tsx b/src/components/TagMenu/index.tsx
index 849a3f42d1..13e4e7d6b2 100644
--- a/src/components/TagMenu/index.tsx
+++ b/src/components/TagMenu/index.tsx
@@ -59,7 +59,7 @@ export function TagMenu({
const displayTag = '#' + tag
const isMuted = Boolean(
- (preferences?.mutedWords?.find(
+ (preferences?.moderationPrefs.mutedWords?.find(
m => m.value === tag && m.targets.includes('tag'),
) ??
optimisticUpsert?.find(
diff --git a/src/components/TagMenu/index.web.tsx b/src/components/TagMenu/index.web.tsx
index 8245bd0191..b2f5c90756 100644
--- a/src/components/TagMenu/index.web.tsx
+++ b/src/components/TagMenu/index.web.tsx
@@ -50,7 +50,7 @@ export function TagMenu({
const {mutateAsync: removeMutedWord, variables: optimisticRemove} =
useRemoveMutedWordMutation()
const isMuted = Boolean(
- (preferences?.mutedWords?.find(
+ (preferences?.moderationPrefs.mutedWords?.find(
m => m.value === tag && m.targets.includes('tag'),
) ??
optimisticUpsert?.find(
diff --git a/src/components/Typography.tsx b/src/components/Typography.tsx
index 5268e7f468..f8b3ad1bd8 100644
--- a/src/components/Typography.tsx
+++ b/src/components/Typography.tsx
@@ -1,5 +1,10 @@
import React from 'react'
-import {Text as RNText, TextStyle, TextProps as RNTextProps} from 'react-native'
+import {
+ Text as RNText,
+ StyleProp,
+ TextStyle,
+ TextProps as RNTextProps,
+} from 'react-native'
import {UITextView} from 'react-native-ui-text-view'
import {useTheme, atoms, web, flatten} from '#/alf'
@@ -34,7 +39,7 @@ export function leading<
* If the `lineHeight` value is > 2, we assume it's an absolute value and
* returns it as-is.
*/
-function normalizeTextStyles(styles: TextStyle[]) {
+export function normalizeTextStyles(styles: StyleProp) {
const s = flatten(styles)
// should always be defined on these components
const fontSize = s.fontSize || atoms.text_md.fontSize
diff --git a/src/components/dialogs/BirthDateSettings.tsx b/src/components/dialogs/BirthDateSettings.tsx
new file mode 100644
index 0000000000..62c95c78d6
--- /dev/null
+++ b/src/components/dialogs/BirthDateSettings.tsx
@@ -0,0 +1,124 @@
+import React from 'react'
+import {useLingui} from '@lingui/react'
+import {Trans, msg} from '@lingui/macro'
+
+import * as Dialog from '#/components/Dialog'
+import {Text} from '../Typography'
+import {DateInput} from '#/view/com/util/forms/DateInput'
+import {logger} from '#/logger'
+import {
+ usePreferencesSetBirthDateMutation,
+ UsePreferencesQueryResponse,
+} from '#/state/queries/preferences'
+import {Button, ButtonText} from '../Button'
+import {atoms as a, useTheme} from '#/alf'
+import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
+import {cleanError} from '#/lib/strings/errors'
+import {ActivityIndicator, View} from 'react-native'
+import {isIOS, isWeb} from '#/platform/detection'
+
+export function BirthDateSettingsDialog({
+ control,
+ preferences,
+}: {
+ control: Dialog.DialogControlProps
+ preferences: UsePreferencesQueryResponse | undefined
+}) {
+ const {_} = useLingui()
+ const {isPending, isError, error, mutateAsync} =
+ usePreferencesSetBirthDateMutation()
+
+ return (
+
+
+
+ {preferences && !isPending ? (
+
+ ) : (
+
+ )}
+
+
+ )
+}
+
+function BirthdayInner({
+ control,
+ preferences,
+ isError,
+ error,
+ setBirthDate,
+}: {
+ control: Dialog.DialogControlProps
+ preferences: UsePreferencesQueryResponse
+ isError: boolean
+ error: unknown
+ setBirthDate: (args: {birthDate: Date}) => Promise
+}) {
+ const {_} = useLingui()
+ const [date, setDate] = React.useState(preferences.birthDate || new Date())
+ const t = useTheme()
+
+ const hasChanged = date !== preferences.birthDate
+
+ const onSave = React.useCallback(async () => {
+ try {
+ // skip if date is the same
+ if (hasChanged) {
+ await setBirthDate({birthDate: date})
+ }
+ control.close()
+ } catch (e) {
+ logger.error(`setBirthDate failed`, {message: e})
+ }
+ }, [date, setBirthDate, control, hasChanged])
+
+ return (
+
+
+
+ My Birthday
+
+
+ This information is not shared with other users.
+
+
+
+
+
+ {isError ? (
+
+ ) : undefined}
+
+
+
+
+
+ )
+}
diff --git a/src/components/dialogs/Context.tsx b/src/components/dialogs/Context.tsx
index d86c90a92e..87bd5c2ed7 100644
--- a/src/components/dialogs/Context.tsx
+++ b/src/components/dialogs/Context.tsx
@@ -18,7 +18,7 @@ export function useGlobalDialogsControlContext() {
export function Provider({children}: React.PropsWithChildren<{}>) {
const mutedWordsDialogControl = Dialog.useDialogControl()
- const ctx = React.useMemo(
+ const ctx = React.useMemo(
() => ({mutedWordsDialogControl}),
[mutedWordsDialogControl],
)
diff --git a/src/components/dialogs/MutedWords.tsx b/src/components/dialogs/MutedWords.tsx
index dcdaccea3e..46f319adfe 100644
--- a/src/components/dialogs/MutedWords.tsx
+++ b/src/components/dialogs/MutedWords.tsx
@@ -233,8 +233,8 @@ function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) {
- ) : preferences.mutedWords.length ? (
- [...preferences.mutedWords]
+ ) : preferences.moderationPrefs.mutedWords.length ? (
+ [...preferences.moderationPrefs.mutedWords]
.reverse()
.map((word, i) => (
+ style={[a.flex_row, a.align_center, a.gap_sm, flatten(style)]}>
{typeof children === 'function' ? children(state) : children}
)
}
-export function Label({children}: React.PropsWithChildren<{}>) {
+export function Label({
+ children,
+ style,
+}: React.PropsWithChildren) {
const t = useTheme()
const {disabled} = useItemContext()
return (
@@ -242,11 +246,14 @@ export function Label({children}: React.PropsWithChildren<{}>) {
a.font_bold,
{
userSelect: 'none',
- color: disabled ? t.palette.contrast_400 : t.palette.contrast_600,
+ color: disabled
+ ? t.atoms.text_contrast_low.color
+ : t.atoms.text_contrast_high.color,
},
native({
paddingTop: 3,
}),
+ flatten(style),
]}>
{children}
@@ -257,7 +264,6 @@ export function Label({children}: React.PropsWithChildren<{}>) {
export function createSharedToggleStyles({
theme: t,
hovered,
- focused,
selected,
disabled,
isInvalid,
@@ -280,7 +286,7 @@ export function createSharedToggleStyles({
borderColor: t.palette.primary_500,
})
- if (hovered || focused) {
+ if (hovered) {
baseHover.push({
backgroundColor:
t.name === 'light' ? t.palette.primary_100 : t.palette.primary_800,
@@ -289,7 +295,7 @@ export function createSharedToggleStyles({
})
}
} else {
- if (hovered || focused) {
+ if (hovered) {
baseHover.push({
backgroundColor:
t.name === 'light' ? t.palette.contrast_50 : t.palette.contrast_100,
@@ -306,7 +312,7 @@ export function createSharedToggleStyles({
t.name === 'light' ? t.palette.negative_300 : t.palette.negative_800,
})
- if (hovered || focused) {
+ if (hovered) {
baseHover.push({
backgroundColor:
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
@@ -353,7 +359,7 @@ export function Checkbox() {
width: 20,
},
baseStyles,
- hovered || focused ? baseHoverStyles : {},
+ hovered ? baseHoverStyles : {},
]}>
{selected ? : null}
@@ -385,7 +391,7 @@ export function Switch() {
width: 30,
},
baseStyles,
- hovered || focused ? baseHoverStyles : {},
+ hovered ? baseHoverStyles : {},
]}>
{selected ? (
&
AccessibilityProps &
- React.PropsWithChildren<{testID?: string}>
+ React.PropsWithChildren<{
+ testID?: string
+ }>
export type GroupProps = Omit & {
multiple?: boolean
@@ -101,12 +103,12 @@ function ButtonInner({children}: React.PropsWithChildren<{}>) {
native({
paddingBottom: 10,
}),
- a.px_sm,
+ a.px_md,
t.atoms.bg,
t.atoms.border_contrast_low,
baseStyles,
activeStyles,
- (state.hovered || state.focused || state.pressed) && hoverStyles,
+ (state.hovered || state.pressed) && hoverStyles,
]}>
{typeof children === 'string' ? (
{
+ let timeout: NodeJS.Timeout
+ // on initial load, show a loading spinner for a hot sec to prevent flash
+ if (isLoading) timeout = setTimeout(() => setIsLoading(false), delay)
+
+ return () => timeout && clearTimeout(timeout)
+ }, [isLoading, delay])
+
+ return isLoading
+}
diff --git a/src/components/icons/ArrowTriangle.tsx b/src/components/icons/ArrowTriangle.tsx
new file mode 100644
index 0000000000..b27b719aef
--- /dev/null
+++ b/src/components/icons/ArrowTriangle.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const ArrowTriangleBottom_Stroke2_Corner1_Rounded = createSinglePathSVG({
+ path: 'M4.213 6.886c-.673-1.35.334-2.889 1.806-2.889H17.98c1.472 0 2.479 1.539 1.806 2.89l-5.982 11.997c-.74 1.484-2.87 1.484-3.61 0L4.213 6.886Z',
+})
diff --git a/src/components/icons/Bars.tsx b/src/components/icons/Bars.tsx
new file mode 100644
index 0000000000..7b1415a4bc
--- /dev/null
+++ b/src/components/icons/Bars.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Bars3_Stroke2_Corner0_Rounded = createSinglePathSVG({
+ path: 'M3 5a1 1 0 0 0 0 2h18a1 1 0 1 0 0-2H3Zm-1 7a1 1 0 0 1 1-1h18a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1Zm0 6a1 1 0 0 1 1-1h18a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1Z',
+})
diff --git a/src/components/icons/Chevron.tsx b/src/components/icons/Chevron.tsx
index b1a9deea0b..a04e6e0092 100644
--- a/src/components/icons/Chevron.tsx
+++ b/src/components/icons/Chevron.tsx
@@ -7,3 +7,11 @@ export const ChevronLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({
export const ChevronRight_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M8.293 3.293a1 1 0 0 1 1.414 0l8 8a1 1 0 0 1 0 1.414l-8 8a1 1 0 0 1-1.414-1.414L15.586 12 8.293 4.707a1 1 0 0 1 0-1.414Z',
})
+
+export const ChevronTop_Stroke2_Corner0_Rounded = createSinglePathSVG({
+ path: 'M12 6a1 1 0 0 1 .707.293l8 8a1 1 0 0 1-1.414 1.414L12 8.414l-7.293 7.293a1 1 0 0 1-1.414-1.414l8-8A1 1 0 0 1 12 6Z',
+})
+
+export const ChevronBottom_Stroke2_Corner0_Rounded = createSinglePathSVG({
+ path: 'M3.293 8.293a1 1 0 0 1 1.414 0L12 15.586l7.293-7.293a1 1 0 1 1 1.414 1.414l-8 8a1 1 0 0 1-1.414 0l-8-8a1 1 0 0 1 0-1.414Z',
+})
diff --git a/src/components/icons/CircleBanSign.tsx b/src/components/icons/CircleBanSign.tsx
new file mode 100644
index 0000000000..543985d43a
--- /dev/null
+++ b/src/components/icons/CircleBanSign.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const CircleBanSign_Stroke2_Corner0_Rounded = createSinglePathSVG({
+ path: 'M12 4a8 8 0 0 0-6.32 12.906L16.906 5.68A7.962 7.962 0 0 0 12 4Zm6.32 3.094L7.094 18.32A8 8 0 0 0 18.32 7.094ZM2 12C2 6.477 6.477 2 12 2a9.972 9.972 0 0 1 7.071 2.929A9.972 9.972 0 0 1 22 12c0 5.523-4.477 10-10 10a9.972 9.972 0 0 1-7.071-2.929A9.972 9.972 0 0 1 2 12Z',
+})
diff --git a/src/components/icons/Gear.tsx b/src/components/icons/Gear.tsx
new file mode 100644
index 0000000000..980b7413bd
--- /dev/null
+++ b/src/components/icons/Gear.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const SettingsGear2_Stroke2_Corner0_Rounded = createSinglePathSVG({
+ path: 'M11.1 2a1 1 0 0 0-.832.445L8.851 4.57 6.6 4.05a1 1 0 0 0-.932.268l-1.35 1.35a1 1 0 0 0-.267.932l.52 2.251-2.126 1.417A1 1 0 0 0 2 11.1v1.8a1 1 0 0 0 .445.832l2.125 1.417-.52 2.251a1 1 0 0 0 .268.932l1.35 1.35a1 1 0 0 0 .932.267l2.251-.52 1.417 2.126A1 1 0 0 0 11.1 22h1.8a1 1 0 0 0 .832-.445l1.417-2.125 2.251.52a1 1 0 0 0 .932-.268l1.35-1.35a1 1 0 0 0 .267-.932l-.52-2.251 2.126-1.417A1 1 0 0 0 22 12.9v-1.8a1 1 0 0 0-.445-.832L19.43 8.851l.52-2.251a1 1 0 0 0-.268-.932l-1.35-1.35a1 1 0 0 0-.932-.267l-2.251.52-1.417-2.126A1 1 0 0 0 12.9 2h-1.8Zm-.968 4.255L11.635 4h.73l1.503 2.255a1 1 0 0 0 1.057.42l2.385-.551.566.566-.55 2.385a1 1 0 0 0 .42 1.057L20 11.635v.73l-2.255 1.503a1 1 0 0 0-.42 1.057l.551 2.385-.566.566-2.385-.55a1 1 0 0 0-1.057.42L12.365 20h-.73l-1.503-2.255a1 1 0 0 0-1.057-.42l-2.385.551-.566-.566.55-2.385a1 1 0 0 0-.42-1.057L4 12.365v-.73l2.255-1.503a1 1 0 0 0 .42-1.057L6.123 6.69l.566-.566 2.385.55a1 1 0 0 0 1.057-.42ZM8 12a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm4-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z',
+})
diff --git a/src/components/icons/Group3.tsx b/src/components/icons/Group.tsx
similarity index 100%
rename from src/components/icons/Group3.tsx
rename to src/components/icons/Group.tsx
diff --git a/src/components/icons/RaisingHand.tsx b/src/components/icons/RaisingHand.tsx
new file mode 100644
index 0000000000..cd023cb7ef
--- /dev/null
+++ b/src/components/icons/RaisingHand.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const RaisingHande4Finger_Stroke2_Corner0_Rounded = createSinglePathSVG({
+ path: 'M10.25 4a.75.75 0 0 0-.75.75V11a1 1 0 1 1-2 0V6.75a.75.75 0 0 0-1.5 0V14a6 6 0 0 0 12 0V9a2 2 0 0 0-2 2v1.5a1 1 0 0 1-.684.949l-.628.21A2.469 2.469 0 0 0 13 16a1 1 0 1 1-2 0 4.469 4.469 0 0 1 3-4.22V11c0-.703.181-1.364.5-1.938V5.75a.75.75 0 0 0-1.5 0V9a1 1 0 1 1-2 0V4.75a.75.75 0 0 0-.75-.75Zm2.316-.733A2.75 2.75 0 0 1 16.5 5.75v1.54c.463-.187.97-.29 1.5-.29h1a1 1 0 0 1 1 1v6a8 8 0 1 1-16 0V6.75a2.75 2.75 0 0 1 3.571-2.625 2.751 2.751 0 0 1 4.995-.858Z',
+})
diff --git a/src/components/icons/Shield.tsx b/src/components/icons/Shield.tsx
new file mode 100644
index 0000000000..5038d5c244
--- /dev/null
+++ b/src/components/icons/Shield.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Shield_Stroke2_Corner0_Rounded = createSinglePathSVG({
+ path: 'M11.675 2.054a1 1 0 0 1 .65 0l8 2.75A1 1 0 0 1 21 5.75v6.162c0 2.807-1.149 4.83-2.813 6.405-1.572 1.488-3.632 2.6-5.555 3.636l-.157.085a1 1 0 0 1-.95 0l-.157-.085c-1.923-1.037-3.983-2.148-5.556-3.636C4.15 16.742 3 14.719 3 11.912V5.75a1 1 0 0 1 .675-.946l8-2.75ZM5 6.464v5.448c0 2.166.851 3.687 2.188 4.952 1.276 1.209 2.964 2.158 4.812 3.157 1.848-1 3.536-1.948 4.813-3.157C18.148 15.6 19 14.078 19 11.912V6.464l-7-2.407-7 2.407Z',
+})
diff --git a/src/components/icons/SquareArrowTopRight.tsx b/src/components/icons/SquareArrowTopRight.tsx
new file mode 100644
index 0000000000..7701e26e56
--- /dev/null
+++ b/src/components/icons/SquareArrowTopRight.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const SquareArrowTopRight_Stroke2_Corner0_Rounded = createSinglePathSVG({
+ path: 'M14 5a1 1 0 1 1 0-2h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0V6.414l-7.293 7.293a1 1 0 0 1-1.414-1.414L17.586 5H14ZM3 6a1 1 0 0 1 1-1h5a1 1 0 0 1 0 2H5v12h12v-4a1 1 0 1 1 2 0v5a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6Z',
+})
diff --git a/src/components/icons/SquareBehindSquare4.tsx b/src/components/icons/SquareBehindSquare4.tsx
new file mode 100644
index 0000000000..425599cbce
--- /dev/null
+++ b/src/components/icons/SquareBehindSquare4.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const SquareBehindSquare4_Stroke2_Corner0_Rounded = createSinglePathSVG({
+ path: 'M8 8V3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1h-5v5a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h5Zm1 8a1 1 0 0 1-1-1v-5H4v10h10v-4H9Z',
+})
diff --git a/src/components/moderation/ContentHider.tsx b/src/components/moderation/ContentHider.tsx
new file mode 100644
index 0000000000..1e8f36d310
--- /dev/null
+++ b/src/components/moderation/ContentHider.tsx
@@ -0,0 +1,182 @@
+import React from 'react'
+import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
+import {ModerationUI} from '@atproto/api'
+import {useLingui} from '@lingui/react'
+import {msg, Trans} from '@lingui/macro'
+
+import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
+import {isJustAMute} from '#/lib/moderation'
+import {sanitizeDisplayName} from '#/lib/strings/display-names'
+
+import {atoms as a, useTheme, useBreakpoints, web} from '#/alf'
+import {Button} from '#/components/Button'
+import {Text} from '#/components/Typography'
+import {
+ ModerationDetailsDialog,
+ useModerationDetailsDialogControl,
+} from '#/components/moderation/ModerationDetailsDialog'
+
+export function ContentHider({
+ testID,
+ modui,
+ ignoreMute,
+ style,
+ childContainerStyle,
+ children,
+}: React.PropsWithChildren<{
+ testID?: string
+ modui: ModerationUI | undefined
+ ignoreMute?: boolean
+ style?: StyleProp
+ childContainerStyle?: StyleProp
+}>) {
+ const t = useTheme()
+ const {_} = useLingui()
+ const {gtMobile} = useBreakpoints()
+ const [override, setOverride] = React.useState(false)
+ const control = useModerationDetailsDialogControl()
+
+ const blur = modui?.blurs[0]
+ const desc = useModerationCauseDescription(blur)
+
+ if (!blur || (ignoreMute && isJustAMute(modui))) {
+ return (
+
+ {children}
+
+ )
+ }
+
+ return (
+
+
+
+
+
+ {desc.source && blur.type === 'label' && !override && (
+
+ )}
+
+ {override && {children}}
+
+ )
+}
+
+const styles = StyleSheet.create({
+ outer: {
+ overflow: 'hidden',
+ },
+ cover: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 6,
+ borderRadius: 8,
+ marginTop: 4,
+ paddingVertical: 14,
+ paddingLeft: 14,
+ paddingRight: 18,
+ },
+ showBtn: {
+ marginLeft: 'auto',
+ alignSelf: 'center',
+ },
+})
diff --git a/src/components/moderation/GlobalModerationLabelPref.tsx b/src/components/moderation/GlobalModerationLabelPref.tsx
new file mode 100644
index 0000000000..7633cb9f21
--- /dev/null
+++ b/src/components/moderation/GlobalModerationLabelPref.tsx
@@ -0,0 +1,93 @@
+import React from 'react'
+import {View} from 'react-native'
+import {InterpretedLabelValueDefinition, LabelPreference} from '@atproto/api'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
+
+import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings'
+import {
+ usePreferencesQuery,
+ usePreferencesSetContentLabelMutation,
+} from '#/state/queries/preferences'
+
+import {useTheme, atoms as a} from '#/alf'
+import {Text} from '#/components/Typography'
+import * as ToggleButton from '#/components/forms/ToggleButton'
+
+export function GlobalModerationLabelPref({
+ labelValueDefinition,
+ disabled,
+}: {
+ labelValueDefinition: InterpretedLabelValueDefinition
+ disabled?: boolean
+}) {
+ const {_} = useLingui()
+ const t = useTheme()
+
+ const {identifier} = labelValueDefinition
+ const {data: preferences} = usePreferencesQuery()
+ const {mutate, variables} = usePreferencesSetContentLabelMutation()
+ const savedPref = preferences?.moderationPrefs.labels[identifier]
+ const pref = variables?.visibility ?? savedPref ?? 'warn'
+
+ const allLabelStrings = useGlobalLabelStrings()
+ const labelStrings =
+ labelValueDefinition.identifier in allLabelStrings
+ ? allLabelStrings[labelValueDefinition.identifier]
+ : {
+ name: labelValueDefinition.identifier,
+ description: `Labeled "${labelValueDefinition.identifier}"`,
+ }
+
+ const labelOptions = {
+ hide: _(msg`Hide`),
+ warn: _(msg`Warn`),
+ ignore: _(msg`Show`),
+ }
+
+ return (
+
+
+ {labelStrings.name}
+
+ {labelStrings.description}
+
+
+
+ {!disabled && (
+
+ mutate({
+ label: identifier,
+ visibility: newPref[0] as LabelPreference,
+ labelerDid: undefined,
+ })
+ }>
+
+ {labelOptions.ignore}
+
+
+ {labelOptions.warn}
+
+
+ {labelOptions.hide}
+
+
+ )}
+
+
+ )
+}
diff --git a/src/components/moderation/LabelsOnMe.tsx b/src/components/moderation/LabelsOnMe.tsx
new file mode 100644
index 0000000000..099769fa76
--- /dev/null
+++ b/src/components/moderation/LabelsOnMe.tsx
@@ -0,0 +1,83 @@
+import React from 'react'
+import {StyleProp, View, ViewStyle} from 'react-native'
+import {AppBskyFeedDefs, ComAtprotoLabelDefs} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useSession} from '#/state/session'
+
+import {atoms as a} from '#/alf'
+import {Button, ButtonText, ButtonIcon, ButtonSize} from '#/components/Button'
+import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
+import {
+ LabelsOnMeDialog,
+ useLabelsOnMeDialogControl,
+} from '#/components/moderation/LabelsOnMeDialog'
+
+export function LabelsOnMe({
+ details,
+ labels,
+ size,
+ style,
+}: {
+ details: {did: string} | {uri: string; cid: string}
+ labels: ComAtprotoLabelDefs.Label[] | undefined
+ size?: ButtonSize
+ style?: StyleProp
+}) {
+ const {_} = useLingui()
+ const {currentAccount} = useSession()
+ const isAccount = 'did' in details
+ const control = useLabelsOnMeDialogControl()
+
+ if (!labels || !currentAccount) {
+ return null
+ }
+ labels = labels.filter(
+ l => !l.val.startsWith('!') && l.src !== currentAccount.did,
+ )
+ if (!labels.length) {
+ return null
+ }
+
+ const labelTarget = isAccount ? _(msg`account`) : _(msg`content`)
+ return (
+
+
+
+
+
+ )
+}
+
+export function LabelsOnMyPost({
+ post,
+ style,
+}: {
+ post: AppBskyFeedDefs.PostView
+ style?: StyleProp
+}) {
+ const {currentAccount} = useSession()
+ if (post.author.did !== currentAccount?.did) {
+ return null
+ }
+ return (
+
+ )
+}
diff --git a/src/components/moderation/LabelsOnMeDialog.tsx b/src/components/moderation/LabelsOnMeDialog.tsx
new file mode 100644
index 0000000000..6eddbc7ceb
--- /dev/null
+++ b/src/components/moderation/LabelsOnMeDialog.tsx
@@ -0,0 +1,262 @@
+import React from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {ComAtprotoLabelDefs, ComAtprotoModerationDefs} from '@atproto/api'
+
+import {useLabelInfo} from '#/lib/moderation/useLabelInfo'
+import {makeProfileLink} from '#/lib/routes/links'
+import {sanitizeHandle} from '#/lib/strings/handles'
+import {getAgent} from '#/state/session'
+
+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import {Text} from '#/components/Typography'
+import * as Dialog from '#/components/Dialog'
+import {Button, ButtonText} from '#/components/Button'
+import {InlineLink} from '#/components/Link'
+import * as Toast from '#/view/com/util/Toast'
+import {Divider} from '../Divider'
+
+export {useDialogControl as useLabelsOnMeDialogControl} from '#/components/Dialog'
+
+type Subject =
+ | {
+ uri: string
+ cid: string
+ }
+ | {
+ did: string
+ }
+
+export interface LabelsOnMeDialogProps {
+ control: Dialog.DialogOuterProps['control']
+ subject: Subject
+ labels: ComAtprotoLabelDefs.Label[]
+}
+
+export function LabelsOnMeDialogInner(props: LabelsOnMeDialogProps) {
+ const {_} = useLingui()
+ const [appealingLabel, setAppealingLabel] = React.useState<
+ ComAtprotoLabelDefs.Label | undefined
+ >(undefined)
+ const {subject, labels} = props
+ const isAccount = 'did' in subject
+
+ return (
+
+ {appealingLabel ? (
+ setAppealingLabel(undefined)}
+ />
+ ) : (
+ <>
+
+ {isAccount ? (
+ Labels on your account
+ ) : (
+ Labels on your content
+ )}
+
+
+
+ You may appeal these labels if you feel they were placed in error.
+
+
+
+
+ {labels.map(label => (
+
+ >
+ )}
+
+
+
+ )
+}
+
+export function LabelsOnMeDialog(props: LabelsOnMeDialogProps) {
+ return (
+
+
+
+
+
+ )
+}
+
+function Label({
+ label,
+ control,
+ onPressAppeal,
+}: {
+ label: ComAtprotoLabelDefs.Label
+ control: Dialog.DialogOuterProps['control']
+ onPressAppeal: (label: ComAtprotoLabelDefs.Label) => void
+}) {
+ const t = useTheme()
+ const {_} = useLingui()
+ const {labeler, strings} = useLabelInfo(label)
+ return (
+
+
+
+ {strings.name}
+
+ {strings.description}
+
+
+
+
+
+
+
+
+
+
+
+ Source:{' '}
+ control.close()}>
+ {labeler ? sanitizeHandle(labeler.creator.handle, '@') : label.src}
+
+
+
+
+ )
+}
+
+function AppealForm({
+ label,
+ subject,
+ control,
+ onPressBack,
+}: {
+ label: ComAtprotoLabelDefs.Label
+ subject: Subject
+ control: Dialog.DialogOuterProps['control']
+ onPressBack: () => void
+}) {
+ const {_} = useLingui()
+ const {labeler, strings} = useLabelInfo(label)
+ const {gtMobile} = useBreakpoints()
+ const [details, setDetails] = React.useState('')
+ const isAccountReport = 'did' in subject
+
+ const onSubmit = async () => {
+ try {
+ const $type = !isAccountReport
+ ? 'com.atproto.repo.strongRef'
+ : 'com.atproto.admin.defs#repoRef'
+ await getAgent()
+ .withProxy('atproto_labeler', label.src)
+ .createModerationReport({
+ reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
+ subject: {
+ $type,
+ ...subject,
+ },
+ reason: details,
+ })
+ Toast.show(_(msg`Appeal submitted.`))
+ } finally {
+ control.close()
+ }
+ }
+
+ return (
+ <>
+
+ Appeal "{strings.name}" label
+
+
+
+ This appeal will be sent to{' '}
+ control.close()}
+ style={[a.text_md, a.leading_snug]}>
+ {labeler ? sanitizeHandle(labeler.creator.handle, '@') : label.src}
+
+ .
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/src/components/moderation/ModerationDetailsDialog.tsx b/src/components/moderation/ModerationDetailsDialog.tsx
new file mode 100644
index 0000000000..da490cb43e
--- /dev/null
+++ b/src/components/moderation/ModerationDetailsDialog.tsx
@@ -0,0 +1,148 @@
+import React from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {ModerationCause} from '@atproto/api'
+
+import {listUriToHref} from '#/lib/strings/url-helpers'
+import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
+import {makeProfileLink} from '#/lib/routes/links'
+
+import {isNative} from '#/platform/detection'
+import {useTheme, atoms as a} from '#/alf'
+import {Text} from '#/components/Typography'
+import * as Dialog from '#/components/Dialog'
+import {InlineLink} from '#/components/Link'
+import {Divider} from '#/components/Divider'
+
+export {useDialogControl as useModerationDetailsDialogControl} from '#/components/Dialog'
+
+export interface ModerationDetailsDialogProps {
+ control: Dialog.DialogOuterProps['control']
+ modcause: ModerationCause
+}
+
+export function ModerationDetailsDialog(props: ModerationDetailsDialogProps) {
+ return (
+
+
+
+
+ )
+}
+
+function ModerationDetailsDialogInner({
+ modcause,
+ control,
+}: ModerationDetailsDialogProps & {
+ control: Dialog.DialogOuterProps['control']
+}) {
+ const t = useTheme()
+ const {_} = useLingui()
+ const desc = useModerationCauseDescription(modcause)
+
+ let name
+ let description
+ if (!modcause) {
+ name = _(msg`Content Warning`)
+ description = _(
+ msg`Moderator has chosen to set a general warning on the content.`,
+ )
+ } else if (modcause.type === 'blocking') {
+ if (modcause.source.type === 'list') {
+ const list = modcause.source.list
+ name = _(msg`User Blocked by List`)
+ description = (
+
+ This user is included in the{' '}
+
+ {list.name}
+ {' '}
+ list which you have blocked.
+
+ )
+ } else {
+ name = _(msg`User Blocked`)
+ description = _(
+ msg`You have blocked this user. You cannot view their content.`,
+ )
+ }
+ } else if (modcause.type === 'blocked-by') {
+ name = _(msg`User Blocks You`)
+ description = _(
+ msg`This user has blocked you. You cannot view their content.`,
+ )
+ } else if (modcause.type === 'block-other') {
+ name = _(msg`Content Not Available`)
+ description = _(
+ msg`This content is not available because one of the users involved has blocked the other.`,
+ )
+ } else if (modcause.type === 'muted') {
+ if (modcause.source.type === 'list') {
+ const list = modcause.source.list
+ name = _(msg`Account Muted by List`)
+ description = (
+
+ This user is included in the{' '}
+
+ {list.name}
+ {' '}
+ list which you have muted.
+
+ )
+ } else {
+ name = _(msg`Account Muted`)
+ description = _(msg`You have muted this account.`)
+ }
+ } else if (modcause.type === 'mute-word') {
+ name = _(msg`Post Hidden by Muted Word`)
+ description = _(msg`You've chosen to hide a word or tag within this post.`)
+ } else if (modcause.type === 'hidden') {
+ name = _(msg`Post Hidden by You`)
+ description = _(msg`You have hidden this post.`)
+ } else if (modcause.type === 'label') {
+ name = desc.name
+ description = desc.description
+ } else {
+ // should never happen
+ name = ''
+ description = ''
+ }
+
+ return (
+
+
+ {name}
+
+
+ {description}
+
+
+ {modcause.type === 'label' && (
+ <>
+
+
+
+ This label was applied by{' '}
+ {modcause.source.type === 'user' ? (
+ the author
+ ) : (
+ control.close()}
+ style={a.text_md}>
+ {desc.source}
+
+ )}
+ .
+
+
+ >
+ )}
+
+ {isNative && }
+
+
+
+ )
+}
diff --git a/src/components/moderation/ModerationLabelPref.tsx b/src/components/moderation/ModerationLabelPref.tsx
new file mode 100644
index 0000000000..f145504881
--- /dev/null
+++ b/src/components/moderation/ModerationLabelPref.tsx
@@ -0,0 +1,154 @@
+import React from 'react'
+import {View} from 'react-native'
+import {InterpretedLabelValueDefinition, LabelPreference} from '@atproto/api'
+import {useLingui} from '@lingui/react'
+import {msg, Trans} from '@lingui/macro'
+
+import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings'
+import {useLabelBehaviorDescription} from '#/lib/moderation/useLabelBehaviorDescription'
+import {
+ usePreferencesQuery,
+ usePreferencesSetContentLabelMutation,
+} from '#/state/queries/preferences'
+import {getLabelStrings} from '#/lib/moderation/useLabelInfo'
+
+import {useTheme, atoms as a} from '#/alf'
+import {Text} from '#/components/Typography'
+import {InlineLink} from '#/components/Link'
+import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '../icons/CircleInfo'
+import * as ToggleButton from '#/components/forms/ToggleButton'
+
+export function ModerationLabelPref({
+ labelValueDefinition,
+ labelerDid,
+ disabled,
+}: {
+ labelValueDefinition: InterpretedLabelValueDefinition
+ labelerDid: string | undefined
+ disabled?: boolean
+}) {
+ const {_, i18n} = useLingui()
+ const t = useTheme()
+
+ const isGlobalLabel = !labelValueDefinition.definedBy
+ const {identifier} = labelValueDefinition
+ const {data: preferences} = usePreferencesQuery()
+ const {mutate, variables} = usePreferencesSetContentLabelMutation()
+ const savedPref =
+ labelerDid && !isGlobalLabel
+ ? preferences?.moderationPrefs.labelers.find(l => l.did === labelerDid)
+ ?.labels[identifier]
+ : preferences?.moderationPrefs.labels[identifier]
+ const pref =
+ variables?.visibility ??
+ savedPref ??
+ labelValueDefinition.defaultSetting ??
+ 'warn'
+
+ // does the 'warn' setting make sense for this label?
+ const canWarn = !(
+ labelValueDefinition.blurs === 'none' &&
+ labelValueDefinition.severity === 'none'
+ )
+ // is this label adult only?
+ const adultOnly = labelValueDefinition.flags.includes('adult')
+ // is this label disabled because it's adult only?
+ const adultDisabled =
+ adultOnly && !preferences?.moderationPrefs.adultContentEnabled
+ // are there any reasons we cant configure this label here?
+ const cantConfigure = isGlobalLabel || adultDisabled
+
+ // adjust the pref based on whether warn is available
+ let prefAdjusted = pref
+ if (adultDisabled) {
+ prefAdjusted = 'hide'
+ } else if (!canWarn && pref === 'warn') {
+ prefAdjusted = 'ignore'
+ }
+
+ // grab localized descriptions of the label and its settings
+ const currentPrefLabel = useLabelBehaviorDescription(
+ labelValueDefinition,
+ prefAdjusted,
+ )
+ const hideLabel = useLabelBehaviorDescription(labelValueDefinition, 'hide')
+ const warnLabel = useLabelBehaviorDescription(labelValueDefinition, 'warn')
+ const ignoreLabel = useLabelBehaviorDescription(
+ labelValueDefinition,
+ 'ignore',
+ )
+ const globalLabelStrings = useGlobalLabelStrings()
+ const labelStrings = getLabelStrings(
+ i18n.locale,
+ globalLabelStrings,
+ labelValueDefinition,
+ )
+
+ return (
+
+
+ {labelStrings.name}
+
+ {labelStrings.description}
+
+
+ {cantConfigure && (
+
+
+
+
+ {adultDisabled ? (
+ Adult content is disabled.
+ ) : isGlobalLabel ? (
+
+ Configured in{' '}
+
+ moderation settings
+
+ .
+
+ ) : null}
+
+
+ )}
+
+ {disabled ? (
+ <>>
+ ) : cantConfigure ? (
+
+
+ {currentPrefLabel}
+
+
+ ) : (
+
+
+ mutate({
+ label: identifier,
+ visibility: newPref[0] as LabelPreference,
+ labelerDid,
+ })
+ }>
+
+ {ignoreLabel}
+
+ {canWarn && (
+
+ {warnLabel}
+
+ )}
+
+ {hideLabel}
+
+
+
+ )}
+
+ )
+}
diff --git a/src/components/moderation/PostAlerts.tsx b/src/components/moderation/PostAlerts.tsx
new file mode 100644
index 0000000000..0bfe696788
--- /dev/null
+++ b/src/components/moderation/PostAlerts.tsx
@@ -0,0 +1,66 @@
+import React from 'react'
+import {StyleProp, View, ViewStyle} from 'react-native'
+import {ModerationUI, ModerationCause} from '@atproto/api'
+
+import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
+import {getModerationCauseKey} from '#/lib/moderation'
+
+import {atoms as a} from '#/alf'
+import {Button, ButtonText, ButtonIcon} from '#/components/Button'
+import {
+ ModerationDetailsDialog,
+ useModerationDetailsDialogControl,
+} from '#/components/moderation/ModerationDetailsDialog'
+
+export function PostAlerts({
+ modui,
+ style,
+}: {
+ modui: ModerationUI
+ includeMute?: boolean
+ style?: StyleProp
+}) {
+ if (!modui.alert && !modui.inform) {
+ return null
+ }
+
+ return (
+
+
+ {modui.alerts.map(cause => (
+
+ ))}
+ {modui.informs.map(cause => (
+
+ ))}
+
+
+ )
+}
+
+function PostLabel({cause}: {cause: ModerationCause}) {
+ const control = useModerationDetailsDialogControl()
+ const desc = useModerationCauseDescription(cause)
+
+ return (
+ <>
+
+
+
+ >
+ )
+}
diff --git a/src/view/com/util/moderation/PostHider.tsx b/src/components/moderation/PostHider.tsx
similarity index 54%
rename from src/view/com/util/moderation/PostHider.tsx
rename to src/components/moderation/PostHider.tsx
index ede62e988c..464ee20778 100644
--- a/src/view/com/util/moderation/PostHider.tsx
+++ b/src/components/moderation/PostHider.tsx
@@ -1,45 +1,50 @@
import React, {ComponentProps} from 'react'
import {StyleSheet, Pressable, View, ViewStyle, StyleProp} from 'react-native'
import {ModerationUI} from '@atproto/api'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {usePalette} from 'lib/hooks/usePalette'
-import {Link} from '../Link'
-import {Text} from '../text/Text'
-import {addStyle} from 'lib/styles'
-import {describeModerationCause} from 'lib/moderation'
-import {ShieldExclamation} from 'lib/icons'
import {useLingui} from '@lingui/react'
import {Trans, msg} from '@lingui/macro'
-import {useModalControls} from '#/state/modals'
+
+import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
+import {addStyle} from 'lib/styles'
+
+import {useTheme, atoms as a} from '#/alf'
+import {
+ ModerationDetailsDialog,
+ useModerationDetailsDialogControl,
+} from '#/components/moderation/ModerationDetailsDialog'
+import {Text} from '#/components/Typography'
+// import {Link} from '#/components/Link' TODO this imposes some styles that screw things up
+import {Link} from '#/view/com/util/Link'
interface Props extends ComponentProps {
iconSize: number
iconStyles: StyleProp
- moderation: ModerationUI
+ modui: ModerationUI
}
export function PostHider({
testID,
href,
- moderation,
+ modui,
style,
children,
iconSize,
iconStyles,
...props
}: Props) {
- const pal = usePalette('default')
+ const t = useTheme()
const {_} = useLingui()
const [override, setOverride] = React.useState(false)
- const {openModal} = useModalControls()
+ const control = useModerationDetailsDialogControl()
+ const blur = modui.blurs[0]
+ const desc = useModerationCauseDescription(blur)
- if (!moderation.blur) {
+ if (!blur) {
return (
{children}
@@ -47,12 +52,10 @@ export function PostHider({
)
}
- const isMute = ['muted', 'muted-word'].includes(moderation.cause?.type || '')
- const desc = describeModerationCause(moderation.cause, 'content')
return !override ? (
{
- if (!moderation.noOverride) {
+ if (!modui.noOverride) {
setOverride(v => !v)
}
}}
@@ -62,49 +65,45 @@ export function PostHider({
}
accessibilityLabel=""
style={[
- styles.description,
+ a.flex_row,
+ a.align_center,
+ a.gap_sm,
+ a.py_md,
+ {
+ paddingLeft: 6,
+ paddingRight: 18,
+ },
override ? {paddingBottom: 0} : undefined,
- pal.view,
+ t.atoms.bg,
]}>
+
{
- openModal({
- name: 'moderation-details',
- context: 'content',
- moderation,
- })
+ control.open()
}}
accessibilityRole="button"
accessibilityLabel={_(msg`Learn more about this warning`)}
accessibilityHint="">
- {isMute ? (
-
- ) : (
-
- )}
+
-
+
{desc.name}
- {!moderation.noOverride && (
-
+ {!modui.noOverride && (
+
{override ? Hide : Show}
)}
@@ -114,26 +113,14 @@ export function PostHider({
testID={testID}
style={addStyle(style, styles.child)}
href={href}
- noFeedback>
+ accessible={false}
+ {...props}>
{children}
)
}
const styles = StyleSheet.create({
- description: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: 4,
- paddingVertical: 10,
- paddingLeft: 6,
- paddingRight: 18,
- marginTop: 1,
- },
- showBtn: {
- marginLeft: 'auto',
- alignSelf: 'center',
- },
child: {
borderWidth: 0,
borderTopWidth: 0,
diff --git a/src/components/moderation/ProfileHeaderAlerts.tsx b/src/components/moderation/ProfileHeaderAlerts.tsx
new file mode 100644
index 0000000000..dfc2aa5571
--- /dev/null
+++ b/src/components/moderation/ProfileHeaderAlerts.tsx
@@ -0,0 +1,66 @@
+import React from 'react'
+import {StyleProp, View, ViewStyle} from 'react-native'
+import {ModerationCause, ModerationDecision} from '@atproto/api'
+
+import {getModerationCauseKey} from 'lib/moderation'
+import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
+
+import {atoms as a} from '#/alf'
+import {Button, ButtonText, ButtonIcon} from '#/components/Button'
+import {
+ ModerationDetailsDialog,
+ useModerationDetailsDialogControl,
+} from '#/components/moderation/ModerationDetailsDialog'
+
+export function ProfileHeaderAlerts({
+ moderation,
+ style,
+}: {
+ moderation: ModerationDecision
+ style?: StyleProp
+}) {
+ const modui = moderation.ui('profileView')
+ if (!modui.alert && !modui.inform) {
+ return null
+ }
+
+ return (
+
+
+ {modui.alerts.map(cause => (
+
+ ))}
+ {modui.informs.map(cause => (
+
+ ))}
+
+
+ )
+}
+
+function ProfileLabel({cause}: {cause: ModerationCause}) {
+ const control = useModerationDetailsDialogControl()
+ const desc = useModerationCauseDescription(cause)
+
+ return (
+ <>
+
+
+
+ >
+ )
+}
diff --git a/src/components/moderation/ScreenHider.tsx b/src/components/moderation/ScreenHider.tsx
new file mode 100644
index 0000000000..71ca85a926
--- /dev/null
+++ b/src/components/moderation/ScreenHider.tsx
@@ -0,0 +1,171 @@
+import React from 'react'
+import {
+ TouchableWithoutFeedback,
+ StyleProp,
+ View,
+ ViewStyle,
+} from 'react-native'
+import {useNavigation} from '@react-navigation/native'
+import {ModerationUI} from '@atproto/api'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {NavigationProp} from 'lib/routes/types'
+import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
+
+import {useTheme, atoms as a} from '#/alf'
+import {CenteredView} from '#/view/com/util/Views'
+import {Text} from '#/components/Typography'
+import {Button, ButtonText} from '#/components/Button'
+import {
+ ModerationDetailsDialog,
+ useModerationDetailsDialogControl,
+} from '#/components/moderation/ModerationDetailsDialog'
+
+export function ScreenHider({
+ testID,
+ screenDescription,
+ modui,
+ style,
+ containerStyle,
+ children,
+}: React.PropsWithChildren<{
+ testID?: string
+ screenDescription: string
+ modui: ModerationUI
+ style?: StyleProp
+ containerStyle?: StyleProp
+}>) {
+ const t = useTheme()
+ const {_} = useLingui()
+ const [override, setOverride] = React.useState(false)
+ const navigation = useNavigation()
+ const {isMobile} = useWebMediaQueries()
+ const control = useModerationDetailsDialogControl()
+ const blur = modui.blurs[0]
+ const desc = useModerationCauseDescription(blur)
+
+ if (!blur || override) {
+ return (
+
+ {children}
+
+ )
+ }
+
+ const isNoPwi = !!modui.blurs.find(
+ cause =>
+ cause.type === 'label' && cause.labelDef.id === '!no-unauthenticated',
+ )
+ return (
+
+
+
+
+
+
+
+ {isNoPwi ? (
+ Sign-in Required
+ ) : (
+ Content Warning
+ )}
+
+
+ {isNoPwi ? (
+
+ This account has requested that users sign in to view their profile.
+
+ ) : (
+ <>
+ This {screenDescription} has been flagged:
+
+ {desc.name}.{' '}
+
+ {
+ control.open()
+ }}
+ accessibilityRole="button"
+ accessibilityLabel={_(msg`Learn more about this warning`)}
+ accessibilityHint="">
+
+ Learn More
+
+
+
+
+ >
+ )}{' '}
+
+ {isMobile && }
+
+
+ {!modui.noOverride && (
+
+ )}
+
+
+ )
+}
diff --git a/src/lib/__tests__/moderatePost_wrapped.test.ts b/src/lib/__tests__/moderatePost_wrapped.test.ts
deleted file mode 100644
index 45566281af..0000000000
--- a/src/lib/__tests__/moderatePost_wrapped.test.ts
+++ /dev/null
@@ -1,692 +0,0 @@
-import {describe, it, expect} from '@jest/globals'
-import {RichText} from '@atproto/api'
-
-import {hasMutedWord} from '../moderatePost_wrapped'
-
-describe(`hasMutedWord`, () => {
- describe(`tags`, () => {
- it(`match: outline tag`, () => {
- const rt = new RichText({
- text: `This is a post #inlineTag`,
- })
- rt.detectFacetsWithoutResolution()
-
- const match = hasMutedWord({
- mutedWords: [{value: 'outlineTag', targets: ['tag']}],
- text: rt.text,
- facets: rt.facets,
- outlineTags: ['outlineTag'],
- isOwnPost: false,
- })
-
- expect(match).toBe(true)
- })
-
- it(`match: inline tag`, () => {
- const rt = new RichText({
- text: `This is a post #inlineTag`,
- })
- rt.detectFacetsWithoutResolution()
-
- const match = hasMutedWord({
- mutedWords: [{value: 'inlineTag', targets: ['tag']}],
- text: rt.text,
- facets: rt.facets,
- outlineTags: ['outlineTag'],
- isOwnPost: false,
- })
-
- expect(match).toBe(true)
- })
-
- it(`match: content target matches inline tag`, () => {
- const rt = new RichText({
- text: `This is a post #inlineTag`,
- })
- rt.detectFacetsWithoutResolution()
-
- const match = hasMutedWord({
- mutedWords: [{value: 'inlineTag', targets: ['content']}],
- text: rt.text,
- facets: rt.facets,
- outlineTags: ['outlineTag'],
- isOwnPost: false,
- })
-
- expect(match).toBe(true)
- })
-
- it(`no match: only tag targets`, () => {
- const rt = new RichText({
- text: `This is a post`,
- })
- rt.detectFacetsWithoutResolution()
-
- const match = hasMutedWord({
- mutedWords: [{value: 'inlineTag', targets: ['tag']}],
- text: rt.text,
- facets: rt.facets,
- outlineTags: [],
- isOwnPost: false,
- })
-
- expect(match).toBe(false)
- })
- })
-
- describe(`early exits`, () => {
- it(`match: single character 希`, () => {
- /**
- * @see https://bsky.app/profile/mukuuji.bsky.social/post/3klji4fvsdk2c
- */
- const rt = new RichText({
- text: `改善希望です`,
- })
- rt.detectFacetsWithoutResolution()
-
- const match = hasMutedWord({
- mutedWords: [{value: '希', targets: ['content']}],
- text: rt.text,
- facets: rt.facets,
- outlineTags: [],
- isOwnPost: false,
- })
-
- expect(match).toBe(true)
- })
-
- it(`no match: long muted word, short post`, () => {
- const rt = new RichText({
- text: `hey`,
- })
- rt.detectFacetsWithoutResolution()
-
- const match = hasMutedWord({
- mutedWords: [{value: 'politics', targets: ['content']}],
- text: rt.text,
- facets: rt.facets,
- outlineTags: [],
- isOwnPost: false,
- })
-
- expect(match).toBe(false)
- })
-
- it(`match: exact text`, () => {
- const rt = new RichText({
- text: `javascript`,
- })
- rt.detectFacetsWithoutResolution()
-
- const match = hasMutedWord({
- mutedWords: [{value: 'javascript', targets: ['content']}],
- text: rt.text,
- facets: rt.facets,
- outlineTags: [],
- isOwnPost: false,
- })
-
- expect(match).toBe(true)
- })
- })
-
- describe(`general content`, () => {
- it(`match: word within post`, () => {
- const rt = new RichText({
- text: `This is a post about javascript`,
- })
- rt.detectFacetsWithoutResolution()
-
- const match = hasMutedWord({
- mutedWords: [{value: 'javascript', targets: ['content']}],
- text: rt.text,
- facets: rt.facets,
- outlineTags: [],
- isOwnPost: false,
- })
-
- expect(match).toBe(true)
- })
-
- it(`no match: partial word`, () => {
- const rt = new RichText({
- text: `Use your brain, Eric`,
- })
- rt.detectFacetsWithoutResolution()
-
- const match = hasMutedWord({
- mutedWords: [{value: 'ai', targets: ['content']}],
- text: rt.text,
- facets: rt.facets,
- outlineTags: [],
- isOwnPost: false,
- })
-
- expect(match).toBe(false)
- })
-
- it(`match: multiline`, () => {
- const rt = new RichText({
- text: `Use your\n\tbrain, Eric`,
- })
- rt.detectFacetsWithoutResolution()
-
- const match = hasMutedWord({
- mutedWords: [{value: 'brain', targets: ['content']}],
- text: rt.text,
- facets: rt.facets,
- outlineTags: [],
- isOwnPost: false,
- })
-
- expect(match).toBe(true)
- })
-
- it(`match: :)`, () => {
- const rt = new RichText({
- text: `So happy :)`,
- })
- rt.detectFacetsWithoutResolution()
-
- const match = hasMutedWord({
- mutedWords: [{value: `:)`, targets: ['content']}],
- text: rt.text,
- facets: rt.facets,
- outlineTags: [],
- isOwnPost: false,
- })
-
- expect(match).toBe(true)
- })
- })
-
- describe(`punctuation semi-fuzzy`, () => {
- describe(`yay!`, () => {
- const rt = new RichText({
- text: `We're federating, yay!`,
- })
- rt.detectFacetsWithoutResolution()
-
- it(`match: yay!`, () => {
- const match = hasMutedWord({
- mutedWords: [{value: 'yay!', targets: ['content']}],
- text: rt.text,
- facets: rt.facets,
- outlineTags: [],
- isOwnPost: false,
- })
-
- expect(match).toBe(true)
- })
-
- it(`match: yay`, () => {
- const match = hasMutedWord({
- mutedWords: [{value: 'yay', targets: ['content']}],
- text: rt.text,
- facets: rt.facets,
- outlineTags: [],
- isOwnPost: false,
- })
-
- expect(match).toBe(true)
- })
- })
-
- describe(`y!ppee!!`, () => {
- const rt = new RichText({
- text: `We're federating, y!ppee!!`,
- })
- rt.detectFacetsWithoutResolution()
-
- it(`match: y!ppee`, () => {
- const match = hasMutedWord({
- mutedWords: [{value: 'y!ppee', targets: ['content']}],
- text: rt.text,
- facets: rt.facets,
- outlineTags: [],
- isOwnPost: false,
- })
-
- expect(match).toBe(true)
- })
-
- // single exclamation point, source has double
- it(`no match: y!ppee!`, () => {
- const match = hasMutedWord({
- mutedWords: [{value: 'y!ppee!', targets: ['content']}],
- text: rt.text,
- facets: rt.facets,
- outlineTags: [],
- isOwnPost: false,
- })
-
- expect(match).toBe(true)
- })
- })
-
- describe(`Why so S@assy?`, () => {
- const rt = new RichText({
- text: `Why so S@assy?`,
- })
- rt.detectFacetsWithoutResolution()
-
- it(`match: S@assy`, () => {
- const match = hasMutedWord({
- mutedWords: [{value: 'S@assy', targets: ['content']}],
- text: rt.text,
- facets: rt.facets,
- outlineTags: [],
- isOwnPost: false,
- })
-
- expect(match).toBe(true)
- })
-
- it(`match: s@assy`, () => {
- const match = hasMutedWord({
- mutedWords: [{value: 's@assy', targets: ['content']}],
- text: rt.text,
- facets: rt.facets,
- outlineTags: [],
- isOwnPost: false,
- })
-
- expect(match).toBe(true)
- })
- })
-
- describe(`New York Times`, () => {
- const rt = new RichText({
- text: `New York Times`,
- })
- rt.detectFacetsWithoutResolution()
-
- // case insensitive
- it(`match: new york times`, () => {
- const match = hasMutedWord({
- mutedWords: [{value: 'new york times', targets: ['content']}],
- text: rt.text,
- facets: rt.facets,
- outlineTags: [],
- isOwnPost: false,
- })
-
- expect(match).toBe(true)
- })
- })
-
- describe(`!command`, () => {
- const rt = new RichText({
- text: `Idk maybe a bot !command`,
- })
- rt.detectFacetsWithoutResolution()
-
- it(`match: !command`, () => {
- const match = hasMutedWord({
- mutedWords: [{value: `!command`, targets: ['content']}],
- text: rt.text,
- facets: rt.facets,
- outlineTags: [],
- isOwnPost: false,
- })
-
- expect(match).toBe(true)
- })
-
- it(`match: command`, () => {
- const match = hasMutedWord({
- mutedWords: [{value: `command`, targets: ['content']}],
- text: rt.text,
- facets: rt.facets,
- outlineTags: [],
- isOwnPost: false,
- })
-
- expect(match).toBe(true)
- })
-
- it(`no match: !command`, () => {
- const rt = new RichText({
- text: `Idk maybe a bot command`,
- })
- rt.detectFacetsWithoutResolution()
-
- const match = hasMutedWord({
- mutedWords: [{value: `!command`, targets: ['content']}],
- text: rt.text,
- facets: rt.facets,
- outlineTags: [],
- isOwnPost: false,
- })
-
- expect(match).toBe(false)
- })
- })
-
- describe(`e/acc`, () => {
- const rt = new RichText({
- text: `I'm e/acc pilled`,
- })
- rt.detectFacetsWithoutResolution()
-
- it(`match: e/acc`, () => {
- const match = hasMutedWord({
- mutedWords: [{value: `e/acc`, targets: ['content']}],
- text: rt.text,
- facets: rt.facets,
- outlineTags: [],
- isOwnPost: false,
- })
-
- expect(match).toBe(true)
- })
-
- it(`match: acc`, () => {
- const match = hasMutedWord({
- mutedWords: [{value: `acc`, targets: ['content']}],
- text: rt.text,
- facets: rt.facets,
- outlineTags: [],
- isOwnPost: false,
- })
-
- expect(match).toBe(true)
- })
- })
-
- describe(`super-bad`, () => {
- const rt = new RichText({
- text: `I'm super-bad`,
- })
- rt.detectFacetsWithoutResolution()
-
- it(`match: super-bad`, () => {
- const match = hasMutedWord({
- mutedWords: [{value: `super-bad`, targets: ['content']}],
- text: rt.text,
- facets: rt.facets,
- outlineTags: [],
- isOwnPost: false,
- })
-
- expect(match).toBe(true)
- })
-
- it(`match: super`, () => {
- const match = hasMutedWord({
- mutedWords: [{value: `super`, targets: ['content']}],
- text: rt.text,
- facets: rt.facets,
- outlineTags: [],
- isOwnPost: false,
- })
-
- expect(match).toBe(true)
- })
-
- it(`match: super bad`, () => {
- const match = hasMutedWord({
- mutedWords: [{value: `super bad`, targets: ['content']}],
- text: rt.text,
- facets: rt.facets,
- outlineTags: [],
- isOwnPost: false,
- })
-
- expect(match).toBe(true)
- })
-
- it(`match: superbad`, () => {
- const match = hasMutedWord({
- mutedWords: [{value: `superbad`, targets: ['content']}],
- text: rt.text,
- facets: rt.facets,
- outlineTags: [],
- isOwnPost: false,
- })
-
- expect(match).toBe(false)
- })
- })
-
- describe(`idk_what_this_would_be`, () => {
- const rt = new RichText({
- text: `Weird post with idk_what_this_would_be`,
- })
- rt.detectFacetsWithoutResolution()
-
- it(`match: idk what this would be`, () => {
- const match = hasMutedWord({
- mutedWords: [{value: `idk what this would be`, targets: ['content']}],
- text: rt.text,
- facets: rt.facets,
- outlineTags: [],
- isOwnPost: false,
- })
-
- expect(match).toBe(true)
- })
-
- it(`no match: idk what this would be for`, () => {
- // extra word
- const match = hasMutedWord({
- mutedWords: [
- {value: `idk what this would be for`, targets: ['content']},
- ],
- text: rt.text,
- facets: rt.facets,
- outlineTags: [],
- isOwnPost: false,
- })
-
- expect(match).toBe(false)
- })
-
- it(`match: idk`, () => {
- // extra word
- const match = hasMutedWord({
- mutedWords: [{value: `idk`, targets: ['content']}],
- text: rt.text,
- facets: rt.facets,
- outlineTags: [],
- isOwnPost: false,
- })
-
- expect(match).toBe(true)
- })
-
- it(`match: idkwhatthiswouldbe`, () => {
- const match = hasMutedWord({
- mutedWords: [{value: `idkwhatthiswouldbe`, targets: ['content']}],
- text: rt.text,
- facets: rt.facets,
- outlineTags: [],
- isOwnPost: false,
- })
-
- expect(match).toBe(false)
- })
- })
-
- describe(`parentheses`, () => {
- const rt = new RichText({
- text: `Post with context(iykyk)`,
- })
- rt.detectFacetsWithoutResolution()
-
- it(`match: context(iykyk)`, () => {
- const match = hasMutedWord({
- mutedWords: [{value: `context(iykyk)`, targets: ['content']}],
- text: rt.text,
- facets: rt.facets,
- outlineTags: [],
- isOwnPost: false,
- })
-
- expect(match).toBe(true)
- })
-
- it(`match: context`, () => {
- const match = hasMutedWord({
- mutedWords: [{value: `context`, targets: ['content']}],
- text: rt.text,
- facets: rt.facets,
- outlineTags: [],
- isOwnPost: false,
- })
-
- expect(match).toBe(true)
- })
-
- it(`match: iykyk`, () => {
- const match = hasMutedWord({
- mutedWords: [{value: `iykyk`, targets: ['content']}],
- text: rt.text,
- facets: rt.facets,
- outlineTags: [],
- isOwnPost: false,
- })
-
- expect(match).toBe(true)
- })
-
- it(`match: (iykyk)`, () => {
- const match = hasMutedWord({
- mutedWords: [{value: `(iykyk)`, targets: ['content']}],
- text: rt.text,
- facets: rt.facets,
- outlineTags: [],
- isOwnPost: false,
- })
-
- expect(match).toBe(true)
- })
- })
-
- describe(`🦋`, () => {
- const rt = new RichText({
- text: `Post with 🦋`,
- })
- rt.detectFacetsWithoutResolution()
-
- it(`match: 🦋`, () => {
- const match = hasMutedWord({
- mutedWords: [{value: `🦋`, targets: ['content']}],
- text: rt.text,
- facets: rt.facets,
- outlineTags: [],
- isOwnPost: false,
- })
-
- expect(match).toBe(true)
- })
- })
- })
-
- describe(`phrases`, () => {
- describe(`I like turtles, or how I learned to stop worrying and love the internet.`, () => {
- const rt = new RichText({
- text: `I like turtles, or how I learned to stop worrying and love the internet.`,
- })
- rt.detectFacetsWithoutResolution()
-
- it(`match: stop worrying`, () => {
- const match = hasMutedWord({
- mutedWords: [{value: 'stop worrying', targets: ['content']}],
- text: rt.text,
- facets: rt.facets,
- outlineTags: [],
- isOwnPost: false,
- })
-
- expect(match).toBe(true)
- })
-
- it(`match: turtles, or how`, () => {
- const match = hasMutedWord({
- mutedWords: [{value: 'turtles, or how', targets: ['content']}],
- text: rt.text,
- facets: rt.facets,
- outlineTags: [],
- isOwnPost: false,
- })
-
- expect(match).toBe(true)
- })
- })
- })
-
- describe(`languages without spaces`, () => {
- // I love turtles, or how I learned to stop worrying and love the internet
- describe(`私はカメが好きです、またはどのようにして心配するのをやめてインターネットを愛するようになったのか`, () => {
- const rt = new RichText({
- text: `私はカメが好きです、またはどのようにして心配するのをやめてインターネットを愛するようになったのか`,
- })
- rt.detectFacetsWithoutResolution()
-
- // internet
- it(`match: インターネット`, () => {
- const match = hasMutedWord({
- mutedWords: [{value: 'インターネット', targets: ['content']}],
- text: rt.text,
- facets: rt.facets,
- outlineTags: [],
- languages: ['ja'],
- isOwnPost: false,
- })
-
- expect(match).toBe(true)
- })
- })
- })
-
- describe(`doesn't mute own post`, () => {
- it(`does mute if it isn't own post`, () => {
- const rt = new RichText({
- text: `Mute words!`,
- })
-
- const match = hasMutedWord({
- mutedWords: [{value: 'words', targets: ['content']}],
- text: rt.text,
- facets: rt.facets,
- outlineTags: [],
- isOwnPost: false,
- })
-
- expect(match).toBe(true)
- })
-
- it(`doesn't mute own post when muted word is in text`, () => {
- const rt = new RichText({
- text: `Mute words!`,
- })
-
- const match = hasMutedWord({
- mutedWords: [{value: 'words', targets: ['content']}],
- text: rt.text,
- facets: rt.facets,
- outlineTags: [],
- isOwnPost: true,
- })
-
- expect(match).toBe(false)
- })
-
- it(`doesn't mute own post when muted word is in tags`, () => {
- const rt = new RichText({
- text: `Mute #words!`,
- })
-
- const match = hasMutedWord({
- mutedWords: [{value: 'words', targets: ['tags']}],
- text: rt.text,
- facets: rt.facets,
- outlineTags: [],
- isOwnPost: true,
- })
-
- expect(match).toBe(false)
- })
- })
-})
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index e868443950..f5a72669a9 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -35,6 +35,10 @@ export const MAX_GRAPHEME_LENGTH = 300
// but increasing limit per user feedback
export const MAX_ALT_TEXT = 1000
+export function IS_TEST_USER(handle?: string) {
+ return handle && handle?.endsWith('.test')
+}
+
export function IS_PROD_SERVICE(url?: string) {
return url && url !== STAGING_SERVICE && url !== LOCAL_DEV_SERVICE
}
diff --git a/src/lib/moderatePost_wrapped.ts b/src/lib/moderatePost_wrapped.ts
index 9f6fa9c076..0ce01368af 100644
--- a/src/lib/moderatePost_wrapped.ts
+++ b/src/lib/moderatePost_wrapped.ts
@@ -1,380 +1,30 @@
-import {
- AppBskyEmbedRecord,
- AppBskyEmbedRecordWithMedia,
- moderatePost,
- AppBskyActorDefs,
- AppBskyFeedPost,
- AppBskyRichtextFacet,
- AppBskyEmbedImages,
- AppBskyEmbedExternal,
-} from '@atproto/api'
+import {moderatePost, BSKY_LABELER_DID} from '@atproto/api'
type ModeratePost = typeof moderatePost
-type Options = Parameters[1] & {
- hiddenPosts?: string[]
- mutedWords?: AppBskyActorDefs.MutedWord[]
-}
-
-const REGEX = {
- LEADING_TRAILING_PUNCTUATION: /(?:^\p{P}+|\p{P}+$)/gu,
- ESCAPE: /[[\]{}()*+?.\\^$|\s]/g,
- SEPARATORS: /[\/\-\–\—\(\)\[\]\_]+/g,
- WORD_BOUNDARY: /[\s\n\t\r\f\v]+?/g,
-}
-
-/**
- * List of 2-letter lang codes for languages that either don't use spaces, or
- * don't use spaces in a way conducive to word-based filtering.
- *
- * For these, we use a simple `String.includes` to check for a match.
- */
-const LANGUAGE_EXCEPTIONS = [
- 'ja', // Japanese
- 'zh', // Chinese
- 'ko', // Korean
- 'th', // Thai
- 'vi', // Vietnamese
-]
-
-export function hasMutedWord({
- mutedWords,
- text,
- facets,
- outlineTags,
- languages,
- isOwnPost,
-}: {
- mutedWords: AppBskyActorDefs.MutedWord[]
- text: string
- facets?: AppBskyRichtextFacet.Main[]
- outlineTags?: string[]
- languages?: string[]
- isOwnPost: boolean
-}) {
- if (isOwnPost) return false
-
- const exception = LANGUAGE_EXCEPTIONS.includes(languages?.[0] || '')
- const tags = ([] as string[])
- .concat(outlineTags || [])
- .concat(
- facets
- ?.filter(facet => {
- return facet.features.find(feature =>
- AppBskyRichtextFacet.isTag(feature),
- )
- })
- .map(t => t.features[0].tag as string) || [],
- )
- .map(t => t.toLowerCase())
-
- for (const mute of mutedWords) {
- const mutedWord = mute.value.toLowerCase()
- const postText = text.toLowerCase()
-
- // `content` applies to tags as well
- if (tags.includes(mutedWord)) return true
- // rest of the checks are for `content` only
- if (!mute.targets.includes('content')) continue
- // single character or other exception, has to use includes
- if ((mutedWord.length === 1 || exception) && postText.includes(mutedWord))
- return true
- // too long
- if (mutedWord.length > postText.length) continue
- // exact match
- if (mutedWord === postText) return true
- // any muted phrase with space or punctuation
- if (/(?:\s|\p{P})+?/u.test(mutedWord) && postText.includes(mutedWord))
- return true
-
- // check individual character groups
- const words = postText.split(REGEX.WORD_BOUNDARY)
- for (const word of words) {
- if (word === mutedWord) return true
-
- // compare word without leading/trailing punctuation, but allow internal
- // punctuation (such as `s@ssy`)
- const wordTrimmedPunctuation = word.replace(
- REGEX.LEADING_TRAILING_PUNCTUATION,
- '',
- )
-
- if (mutedWord === wordTrimmedPunctuation) return true
- if (mutedWord.length > wordTrimmedPunctuation.length) continue
-
- // handle hyphenated, slash separated words, etc
- if (REGEX.SEPARATORS.test(wordTrimmedPunctuation)) {
- // check against full normalized phrase
- const wordNormalizedSeparators = wordTrimmedPunctuation.replace(
- REGEX.SEPARATORS,
- ' ',
- )
- const mutedWordNormalizedSeparators = mutedWord.replace(
- REGEX.SEPARATORS,
- ' ',
- )
- // hyphenated (or other sep) to spaced words
- if (wordNormalizedSeparators === mutedWordNormalizedSeparators)
- return true
-
- /* Disabled for now e.g. `super-cool` to `supercool`
- const wordNormalizedCompressed = wordNormalizedSeparators.replace(
- REGEX.WORD_BOUNDARY,
- '',
- )
- const mutedWordNormalizedCompressed =
- mutedWordNormalizedSeparators.replace(/\s+?/g, '')
- // hyphenated (or other sep) to non-hyphenated contiguous word
- if (mutedWordNormalizedCompressed === wordNormalizedCompressed)
- return true
- */
-
- // then individual parts of separated phrases/words
- const wordParts = wordTrimmedPunctuation.split(REGEX.SEPARATORS)
- for (const wp of wordParts) {
- // still retain internal punctuation
- if (wp === mutedWord) return true
- }
- }
- }
- }
-
- return false
-}
+type Options = Parameters[1]
export function moderatePost_wrapped(
subject: Parameters[0],
opts: Options,
) {
- const {hiddenPosts = [], mutedWords = [], ...options} = opts
- const moderations = moderatePost(subject, options)
- const isOwnPost = subject.author.did === opts.userDid
+ // HACK
+ // temporarily translate 'gore' into 'graphic-media' during the transition period
+ // can remove this in a few months
+ // -prf
+ translateOldLabels(subject)
- if (hiddenPosts.includes(subject.uri)) {
- moderations.content.filter = true
- moderations.content.blur = true
- if (!moderations.content.cause) {
- moderations.content.cause = {
- // @ts-ignore Temporary extension to the moderation system -prf
- type: 'post-hidden',
- source: {type: 'user'},
- priority: 1,
- }
- }
- }
-
- if (AppBskyFeedPost.isRecord(subject.record)) {
- let muted = hasMutedWord({
- mutedWords,
- text: subject.record.text,
- facets: subject.record.facets || [],
- outlineTags: subject.record.tags || [],
- languages: subject.record.langs,
- isOwnPost,
- })
-
- if (
- subject.record.embed &&
- AppBskyEmbedImages.isMain(subject.record.embed)
- ) {
- for (const image of subject.record.embed.images) {
- muted =
- muted ||
- hasMutedWord({
- mutedWords,
- text: image.alt,
- facets: [],
- outlineTags: [],
- languages: subject.record.langs,
- isOwnPost,
- })
- }
- }
-
- if (muted) {
- moderations.content.filter = true
- moderations.content.blur = true
- if (!moderations.content.cause) {
- moderations.content.cause = {
- // @ts-ignore Temporary extension to the moderation system -prf
- type: 'muted-word',
- source: {type: 'user'},
- priority: 1,
- }
- }
- }
- }
-
- if (subject.embed) {
- let embedHidden = false
- let embedMuted = false
- let externalMuted = false
-
- if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) {
- embedHidden = hiddenPosts.includes(subject.embed.record.uri)
- }
- if (
- AppBskyEmbedRecordWithMedia.isView(subject.embed) &&
- AppBskyEmbedRecord.isViewRecord(subject.embed.record.record)
- ) {
- embedHidden = hiddenPosts.includes(subject.embed.record.record.uri)
- }
-
- if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) {
- if (AppBskyFeedPost.isRecord(subject.embed.record.value)) {
- const embeddedPost = subject.embed.record.value
-
- embedMuted =
- embedMuted ||
- hasMutedWord({
- mutedWords,
- text: embeddedPost.text,
- facets: embeddedPost.facets,
- outlineTags: embeddedPost.tags,
- languages: embeddedPost.langs,
- isOwnPost,
- })
-
- if (AppBskyEmbedImages.isMain(embeddedPost.embed)) {
- for (const image of embeddedPost.embed.images) {
- embedMuted =
- embedMuted ||
- hasMutedWord({
- mutedWords,
- text: image.alt,
- facets: [],
- outlineTags: [],
- languages: embeddedPost.langs,
- isOwnPost,
- })
- }
- }
-
- if (AppBskyEmbedExternal.isMain(embeddedPost.embed)) {
- const {external} = embeddedPost.embed
-
- embedMuted =
- embedMuted ||
- hasMutedWord({
- mutedWords,
- text: external.title + ' ' + external.description,
- facets: [],
- outlineTags: [],
- languages: [],
- isOwnPost,
- })
- }
-
- if (AppBskyEmbedRecordWithMedia.isMain(embeddedPost.embed)) {
- if (AppBskyEmbedExternal.isMain(embeddedPost.embed.media)) {
- const {external} = embeddedPost.embed.media
-
- embedMuted =
- embedMuted ||
- hasMutedWord({
- mutedWords,
- text: external.title + ' ' + external.description,
- facets: [],
- outlineTags: [],
- languages: [],
- isOwnPost,
- })
- }
-
- if (AppBskyEmbedImages.isMain(embeddedPost.embed.media)) {
- for (const image of embeddedPost.embed.media.images) {
- embedMuted =
- embedMuted ||
- hasMutedWord({
- mutedWords,
- text: image.alt,
- facets: [],
- outlineTags: [],
- languages: AppBskyFeedPost.isRecord(embeddedPost.record)
- ? embeddedPost.langs
- : [],
- isOwnPost,
- })
- }
- }
- }
- }
- }
-
- if (AppBskyEmbedExternal.isView(subject.embed)) {
- const {external} = subject.embed
-
- externalMuted =
- externalMuted ||
- hasMutedWord({
- mutedWords,
- text: external.title + ' ' + external.description,
- facets: [],
- outlineTags: [],
- languages: [],
- isOwnPost,
- })
- }
-
- if (
- AppBskyEmbedRecordWithMedia.isView(subject.embed) &&
- AppBskyEmbedRecord.isViewRecord(subject.embed.record.record)
- ) {
- if (AppBskyFeedPost.isRecord(subject.embed.record.record.value)) {
- const post = subject.embed.record.record.value
- embedMuted =
- embedMuted ||
- hasMutedWord({
- mutedWords,
- text: post.text,
- facets: post.facets,
- outlineTags: post.tags,
- languages: post.langs,
- isOwnPost,
- })
- }
-
- if (AppBskyEmbedImages.isView(subject.embed.media)) {
- for (const image of subject.embed.media.images) {
- embedMuted =
- embedMuted ||
- hasMutedWord({
- mutedWords,
- text: image.alt,
- facets: [],
- outlineTags: [],
- languages: AppBskyFeedPost.isRecord(subject.record)
- ? subject.record.langs
- : [],
- isOwnPost,
- })
- }
- }
- }
-
- if (embedHidden) {
- moderations.embed.filter = true
- moderations.embed.blur = true
- if (!moderations.embed.cause) {
- moderations.embed.cause = {
- // @ts-ignore Temporary extension to the moderation system -prf
- type: 'post-hidden',
- source: {type: 'user'},
- priority: 1,
- }
- }
- } else if (externalMuted || embedMuted) {
- moderations.content.filter = true
- moderations.content.blur = true
- if (!moderations.content.cause) {
- moderations.content.cause = {
- // @ts-ignore Temporary extension to the moderation system -prf
- type: 'muted-word',
- source: {type: 'user'},
- priority: 1,
- }
- }
- }
- }
-
- return moderations
+ return moderatePost(subject, opts)
+}
+
+function translateOldLabels(subject: Parameters[0]) {
+ if (subject.labels) {
+ for (const label of subject.labels) {
+ if (
+ label.val === 'gore' &&
+ (!label.src || label.src === BSKY_LABELER_DID)
+ ) {
+ label.val = 'graphic-media'
+ }
+ }
+ }
}
diff --git a/src/lib/moderation.ts b/src/lib/moderation.ts
index b6ebb47a02..4105c2c2dd 100644
--- a/src/lib/moderation.ts
+++ b/src/lib/moderation.ts
@@ -1,144 +1,20 @@
-import {ModerationCause, ProfileModeration, PostModeration} from '@atproto/api'
+import {
+ ModerationCause,
+ ModerationUI,
+ InterpretedLabelValueDefinition,
+ LABELS,
+ AppBskyLabelerDefs,
+ BskyAgent,
+ ModerationOpts,
+} from '@atproto/api'
-export interface ModerationCauseDescription {
- name: string
- description: string
-}
-
-export function describeModerationCause(
- cause: ModerationCause | undefined,
- context: 'account' | 'content',
-): ModerationCauseDescription {
- if (!cause) {
- return {
- name: 'Content Warning',
- description:
- 'Moderator has chosen to set a general warning on the content.',
- }
- }
- if (cause.type === 'blocking') {
- if (cause.source.type === 'list') {
- return {
- name: `User Blocked by "${cause.source.list.name}"`,
- description:
- 'You have blocked this user. You cannot view their content.',
- }
- } else {
- return {
- name: 'User Blocked',
- description:
- 'You have blocked this user. You cannot view their content.',
- }
- }
- }
- if (cause.type === 'blocked-by') {
- return {
- name: 'User Blocking You',
- description: 'This user has blocked you. You cannot view their content.',
- }
- }
- if (cause.type === 'block-other') {
- return {
- name: 'Content Not Available',
- description:
- 'This content is not available because one of the users involved has blocked the other.',
- }
- }
- if (cause.type === 'muted') {
- if (cause.source.type === 'list') {
- return {
- name:
- context === 'account'
- ? `Muted by "${cause.source.list.name}"`
- : `Post by muted user ("${cause.source.list.name}")`,
- description: 'You have muted this user',
- }
- } else {
- return {
- name: context === 'account' ? 'Muted User' : 'Post by muted user',
- description: 'You have muted this user',
- }
- }
- }
- // @ts-ignore Temporary extension to the moderation system -prf
- if (cause.type === 'post-hidden') {
- return {
- name: 'Post Hidden by You',
- description: 'You have hidden this post',
- }
- }
- // @ts-ignore Temporary extension to the moderation system -prf
- if (cause.type === 'muted-word') {
- return {
- name: 'Post hidden by muted word',
- description: `You've chosen to hide a word or tag within this post.`,
- }
- }
- return cause.labelDef.strings[context].en
-}
-
-export function getProfileModerationCauses(
- moderation: ProfileModeration,
-): ModerationCause[] {
- /*
- Gather everything on profile and account that blurs or alerts
- */
- return [
- moderation.decisions.profile.cause,
- ...moderation.decisions.profile.additionalCauses,
- moderation.decisions.account.cause,
- ...moderation.decisions.account.additionalCauses,
- ].filter(cause => {
- if (!cause) {
- return false
- }
- if (cause?.type === 'label') {
- if (
- cause.labelDef.onwarn === 'blur' ||
- cause.labelDef.onwarn === 'alert'
- ) {
- return true
- } else {
- return false
- }
- }
- return true
- }) as ModerationCause[]
-}
-
-export function isPostMediaBlurred(
- decisions: PostModeration['decisions'],
-): boolean {
- return decisions.post.blurMedia
-}
-
-export function isQuoteBlurred(
- decisions: PostModeration['decisions'],
-): boolean {
- return (
- decisions.quote?.blur ||
- decisions.quote?.blurMedia ||
- decisions.quote?.filter ||
- decisions.quotedAccount?.blur ||
- decisions.quotedAccount?.filter ||
- false
- )
-}
-
-export function isCauseALabelOnUri(
- cause: ModerationCause | undefined,
- uri: string,
-): boolean {
- if (cause?.type !== 'label') {
- return false
- }
- return cause.label.uri === uri
-}
+import {sanitizeDisplayName} from '#/lib/strings/display-names'
+import {sanitizeHandle} from '#/lib/strings/handles'
export function getModerationCauseKey(cause: ModerationCause): string {
const source =
cause.source.type === 'labeler'
- ? cause.source.labeler.did
+ ? cause.source.did
: cause.source.type === 'list'
? cause.source.list.uri
: 'user'
@@ -147,3 +23,59 @@ export function getModerationCauseKey(cause: ModerationCause): string {
}
return `${cause.type}:${source}`
}
+
+export function isJustAMute(modui: ModerationUI): boolean {
+ return modui.filters.length === 1 && modui.filters[0].type === 'muted'
+}
+
+export function getLabelingServiceTitle({
+ displayName,
+ handle,
+}: {
+ displayName?: string
+ handle: string
+}) {
+ return displayName
+ ? sanitizeDisplayName(displayName)
+ : sanitizeHandle(handle, '@')
+}
+
+export function lookupLabelValueDefinition(
+ labelValue: string,
+ customDefs: InterpretedLabelValueDefinition[] | undefined,
+): InterpretedLabelValueDefinition | undefined {
+ let def
+ if (!labelValue.startsWith('!') && customDefs) {
+ def = customDefs.find(d => d.identifier === labelValue)
+ }
+ if (!def) {
+ def = LABELS[labelValue as keyof typeof LABELS]
+ }
+ return def
+}
+
+export function isAppLabeler(
+ labeler:
+ | string
+ | AppBskyLabelerDefs.LabelerView
+ | AppBskyLabelerDefs.LabelerViewDetailed,
+): boolean {
+ if (typeof labeler === 'string') {
+ return BskyAgent.appLabelers.includes(labeler)
+ }
+ return BskyAgent.appLabelers.includes(labeler.creator.did)
+}
+
+export function isLabelerSubscribed(
+ labeler:
+ | string
+ | AppBskyLabelerDefs.LabelerView
+ | AppBskyLabelerDefs.LabelerViewDetailed,
+ modOpts: ModerationOpts,
+) {
+ labeler = typeof labeler === 'string' ? labeler : labeler.creator.did
+ if (isAppLabeler(labeler)) {
+ return true
+ }
+ return modOpts.prefs.labelers.find(l => l.did === labeler)
+}
diff --git a/src/lib/moderation/useGlobalLabelStrings.ts b/src/lib/moderation/useGlobalLabelStrings.ts
new file mode 100644
index 0000000000..1c5a482314
--- /dev/null
+++ b/src/lib/moderation/useGlobalLabelStrings.ts
@@ -0,0 +1,52 @@
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useMemo} from 'react'
+
+export type GlobalLabelStrings = Record<
+ string,
+ {
+ name: string
+ description: string
+ }
+>
+
+export function useGlobalLabelStrings(): GlobalLabelStrings {
+ const {_} = useLingui()
+ return useMemo(
+ () => ({
+ '!hide': {
+ name: _(msg`Content Blocked`),
+ description: _(msg`This content has been hidden by the moderators.`),
+ },
+ '!warn': {
+ name: _(msg`Content Warning`),
+ description: _(
+ msg`This content has received a general warning from moderators.`,
+ ),
+ },
+ '!no-unauthenticated': {
+ name: _(msg`Sign-in Required`),
+ description: _(
+ msg`This user has requested that their content only be shown to signed-in users.`,
+ ),
+ },
+ porn: {
+ name: _(msg`Pornography`),
+ description: _(msg`Explicit sexual images.`),
+ },
+ sexual: {
+ name: _(msg`Sexually Suggestive`),
+ description: _(msg`Does not include nudity.`),
+ },
+ nudity: {
+ name: _(msg`Non-sexual Nudity`),
+ description: _(msg`E.g. artistic nudes.`),
+ },
+ 'graphic-media': {
+ name: _(msg`Graphic Media`),
+ description: _(msg`Explicit or potentially disturbing media.`),
+ },
+ }),
+ [_],
+ )
+}
diff --git a/src/lib/moderation/useLabelBehaviorDescription.ts b/src/lib/moderation/useLabelBehaviorDescription.ts
new file mode 100644
index 0000000000..0250c1bc89
--- /dev/null
+++ b/src/lib/moderation/useLabelBehaviorDescription.ts
@@ -0,0 +1,70 @@
+import {InterpretedLabelValueDefinition, LabelPreference} from '@atproto/api'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
+
+export function useLabelBehaviorDescription(
+ labelValueDef: InterpretedLabelValueDefinition,
+ pref: LabelPreference,
+) {
+ const {_} = useLingui()
+ if (pref === 'ignore') {
+ return _(msg`Off`)
+ }
+ if (labelValueDef.blurs === 'content' || labelValueDef.blurs === 'media') {
+ if (pref === 'hide') {
+ return _(msg`Hide`)
+ }
+ return _(msg`Warn`)
+ } else if (labelValueDef.severity === 'alert') {
+ if (pref === 'hide') {
+ return _(msg`Hide`)
+ }
+ return _(msg`Warn`)
+ } else if (labelValueDef.severity === 'inform') {
+ if (pref === 'hide') {
+ return _(msg`Hide`)
+ }
+ return _(msg`Show badge`)
+ } else {
+ if (pref === 'hide') {
+ return _(msg`Hide`)
+ }
+ return _(msg`Disabled`)
+ }
+}
+
+export function useLabelLongBehaviorDescription(
+ labelValueDef: InterpretedLabelValueDefinition,
+ pref: LabelPreference,
+) {
+ const {_} = useLingui()
+ if (pref === 'ignore') {
+ return _(msg`Disabled`)
+ }
+ if (labelValueDef.blurs === 'content') {
+ if (pref === 'hide') {
+ return _(msg`Warn content and filter from feeds`)
+ }
+ return _(msg`Warn content`)
+ } else if (labelValueDef.blurs === 'media') {
+ if (pref === 'hide') {
+ return _(msg`Blur images and filter from feeds`)
+ }
+ return _(msg`Blur images`)
+ } else if (labelValueDef.severity === 'alert') {
+ if (pref === 'hide') {
+ return _(msg`Show warning and filter from feeds`)
+ }
+ return _(msg`Show warning`)
+ } else if (labelValueDef.severity === 'inform') {
+ if (pref === 'hide') {
+ return _(msg`Show badge and filter from feeds`)
+ }
+ return _(msg`Show badge`)
+ } else {
+ if (pref === 'hide') {
+ return _(msg`Filter from feeds`)
+ }
+ return _(msg`Disabled`)
+ }
+}
diff --git a/src/lib/moderation/useLabelInfo.ts b/src/lib/moderation/useLabelInfo.ts
new file mode 100644
index 0000000000..b1cffe1e71
--- /dev/null
+++ b/src/lib/moderation/useLabelInfo.ts
@@ -0,0 +1,100 @@
+import {
+ ComAtprotoLabelDefs,
+ AppBskyLabelerDefs,
+ LABELS,
+ interpretLabelValueDefinition,
+ InterpretedLabelValueDefinition,
+} from '@atproto/api'
+import {useLingui} from '@lingui/react'
+import * as bcp47Match from 'bcp-47-match'
+
+import {
+ GlobalLabelStrings,
+ useGlobalLabelStrings,
+} from '#/lib/moderation/useGlobalLabelStrings'
+import {useLabelDefinitions} from '#/state/preferences'
+
+export interface LabelInfo {
+ label: ComAtprotoLabelDefs.Label
+ def: InterpretedLabelValueDefinition
+ strings: ComAtprotoLabelDefs.LabelValueDefinitionStrings
+ labeler: AppBskyLabelerDefs.LabelerViewDetailed | undefined
+}
+
+export function useLabelInfo(label: ComAtprotoLabelDefs.Label): LabelInfo {
+ const {i18n} = useLingui()
+ const {labelDefs, labelers} = useLabelDefinitions()
+ const globalLabelStrings = useGlobalLabelStrings()
+ const def = getDefinition(labelDefs, label)
+ return {
+ label,
+ def,
+ strings: getLabelStrings(i18n.locale, globalLabelStrings, def),
+ labeler: labelers.find(labeler => label.src === labeler.creator.did),
+ }
+}
+
+export function getDefinition(
+ labelDefs: Record,
+ label: ComAtprotoLabelDefs.Label,
+): InterpretedLabelValueDefinition {
+ // check local definitions
+ const customDef =
+ !label.val.startsWith('!') &&
+ labelDefs[label.src]?.find(
+ def => def.identifier === label.val && def.definedBy === label.src,
+ )
+ if (customDef) {
+ return customDef
+ }
+
+ // check global definitions
+ const globalDef = LABELS[label.val as keyof typeof LABELS]
+ if (globalDef) {
+ return globalDef
+ }
+
+ // fallback to a noop definition
+ return interpretLabelValueDefinition(
+ {
+ identifier: label.val,
+ severity: 'none',
+ blurs: 'none',
+ defaultSetting: 'ignore',
+ locales: [],
+ },
+ label.src,
+ )
+}
+
+export function getLabelStrings(
+ locale: string,
+ globalLabelStrings: GlobalLabelStrings,
+ def: InterpretedLabelValueDefinition,
+): ComAtprotoLabelDefs.LabelValueDefinitionStrings {
+ if (!def.definedBy) {
+ // global definition, look up strings
+ if (def.identifier in globalLabelStrings) {
+ return globalLabelStrings[
+ def.identifier
+ ] as ComAtprotoLabelDefs.LabelValueDefinitionStrings
+ }
+ } else {
+ // try to find locale match in the definition's strings
+ const localeMatch = def.locales.find(
+ strings => bcp47Match.basicFilter(locale, strings.lang).length > 0,
+ )
+ if (localeMatch) {
+ return localeMatch
+ }
+ // fall back to the zero item if no match
+ if (def.locales[0]) {
+ return def.locales[0]
+ }
+ }
+ return {
+ lang: locale,
+ name: def.identifier,
+ description: `Labeled "${def.identifier}"`,
+ }
+}
diff --git a/src/lib/moderation/useModerationCauseDescription.ts b/src/lib/moderation/useModerationCauseDescription.ts
new file mode 100644
index 0000000000..46771e9584
--- /dev/null
+++ b/src/lib/moderation/useModerationCauseDescription.ts
@@ -0,0 +1,146 @@
+import React from 'react'
+import {
+ BSKY_LABELER_DID,
+ ModerationCause,
+ ModerationCauseSource,
+} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {getDefinition, getLabelStrings} from './useLabelInfo'
+import {useLabelDefinitions} from '#/state/preferences'
+import {useGlobalLabelStrings} from './useGlobalLabelStrings'
+
+import {Props as SVGIconProps} from '#/components/icons/common'
+import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
+import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
+import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
+import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
+
+export interface ModerationCauseDescription {
+ icon: React.ComponentType
+ name: string
+ description: string
+ source?: string
+ sourceType?: ModerationCauseSource['type']
+}
+
+export function useModerationCauseDescription(
+ cause: ModerationCause | undefined,
+): ModerationCauseDescription {
+ const {_, i18n} = useLingui()
+ const {labelDefs, labelers} = useLabelDefinitions()
+ const globalLabelStrings = useGlobalLabelStrings()
+
+ return React.useMemo(() => {
+ if (!cause) {
+ return {
+ icon: Warning,
+ name: _(msg`Content Warning`),
+ description: _(
+ msg`Moderator has chosen to set a general warning on the content.`,
+ ),
+ }
+ }
+ if (cause.type === 'blocking') {
+ if (cause.source.type === 'list') {
+ return {
+ icon: CircleBanSign,
+ name: _(msg`User Blocked by "${cause.source.list.name}"`),
+ description: _(
+ msg`You have blocked this user. You cannot view their content.`,
+ ),
+ }
+ } else {
+ return {
+ icon: CircleBanSign,
+ name: _(msg`User Blocked`),
+ description: _(
+ msg`You have blocked this user. You cannot view their content.`,
+ ),
+ }
+ }
+ }
+ if (cause.type === 'blocked-by') {
+ return {
+ icon: CircleBanSign,
+ name: _(msg`User Blocking You`),
+ description: _(
+ msg`This user has blocked you. You cannot view their content.`,
+ ),
+ }
+ }
+ if (cause.type === 'block-other') {
+ return {
+ icon: CircleBanSign,
+ name: _(msg`Content Not Available`),
+ description: _(
+ msg`This content is not available because one of the users involved has blocked the other.`,
+ ),
+ }
+ }
+ if (cause.type === 'muted') {
+ if (cause.source.type === 'list') {
+ return {
+ icon: EyeSlash,
+ name: _(msg`Muted by "${cause.source.list.name}"`),
+ description: _(msg`You have muted this user`),
+ }
+ } else {
+ return {
+ icon: EyeSlash,
+ name: _(msg`Account Muted`),
+ description: _(msg`You have muted this account.`),
+ }
+ }
+ }
+ if (cause.type === 'mute-word') {
+ return {
+ icon: EyeSlash,
+ name: _(msg`Post Hidden by Muted Word`),
+ description: _(
+ msg`You've chosen to hide a word or tag within this post.`,
+ ),
+ }
+ }
+ if (cause.type === 'hidden') {
+ return {
+ icon: EyeSlash,
+ name: _(msg`Post Hidden by You`),
+ description: _(msg`You have hidden this post`),
+ }
+ }
+ if (cause.type === 'label') {
+ const def = cause.labelDef || getDefinition(labelDefs, cause.label)
+ const strings = getLabelStrings(i18n.locale, globalLabelStrings, def)
+ const labeler = labelers.find(l => l.creator.did === cause.label.src)
+ let source =
+ labeler?.creator.displayName ||
+ (labeler?.creator.handle ? '@' + labeler?.creator.handle : undefined)
+ if (!source) {
+ if (cause.label.src === BSKY_LABELER_DID) {
+ source = 'Bluesky Moderation'
+ } else {
+ source = cause.label.src
+ }
+ }
+ return {
+ icon:
+ def.identifier === '!no-unauthenticated'
+ ? EyeSlash
+ : def.severity === 'alert'
+ ? Warning
+ : CircleInfo,
+ name: strings.name,
+ description: strings.description,
+ source,
+ sourceType: cause.source.type,
+ }
+ }
+ // should never happen
+ return {
+ icon: CircleInfo,
+ name: '',
+ description: ``,
+ }
+ }, [labelDefs, labelers, globalLabelStrings, cause, _, i18n.locale])
+}
diff --git a/src/lib/moderation/useReportOptions.ts b/src/lib/moderation/useReportOptions.ts
new file mode 100644
index 0000000000..e001705943
--- /dev/null
+++ b/src/lib/moderation/useReportOptions.ts
@@ -0,0 +1,94 @@
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useMemo} from 'react'
+import {ComAtprotoModerationDefs} from '@atproto/api'
+
+export interface ReportOption {
+ reason: string
+ title: string
+ description: string
+}
+
+interface ReportOptions {
+ account: ReportOption[]
+ post: ReportOption[]
+ list: ReportOption[]
+ feedgen: ReportOption[]
+ other: ReportOption[]
+}
+
+export function useReportOptions(): ReportOptions {
+ const {_} = useLingui()
+ return useMemo(() => {
+ const other = {
+ reason: ComAtprotoModerationDefs.REASONOTHER,
+ title: _(msg`Other`),
+ description: _(msg`An issue not included in these options`),
+ }
+ const common = [
+ {
+ reason: ComAtprotoModerationDefs.REASONRUDE,
+ title: _(msg`Anti-Social Behavior`),
+ description: _(msg`Harassment, trolling, or intolerance`),
+ },
+ {
+ reason: ComAtprotoModerationDefs.REASONVIOLATION,
+ title: _(msg`Illegal and Urgent`),
+ description: _(msg`Glaring violations of law or terms of service`),
+ },
+ other,
+ ]
+ return {
+ account: [
+ {
+ reason: ComAtprotoModerationDefs.REASONMISLEADING,
+ title: _(msg`Misleading Account`),
+ description: _(
+ msg`Impersonation or false claims about identity or affiliation`,
+ ),
+ },
+ {
+ reason: ComAtprotoModerationDefs.REASONSPAM,
+ title: _(msg`Frequently Posts Unwanted Content`),
+ description: _(msg`Spam; excessive mentions or replies`),
+ },
+ {
+ reason: ComAtprotoModerationDefs.REASONVIOLATION,
+ title: _(msg`Name or Description Violates Community Standards`),
+ description: _(msg`Terms used violate community standards`),
+ },
+ other,
+ ],
+ post: [
+ {
+ reason: ComAtprotoModerationDefs.REASONSPAM,
+ title: _(msg`Spam`),
+ description: _(msg`Excessive mentions or replies`),
+ },
+ {
+ reason: ComAtprotoModerationDefs.REASONSEXUAL,
+ title: _(msg`Unwanted Sexual Content`),
+ description: _(msg`Nudity or pornography not labeled as such`),
+ },
+ ...common,
+ ],
+ list: [
+ {
+ reason: ComAtprotoModerationDefs.REASONVIOLATION,
+ title: _(msg`Name or Description Violates Community Standards`),
+ description: _(msg`Terms used violate community standards`),
+ },
+ ...common,
+ ],
+ feedgen: [
+ {
+ reason: ComAtprotoModerationDefs.REASONVIOLATION,
+ title: _(msg`Name or Description Violates Community Standards`),
+ description: _(msg`Terms used violate community standards`),
+ },
+ ...common,
+ ],
+ other: common,
+ }
+ }, [_])
+}
diff --git a/src/lib/react-query.ts b/src/lib/react-query.ts
index 7fe3fe7a47..d6cd3c54b2 100644
--- a/src/lib/react-query.ts
+++ b/src/lib/react-query.ts
@@ -1,7 +1,14 @@
import {AppState, AppStateStatus} from 'react-native'
import {QueryClient, focusManager} from '@tanstack/react-query'
+import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister'
+import AsyncStorage from '@react-native-async-storage/async-storage'
+import {PersistQueryClientProviderProps} from '@tanstack/react-query-persist-client'
+
import {isNative} from '#/platform/detection'
+// any query keys in this array will be persisted to AsyncStorage
+const STORED_CACHE_QUERY_KEYS = ['labelers-detailed-info']
+
focusManager.setEventListener(onFocus => {
if (isNative) {
const subscription = AppState.addEventListener(
@@ -48,3 +55,16 @@ export const queryClient = new QueryClient({
},
},
})
+
+export const asyncStoragePersister = createAsyncStoragePersister({
+ storage: AsyncStorage,
+ key: 'queryCache',
+})
+
+export const dehydrateOptions: PersistQueryClientProviderProps['persistOptions']['dehydrateOptions'] =
+ {
+ shouldDehydrateMutation: (_: any) => false,
+ shouldDehydrateQuery: query => {
+ return STORED_CACHE_QUERY_KEYS.includes(String(query.queryKey[0]))
+ },
+ }
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index 6756a62a67..95af2f237d 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -21,7 +21,9 @@ export type CommonNavigatorParams = {
PostRepostedBy: {name: string; rkey: string}
ProfileFeed: {name: string; rkey: string}
ProfileFeedLikedBy: {name: string; rkey: string}
+ ProfileLabelerLikedBy: {name: string}
Debug: undefined
+ DebugMod: undefined
Log: undefined
Support: undefined
PrivacyPolicy: undefined
diff --git a/src/lib/strings/display-names.ts b/src/lib/strings/display-names.ts
index 75383dd4fd..e0f23fa2cc 100644
--- a/src/lib/strings/display-names.ts
+++ b/src/lib/strings/display-names.ts
@@ -1,5 +1,4 @@
import {ModerationUI} from '@atproto/api'
-import {describeModerationCause} from '../moderation'
// \u2705 = ✅
// \u2713 = ✓
@@ -14,7 +13,7 @@ export function sanitizeDisplayName(
moderation?: ModerationUI,
): string {
if (moderation?.blur) {
- return `⚠${describeModerationCause(moderation.cause, 'account').name}`
+ return ''
}
if (typeof str === 'string') {
return str.replace(CHECK_MARKS_RE, '').replace(CONTROL_CHARS_RE, '').trim()
diff --git a/src/lib/strings/embed-player.ts b/src/lib/strings/embed-player.ts
index 1cf3b12937..ee73284785 100644
--- a/src/lib/strings/embed-player.ts
+++ b/src/lib/strings/embed-player.ts
@@ -2,6 +2,15 @@ import {Dimensions} from 'react-native'
import {isWeb} from 'platform/detection'
const {height: SCREEN_HEIGHT} = Dimensions.get('window')
+const IFRAME_HOST = isWeb
+ ? // @ts-ignore only for web
+ window.location.host === 'localhost:8100'
+ ? 'http://localhost:8100'
+ : 'https://bsky.app'
+ : __DEV__ && !process.env.JEST_WORKER_ID
+ ? 'http://localhost:8100'
+ : 'https://bsky.app'
+
export const embedPlayerSources = [
'youtube',
'youtubeShorts',
@@ -74,7 +83,7 @@ export function parseEmbedPlayerFromUrl(
return {
type: 'youtube_video',
source: 'youtube',
- playerUri: `https://bsky.app/iframe/youtube.html?videoId=${videoId}&start=${seek}`,
+ playerUri: `${IFRAME_HOST}/iframe/youtube.html?videoId=${videoId}&start=${seek}`,
}
}
}
@@ -93,7 +102,7 @@ export function parseEmbedPlayerFromUrl(
type: page === 'shorts' ? 'youtube_short' : 'youtube_video',
source: page === 'shorts' ? 'youtubeShorts' : 'youtube',
hideDetails: page === 'shorts' ? true : undefined,
- playerUri: `https://bsky.app/iframe/youtube.html?videoId=${videoId}&start=${seek}`,
+ playerUri: `${IFRAME_HOST}/iframe/youtube.html?videoId=${videoId}&start=${seek}`,
}
}
}
diff --git a/src/lib/themes.ts b/src/lib/themes.ts
index bd75aabea6..6fada40a7f 100644
--- a/src/lib/themes.ts
+++ b/src/lib/themes.ts
@@ -9,7 +9,7 @@ export const defaultTheme: Theme = {
palette: {
default: {
background: lightPalette.white,
- backgroundLight: lightPalette.contrast_50,
+ backgroundLight: lightPalette.contrast_25,
text: lightPalette.black,
textLight: lightPalette.contrast_700,
textInverted: lightPalette.white,
diff --git a/src/routes.ts b/src/routes.ts
index 5c263fd6f5..f6f3729475 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -21,7 +21,9 @@ export const router = new Router({
PostRepostedBy: '/profile/:name/post/:rkey/reposted-by',
ProfileFeed: '/profile/:name/feed/:rkey',
ProfileFeedLikedBy: '/profile/:name/feed/:rkey/liked-by',
+ ProfileLabelerLikedBy: '/profile/:name/labeler/liked-by',
Debug: '/sys/debug',
+ DebugMod: '/sys/debug-mod',
Log: '/sys/log',
AppPasswords: '/settings/app-passwords',
PreferencesFollowingFeed: '/settings/following-feed',
diff --git a/src/screens/Moderation/index.tsx b/src/screens/Moderation/index.tsx
new file mode 100644
index 0000000000..26fa9ec77c
--- /dev/null
+++ b/src/screens/Moderation/index.tsx
@@ -0,0 +1,560 @@
+import React from 'react'
+import {View} from 'react-native'
+import {useFocusEffect} from '@react-navigation/native'
+import {ComAtprotoLabelDefs} from '@atproto/api'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {LABELS} from '@atproto/api'
+import {useSafeAreaFrame} from 'react-native-safe-area-context'
+
+import {NativeStackScreenProps, CommonNavigatorParams} from '#/lib/routes/types'
+import {CenteredView} from '#/view/com/util/Views'
+import {ViewHeader} from '#/view/com/util/ViewHeader'
+import {useAnalytics} from 'lib/analytics/analytics'
+import {useSetMinimalShellMode} from '#/state/shell'
+import {useSession} from '#/state/session'
+import {
+ useProfileQuery,
+ useProfileUpdateMutation,
+} from '#/state/queries/profile'
+import {ScrollView} from '#/view/com/util/Views'
+
+import {
+ UsePreferencesQueryResponse,
+ useMyLabelersQuery,
+ usePreferencesQuery,
+ usePreferencesSetAdultContentMutation,
+} from '#/state/queries/preferences'
+
+import {getLabelingServiceTitle} from '#/lib/moderation'
+import {logger} from '#/logger'
+import {useTheme, atoms as a, useBreakpoints, ViewStyleProp} from '#/alf'
+import {Divider} from '#/components/Divider'
+import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
+import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
+import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
+import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
+import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter'
+import {Text} from '#/components/Typography'
+import * as Toggle from '#/components/forms/Toggle'
+import {InlineLink, Link} from '#/components/Link'
+import {Button, ButtonText} from '#/components/Button'
+import {Loader} from '#/components/Loader'
+import * as LabelingService from '#/components/LabelingServiceCard'
+import {GlobalModerationLabelPref} from '#/components/moderation/GlobalModerationLabelPref'
+import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
+import {Props as SVGIconProps} from '#/components/icons/common'
+import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
+import * as Dialog from '#/components/Dialog'
+
+function ErrorState({error}: {error: string}) {
+ const t = useTheme()
+ return (
+
+
+
+ Hmmmm, it seems we're having trouble loading this data. See below for
+ more details. If this issue persists, please contact us.
+
+
+
+ {error}
+
+
+ )
+}
+
+export function ModerationScreen(
+ _props: NativeStackScreenProps,
+) {
+ const t = useTheme()
+ const {_} = useLingui()
+ const {
+ isLoading: isPreferencesLoading,
+ error: preferencesError,
+ data: preferences,
+ } = usePreferencesQuery()
+ const {gtMobile} = useBreakpoints()
+ const {height} = useSafeAreaFrame()
+
+ const isLoading = isPreferencesLoading
+ const error = preferencesError
+
+ return (
+
+
+
+ {isLoading ? (
+
+
+
+ ) : error || !preferences ? (
+
+ ) : (
+
+ )}
+
+ )
+}
+
+function SubItem({
+ title,
+ icon: Icon,
+ style,
+}: ViewStyleProp & {
+ title: string
+ icon: React.ComponentType
+}) {
+ const t = useTheme()
+ return (
+
+
+
+ {title}
+
+
+
+ )
+}
+
+export function ModerationScreenInner({
+ preferences,
+}: {
+ preferences: UsePreferencesQueryResponse
+}) {
+ const {_} = useLingui()
+ const t = useTheme()
+ const setMinimalShellMode = useSetMinimalShellMode()
+ const {screen} = useAnalytics()
+ const {gtMobile} = useBreakpoints()
+ const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
+ const birthdateDialogControl = Dialog.useDialogControl()
+ const {
+ isLoading: isLabelersLoading,
+ data: labelers,
+ error: labelersError,
+ } = useMyLabelersQuery()
+
+ useFocusEffect(
+ React.useCallback(() => {
+ screen('Moderation')
+ setMinimalShellMode(false)
+ }, [screen, setMinimalShellMode]),
+ )
+
+ const {mutateAsync: setAdultContentPref, variables: optimisticAdultContent} =
+ usePreferencesSetAdultContentMutation()
+ const adultContentEnabled = !!(
+ (optimisticAdultContent && optimisticAdultContent.enabled) ||
+ (!optimisticAdultContent && preferences.moderationPrefs.adultContentEnabled)
+ )
+ const ageNotSet = !preferences.userAge
+ const isUnderage = (preferences.userAge || 0) < 18
+
+ const onToggleAdultContentEnabled = React.useCallback(
+ async (selected: boolean) => {
+ try {
+ await setAdultContentPref({
+ enabled: selected,
+ })
+ } catch (e: any) {
+ logger.error(`Failed to set adult content pref`, {
+ message: e.message,
+ })
+ }
+ },
+ [setAdultContentPref],
+ )
+
+ return (
+
+
+
+ Moderation tools
+
+
+
+
+
+
+ {state => (
+
+ )}
+
+
+
+ {state => (
+
+ )}
+
+
+
+ {state => (
+
+ )}
+
+
+
+
+ Content filters
+
+
+
+ {ageNotSet && (
+ <>
+
+
+
+ >
+ )}
+
+ {!ageNotSet && !isUnderage && (
+ <>
+
+
+ Enable adult content
+
+
+
+
+ {adultContentEnabled ? (
+ Enabled
+ ) : (
+ Disabled
+ )}
+
+
+
+
+
+
+ >
+ )}
+ {!isUnderage && adultContentEnabled && (
+ <>
+
+
+
+
+
+
+ >
+ )}
+
+
+
+
+
+ Advanced
+
+
+ {isLabelersLoading ? (
+
+ ) : labelersError || !labelers ? (
+
+
+
+ We were unable to load your configured labelers at this time.
+
+
+
+ ) : (
+
+ {labelers.map((labeler, i) => {
+ return (
+
+ {i !== 0 && }
+
+ {state => (
+
+
+
+
+
+
+
+ )}
+
+
+ )
+ })}
+
+ )}
+
+
+ Logged-out visibility
+
+
+
+
+
+
+
+ )
+}
+
+function PwiOptOut() {
+ const t = useTheme()
+ const {_} = useLingui()
+ const {currentAccount} = useSession()
+ const {data: profile} = useProfileQuery({did: currentAccount?.did})
+ const updateProfile = useProfileUpdateMutation()
+
+ const isOptedOut =
+ profile?.labels?.some(l => l.val === '!no-unauthenticated') || false
+ const canToggle = profile && !updateProfile.isPending
+
+ const onToggleOptOut = React.useCallback(() => {
+ if (!profile) {
+ return
+ }
+ let wasAdded = false
+ updateProfile.mutate({
+ profile,
+ updates: existing => {
+ // create labels attr if needed
+ existing.labels = ComAtprotoLabelDefs.isSelfLabels(existing.labels)
+ ? existing.labels
+ : {
+ $type: 'com.atproto.label.defs#selfLabels',
+ values: [],
+ }
+
+ // toggle the label
+ const hasLabel = existing.labels.values.some(
+ l => l.val === '!no-unauthenticated',
+ )
+ if (hasLabel) {
+ wasAdded = false
+ existing.labels.values = existing.labels.values.filter(
+ l => l.val !== '!no-unauthenticated',
+ )
+ } else {
+ wasAdded = true
+ existing.labels.values.push({val: '!no-unauthenticated'})
+ }
+
+ // delete if no longer needed
+ if (existing.labels.values.length === 0) {
+ delete existing.labels
+ }
+ return existing
+ },
+ checkCommitted: res => {
+ const exists = !!res.data.labels?.some(
+ l => l.val === '!no-unauthenticated',
+ )
+ return exists === wasAdded
+ },
+ })
+ }, [updateProfile, profile])
+
+ return (
+
+
+
+
+
+
+ Discourage apps from showing my account to logged-out users
+
+
+
+
+ {updateProfile.isPending && }
+
+
+
+
+
+ Bluesky will not show your profile and posts to logged-out users.
+ Other apps may not honor this request. This does not make your
+ account private.
+
+
+
+
+ Note: Bluesky is an open and public network. This setting only
+ limits the visibility of your content on the Bluesky app and
+ website, and other apps may not respect this setting. Your content
+ may still be shown to logged-out users by other apps and websites.
+
+
+
+
+ Learn more about what is public on Bluesky.
+
+
+
+ )
+}
diff --git a/src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx b/src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx
index 360025c02f..0bff534362 100644
--- a/src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx
+++ b/src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx
@@ -56,7 +56,9 @@ export function AdultContentEnabledPref({
try {
mutate({
- enabled: !(variables?.enabled ?? preferences?.adultContentEnabled),
+ enabled: !(
+ variables?.enabled ?? preferences?.moderationPrefs.adultContentEnabled
+ ),
})
} catch (e) {
Toast.show(
@@ -75,7 +77,10 @@ export function AdultContentEnabledPref({
+ labelValueDefinition: InterpretedLabelValueDefinition
+ disabled?: boolean
}) {
const {_} = useLingui()
const t = useTheme()
- const groupInfo = CONFIGURABLE_LABEL_GROUPS[labelGroup]
const {data: preferences} = usePreferencesQuery()
const {mutate, variables} = usePreferencesSetContentLabelMutation()
+ const label = labelValueDefinition.identifier
const visibility =
- variables?.visibility ?? preferences?.contentLabels?.[labelGroup]
+ variables?.visibility ?? preferences?.moderationPrefs.labels?.[label]
+
+ const allLabelStrings = useGlobalLabelStrings()
+ const labelStrings =
+ labelValueDefinition.identifier in allLabelStrings
+ ? allLabelStrings[labelValueDefinition.identifier]
+ : {
+ name: labelValueDefinition.identifier,
+ description: `Labeled "${labelValueDefinition.identifier}"`,
+ }
const onChange = React.useCallback(
(vis: string[]) => {
- mutate({labelGroup, visibility: vis[0] as LabelPreference})
+ mutate({
+ label,
+ visibility: vis[0] as LabelPreference,
+ labelerDid: undefined,
+ })
},
- [mutate, labelGroup],
+ [mutate, label],
)
const labels = {
@@ -44,7 +55,7 @@ export function ModerationOption({
}
return (
-
-
- {groupInfo.title}
+ ]}>
+
+ {labelStrings.name}
- {groupInfo.subtitle}
+ {labelStrings.description}
-
-
-
- {labels.hide}
-
-
- {labels.warn}
-
-
- {labels.show}
-
-
+
+ {disabled ? (
+
+ Hide
+
+ ) : (
+
+
+ {labels.show}
+
+
+ {labels.warn}
+
+
+ {labels.hide}
+
+
+ )}
-
+
)
}
diff --git a/src/screens/Onboarding/StepModeration/index.tsx b/src/screens/Onboarding/StepModeration/index.tsx
index 543a5b1598..9b52f9f439 100644
--- a/src/screens/Onboarding/StepModeration/index.tsx
+++ b/src/screens/Onboarding/StepModeration/index.tsx
@@ -2,15 +2,10 @@ import React from 'react'
import {View} from 'react-native'
import {useLingui} from '@lingui/react'
import {msg, Trans} from '@lingui/macro'
-import Animated, {Easing, Layout} from 'react-native-reanimated'
+import {LABELS} from '@atproto/api'
import {atoms as a} from '#/alf'
-import {
- configurableAdultLabelGroups,
- configurableOtherLabelGroups,
- usePreferencesSetAdultContentMutation,
-} from 'state/queries/preferences'
-import {Divider} from '#/components/Divider'
+import {usePreferencesSetAdultContentMutation} from 'state/queries/preferences'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
@@ -28,14 +23,6 @@ import {AdultContentEnabledPref} from '#/screens/Onboarding/StepModeration/Adult
import {Context} from '#/screens/Onboarding/state'
import {IconCircle} from '#/components/IconCircle'
-function AnimatedDivider() {
- return (
-
-
-
- )
-}
-
export function StepModeration() {
const {_} = useLingui()
const {track} = useAnalytics()
@@ -52,7 +39,7 @@ export function StepModeration() {
const adultContentEnabled = !!(
(variables && variables.enabled) ||
- (!variables && preferences?.adultContentEnabled)
+ (!variables && preferences?.moderationPrefs.adultContentEnabled)
)
const onContinue = React.useCallback(() => {
@@ -86,22 +73,19 @@ export function StepModeration() {
- {adultContentEnabled &&
- configurableAdultLabelGroups.map((g, index) => (
-
- {index === 0 && }
-
-
-
- ))}
-
- {configurableOtherLabelGroups.map((g, index) => (
-
- {!adultContentEnabled && index === 0 && }
-
-
-
- ))}
+
+
+
+
>
)}
diff --git a/src/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard.tsx b/src/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard.tsx
index 0670058920..7e4ea1f8b8 100644
--- a/src/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard.tsx
+++ b/src/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard.tsx
@@ -88,7 +88,7 @@ export function SuggestedAccountCard({
diff --git a/src/screens/Onboarding/StepSuggestedAccounts/index.tsx b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx
index 14faddc10f..bdf94d8246 100644
--- a/src/screens/Onboarding/StepSuggestedAccounts/index.tsx
+++ b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx
@@ -76,7 +76,7 @@ export function StepSuggestedAccounts() {
return aggregateInterestItems(
state.interestsStepResults.selectedInterests,
state.interestsStepResults.apiResponse.suggestedAccountDids,
- state.interestsStepResults.apiResponse.suggestedAccountDids.default,
+ state.interestsStepResults.apiResponse.suggestedAccountDids.default || [],
)
}, [state.interestsStepResults])
const moderationOpts = useModerationOpts()
diff --git a/src/screens/Onboarding/StepTopicalFeeds.tsx b/src/screens/Onboarding/StepTopicalFeeds.tsx
index 636565e340..089363c233 100644
--- a/src/screens/Onboarding/StepTopicalFeeds.tsx
+++ b/src/screens/Onboarding/StepTopicalFeeds.tsx
@@ -21,7 +21,7 @@ import {
import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard'
import {aggregateInterestItems} from '#/screens/Onboarding/util'
import {IconCircle} from '#/components/IconCircle'
-import {IS_PROD_SERVICE} from 'lib/constants'
+import {IS_TEST_USER} from 'lib/constants'
import {useSession} from 'state/session'
export function StepTopicalFeeds() {
@@ -32,14 +32,14 @@ export function StepTopicalFeeds() {
const [selectedFeedUris, setSelectedFeedUris] = React.useState([])
const [saving, setSaving] = React.useState(false)
const suggestedFeedUris = React.useMemo(() => {
- if (!IS_PROD_SERVICE(currentAccount?.service)) return []
+ if (IS_TEST_USER(currentAccount?.handle)) return []
return aggregateInterestItems(
state.interestsStepResults.selectedInterests,
state.interestsStepResults.apiResponse.suggestedFeedUris,
- state.interestsStepResults.apiResponse.suggestedFeedUris.default,
+ state.interestsStepResults.apiResponse.suggestedFeedUris.default || [],
).slice(0, 10)
}, [
- currentAccount?.service,
+ currentAccount?.handle,
state.interestsStepResults.apiResponse.suggestedFeedUris,
state.interestsStepResults.selectedInterests,
])
diff --git a/src/screens/Profile/ErrorState.tsx b/src/screens/Profile/ErrorState.tsx
new file mode 100644
index 0000000000..2ec2cf592e
--- /dev/null
+++ b/src/screens/Profile/ErrorState.tsx
@@ -0,0 +1,72 @@
+import React from 'react'
+import {View} from 'react-native'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useNavigation} from '@react-navigation/native'
+
+import {useTheme, atoms as a} from '#/alf'
+import {Text} from '#/components/Typography'
+import {Button, ButtonText} from '#/components/Button'
+import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
+import {NavigationProp} from '#/lib/routes/types'
+
+export function ErrorState({error}: {error: string}) {
+ const t = useTheme()
+ const {_} = useLingui()
+ const navigation = useNavigation()
+
+ const onPressBack = React.useCallback(() => {
+ if (navigation.canGoBack()) {
+ navigation.goBack()
+ } else {
+ navigation.navigate('Home')
+ }
+ }, [navigation])
+
+ return (
+
+
+
+
+ Hmmmm, we couldn't load that moderation service.
+
+
+
+ This moderation service is unavailable. See below for more details. If
+ this issue persists, contact us.
+
+
+
+ {error}
+
+
+
+
+
+
+ )
+}
diff --git a/src/screens/Profile/Header/DisplayName.tsx b/src/screens/Profile/Header/DisplayName.tsx
new file mode 100644
index 0000000000..b6d88db712
--- /dev/null
+++ b/src/screens/Profile/Header/DisplayName.tsx
@@ -0,0 +1,31 @@
+import React from 'react'
+import {View} from 'react-native'
+import {AppBskyActorDefs, ModerationDecision} from '@atproto/api'
+import {sanitizeHandle} from 'lib/strings/handles'
+import {sanitizeDisplayName} from 'lib/strings/display-names'
+import {Shadow} from '#/state/cache/types'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Text} from '#/components/Typography'
+
+export function ProfileHeaderDisplayName({
+ profile,
+ moderation,
+}: {
+ profile: Shadow
+ moderation: ModerationDecision
+}) {
+ const t = useTheme()
+ return (
+
+
+ {sanitizeDisplayName(
+ profile.displayName || sanitizeHandle(profile.handle),
+ moderation.ui('displayName'),
+ )}
+
+
+ )
+}
diff --git a/src/screens/Profile/Header/Handle.tsx b/src/screens/Profile/Header/Handle.tsx
new file mode 100644
index 0000000000..fd1cbe5333
--- /dev/null
+++ b/src/screens/Profile/Header/Handle.tsx
@@ -0,0 +1,46 @@
+import React from 'react'
+import {View} from 'react-native'
+import {AppBskyActorDefs} from '@atproto/api'
+import {isInvalidHandle} from 'lib/strings/handles'
+import {Shadow} from '#/state/cache/types'
+import {Trans} from '@lingui/macro'
+
+import {atoms as a, useTheme, web} from '#/alf'
+import {Text} from '#/components/Typography'
+
+export function ProfileHeaderHandle({
+ profile,
+}: {
+ profile: Shadow
+}) {
+ const t = useTheme()
+ const invalidHandle = isInvalidHandle(profile.handle)
+ const blockHide = profile.viewer?.blocking || profile.viewer?.blockedBy
+ return (
+
+ {profile.viewer?.followedBy && !blockHide ? (
+
+
+ Follows you
+
+
+ ) : undefined}
+
+ {invalidHandle ? ⚠Invalid Handle : `@${profile.handle}`}
+
+
+ )
+}
diff --git a/src/screens/Profile/Header/Metrics.tsx b/src/screens/Profile/Header/Metrics.tsx
new file mode 100644
index 0000000000..d9a8a01a86
--- /dev/null
+++ b/src/screens/Profile/Header/Metrics.tsx
@@ -0,0 +1,61 @@
+import React from 'react'
+import {View} from 'react-native'
+import {AppBskyActorDefs} from '@atproto/api'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {Shadow} from '#/state/cache/types'
+import {pluralize} from '#/lib/strings/helpers'
+import {makeProfileLink} from 'lib/routes/links'
+import {formatCount} from 'view/com/util/numeric/format'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Text} from '#/components/Typography'
+import {InlineLink} from '#/components/Link'
+
+export function ProfileHeaderMetrics({
+ profile,
+}: {
+ profile: Shadow
+}) {
+ const t = useTheme()
+ const {_} = useLingui()
+ const following = formatCount(profile.followsCount || 0)
+ const followers = formatCount(profile.followersCount || 0)
+ const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower')
+
+ return (
+
+
+ {followers}
+
+ {pluralizedFollowers}
+
+
+
+
+ {following}
+
+ following
+
+
+
+
+ {formatCount(profile.postsCount || 0)}{' '}
+
+ {pluralize(profile.postsCount || 0, 'post')}
+
+
+
+ )
+}
diff --git a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx
new file mode 100644
index 0000000000..6722ed09b2
--- /dev/null
+++ b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx
@@ -0,0 +1,329 @@
+import React, {memo, useMemo} from 'react'
+import {View} from 'react-native'
+import {
+ AppBskyActorDefs,
+ AppBskyLabelerDefs,
+ ModerationOpts,
+ moderateProfile,
+ RichText as RichTextAPI,
+} from '@atproto/api'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {RichText} from '#/components/RichText'
+import {useModalControls} from '#/state/modals'
+import {usePreferencesQuery} from '#/state/queries/preferences'
+import {useAnalytics} from 'lib/analytics/analytics'
+import {useSession} from '#/state/session'
+import {Shadow} from '#/state/cache/types'
+import {useProfileShadow} from 'state/cache/profile-shadow'
+import {useLabelerSubscriptionMutation} from '#/state/queries/labeler'
+import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like'
+import {logger} from '#/logger'
+import {Haptics} from '#/lib/haptics'
+import {pluralize} from '#/lib/strings/helpers'
+import {isAppLabeler} from '#/lib/moderation'
+
+import {atoms as a, useTheme, tokens} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import {Text} from '#/components/Typography'
+import * as Toast from '#/view/com/util/Toast'
+import {ProfileHeaderShell} from './Shell'
+import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
+import {ProfileHeaderDisplayName} from './DisplayName'
+import {ProfileHeaderHandle} from './Handle'
+import {ProfileHeaderMetrics} from './Metrics'
+import {
+ Heart2_Stroke2_Corner0_Rounded as Heart,
+ Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled,
+} from '#/components/icons/Heart2'
+import {DialogOuterProps} from '#/components/Dialog'
+import * as Prompt from '#/components/Prompt'
+import {Link} from '#/components/Link'
+
+interface Props {
+ profile: AppBskyActorDefs.ProfileViewDetailed
+ labeler: AppBskyLabelerDefs.LabelerViewDetailed
+ descriptionRT: RichTextAPI | null
+ moderationOpts: ModerationOpts
+ hideBackButton?: boolean
+ isPlaceholderProfile?: boolean
+}
+
+let ProfileHeaderLabeler = ({
+ profile: profileUnshadowed,
+ labeler,
+ descriptionRT,
+ moderationOpts,
+ hideBackButton = false,
+ isPlaceholderProfile,
+}: Props): React.ReactNode => {
+ const profile: Shadow =
+ useProfileShadow(profileUnshadowed)
+ const t = useTheme()
+ const {_} = useLingui()
+ const {currentAccount, hasSession} = useSession()
+ const {openModal} = useModalControls()
+ const {track} = useAnalytics()
+ const cantSubscribePrompt = Prompt.usePromptControl()
+ const isSelf = currentAccount?.did === profile.did
+
+ const moderation = useMemo(
+ () => moderateProfile(profile, moderationOpts),
+ [profile, moderationOpts],
+ )
+ const {data: preferences} = usePreferencesQuery()
+ const {mutateAsync: toggleSubscription, variables} =
+ useLabelerSubscriptionMutation()
+ const isSubscribed =
+ variables?.subscribe ??
+ preferences?.moderationPrefs.labelers.find(l => l.did === profile.did)
+ const canSubscribe =
+ isSubscribed ||
+ (preferences ? preferences?.moderationPrefs.labelers.length < 9 : false)
+ const {mutateAsync: likeMod, isPending: isLikePending} = useLikeMutation()
+ const {mutateAsync: unlikeMod, isPending: isUnlikePending} =
+ useUnlikeMutation()
+ const [likeUri, setLikeUri] = React.useState(
+ labeler.viewer?.like || '',
+ )
+ const [likeCount, setLikeCount] = React.useState(labeler.likeCount || 0)
+
+ const onToggleLiked = React.useCallback(async () => {
+ if (!labeler) {
+ return
+ }
+ try {
+ Haptics.default()
+
+ if (likeUri) {
+ await unlikeMod({uri: likeUri})
+ track('CustomFeed:Unlike')
+ setLikeCount(c => c - 1)
+ setLikeUri('')
+ } else {
+ const res = await likeMod({uri: labeler.uri, cid: labeler.cid})
+ track('CustomFeed:Like')
+ setLikeCount(c => c + 1)
+ setLikeUri(res.uri)
+ }
+ } catch (e: any) {
+ Toast.show(
+ _(
+ msg`There was an an issue contacting the server, please check your internet connection and try again.`,
+ ),
+ )
+ logger.error(`Failed to toggle labeler like`, {message: e.message})
+ }
+ }, [labeler, likeUri, likeMod, unlikeMod, track, _])
+
+ const onPressEditProfile = React.useCallback(() => {
+ track('ProfileHeader:EditProfileButtonClicked')
+ openModal({
+ name: 'edit-profile',
+ profile,
+ })
+ }, [track, openModal, profile])
+
+ const onPressSubscribe = React.useCallback(async () => {
+ if (!canSubscribe) {
+ cantSubscribePrompt.open()
+ return
+ }
+ try {
+ await toggleSubscription({
+ did: profile.did,
+ subscribe: !isSubscribed,
+ })
+ } catch (e: any) {
+ // setSubscriptionError(e.message)
+ logger.error(`Failed to subscribe to labeler`, {message: e.message})
+ }
+ }, [
+ toggleSubscription,
+ isSubscribed,
+ profile,
+ canSubscribe,
+ cantSubscribePrompt,
+ ])
+
+ const isMe = React.useMemo(
+ () => currentAccount?.did === profile.did,
+ [currentAccount, profile],
+ )
+
+ return (
+
+
+
+ {isMe ? (
+
+ ) : !isAppLabeler(profile.did) ? (
+ <>
+
+ >
+ ) : null}
+
+
+
+
+
+
+ {!isPlaceholderProfile && (
+ <>
+ {isSelf && }
+ {descriptionRT && !moderation.ui('profileView').blur ? (
+
+
+
+ ) : undefined}
+ {!isAppLabeler(profile.did) && (
+
+
+
+ {typeof likeCount === 'number' && (
+
+ {({hovered, focused, pressed}) => (
+
+
+ Liked by {likeCount} {pluralize(likeCount, 'user')}
+
+
+ )}
+
+ )}
+
+ )}
+ >
+ )}
+
+
+
+ )
+}
+ProfileHeaderLabeler = memo(ProfileHeaderLabeler)
+export {ProfileHeaderLabeler}
+
+function CantSubscribePrompt({
+ control,
+}: {
+ control: DialogOuterProps['control']
+}) {
+ return (
+
+ Unable to subscribe
+
+
+ We're sorry! You can only subscribe to ten labelers, and you've
+ reached your limit of ten.
+
+
+
+ OK
+
+
+ )
+}
diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx
new file mode 100644
index 0000000000..8b90382441
--- /dev/null
+++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx
@@ -0,0 +1,286 @@
+import React, {memo, useMemo} from 'react'
+import {View} from 'react-native'
+import {
+ AppBskyActorDefs,
+ ModerationOpts,
+ moderateProfile,
+ RichText as RichTextAPI,
+} from '@atproto/api'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+
+import {useModalControls} from '#/state/modals'
+import {useAnalytics} from 'lib/analytics/analytics'
+import {useSession, useRequireAuth} from '#/state/session'
+import {Shadow} from '#/state/cache/types'
+import {useProfileShadow} from 'state/cache/profile-shadow'
+import {
+ useProfileFollowMutationQueue,
+ useProfileBlockMutationQueue,
+} from '#/state/queries/profile'
+import {logger} from '#/logger'
+import {sanitizeDisplayName} from 'lib/strings/display-names'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonText, ButtonIcon} from '#/components/Button'
+import * as Toast from '#/view/com/util/Toast'
+import {ProfileHeaderShell} from './Shell'
+import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
+import {ProfileHeaderDisplayName} from './DisplayName'
+import {ProfileHeaderHandle} from './Handle'
+import {ProfileHeaderMetrics} from './Metrics'
+import {ProfileHeaderSuggestedFollows} from '#/view/com/profile/ProfileHeaderSuggestedFollows'
+import {RichText} from '#/components/RichText'
+import * as Prompt from '#/components/Prompt'
+import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
+import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
+
+interface Props {
+ profile: AppBskyActorDefs.ProfileViewDetailed
+ descriptionRT: RichTextAPI | null
+ moderationOpts: ModerationOpts
+ hideBackButton?: boolean
+ isPlaceholderProfile?: boolean
+}
+
+let ProfileHeaderStandard = ({
+ profile: profileUnshadowed,
+ descriptionRT,
+ moderationOpts,
+ hideBackButton = false,
+ isPlaceholderProfile,
+}: Props): React.ReactNode => {
+ const profile: Shadow =
+ useProfileShadow(profileUnshadowed)
+ const t = useTheme()
+ const {currentAccount, hasSession} = useSession()
+ const {_} = useLingui()
+ const {openModal} = useModalControls()
+ const {track} = useAnalytics()
+ const moderation = useMemo(
+ () => moderateProfile(profile, moderationOpts),
+ [profile, moderationOpts],
+ )
+ const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false)
+ const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
+ profile,
+ 'ProfileHeader',
+ )
+ const [_queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
+ const unblockPromptControl = Prompt.usePromptControl()
+ const requireAuth = useRequireAuth()
+
+ const onPressEditProfile = React.useCallback(() => {
+ track('ProfileHeader:EditProfileButtonClicked')
+ openModal({
+ name: 'edit-profile',
+ profile,
+ })
+ }, [track, openModal, profile])
+
+ const onPressFollow = () => {
+ requireAuth(async () => {
+ try {
+ track('ProfileHeader:FollowButtonClicked')
+ await queueFollow()
+ Toast.show(
+ _(
+ msg`Following ${sanitizeDisplayName(
+ profile.displayName || profile.handle,
+ moderation.ui('displayName'),
+ )}`,
+ ),
+ )
+ } catch (e: any) {
+ if (e?.name !== 'AbortError') {
+ logger.error('Failed to follow', {message: String(e)})
+ Toast.show(_(msg`There was an issue! ${e.toString()}`))
+ }
+ }
+ })
+ }
+
+ const onPressUnfollow = () => {
+ requireAuth(async () => {
+ try {
+ track('ProfileHeader:UnfollowButtonClicked')
+ await queueUnfollow()
+ Toast.show(
+ _(
+ msg`No longer following ${sanitizeDisplayName(
+ profile.displayName || profile.handle,
+ moderation.ui('displayName'),
+ )}`,
+ ),
+ )
+ } catch (e: any) {
+ if (e?.name !== 'AbortError') {
+ logger.error('Failed to unfollow', {message: String(e)})
+ Toast.show(_(msg`There was an issue! ${e.toString()}`))
+ }
+ }
+ })
+ }
+
+ const unblockAccount = React.useCallback(async () => {
+ track('ProfileHeader:UnblockAccountButtonClicked')
+ try {
+ await queueUnblock()
+ Toast.show(_(msg`Account unblocked`))
+ } catch (e: any) {
+ if (e?.name !== 'AbortError') {
+ logger.error('Failed to unblock account', {message: e})
+ Toast.show(_(msg`There was an issue! ${e.toString()}`))
+ }
+ }
+ }, [_, queueUnblock, track])
+
+ const isMe = React.useMemo(
+ () => currentAccount?.did === profile.did,
+ [currentAccount, profile],
+ )
+
+ return (
+
+
+
+ {isMe ? (
+
+ ) : profile.viewer?.blocking ? (
+ profile.viewer?.blockingByList ? null : (
+
+ )
+ ) : !profile.viewer?.blockedBy ? (
+ <>
+ {hasSession && (
+
+ )}
+
+
+ >
+ ) : null}
+
+
+
+
+
+
+ {!isPlaceholderProfile && (
+ <>
+
+ {descriptionRT && !moderation.ui('profileView').blur ? (
+
+
+
+ ) : undefined}
+ >
+ )}
+
+ {showSuggestedFollows && (
+ {
+ if (showSuggestedFollows) {
+ setShowSuggestedFollows(false)
+ } else {
+ track('ProfileHeader:SuggestedFollowsOpened')
+ setShowSuggestedFollows(true)
+ }
+ }}
+ />
+ )}
+
+
+ )
+}
+ProfileHeaderStandard = memo(ProfileHeaderStandard)
+export {ProfileHeaderStandard}
diff --git a/src/screens/Profile/Header/Shell.tsx b/src/screens/Profile/Header/Shell.tsx
new file mode 100644
index 0000000000..c470cb2861
--- /dev/null
+++ b/src/screens/Profile/Header/Shell.tsx
@@ -0,0 +1,164 @@
+import React, {memo} from 'react'
+import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {useNavigation} from '@react-navigation/native'
+import {AppBskyActorDefs, ModerationDecision} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {NavigationProp} from 'lib/routes/types'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {BACK_HITSLOP} from 'lib/constants'
+import {useSession} from '#/state/session'
+import {Shadow} from '#/state/cache/types'
+import {useLightboxControls, ProfileImageLightbox} from '#/state/lightbox'
+
+import {atoms as a, useTheme} from '#/alf'
+import {LabelsOnMe} from '#/components/moderation/LabelsOnMe'
+import {BlurView} from 'view/com/util/BlurView'
+import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
+import {UserAvatar} from 'view/com/util/UserAvatar'
+import {UserBanner} from 'view/com/util/UserBanner'
+import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts'
+
+interface Props {
+ profile: Shadow
+ moderation: ModerationDecision
+ hideBackButton?: boolean
+ isPlaceholderProfile?: boolean
+}
+
+let ProfileHeaderShell = ({
+ children,
+ profile,
+ moderation,
+ hideBackButton = false,
+ isPlaceholderProfile,
+}: React.PropsWithChildren): React.ReactNode => {
+ const t = useTheme()
+ const {currentAccount} = useSession()
+ const {_} = useLingui()
+ const {openLightbox} = useLightboxControls()
+ const navigation = useNavigation()
+ const {isDesktop} = useWebMediaQueries()
+
+ const onPressBack = React.useCallback(() => {
+ if (navigation.canGoBack()) {
+ navigation.goBack()
+ } else {
+ navigation.navigate('Home')
+ }
+ }, [navigation])
+
+ const onPressAvi = React.useCallback(() => {
+ const modui = moderation.ui('avatar')
+ if (profile.avatar && !(modui.blur && modui.noOverride)) {
+ openLightbox(new ProfileImageLightbox(profile))
+ }
+ }, [openLightbox, profile, moderation])
+
+ const isMe = React.useMemo(
+ () => currentAccount?.did === profile.did,
+ [currentAccount, profile],
+ )
+
+ return (
+
+
+ {isPlaceholderProfile ? (
+
+ ) : (
+
+ )}
+
+
+ {children}
+
+
+
+ {isMe && (
+
+ )}
+
+
+ {!isDesktop && !hideBackButton && (
+
+
+
+
+
+
+
+ )}
+
+
+
+
+
+
+ )
+}
+ProfileHeaderShell = memo(ProfileHeaderShell)
+export {ProfileHeaderShell}
+
+const styles = StyleSheet.create({
+ backBtnWrapper: {
+ position: 'absolute',
+ top: 10,
+ left: 10,
+ width: 30,
+ height: 30,
+ overflow: 'hidden',
+ borderRadius: 15,
+ // @ts-ignore web only
+ cursor: 'pointer',
+ },
+ backBtn: {
+ width: 30,
+ height: 30,
+ borderRadius: 15,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ avi: {
+ position: 'absolute',
+ top: 110,
+ left: 10,
+ width: 94,
+ height: 94,
+ borderRadius: 47,
+ borderWidth: 2,
+ },
+ aviLabeler: {
+ borderRadius: 10,
+ },
+})
diff --git a/src/screens/Profile/Header/index.tsx b/src/screens/Profile/Header/index.tsx
new file mode 100644
index 0000000000..1280dd8b10
--- /dev/null
+++ b/src/screens/Profile/Header/index.tsx
@@ -0,0 +1,78 @@
+import React, {memo} from 'react'
+import {StyleSheet, View} from 'react-native'
+import {
+ AppBskyActorDefs,
+ AppBskyLabelerDefs,
+ ModerationOpts,
+ RichText as RichTextAPI,
+} from '@atproto/api'
+import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
+import {usePalette} from 'lib/hooks/usePalette'
+
+import {ProfileHeaderStandard} from './ProfileHeaderStandard'
+import {ProfileHeaderLabeler} from './ProfileHeaderLabeler'
+
+let ProfileHeaderLoading = (_props: {}): React.ReactNode => {
+ const pal = usePalette('default')
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+ProfileHeaderLoading = memo(ProfileHeaderLoading)
+export {ProfileHeaderLoading}
+
+interface Props {
+ profile: AppBskyActorDefs.ProfileViewDetailed
+ labeler: AppBskyLabelerDefs.LabelerViewDetailed | undefined
+ descriptionRT: RichTextAPI | null
+ moderationOpts: ModerationOpts
+ hideBackButton?: boolean
+ isPlaceholderProfile?: boolean
+}
+
+let ProfileHeader = (props: Props): React.ReactNode => {
+ if (props.profile.associated?.labeler) {
+ if (!props.labeler) {
+ return
+ }
+ return
+ }
+ return
+}
+ProfileHeader = memo(ProfileHeader)
+export {ProfileHeader}
+
+const styles = StyleSheet.create({
+ avi: {
+ position: 'absolute',
+ top: 110,
+ left: 10,
+ width: 84,
+ height: 84,
+ borderRadius: 42,
+ borderWidth: 2,
+ },
+ content: {
+ paddingTop: 8,
+ paddingHorizontal: 14,
+ paddingBottom: 4,
+ },
+ buttonsLine: {
+ flexDirection: 'row',
+ marginLeft: 'auto',
+ marginBottom: 12,
+ },
+ br40: {borderRadius: 40},
+ br50: {borderRadius: 50},
+})
diff --git a/src/screens/Profile/ProfileLabelerLikedBy.tsx b/src/screens/Profile/ProfileLabelerLikedBy.tsx
new file mode 100644
index 0000000000..1d21675208
--- /dev/null
+++ b/src/screens/Profile/ProfileLabelerLikedBy.tsx
@@ -0,0 +1,46 @@
+import React from 'react'
+import {View} from 'react-native'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useFocusEffect} from '@react-navigation/native'
+
+import {NativeStackScreenProps, CommonNavigatorParams} from '#/lib/routes/types'
+import {ViewHeader} from '#/view/com/util/ViewHeader'
+import {LikedByList} from '#/components/LikedByList'
+import {useSetMinimalShellMode} from '#/state/shell'
+import {makeRecordUri} from '#/lib/strings/url-helpers'
+
+import {atoms as a, useBreakpoints} from '#/alf'
+
+export function ProfileLabelerLikedByScreen({
+ route,
+}: NativeStackScreenProps) {
+ const setMinimalShellMode = useSetMinimalShellMode()
+ const {name: handleOrDid} = route.params
+ const uri = makeRecordUri(handleOrDid, 'app.bsky.labeler.service', 'self')
+ const {_} = useLingui()
+ const {gtMobile} = useBreakpoints()
+
+ useFocusEffect(
+ React.useCallback(() => {
+ setMinimalShellMode(false)
+ }, [setMinimalShellMode]),
+ )
+
+ return (
+
+
+
+
+ )
+}
diff --git a/src/screens/Profile/Sections/Feed.tsx b/src/screens/Profile/Sections/Feed.tsx
new file mode 100644
index 0000000000..0a5e2208d6
--- /dev/null
+++ b/src/screens/Profile/Sections/Feed.tsx
@@ -0,0 +1,88 @@
+import React from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {ListRef} from 'view/com/util/List'
+import {Feed} from 'view/com/posts/Feed'
+import {EmptyState} from 'view/com/util/EmptyState'
+import {FeedDescriptor} from '#/state/queries/post-feed'
+import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
+import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
+import {useQueryClient} from '@tanstack/react-query'
+import {truncateAndInvalidate} from '#/state/queries/util'
+import {Text} from '#/view/com/util/text/Text'
+import {usePalette} from 'lib/hooks/usePalette'
+import {isNative} from '#/platform/detection'
+import {SectionRef} from './types'
+
+interface FeedSectionProps {
+ feed: FeedDescriptor
+ headerHeight: number
+ isFocused: boolean
+ scrollElRef: ListRef
+ ignoreFilterFor?: string
+}
+export const ProfileFeedSection = React.forwardRef<
+ SectionRef,
+ FeedSectionProps
+>(function FeedSectionImpl(
+ {feed, headerHeight, isFocused, scrollElRef, ignoreFilterFor},
+ ref,
+) {
+ const {_} = useLingui()
+ const queryClient = useQueryClient()
+ const [hasNew, setHasNew] = React.useState(false)
+ const [isScrolledDown, setIsScrolledDown] = React.useState(false)
+
+ const onScrollToTop = React.useCallback(() => {
+ scrollElRef.current?.scrollToOffset({
+ animated: isNative,
+ offset: -headerHeight,
+ })
+ truncateAndInvalidate(queryClient, FEED_RQKEY(feed))
+ setHasNew(false)
+ }, [scrollElRef, headerHeight, queryClient, feed, setHasNew])
+ React.useImperativeHandle(ref, () => ({
+ scrollToTop: onScrollToTop,
+ }))
+
+ const renderPostsEmpty = React.useCallback(() => {
+ return
+ }, [_])
+
+ return (
+
+
+ {(isScrolledDown || hasNew) && (
+
+ )}
+
+ )
+})
+
+function ProfileEndOfFeed() {
+ const pal = usePalette('default')
+
+ return (
+
+
+ End of feed
+
+
+ )
+}
diff --git a/src/screens/Profile/Sections/Labels.tsx b/src/screens/Profile/Sections/Labels.tsx
new file mode 100644
index 0000000000..07beb95294
--- /dev/null
+++ b/src/screens/Profile/Sections/Labels.tsx
@@ -0,0 +1,233 @@
+import React from 'react'
+import {View} from 'react-native'
+import {
+ AppBskyLabelerDefs,
+ ModerationOpts,
+ interpretLabelValueDefinitions,
+ InterpretedLabelValueDefinition,
+} from '@atproto/api'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useSafeAreaFrame} from 'react-native-safe-area-context'
+
+import {useScrollHandlers} from '#/lib/ScrollContext'
+import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
+import {isLabelerSubscribed, lookupLabelValueDefinition} from '#/lib/moderation'
+import {ListRef} from '#/view/com/util/List'
+import {SectionRef} from './types'
+import {isNative} from '#/platform/detection'
+
+import {useTheme, atoms as a} from '#/alf'
+import {Text} from '#/components/Typography'
+import {Loader} from '#/components/Loader'
+import {Divider} from '#/components/Divider'
+import {CenteredView, ScrollView} from '#/view/com/util/Views'
+import {ErrorState} from '../ErrorState'
+import {ModerationLabelPref} from '#/components/moderation/ModerationLabelPref'
+import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
+
+interface LabelsSectionProps {
+ isLabelerLoading: boolean
+ labelerInfo: AppBskyLabelerDefs.LabelerViewDetailed | undefined
+ labelerError: Error | null
+ moderationOpts: ModerationOpts
+ scrollElRef: ListRef
+ headerHeight: number
+}
+export const ProfileLabelsSection = React.forwardRef<
+ SectionRef,
+ LabelsSectionProps
+>(function LabelsSectionImpl(
+ {
+ isLabelerLoading,
+ labelerInfo,
+ labelerError,
+ moderationOpts,
+ scrollElRef,
+ headerHeight,
+ },
+ ref,
+) {
+ const t = useTheme()
+ const {_} = useLingui()
+ const {height: minHeight} = useSafeAreaFrame()
+
+ const onScrollToTop = React.useCallback(() => {
+ // @ts-ignore TODO fix this
+ scrollElRef.current?.scrollTo({
+ animated: isNative,
+ x: 0,
+ y: -headerHeight,
+ })
+ }, [scrollElRef, headerHeight])
+
+ React.useImperativeHandle(ref, () => ({
+ scrollToTop: onScrollToTop,
+ }))
+
+ return (
+
+
+ {isLabelerLoading ? (
+
+
+
+ ) : labelerError || !labelerInfo ? (
+
+ ) : (
+
+ )}
+
+
+ )
+})
+
+export function ProfileLabelsSectionInner({
+ moderationOpts,
+ labelerInfo,
+ scrollElRef,
+ headerHeight,
+}: {
+ moderationOpts: ModerationOpts
+ labelerInfo: AppBskyLabelerDefs.LabelerViewDetailed
+ scrollElRef: ListRef
+ headerHeight: number
+}) {
+ const t = useTheme()
+ const contextScrollHandlers = useScrollHandlers()
+
+ const scrollHandler = useAnimatedScrollHandler({
+ onBeginDrag(e, ctx) {
+ contextScrollHandlers.onBeginDrag?.(e, ctx)
+ },
+ onEndDrag(e, ctx) {
+ contextScrollHandlers.onEndDrag?.(e, ctx)
+ },
+ onScroll(e, ctx) {
+ contextScrollHandlers.onScroll?.(e, ctx)
+ },
+ })
+
+ const {labelValues} = labelerInfo.policies
+ const isSubscribed = isLabelerSubscribed(labelerInfo, moderationOpts)
+ const labelDefs = React.useMemo(() => {
+ const customDefs = interpretLabelValueDefinitions(labelerInfo)
+ return labelValues
+ .map(val => lookupLabelValueDefinition(val, customDefs))
+ .filter(
+ def => def && def?.configurable,
+ ) as InterpretedLabelValueDefinition[]
+ }, [labelerInfo, labelValues])
+
+ return (
+
+
+
+
+
+ Labels are annotations on users and content. They can be used to
+ hide, warn, and categorize the network.
+
+
+ {labelerInfo.creator.viewer?.blocking ? (
+
+
+
+
+ Blocking does not prevent this labeler from placing labels on
+ your account.
+
+
+
+ ) : null}
+ {labelValues.length === 0 ? (
+
+
+ This labeler hasn't declared what labels it publishes, and may
+ not be active.
+
+
+ ) : !isSubscribed ? (
+
+
+ Subscribe to @{labelerInfo.creator.handle} to use these labels:
+
+
+ ) : null}
+
+ {labelDefs.length > 0 && (
+
+ {labelDefs.map((labelDef, i) => {
+ return (
+
+ {i !== 0 && }
+
+
+ )
+ })}
+
+ )}
+
+
+
+
+ )
+}
diff --git a/src/screens/Profile/Sections/types.ts b/src/screens/Profile/Sections/types.ts
new file mode 100644
index 0000000000..a7f77d648c
--- /dev/null
+++ b/src/screens/Profile/Sections/types.ts
@@ -0,0 +1,3 @@
+export interface SectionRef {
+ scrollToTop: () => void
+}
diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx
index a18f6c87c0..524dcb1bac 100644
--- a/src/state/modals/index.tsx
+++ b/src/state/modals/index.tsx
@@ -1,5 +1,5 @@
import React from 'react'
-import {AppBskyActorDefs, AppBskyGraphDefs, ModerationUI} from '@atproto/api'
+import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api'
import {Image as RNImage} from 'react-native-image-crop-picker'
import {ImageModel} from '#/state/models/media/image'
@@ -14,32 +14,6 @@ export interface EditProfileModal {
onUpdate?: () => void
}
-export interface ModerationDetailsModal {
- name: 'moderation-details'
- context: 'account' | 'content'
- moderation: ModerationUI
-}
-
-export type ReportModal = {
- name: 'report'
-} & (
- | {
- uri: string
- cid: string
- }
- | {did: string}
-)
-
-export type AppealLabelModal = {
- name: 'appeal-label'
-} & (
- | {
- uri: string
- cid: string
- }
- | {did: string}
-)
-
export interface CreateOrEditListModal {
name: 'create-or-edit-list'
purpose?: string
@@ -123,10 +97,6 @@ export interface AddAppPasswordModal {
name: 'add-app-password'
}
-export interface ContentFilteringSettingsModal {
- name: 'content-filtering-settings'
-}
-
export interface ContentLanguagesSettingsModal {
name: 'content-languages-settings'
}
@@ -135,10 +105,6 @@ export interface PostLanguagesSettingsModal {
name: 'post-languages-settings'
}
-export interface BirthDateSettingsModal {
- name: 'birth-date-settings'
-}
-
export interface VerifyEmailModal {
name: 'verify-email'
showReminder?: boolean
@@ -179,22 +145,15 @@ export type Modal =
| ChangeHandleModal
| DeleteAccountModal
| EditProfileModal
- | BirthDateSettingsModal
| VerifyEmailModal
| ChangeEmailModal
| ChangePasswordModal
| SwitchAccountModal
// Curation
- | ContentFilteringSettingsModal
| ContentLanguagesSettingsModal
| PostLanguagesSettingsModal
- // Moderation
- | ModerationDetailsModal
- | ReportModal
- | AppealLabelModal
-
// Lists
| CreateOrEditListModal
| UserAddRemoveListsModal
diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx
index a442b763ad..cf1d901511 100644
--- a/src/state/preferences/index.tsx
+++ b/src/state/preferences/index.tsx
@@ -15,6 +15,7 @@ export {
useSetExternalEmbedPref,
} from './external-embeds-prefs'
export * from './hidden-posts'
+export {useLabelDefinitions} from './label-defs'
export function Provider({children}: React.PropsWithChildren<{}>) {
return (
diff --git a/src/state/preferences/label-defs.tsx b/src/state/preferences/label-defs.tsx
new file mode 100644
index 0000000000..d60f8ccb88
--- /dev/null
+++ b/src/state/preferences/label-defs.tsx
@@ -0,0 +1,25 @@
+import React from 'react'
+import {InterpretedLabelValueDefinition, AppBskyLabelerDefs} from '@atproto/api'
+import {useLabelDefinitionsQuery} from '../queries/preferences'
+
+interface StateContext {
+ labelDefs: Record
+ labelers: AppBskyLabelerDefs.LabelerViewDetailed[]
+}
+
+const stateContext = React.createContext({
+ labelDefs: {},
+ labelers: [],
+})
+
+export function Provider({children}: React.PropsWithChildren<{}>) {
+ const {labelDefs, labelers} = useLabelDefinitionsQuery()
+
+ const state = {labelDefs, labelers}
+
+ return {children}
+}
+
+export function useLabelDefinitions() {
+ return React.useContext(stateContext)
+}
diff --git a/src/state/queries/actor-autocomplete.ts b/src/state/queries/actor-autocomplete.ts
index 3159ad7aaf..f14b3d65f1 100644
--- a/src/state/queries/actor-autocomplete.ts
+++ b/src/state/queries/actor-autocomplete.ts
@@ -6,17 +6,14 @@ import {logger} from '#/logger'
import {getAgent} from '#/state/session'
import {useMyFollowsQuery} from '#/state/queries/my-follows'
import {STALE} from '#/state/queries'
-import {
- DEFAULT_LOGGED_OUT_PREFERENCES,
- getModerationOpts,
- useModerationOpts,
-} from './preferences'
+import {DEFAULT_LOGGED_OUT_PREFERENCES, useModerationOpts} from './preferences'
import {isInvalidHandle} from '#/lib/strings/handles'
+import {isJustAMute} from '#/lib/moderation'
-const DEFAULT_MOD_OPTS = getModerationOpts({
- userDid: '',
- preferences: DEFAULT_LOGGED_OUT_PREFERENCES,
-})
+const DEFAULT_MOD_OPTS = {
+ userDid: undefined,
+ prefs: DEFAULT_LOGGED_OUT_PREFERENCES.moderationPrefs,
+}
export const RQKEY = (prefix: string) => ['actor-autocomplete', prefix]
@@ -114,8 +111,8 @@ function computeSuggestions(
}
}
return items.filter(profile => {
- const mod = moderateProfile(profile, moderationOpts)
- return !mod.account.filter && mod.account.cause?.type !== 'muted'
+ const modui = moderateProfile(profile, moderationOpts).ui('profileList')
+ return !modui.filter || isJustAMute(modui)
})
}
diff --git a/src/state/queries/labeler.ts b/src/state/queries/labeler.ts
new file mode 100644
index 0000000000..b2f93c4a4a
--- /dev/null
+++ b/src/state/queries/labeler.ts
@@ -0,0 +1,89 @@
+import {z} from 'zod'
+import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
+import {AppBskyLabelerDefs} from '@atproto/api'
+
+import {getAgent} from '#/state/session'
+import {preferencesQueryKey} from '#/state/queries/preferences'
+import {STALE} from '#/state/queries'
+
+export const labelerInfoQueryKey = (did: string) => ['labeler-info', did]
+export const labelersInfoQueryKey = (dids: string[]) => [
+ 'labelers-info',
+ dids.sort(),
+]
+export const labelersDetailedInfoQueryKey = (dids: string[]) => [
+ 'labelers-detailed-info',
+ dids,
+]
+
+export function useLabelerInfoQuery({
+ did,
+ enabled,
+}: {
+ did?: string
+ enabled?: boolean
+}) {
+ return useQuery({
+ enabled: !!did && enabled !== false,
+ queryKey: labelerInfoQueryKey(did as string),
+ queryFn: async () => {
+ const res = await getAgent().app.bsky.labeler.getServices({
+ dids: [did as string],
+ detailed: true,
+ })
+ return res.data.views[0] as AppBskyLabelerDefs.LabelerViewDetailed
+ },
+ })
+}
+
+export function useLabelersInfoQuery({dids}: {dids: string[]}) {
+ return useQuery({
+ enabled: !!dids.length,
+ queryKey: labelersInfoQueryKey(dids),
+ queryFn: async () => {
+ const res = await getAgent().app.bsky.labeler.getServices({dids})
+ return res.data.views as AppBskyLabelerDefs.LabelerView[]
+ },
+ })
+}
+
+export function useLabelersDetailedInfoQuery({dids}: {dids: string[]}) {
+ return useQuery({
+ enabled: !!dids.length,
+ queryKey: labelersDetailedInfoQueryKey(dids),
+ gcTime: 1000 * 60 * 60 * 6, // 6 hours
+ staleTime: STALE.MINUTES.ONE,
+ queryFn: async () => {
+ const res = await getAgent().app.bsky.labeler.getServices({
+ dids,
+ detailed: true,
+ })
+ return res.data.views as AppBskyLabelerDefs.LabelerViewDetailed[]
+ },
+ })
+}
+
+export function useLabelerSubscriptionMutation() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ async mutationFn({did, subscribe}: {did: string; subscribe: boolean}) {
+ // TODO
+ z.object({
+ did: z.string(),
+ subscribe: z.boolean(),
+ }).parse({did, subscribe})
+
+ if (subscribe) {
+ await getAgent().addLabeler(did)
+ } else {
+ await getAgent().removeLabeler(did)
+ }
+ },
+ onSuccess() {
+ queryClient.invalidateQueries({
+ queryKey: preferencesQueryKey,
+ })
+ },
+ })
+}
diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts
index 626d3e9118..97fc57dc18 100644
--- a/src/state/queries/notifications/util.ts
+++ b/src/state/queries/notifications/util.ts
@@ -1,14 +1,13 @@
import {
AppBskyNotificationListNotifications,
ModerationOpts,
- moderateProfile,
+ moderateNotification,
AppBskyFeedDefs,
AppBskyFeedPost,
AppBskyFeedRepost,
AppBskyFeedLike,
AppBskyEmbedRecord,
} from '@atproto/api'
-import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
import chunk from 'lodash.chunk'
import {QueryClient} from '@tanstack/react-query'
import {getAgent} from '../../session'
@@ -88,37 +87,20 @@ export async function fetchPage({
// internal methods
// =
-// TODO this should be in the sdk as moderateNotification -prf
-function shouldFilterNotif(
+export function shouldFilterNotif(
notif: AppBskyNotificationListNotifications.Notification,
moderationOpts: ModerationOpts | undefined,
): boolean {
if (!moderationOpts) {
return false
}
- const profile = moderateProfile(notif.author, moderationOpts)
- if (
- profile.account.filter ||
- profile.profile.filter ||
- notif.author.viewer?.muted
- ) {
- return true
+ if (notif.author.viewer?.following) {
+ return false
}
- if (
- notif.type === 'reply' ||
- notif.type === 'quote' ||
- notif.type === 'mention'
- ) {
- // NOTE: the notification overlaps the post enough for this to work
- const post = moderatePost(notif, moderationOpts)
- if (post.content.filter) {
- return true
- }
- }
- return false
+ return moderateNotification(notif, moderationOpts).ui('contentList').filter
}
-function groupNotifications(
+export function groupNotifications(
notifs: AppBskyNotificationListNotifications.Notification[],
): FeedNotification[] {
const groupedNotifs: FeedNotification[] = []
diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts
index c295ffcb03..0e6eef52ca 100644
--- a/src/state/queries/post-feed.ts
+++ b/src/state/queries/post-feed.ts
@@ -3,8 +3,8 @@ import {AppState} from 'react-native'
import {
AppBskyFeedDefs,
AppBskyFeedPost,
+ ModerationDecision,
AtUri,
- PostModeration,
} from '@atproto/api'
import {
useInfiniteQuery,
@@ -29,7 +29,6 @@ import {STALE} from '#/state/queries'
import {precacheFeedPostProfiles} from './profile'
import {getAgent} from '#/state/session'
import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const'
-import {getModerationOpts} from '#/state/queries/preferences/moderation'
import {KnownError} from '#/view/com/posts/FeedErrorMessage'
import {embedViewRecordToPostView, getEmbeddedPost} from './util'
import {useModerationOpts} from './preferences'
@@ -69,7 +68,7 @@ export interface FeedPostSliceItem {
post: AppBskyFeedDefs.PostView
record: AppBskyFeedPost.Record
reason?: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource
- moderation: PostModeration
+ moderation: ModerationDecision
}
export interface FeedPostSlice {
@@ -250,9 +249,17 @@ export function usePostFeedQuery(
// apply moderation filter
for (let i = 0; i < slice.items.length; i++) {
+ const ignoreFilter =
+ slice.items[i].post.author.did === ignoreFilterFor
+ if (ignoreFilter) {
+ // remove mutes to avoid confused UIs
+ moderations[i].causes = moderations[i].causes.filter(
+ cause => cause.type !== 'muted',
+ )
+ }
if (
- moderations[i]?.content.filter &&
- slice.items[i].post.author.did !== ignoreFilterFor
+ !ignoreFilter &&
+ moderations[i]?.ui('contentList').filter
) {
return undefined
}
@@ -435,13 +442,12 @@ function assertSomePostsPassModeration(feed: AppBskyFeedDefs.FeedViewPost[]) {
let somePostsPassModeration = false
for (const item of feed) {
- const moderationOpts = getModerationOpts({
- userDid: '',
- preferences: DEFAULT_LOGGED_OUT_PREFERENCES,
+ const moderation = moderatePost(item.post, {
+ userDid: undefined,
+ prefs: DEFAULT_LOGGED_OUT_PREFERENCES.moderationPrefs,
})
- const moderation = moderatePost(item.post, moderationOpts)
- if (!moderation.content.filter) {
+ if (!moderation.ui('contentList').filter) {
// we have a sfw post
somePostsPassModeration = true
}
diff --git a/src/state/queries/post-liked-by.ts b/src/state/queries/post-liked-by.ts
index 2cde07f280..a0498ada44 100644
--- a/src/state/queries/post-liked-by.ts
+++ b/src/state/queries/post-liked-by.ts
@@ -12,9 +12,9 @@ const PAGE_SIZE = 30
type RQPageParam = string | undefined
// TODO refactor invalidate on mutate?
-export const RQKEY = (resolvedUri: string) => ['post-liked-by', resolvedUri]
+export const RQKEY = (resolvedUri: string) => ['liked-by', resolvedUri]
-export function usePostLikedByQuery(resolvedUri: string | undefined) {
+export function useLikedByQuery(resolvedUri: string | undefined) {
return useInfiniteQuery<
AppBskyFeedGetLikes.OutputSchema,
Error,
diff --git a/src/state/queries/preferences/const.ts b/src/state/queries/preferences/const.ts
index 53c9e482af..4cb4d1e964 100644
--- a/src/state/queries/preferences/const.ts
+++ b/src/state/queries/preferences/const.ts
@@ -29,26 +29,20 @@ export const DEFAULT_PROD_FEEDS = {
export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = {
birthDate: new Date('2022-11-17'), // TODO(pwi)
- adultContentEnabled: false,
feeds: {
saved: [],
pinned: [],
unpinned: [],
},
- // labels are undefined until set by user
- contentLabels: {
- nsfw: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.nsfw,
- nudity: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.nudity,
- suggestive: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.suggestive,
- gore: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.gore,
- hate: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.hate,
- spam: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.spam,
- impersonation: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.impersonation,
+ moderationPrefs: {
+ adultContentEnabled: false,
+ labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES,
+ labelers: [],
+ mutedWords: [],
+ hiddenPosts: [],
},
feedViewPrefs: DEFAULT_HOME_FEED_PREFS,
threadViewPrefs: DEFAULT_THREAD_VIEW_PREFS,
userAge: 13, // TODO(pwi)
interests: {tags: []},
- mutedWords: [],
- hiddenPosts: [],
}
diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts
index 37ef10ae0c..cfc5c5bbe5 100644
--- a/src/state/queries/preferences/index.ts
+++ b/src/state/queries/preferences/index.ts
@@ -1,29 +1,27 @@
-import {useMemo} from 'react'
+import {useMemo, createContext, useContext} from 'react'
import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
import {
LabelPreference,
BskyFeedViewPreference,
+ ModerationOpts,
AppBskyActorDefs,
} from '@atproto/api'
import {track} from '#/lib/analytics/analytics'
import {getAge} from '#/lib/strings/time'
-import {useSession, getAgent} from '#/state/session'
-import {DEFAULT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation'
+import {getAgent, useSession} from '#/state/session'
import {
- ConfigurableLabelGroup,
UsePreferencesQueryResponse,
ThreadViewPreferences,
} from '#/state/queries/preferences/types'
-import {temp__migrateLabelPref} from '#/state/queries/preferences/util'
import {
DEFAULT_HOME_FEED_PREFS,
DEFAULT_THREAD_VIEW_PREFS,
DEFAULT_LOGGED_OUT_PREFERENCES,
} from '#/state/queries/preferences/const'
-import {getModerationOpts} from '#/state/queries/preferences/moderation'
import {STALE} from '#/state/queries'
-import {useHiddenPosts} from '#/state/preferences/hidden-posts'
+import {useHiddenPosts, useLabelDefinitions} from '#/state/preferences'
+import {saveLabelers} from '#/state/session/agent-config'
export * from '#/state/queries/preferences/types'
export * from '#/state/queries/preferences/moderation'
@@ -44,6 +42,13 @@ export function usePreferencesQuery() {
return DEFAULT_LOGGED_OUT_PREFERENCES
} else {
const res = await agent.getPreferences()
+
+ // save to local storage to ensure there are labels on initial requests
+ saveLabelers(
+ agent.session.did,
+ res.moderationPrefs.labelers.map(l => l.did),
+ )
+
const preferences: UsePreferencesQueryResponse = {
...res,
feeds: {
@@ -54,32 +59,6 @@ export function usePreferencesQuery() {
return !res.feeds.pinned?.includes(f)
}) || [],
},
- // labels are undefined until set by user
- contentLabels: {
- nsfw: temp__migrateLabelPref(
- res.contentLabels?.nsfw || DEFAULT_LABEL_PREFERENCES.nsfw,
- ),
- nudity: temp__migrateLabelPref(
- res.contentLabels?.nudity || DEFAULT_LABEL_PREFERENCES.nudity,
- ),
- suggestive: temp__migrateLabelPref(
- res.contentLabels?.suggestive ||
- DEFAULT_LABEL_PREFERENCES.suggestive,
- ),
- gore: temp__migrateLabelPref(
- res.contentLabels?.gore || DEFAULT_LABEL_PREFERENCES.gore,
- ),
- hate: temp__migrateLabelPref(
- res.contentLabels?.hate || DEFAULT_LABEL_PREFERENCES.hate,
- ),
- spam: temp__migrateLabelPref(
- res.contentLabels?.spam || DEFAULT_LABEL_PREFERENCES.spam,
- ),
- impersonation: temp__migrateLabelPref(
- res.contentLabels?.impersonation ||
- DEFAULT_LABEL_PREFERENCES.impersonation,
- ),
- },
feedViewPrefs: {
...DEFAULT_HOME_FEED_PREFS,
...(res.feedViewPrefs.home || {}),
@@ -96,25 +75,30 @@ export function usePreferencesQuery() {
})
}
+// used in the moderation state devtool
+export const moderationOptsOverrideContext = createContext<
+ ModerationOpts | undefined
+>(undefined)
+
export function useModerationOpts() {
+ const override = useContext(moderationOptsOverrideContext)
const {currentAccount} = useSession()
const prefs = usePreferencesQuery()
- const hiddenPosts = useHiddenPosts()
- const opts = useMemo(() => {
+ const {labelDefs} = useLabelDefinitions()
+ const hiddenPosts = useHiddenPosts() // TODO move this into pds-stored prefs
+ const opts = useMemo(() => {
+ if (override) {
+ return override
+ }
if (!prefs.data) {
return
}
- const moderationOpts = getModerationOpts({
- userDid: currentAccount?.did || '',
- preferences: prefs.data,
- })
-
return {
- ...moderationOpts,
- hiddenPosts,
- mutedWords: prefs.data.mutedWords || [],
+ userDid: currentAccount?.did,
+ prefs: {...prefs.data.moderationPrefs, hiddenPosts: hiddenPosts || []},
+ labelDefs,
}
- }, [currentAccount?.did, prefs.data, hiddenPosts])
+ }, [override, currentAccount, labelDefs, prefs.data, hiddenPosts])
return opts
}
@@ -138,10 +122,32 @@ export function usePreferencesSetContentLabelMutation() {
return useMutation<
void,
unknown,
- {labelGroup: ConfigurableLabelGroup; visibility: LabelPreference}
+ {label: string; visibility: LabelPreference; labelerDid: string | undefined}
>({
- mutationFn: async ({labelGroup, visibility}) => {
- await getAgent().setContentLabelPref(labelGroup, visibility)
+ mutationFn: async ({label, visibility, labelerDid}) => {
+ await getAgent().setContentLabelPref(label, visibility, labelerDid)
+ // triggers a refetch
+ await queryClient.invalidateQueries({
+ queryKey: preferencesQueryKey,
+ })
+ },
+ })
+}
+
+export function useSetContentLabelMutation() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async ({
+ label,
+ visibility,
+ labelerDid,
+ }: {
+ label: string
+ visibility: LabelPreference
+ labelerDid?: string
+ }) => {
+ await getAgent().setContentLabelPref(label, visibility, labelerDid)
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
diff --git a/src/state/queries/preferences/moderation.ts b/src/state/queries/preferences/moderation.ts
index cdae529374..9cd183e8b1 100644
--- a/src/state/queries/preferences/moderation.ts
+++ b/src/state/queries/preferences/moderation.ts
@@ -1,181 +1,53 @@
+import React from 'react'
import {
- LabelPreference,
- ComAtprotoLabelDefs,
- ModerationOpts,
+ DEFAULT_LABEL_SETTINGS,
+ BskyAgent,
+ interpretLabelValueDefinitions,
} from '@atproto/api'
-import {
- LabelGroup,
- ConfigurableLabelGroup,
- UsePreferencesQueryResponse,
-} from '#/state/queries/preferences/types'
-
-export type Label = ComAtprotoLabelDefs.Label
-
-export type LabelGroupConfig = {
- id: LabelGroup
- title: string
- isAdultImagery?: boolean
- subtitle?: string
- warning: string
- values: string[]
-}
-
-export const DEFAULT_LABEL_PREFERENCES: Record<
- ConfigurableLabelGroup,
- LabelPreference
-> = {
- nsfw: 'hide',
- nudity: 'warn',
- suggestive: 'warn',
- gore: 'warn',
- hate: 'hide',
- spam: 'hide',
- impersonation: 'hide',
-}
+import {usePreferencesQuery} from './index'
+import {useLabelersDetailedInfoQuery} from '../labeler'
/**
* More strict than our default settings for logged in users.
- *
- * TODO(pwi)
*/
-export const DEFAULT_LOGGED_OUT_LABEL_PREFERENCES: Record<
- ConfigurableLabelGroup,
- LabelPreference
-> = {
- nsfw: 'hide',
- nudity: 'hide',
- suggestive: 'hide',
- gore: 'hide',
- hate: 'hide',
- spam: 'hide',
- impersonation: 'hide',
+export const DEFAULT_LOGGED_OUT_LABEL_PREFERENCES: typeof DEFAULT_LABEL_SETTINGS =
+ Object.fromEntries(
+ Object.entries(DEFAULT_LABEL_SETTINGS).map(([key, _pref]) => [key, 'hide']),
+ )
+
+export function useMyLabelersQuery() {
+ const prefs = usePreferencesQuery()
+ const dids = Array.from(
+ new Set(
+ BskyAgent.appLabelers.concat(
+ prefs.data?.moderationPrefs.labelers.map(l => l.did) || [],
+ ),
+ ),
+ )
+ const labelers = useLabelersDetailedInfoQuery({dids})
+ const isLoading = prefs.isLoading || labelers.isLoading
+ const error = prefs.error || labelers.error
+ return React.useMemo(() => {
+ return {
+ isLoading,
+ error,
+ data: labelers.data,
+ }
+ }, [labelers, isLoading, error])
}
-export const ILLEGAL_LABEL_GROUP: LabelGroupConfig = {
- id: 'illegal',
- title: 'Illegal Content',
- warning: 'Illegal Content',
- values: ['csam', 'dmca-violation', 'nudity-nonconsensual'],
-}
-
-export const ALWAYS_FILTER_LABEL_GROUP: LabelGroupConfig = {
- id: 'always-filter',
- title: 'Content Warning',
- warning: 'Content Warning',
- values: ['!filter'],
-}
-
-export const ALWAYS_WARN_LABEL_GROUP: LabelGroupConfig = {
- id: 'always-warn',
- title: 'Content Warning',
- warning: 'Content Warning',
- values: ['!warn', 'account-security'],
-}
-
-export const UNKNOWN_LABEL_GROUP: LabelGroupConfig = {
- id: 'unknown',
- title: 'Unknown Label',
- warning: 'Content Warning',
- values: [],
-}
-
-export const CONFIGURABLE_LABEL_GROUPS: Record<
- ConfigurableLabelGroup,
- LabelGroupConfig
-> = {
- nsfw: {
- id: 'nsfw',
- title: 'Explicit Sexual Images',
- subtitle: 'i.e. pornography',
- warning: 'Sexually Explicit',
- values: ['porn', 'nsfl'],
- isAdultImagery: true,
- },
- nudity: {
- id: 'nudity',
- title: 'Other Nudity',
- subtitle: 'Including non-sexual and artistic',
- warning: 'Nudity',
- values: ['nudity'],
- isAdultImagery: true,
- },
- suggestive: {
- id: 'suggestive',
- title: 'Sexually Suggestive',
- 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', 'nsfl', 'corpse'],
- isAdultImagery: true,
- },
- hate: {
- id: 'hate',
- title: 'Hate Group Iconography',
- subtitle: 'Images of terror groups, articles covering events, etc.',
- warning: 'Hate Groups',
- values: ['icon-kkk', 'icon-nazi', 'icon-intolerant', 'behavior-intolerant'],
- },
- spam: {
- id: 'spam',
- title: 'Spam',
- subtitle: 'Excessive unwanted interactions',
- warning: 'Spam',
- values: ['spam'],
- },
- impersonation: {
- id: 'impersonation',
- title: 'Impersonation',
- subtitle: 'Accounts falsely claiming to be people or orgs',
- warning: 'Impersonation',
- values: ['impersonation'],
- },
-}
-
-export function getModerationOpts({
- userDid,
- preferences,
-}: {
- userDid: string
- preferences: UsePreferencesQueryResponse
-}): ModerationOpts {
- return {
- userDid: userDid,
- adultContentEnabled: preferences.adultContentEnabled,
- labels: {
- porn: preferences.contentLabels.nsfw,
- sexual: preferences.contentLabels.suggestive,
- nudity: preferences.contentLabels.nudity,
- nsfl: preferences.contentLabels.gore,
- corpse: preferences.contentLabels.gore,
- gore: preferences.contentLabels.gore,
- torture: preferences.contentLabels.gore,
- 'self-harm': preferences.contentLabels.gore,
- 'intolerant-race': preferences.contentLabels.hate,
- 'intolerant-gender': preferences.contentLabels.hate,
- 'intolerant-sexual-orientation': preferences.contentLabels.hate,
- 'intolerant-religion': preferences.contentLabels.hate,
- intolerant: preferences.contentLabels.hate,
- 'icon-intolerant': preferences.contentLabels.hate,
- spam: preferences.contentLabels.spam,
- impersonation: preferences.contentLabels.impersonation,
- scam: 'warn',
- },
- labelers: [
- {
- labeler: {
- did: '',
- displayName: 'Bluesky Social',
- },
- labels: {},
- },
- ],
- }
+export function useLabelDefinitionsQuery() {
+ const labelers = useMyLabelersQuery()
+ return React.useMemo(() => {
+ return {
+ labelDefs: Object.fromEntries(
+ (labelers.data || []).map(labeler => [
+ labeler.creator.did,
+ interpretLabelValueDefinitions(labeler),
+ ]),
+ ),
+ labelers: labelers.data || [],
+ }
+ }, [labelers])
}
diff --git a/src/state/queries/preferences/types.ts b/src/state/queries/preferences/types.ts
index 45c9eed7de..96da16f1a8 100644
--- a/src/state/queries/preferences/types.ts
+++ b/src/state/queries/preferences/types.ts
@@ -1,46 +1,13 @@
import {
BskyPreferences,
- LabelPreference,
BskyThreadViewPreference,
BskyFeedViewPreference,
} from '@atproto/api'
-export const configurableAdultLabelGroups = [
- 'nsfw',
- 'nudity',
- 'suggestive',
- 'gore',
-] as const
-
-export const configurableOtherLabelGroups = [
- 'hate',
- 'spam',
- 'impersonation',
-] as const
-
-export const configurableLabelGroups = [
- ...configurableAdultLabelGroups,
- ...configurableOtherLabelGroups,
-] as const
-export type ConfigurableLabelGroup = (typeof configurableLabelGroups)[number]
-
-export type LabelGroup =
- | ConfigurableLabelGroup
- | 'illegal'
- | 'always-filter'
- | 'always-warn'
- | 'unknown'
-
export type UsePreferencesQueryResponse = Omit<
BskyPreferences,
'contentLabels' | 'feedViewPrefs' | 'feeds'
> & {
- /*
- * Content labels previously included 'show', which has been deprecated in
- * favor of 'ignore'. The API can return legacy data from the database, and
- * we clean up the data in `usePreferencesQuery`.
- */
- contentLabels: Record
feedViewPrefs: BskyFeedViewPreference & {
lab_mergeFeedEnabled?: boolean
}
diff --git a/src/state/queries/preferences/util.ts b/src/state/queries/preferences/util.ts
deleted file mode 100644
index 7b8160c283..0000000000
--- a/src/state/queries/preferences/util.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import {LabelPreference} from '@atproto/api'
-
-/**
- * Content labels previously included 'show', which has been deprecated in
- * favor of 'ignore'. The API can return legacy data from the database, and
- * we clean up the data in `usePreferencesQuery`.
- *
- * @deprecated
- */
-export function temp__migrateLabelPref(
- pref: LabelPreference | 'show',
-): LabelPreference {
- // @ts-ignore
- if (pref === 'show') return 'ignore'
- return pref
-}
diff --git a/src/state/queries/profile-extra-info.ts b/src/state/queries/profile-extra-info.ts
deleted file mode 100644
index 8fc32c33eb..0000000000
--- a/src/state/queries/profile-extra-info.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import {useQuery} from '@tanstack/react-query'
-
-import {getAgent} from '#/state/session'
-import {STALE} from '#/state/queries'
-
-// TODO refactor invalidate on mutate?
-export const RQKEY = (did: string) => ['profile-extra-info', did]
-
-/**
- * Fetches some additional information for the profile screen which
- * is not available in the API's ProfileView
- */
-export function useProfileExtraInfoQuery(did: string) {
- return useQuery({
- staleTime: STALE.MINUTES.ONE,
- queryKey: RQKEY(did),
- async queryFn() {
- const [listsRes, feedsRes] = await Promise.all([
- getAgent().app.bsky.graph.getLists({
- actor: did,
- limit: 1,
- }),
- getAgent().app.bsky.feed.getActorFeeds({
- actor: did,
- limit: 1,
- }),
- ])
- return {
- hasLists: listsRes.data.lists.length > 0,
- hasFeedgens: feedsRes.data.feeds.length > 0,
- }
- },
- })
-}
diff --git a/src/state/queries/suggested-follows.ts b/src/state/queries/suggested-follows.ts
index 932226b75c..45b3ebb62f 100644
--- a/src/state/queries/suggested-follows.ts
+++ b/src/state/queries/suggested-follows.ts
@@ -46,7 +46,8 @@ export function useSuggestedFollowsQuery() {
res.data.actors = res.data.actors
.filter(
- actor => !moderateProfile(actor, moderationOpts!).account.filter,
+ actor =>
+ !moderateProfile(actor, moderationOpts!).ui('profileList').filter,
)
.filter(actor => {
const viewer = actor.viewer
diff --git a/src/state/session/agent-config.ts b/src/state/session/agent-config.ts
new file mode 100644
index 0000000000..3ee2718a39
--- /dev/null
+++ b/src/state/session/agent-config.ts
@@ -0,0 +1,12 @@
+import AsyncStorage from '@react-native-async-storage/async-storage'
+
+const PREFIX = 'agent-labelers'
+
+export async function saveLabelers(did: string, value: string[]) {
+ await AsyncStorage.setItem(`${PREFIX}:${did}`, JSON.stringify(value))
+}
+
+export async function readLabelers(did: string): Promise {
+ const rawData = await AsyncStorage.getItem(`${PREFIX}:${did}`)
+ return rawData ? JSON.parse(rawData) : undefined
+}
diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx
index 46628318c5..6b14748393 100644
--- a/src/state/session/index.tsx
+++ b/src/state/session/index.tsx
@@ -1,8 +1,15 @@
import React from 'react'
-import {BskyAgent, AtpPersistSessionHandler} from '@atproto/api'
+import {
+ BskyAgent,
+ AtpPersistSessionHandler,
+ BSKY_LABELER_DID,
+} from '@atproto/api'
import {useQueryClient} from '@tanstack/react-query'
import {jwtDecode} from 'jwt-decode'
+import {IS_DEV} from '#/env'
+import {IS_TEST_USER} from '#/lib/constants'
+import {isWeb} from '#/platform/detection'
import {networkRetry} from '#/lib/async/retry'
import {logger} from '#/logger'
import * as persisted from '#/state/persisted'
@@ -12,6 +19,7 @@ import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {useCloseAllActiveElements} from '#/state/util'
import {track} from '#/lib/analytics/analytics'
import {hasProp} from '#/lib/type-guards'
+import {readLabelers} from './agent-config'
let __globalAgent: BskyAgent = PUBLIC_BSKY_AGENT
@@ -255,6 +263,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
deactivated,
}
+ await configureModeration(agent, account)
+
agent.setPersistSessionHandler(
createPersistSessionHandler(
account,
@@ -298,6 +308,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
deactivated: isSessionDeactivated(agent.session.accessJwt),
}
+ await configureModeration(agent, account)
+
agent.setPersistSessionHandler(
createPersistSessionHandler(
account,
@@ -309,6 +321,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
)
__globalAgent = agent
+ // @ts-ignore
+ if (IS_DEV && isWeb) window.agent = agent
queryClient.clear()
upsertAccount(account)
@@ -348,6 +362,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
{networkErrorCallback: clearCurrentAccount},
),
})
+ // @ts-ignore
+ if (IS_DEV && isWeb) window.agent = agent
+ await configureModeration(agent, account)
let canReusePrevSession = false
try {
@@ -643,6 +660,28 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
)
}
+async function configureModeration(agent: BskyAgent, account: SessionAccount) {
+ if (IS_TEST_USER(account.handle)) {
+ const did = (
+ await agent
+ .resolveHandle({handle: 'mod-authority.test'})
+ .catch(_ => undefined)
+ )?.data.did
+ if (did) {
+ console.warn('USING TEST ENV MODERATION')
+ BskyAgent.configure({appLabelers: [did]})
+ }
+ } else {
+ BskyAgent.configure({appLabelers: [BSKY_LABELER_DID]})
+ const labelerDids = await readLabelers(account.did).catch(_ => {})
+ if (labelerDids) {
+ agent.configureLabelersHeader(
+ labelerDids.filter(did => did !== BSKY_LABELER_DID),
+ )
+ }
+ }
+}
+
export function useSession() {
return React.useContext(StateContext)
}
diff --git a/src/state/shell/composer.tsx b/src/state/shell/composer.tsx
index c9dbfbeac4..a09e8fba9f 100644
--- a/src/state/shell/composer.tsx
+++ b/src/state/shell/composer.tsx
@@ -2,7 +2,7 @@ import React from 'react'
import {
AppBskyEmbedRecord,
AppBskyRichtextFacet,
- PostModeration,
+ ModerationDecision,
} from '@atproto/api'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
@@ -16,7 +16,7 @@ export interface ComposerOptsPostRef {
avatar?: string
}
embed?: AppBskyEmbedRecord.ViewRecord['embed']
- moderation?: PostModeration
+ moderation?: ModerationDecision
}
export interface ComposerOptsQuote {
uri: string
diff --git a/src/view/com/auth/create/state.ts b/src/view/com/auth/create/state.ts
index 7a727ec0b2..840084dcb7 100644
--- a/src/view/com/auth/create/state.ts
+++ b/src/view/com/auth/create/state.ts
@@ -12,7 +12,7 @@ import {createFullHandle, validateHandle} from '#/lib/strings/handles'
import {cleanError} from '#/lib/strings/errors'
import {useOnboardingDispatch} from '#/state/shell/onboarding'
import {useSessionApi} from '#/state/session'
-import {DEFAULT_SERVICE, IS_PROD_SERVICE} from '#/lib/constants'
+import {DEFAULT_SERVICE, IS_TEST_USER} from '#/lib/constants'
import {
DEFAULT_PROD_FEEDS,
usePreferencesSetBirthDateMutation,
@@ -147,7 +147,7 @@ export function useSubmitCreateAccount(
: undefined,
})
setBirthDate({birthDate: uiState.birthDate})
- if (IS_PROD_SERVICE(uiState.serviceUrl)) {
+ if (!IS_TEST_USER(uiState.handle)) {
setSavedFeeds(DEFAULT_PROD_FEEDS)
}
} catch (e: any) {
diff --git a/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx
index 5f81a4d657..434b10c223 100644
--- a/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx
+++ b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx
@@ -1,6 +1,6 @@
import React from 'react'
import {View, StyleSheet, ActivityIndicator} from 'react-native'
-import {ProfileModeration, AppBskyActorDefs} from '@atproto/api'
+import {ModerationDecision, AppBskyActorDefs} from '@atproto/api'
import {Button} from '#/view/com/util/forms/Button'
import {usePalette} from 'lib/hooks/usePalette'
import {sanitizeDisplayName} from 'lib/strings/display-names'
@@ -18,7 +18,7 @@ import {logger} from '#/logger'
type Props = {
profile: AppBskyActorDefs.ProfileViewBasic
- moderation: ProfileModeration
+ moderation: ModerationDecision
onFollowStateChange: (props: {
did: string
following: boolean
@@ -62,7 +62,7 @@ function ProfileCard({
moderation,
}: {
profile: Shadow
- moderation: ProfileModeration
+ moderation: ModerationDecision
onFollowStateChange: (props: {
did: string
following: boolean
@@ -113,7 +113,7 @@ function ProfileCard({
@@ -124,7 +124,7 @@ function ProfileCard({
lineHeight={1.2}>
{sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
- moderation.profile,
+ moderation.ui('displayName'),
)}
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 97f8e51940..0a2692d06c 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -39,7 +39,7 @@ import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {useExternalLinkFetch} from './useExternalLinkFetch'
import {isWeb, isNative, isAndroid, isIOS} from 'platform/detection'
-import QuoteEmbed from '../util/post-embeds/QuoteEmbed'
+import {QuoteEmbed} from '../util/post-embeds/QuoteEmbed'
import {GalleryModel} from 'state/models/media/gallery'
import {Gallery} from './photos/Gallery'
import {MAX_GRAPHEME_LENGTH} from 'lib/constants'
diff --git a/src/view/com/composer/ComposerReplyTo.tsx b/src/view/com/composer/ComposerReplyTo.tsx
index 39a1473a3b..4832bca02c 100644
--- a/src/view/com/composer/ComposerReplyTo.tsx
+++ b/src/view/com/composer/ComposerReplyTo.tsx
@@ -15,7 +15,7 @@ import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles'
import {UserAvatar} from 'view/com/util/UserAvatar'
import {Text} from 'view/com/util/text/Text'
-import QuoteEmbed from 'view/com/util/post-embeds/QuoteEmbed'
+import {QuoteEmbed} from 'view/com/util/post-embeds/QuoteEmbed'
export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
const pal = usePalette('default')
@@ -86,7 +86,7 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
@@ -103,7 +103,7 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
{replyTo.text}
- {images && !replyTo.moderation?.embed.blur && (
+ {images && !replyTo.moderation?.ui('contentMedia').blur && (
)}
diff --git a/src/view/com/composer/select-language/SelectLangBtn.tsx b/src/view/com/composer/select-language/SelectLangBtn.tsx
index 78b1e9ba27..7856222259 100644
--- a/src/view/com/composer/select-language/SelectLangBtn.tsx
+++ b/src/view/com/composer/select-language/SelectLangBtn.tsx
@@ -20,7 +20,7 @@ import {
toPostLanguages,
hasPostLanguage,
} from '#/state/preferences/languages'
-import {t, msg} from '@lingui/macro'
+import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
export function SelectLangBtn() {
@@ -84,15 +84,15 @@ export function SelectLangBtn() {
}
return [
- {heading: true, label: t`Post language`},
+ {heading: true, label: _(msg`Post language`)},
...arr.slice(0, 6),
{sep: true},
{
- label: t`Other...`,
+ label: _(msg`Other...`),
onPress: onPressMore,
},
]
- }, [onPressMore, langPrefs, setLangPrefs, postLanguagesPref])
+ }, [onPressMore, langPrefs, setLangPrefs, postLanguagesPref, _])
return (
void
@@ -16,20 +18,23 @@ type Props = {
const HIT_SLOP = createHitslop(16)
-const ImageDefaultHeader = ({onRequestClose}: Props) => (
-
-
- ✕
-
-
-)
+const ImageDefaultHeader = ({onRequestClose}: Props) => {
+ const {_} = useLingui()
+ return (
+
+
+ ✕
+
+
+ )
+}
const styles = StyleSheet.create({
root: {
diff --git a/src/view/com/modals/AppealLabel.tsx b/src/view/com/modals/AppealLabel.tsx
deleted file mode 100644
index b0aaaf6255..0000000000
--- a/src/view/com/modals/AppealLabel.tsx
+++ /dev/null
@@ -1,139 +0,0 @@
-import React, {useState} from 'react'
-import {StyleSheet, TouchableOpacity, View} from 'react-native'
-import {ComAtprotoModerationDefs} from '@atproto/api'
-import {ScrollView, TextInput} from './util'
-import {Text} from '../util/text/Text'
-import {s, colors} from 'lib/styles'
-import {usePalette} from 'lib/hooks/usePalette'
-import {Trans, msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {useModalControls} from '#/state/modals'
-import {CharProgress} from '../composer/char-progress/CharProgress'
-import {getAgent} from '#/state/session'
-import * as Toast from '../util/Toast'
-import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
-
-export const snapPoints = ['40%']
-
-type ReportComponentProps =
- | {
- uri: string
- cid: string
- }
- | {
- did: string
- }
-
-export function Component(props: ReportComponentProps) {
- const pal = usePalette('default')
- const [details, setDetails] = useState('')
- const {_} = useLingui()
- const {closeModal} = useModalControls()
- const {isMobile} = useWebMediaQueries()
- const isAccountReport = 'did' in props
-
- const submit = async () => {
- try {
- const $type = !isAccountReport
- ? 'com.atproto.repo.strongRef'
- : 'com.atproto.admin.defs#repoRef'
- await getAgent().createModerationReport({
- reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
- subject: {
- $type,
- ...props,
- },
- reason: details,
- })
- Toast.show(_(msg`We'll look into your appeal promptly.`))
- } finally {
- closeModal()
- }
- }
-
- return (
-
-
- Appeal Content Warning
-
-
-
-
-
-
-
-
-
-
-
-
- Submit
-
-
-
-
- )
-}
-
-const styles = StyleSheet.create({
- detailsInputContainer: {
- borderRadius: 8,
- marginBottom: 8,
- },
- detailsInput: {
- paddingHorizontal: 12,
- paddingTop: 12,
- paddingBottom: 12,
- borderRadius: 8,
- minHeight: 100,
- fontSize: 16,
- },
- detailsInputBottomBar: {
- alignSelf: 'flex-end',
- },
- charCounter: {
- flexDirection: 'row',
- alignItems: 'center',
- paddingRight: 10,
- paddingBottom: 8,
- },
- btn: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- borderRadius: 32,
- padding: 14,
- backgroundColor: colors.blue3,
- },
-})
diff --git a/src/view/com/modals/BirthDateSettings.tsx b/src/view/com/modals/BirthDateSettings.tsx
deleted file mode 100644
index 1cab959896..0000000000
--- a/src/view/com/modals/BirthDateSettings.tsx
+++ /dev/null
@@ -1,151 +0,0 @@
-import React, {useState} from 'react'
-import {
- ActivityIndicator,
- StyleSheet,
- TouchableOpacity,
- View,
-} from 'react-native'
-import {Text} from '../util/text/Text'
-import {DateInput} from '../util/forms/DateInput'
-import {ErrorMessage} from '../util/error/ErrorMessage'
-import {s, colors} from 'lib/styles'
-import {usePalette} from 'lib/hooks/usePalette'
-import {isWeb} from 'platform/detection'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {cleanError} from 'lib/strings/errors'
-import {Trans, msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {useModalControls} from '#/state/modals'
-import {
- usePreferencesQuery,
- usePreferencesSetBirthDateMutation,
- UsePreferencesQueryResponse,
-} from '#/state/queries/preferences'
-import {logger} from '#/logger'
-
-export const snapPoints = ['50%', '90%']
-
-function Inner({preferences}: {preferences: UsePreferencesQueryResponse}) {
- const pal = usePalette('default')
- const {isMobile} = useWebMediaQueries()
- const {_} = useLingui()
- const {
- isPending,
- isError,
- error,
- mutateAsync: setBirthDate,
- } = usePreferencesSetBirthDateMutation()
- const [date, setDate] = useState(preferences.birthDate || new Date())
- const {closeModal} = useModalControls()
-
- const onSave = React.useCallback(async () => {
- try {
- await setBirthDate({birthDate: date})
- closeModal()
- } catch (e) {
- logger.error(`setBirthDate failed`, {message: e})
- }
- }, [date, setBirthDate, closeModal])
-
- return (
-
-
-
- My Birthday
-
-
-
-
- This information is not shared with other users.
-
-
-
-
-
-
- {isError ? (
-
- ) : undefined}
-
-
- {isPending ? (
-
-
-
- ) : (
-
-
- Save
-
-
- )}
-
-
- )
-}
-
-export function Component({}: {}) {
- const {data: preferences} = usePreferencesQuery()
-
- return !preferences ? (
-
- ) : (
-
- )
-}
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- paddingBottom: isWeb ? 0 : 40,
- },
- titleSection: {
- paddingTop: isWeb ? 0 : 4,
- paddingBottom: isWeb ? 14 : 10,
- },
- title: {
- textAlign: 'center',
- fontWeight: '600',
- marginBottom: 5,
- },
- error: {
- borderRadius: 6,
- marginTop: 10,
- },
- dateInputButton: {
- borderWidth: 1,
- borderRadius: 6,
- paddingVertical: 14,
- },
- btn: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- borderRadius: 32,
- padding: 14,
- backgroundColor: colors.blue3,
- },
- btnContainer: {
- paddingTop: 20,
- paddingHorizontal: 20,
- },
-})
diff --git a/src/view/com/modals/ContentFilteringSettings.tsx b/src/view/com/modals/ContentFilteringSettings.tsx
deleted file mode 100644
index 328d23dc29..0000000000
--- a/src/view/com/modals/ContentFilteringSettings.tsx
+++ /dev/null
@@ -1,401 +0,0 @@
-import React from 'react'
-import {LabelPreference} from '@atproto/api'
-import {StyleSheet, Pressable, View, Linking} from 'react-native'
-import LinearGradient from 'react-native-linear-gradient'
-import {ScrollView} from './util'
-import {s, colors, gradients} from 'lib/styles'
-import {Text} from '../util/text/Text'
-import {TextLink} from '../util/Link'
-import {ToggleButton} from '../util/forms/ToggleButton'
-import {Button} from '../util/forms/Button'
-import {usePalette} from 'lib/hooks/usePalette'
-import {isIOS} from 'platform/detection'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import * as Toast from '../util/Toast'
-import {logger} from '#/logger'
-import {Trans, msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {useModalControls} from '#/state/modals'
-import {
- usePreferencesQuery,
- usePreferencesSetContentLabelMutation,
- usePreferencesSetAdultContentMutation,
- ConfigurableLabelGroup,
- CONFIGURABLE_LABEL_GROUPS,
- UsePreferencesQueryResponse,
-} from '#/state/queries/preferences'
-
-export const snapPoints = ['90%']
-
-export function Component({}: {}) {
- const {isMobile} = useWebMediaQueries()
- const pal = usePalette('default')
- const {_} = useLingui()
- const {closeModal} = useModalControls()
- const {data: preferences} = usePreferencesQuery()
-
- const onPressDone = React.useCallback(() => {
- closeModal()
- }, [closeModal])
-
- return (
-
-
- Content Filtering
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Done
-
-
-
-
-
- )
-}
-
-function AdultContentEnabledPref() {
- const pal = usePalette('default')
- const {_} = useLingui()
- const {data: preferences} = usePreferencesQuery()
- const {mutate, variables} = usePreferencesSetAdultContentMutation()
- const {openModal} = useModalControls()
-
- const onSetAge = React.useCallback(
- () => openModal({name: 'birth-date-settings'}),
- [openModal],
- )
-
- const onToggleAdultContent = React.useCallback(async () => {
- if (isIOS) return
-
- try {
- mutate({
- enabled: !(variables?.enabled ?? preferences?.adultContentEnabled),
- })
- } catch (e) {
- Toast.show(
- _(msg`There was an issue syncing your preferences with the server`),
- )
- logger.error('Failed to update preferences with server', {message: e})
- }
- }, [variables, preferences, mutate, _])
-
- const onAdultContentLinkPress = React.useCallback(() => {
- Linking.openURL('https://bsky.app/')
- }, [])
-
- return (
-
- {isIOS ? (
- preferences?.adultContentEnabled ? null : (
-
-
- Adult content can only be enabled via the Web at{' '}
-
- .
-
-
- )
- ) : typeof preferences?.birthDate === 'undefined' ? (
-
-
- Confirm your age to enable adult content.
-
-
-
- ) : (preferences.userAge || 0) >= 18 ? (
-
- ) : (
-
-
- You must be 18 or older to enable adult content.
-
-
-
- )}
-
- )
-}
-
-// TODO: Refactor this component to pass labels down to each tab
-function ContentLabelPref({
- preferences,
- labelGroup,
- disabled,
-}: {
- preferences?: UsePreferencesQueryResponse
- labelGroup: ConfigurableLabelGroup
- disabled?: boolean
-}) {
- const pal = usePalette('default')
- const visibility = preferences?.contentLabels?.[labelGroup]
- const {mutate, variables} = usePreferencesSetContentLabelMutation()
-
- const onChange = React.useCallback(
- (vis: LabelPreference) => {
- mutate({labelGroup, visibility: vis})
- },
- [mutate, labelGroup],
- )
-
- return (
-
-
-
- {CONFIGURABLE_LABEL_GROUPS[labelGroup].title}
-
- {typeof CONFIGURABLE_LABEL_GROUPS[labelGroup].subtitle === 'string' && (
-
- {CONFIGURABLE_LABEL_GROUPS[labelGroup].subtitle}
-
- )}
-
-
- {disabled || !visibility ? (
-
- Hide
-
- ) : (
-
- )}
-
- )
-}
-
-interface SelectGroupProps {
- current: LabelPreference
- onChange: (v: LabelPreference) => void
- labelGroup: ConfigurableLabelGroup
-}
-
-function SelectGroup({current, onChange, labelGroup}: SelectGroupProps) {
- const {_} = useLingui()
-
- return (
-
-
-
-
-
- )
-}
-
-interface SelectableBtnProps {
- current: string
- value: LabelPreference
- label: string
- left?: boolean
- right?: boolean
- onChange: (v: LabelPreference) => void
- labelGroup: ConfigurableLabelGroup
-}
-
-function SelectableBtn({
- current,
- value,
- label,
- left,
- right,
- onChange,
- labelGroup,
-}: SelectableBtnProps) {
- const pal = usePalette('default')
- const palPrimary = usePalette('inverted')
- const {_} = useLingui()
-
- return (
- onChange(value)}
- accessibilityRole="button"
- accessibilityLabel={value}
- accessibilityHint={_(
- msg`Set ${value} for ${labelGroup} content moderation policy`,
- )}>
-
- {label}
-
-
- )
-}
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- },
- title: {
- textAlign: 'center',
- fontWeight: 'bold',
- fontSize: 24,
- marginBottom: 12,
- },
- description: {
- paddingHorizontal: 2,
- marginBottom: 10,
- },
- scrollContainer: {
- flex: 1,
- paddingHorizontal: 10,
- },
- btnContainer: {
- paddingTop: 10,
- paddingHorizontal: 10,
- },
- btnContainerMobile: {
- paddingBottom: 40,
- borderTopWidth: 1,
- },
-
- agePrompt: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- alignItems: 'center',
- paddingLeft: 14,
- paddingRight: 10,
- paddingVertical: 8,
- borderRadius: 8,
- },
-
- contentLabelPref: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- alignItems: 'center',
- paddingTop: 14,
- paddingLeft: 4,
- marginBottom: 14,
- borderTopWidth: 1,
- },
-
- selectableBtns: {
- flexDirection: 'row',
- marginLeft: 10,
- },
- selectableBtn: {
- flexDirection: 'row',
- justifyContent: 'center',
- borderWidth: 1,
- borderLeftWidth: 0,
- paddingHorizontal: 10,
- paddingVertical: 10,
- },
- selectableBtnLeft: {
- borderTopLeftRadius: 8,
- borderBottomLeftRadius: 8,
- borderLeftWidth: 1,
- },
- selectableBtnRight: {
- borderTopRightRadius: 8,
- borderBottomRightRadius: 8,
- },
-
- btn: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- width: '100%',
- borderRadius: 32,
- padding: 14,
- backgroundColor: colors.gray1,
- },
- toggleBtn: {
- paddingHorizontal: 0,
- },
-})
diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx
index e03879c1de..238cfc502c 100644
--- a/src/view/com/modals/Modal.tsx
+++ b/src/view/com/modals/Modal.tsx
@@ -15,17 +15,12 @@ import * as UserAddRemoveListsModal from './UserAddRemoveLists'
import * as ListAddUserModal from './ListAddRemoveUsers'
import * as AltImageModal from './AltImage'
import * as EditImageModal from './AltImage'
-import * as ReportModal from './report/Modal'
-import * as AppealLabelModal from './AppealLabel'
import * as DeleteAccountModal from './DeleteAccount'
import * as ChangeHandleModal from './ChangeHandle'
import * as InviteCodesModal from './InviteCodes'
import * as AddAppPassword from './AddAppPasswords'
-import * as ContentFilteringSettingsModal from './ContentFilteringSettings'
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
-import * as ModerationDetailsModal from './ModerationDetails'
-import * as BirthDateSettingsModal from './BirthDateSettings'
import * as VerifyEmailModal from './VerifyEmail'
import * as ChangeEmailModal from './ChangeEmail'
import * as ChangePasswordModal from './ChangePassword'
@@ -68,12 +63,6 @@ export function ModalsContainer() {
if (activeModal?.name === 'edit-profile') {
snapPoints = EditProfileModal.snapPoints
element =
- } else if (activeModal?.name === 'report') {
- snapPoints = ReportModal.snapPoints
- element =
- } else if (activeModal?.name === 'appeal-label') {
- snapPoints = AppealLabelModal.snapPoints
- element =
} else if (activeModal?.name === 'create-or-edit-list') {
snapPoints = CreateOrEditListModal.snapPoints
element =
@@ -110,21 +99,12 @@ export function ModalsContainer() {
} else if (activeModal?.name === 'add-app-password') {
snapPoints = AddAppPassword.snapPoints
element =
- } else if (activeModal?.name === 'content-filtering-settings') {
- snapPoints = ContentFilteringSettingsModal.snapPoints
- element =
} else if (activeModal?.name === 'content-languages-settings') {
snapPoints = ContentLanguagesSettingsModal.snapPoints
element =
} else if (activeModal?.name === 'post-languages-settings') {
snapPoints = PostLanguagesSettingsModal.snapPoints
element =
- } else if (activeModal?.name === 'moderation-details') {
- snapPoints = ModerationDetailsModal.snapPoints
- element =
- } else if (activeModal?.name === 'birth-date-settings') {
- snapPoints = BirthDateSettingsModal.snapPoints
- element =
} else if (activeModal?.name === 'verify-email') {
snapPoints = VerifyEmailModal.snapPoints
element =
diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx
index d72b7e4855..7e5d548ace 100644
--- a/src/view/com/modals/Modal.web.tsx
+++ b/src/view/com/modals/Modal.web.tsx
@@ -8,8 +8,6 @@ import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
import {useModals, useModalControls} from '#/state/modals'
import type {Modal as ModalIface} from '#/state/modals'
import * as EditProfileModal from './EditProfile'
-import * as ReportModal from './report/Modal'
-import * as AppealLabelModal from './AppealLabel'
import * as CreateOrEditListModal from './CreateOrEditList'
import * as UserAddRemoveLists from './UserAddRemoveLists'
import * as ListAddUserModal from './ListAddRemoveUsers'
@@ -23,11 +21,8 @@ import * as EditImageModal from './EditImage'
import * as ChangeHandleModal from './ChangeHandle'
import * as InviteCodesModal from './InviteCodes'
import * as AddAppPassword from './AddAppPasswords'
-import * as ContentFilteringSettingsModal from './ContentFilteringSettings'
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
-import * as ModerationDetailsModal from './ModerationDetails'
-import * as BirthDateSettingsModal from './BirthDateSettings'
import * as VerifyEmailModal from './VerifyEmail'
import * as ChangeEmailModal from './ChangeEmail'
import * as ChangePasswordModal from './ChangePassword'
@@ -79,10 +74,6 @@ function Modal({modal}: {modal: ModalIface}) {
let element
if (modal.name === 'edit-profile') {
element =
- } else if (modal.name === 'report') {
- element =
- } else if (modal.name === 'appeal-label') {
- element =
} else if (modal.name === 'create-or-edit-list') {
element =
} else if (modal.name === 'user-add-remove-lists') {
@@ -105,8 +96,6 @@ function Modal({modal}: {modal: ModalIface}) {
element =
} else if (modal.name === 'add-app-password') {
element =
- } else if (modal.name === 'content-filtering-settings') {
- element =
} else if (modal.name === 'content-languages-settings') {
element =
} else if (modal.name === 'post-languages-settings') {
@@ -115,10 +104,6 @@ function Modal({modal}: {modal: ModalIface}) {
element =
} else if (modal.name === 'edit-image') {
element =
- } else if (modal.name === 'moderation-details') {
- element =
- } else if (modal.name === 'birth-date-settings') {
- element =
} else if (modal.name === 'verify-email') {
element =
} else if (modal.name === 'change-email') {
diff --git a/src/view/com/modals/ModerationDetails.tsx b/src/view/com/modals/ModerationDetails.tsx
deleted file mode 100644
index f890d50dce..0000000000
--- a/src/view/com/modals/ModerationDetails.tsx
+++ /dev/null
@@ -1,142 +0,0 @@
-import React from 'react'
-import {StyleSheet, View} from 'react-native'
-import {ModerationUI} from '@atproto/api'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {s} from 'lib/styles'
-import {Text} from '../util/text/Text'
-import {TextLink} from '../util/Link'
-import {usePalette} from 'lib/hooks/usePalette'
-import {isWeb} from 'platform/detection'
-import {listUriToHref} from 'lib/strings/url-helpers'
-import {Button} from '../util/forms/Button'
-import {useModalControls} from '#/state/modals'
-import {useLingui} from '@lingui/react'
-import {Trans, msg} from '@lingui/macro'
-
-export const snapPoints = [300]
-
-export function Component({
- context,
- moderation,
-}: {
- context: 'account' | 'content'
- moderation: ModerationUI
-}) {
- const {closeModal} = useModalControls()
- const {isMobile} = useWebMediaQueries()
- const pal = usePalette('default')
- const {_} = useLingui()
-
- let name
- let description
- if (!moderation.cause) {
- name = _(msg`Content Warning`)
- description = _(
- msg`Moderator has chosen to set a general warning on the content.`,
- )
- } else if (moderation.cause.type === 'blocking') {
- if (moderation.cause.source.type === 'list') {
- const list = moderation.cause.source.list
- name = _(msg`User Blocked by List`)
- description = (
-
- This user is included in the{' '}
- {' '}
- list which you have blocked.
-
- )
- } else {
- name = _(msg`User Blocked`)
- description = _(
- msg`You have blocked this user. You cannot view their content.`,
- )
- }
- } else if (moderation.cause.type === 'blocked-by') {
- name = _(msg`User Blocks You`)
- description = _(
- msg`This user has blocked you. You cannot view their content.`,
- )
- } else if (moderation.cause.type === 'block-other') {
- name = _(msg`Content Not Available`)
- description = _(
- msg`This content is not available because one of the users involved has blocked the other.`,
- )
- } else if (moderation.cause.type === 'muted') {
- if (moderation.cause.source.type === 'list') {
- const list = moderation.cause.source.list
- name = _(msg`Account Muted by List`)
- description = (
-
- This user is included in the{' '}
- {' '}
- list which you have muted.
-
- )
- } else {
- name = _(msg`Account Muted`)
- description = _(msg`You have muted this user.`)
- }
- } else {
- name = moderation.cause.labelDef.strings[context].en.name
- description = moderation.cause.labelDef.strings[context].en.description
- }
-
- return (
-
-
- {name}
-
-
- {description}
-
-
-
-
- )
-}
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- },
- title: {
- textAlign: 'center',
- fontWeight: 'bold',
- marginBottom: 12,
- },
- description: {
- textAlign: 'center',
- },
- btn: {
- paddingVertical: 14,
- marginTop: isWeb ? 40 : 0,
- marginBottom: isWeb ? 0 : 40,
- },
-})
diff --git a/src/view/com/modals/report/InputIssueDetails.tsx b/src/view/com/modals/report/InputIssueDetails.tsx
deleted file mode 100644
index 2bc86f75e7..0000000000
--- a/src/view/com/modals/report/InputIssueDetails.tsx
+++ /dev/null
@@ -1,100 +0,0 @@
-import React from 'react'
-import {View, TouchableOpacity, StyleSheet} from 'react-native'
-import {TextInput} from '../util'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {CharProgress} from '../../composer/char-progress/CharProgress'
-import {Text} from '../../util/text/Text'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {s} from 'lib/styles'
-import {SendReportButton} from './SendReportButton'
-import {Trans, msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-
-export function InputIssueDetails({
- details,
- setDetails,
- goBack,
- submitReport,
- isProcessing,
-}: {
- details: string | undefined
- setDetails: (v: string) => void
- goBack: () => void
- submitReport: () => void
- isProcessing: boolean
-}) {
- const pal = usePalette('default')
- const {_} = useLingui()
- const {isMobile} = useWebMediaQueries()
-
- return (
-
-
-
-
- {' '}
- Back
-
-
-
-
-
-
-
-
-
-
-
-
- )
-}
-
-const styles = StyleSheet.create({
- backBtn: {
- flexDirection: 'row',
- alignItems: 'center',
- },
- detailsInputContainer: {
- borderRadius: 8,
- },
- detailsInput: {
- paddingHorizontal: 12,
- paddingTop: 12,
- paddingBottom: 12,
- borderRadius: 8,
- minHeight: 100,
- fontSize: 16,
- },
- detailsInputBottomBar: {
- alignSelf: 'flex-end',
- },
- charCounter: {
- flexDirection: 'row',
- alignItems: 'center',
- paddingRight: 10,
- paddingBottom: 8,
- },
-})
diff --git a/src/view/com/modals/report/Modal.tsx b/src/view/com/modals/report/Modal.tsx
deleted file mode 100644
index abbad9b402..0000000000
--- a/src/view/com/modals/report/Modal.tsx
+++ /dev/null
@@ -1,223 +0,0 @@
-import React, {useState, useMemo} from 'react'
-import {Linking, StyleSheet, TouchableOpacity, View} from 'react-native'
-import {ScrollView} from 'react-native-gesture-handler'
-import {AtUri} from '@atproto/api'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {s} from 'lib/styles'
-import {Text} from '../../util/text/Text'
-import * as Toast from '../../util/Toast'
-import {ErrorMessage} from '../../util/error/ErrorMessage'
-import {cleanError} from 'lib/strings/errors'
-import {usePalette} from 'lib/hooks/usePalette'
-import {SendReportButton} from './SendReportButton'
-import {InputIssueDetails} from './InputIssueDetails'
-import {ReportReasonOptions} from './ReasonOptions'
-import {CollectionId} from './types'
-import {Trans, msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {useModalControls} from '#/state/modals'
-import {getAgent} from '#/state/session'
-
-const DMCA_LINK = 'https://bsky.social/about/support/copyright'
-
-export const snapPoints = [575]
-
-const CollectionNames = {
- [CollectionId.FeedGenerator]: 'Feed',
- [CollectionId.Profile]: 'Profile',
- [CollectionId.List]: 'List',
- [CollectionId.Post]: 'Post',
-}
-
-type ReportComponentProps =
- | {
- uri: string
- cid: string
- }
- | {
- did: string
- }
-
-export function Component(content: ReportComponentProps) {
- const {closeModal} = useModalControls()
- const pal = usePalette('default')
- const {isMobile} = useWebMediaQueries()
- const [isProcessing, setIsProcessing] = useState(false)
- const [showDetailsInput, setShowDetailsInput] = useState(false)
- const [error, setError] = useState('')
- const [issue, setIssue] = useState('')
- const [details, setDetails] = useState('')
- const isAccountReport = 'did' in content
- const subjectKey = isAccountReport ? content.did : content.uri
- const atUri = useMemo(
- () => (!isAccountReport ? new AtUri(subjectKey) : null),
- [isAccountReport, subjectKey],
- )
-
- const submitReport = async () => {
- setError('')
- if (!issue) {
- return
- }
- setIsProcessing(true)
- try {
- if (issue === '__copyright__') {
- Linking.openURL(DMCA_LINK)
- closeModal()
- return
- }
- const $type = !isAccountReport
- ? 'com.atproto.repo.strongRef'
- : 'com.atproto.admin.defs#repoRef'
- await getAgent().createModerationReport({
- reasonType: issue,
- subject: {
- $type,
- ...content,
- },
- reason: details,
- })
- Toast.show("Thank you for your report! We'll look into it promptly.")
-
- closeModal()
- return
- } catch (e: any) {
- setError(cleanError(e))
- setIsProcessing(false)
- }
- }
-
- const goBack = () => {
- setShowDetailsInput(false)
- }
-
- return (
-
-
- {showDetailsInput ? (
-
- ) : (
-
- )}
-
-
- )
-}
-
-// If no atUri is passed, that means the reporting collection is account
-const getCollectionNameForReport = (atUri: AtUri | null) => {
- if (!atUri) return 'Account'
- // Generic fallback for any collection being reported
- return CollectionNames[atUri.collection as CollectionId] || 'Content'
-}
-
-const SelectIssue = ({
- error,
- setShowDetailsInput,
- issue,
- setIssue,
- submitReport,
- isProcessing,
- atUri,
-}: {
- error: string | undefined
- setShowDetailsInput: (v: boolean) => void
- issue: string | undefined
- setIssue: (v: string) => void
- submitReport: () => void
- isProcessing: boolean
- atUri: AtUri | null
-}) => {
- const pal = usePalette('default')
- const {_} = useLingui()
- const collectionName = getCollectionNameForReport(atUri)
- const onSelectIssue = (v: string) => setIssue(v)
- const goToDetails = () => {
- if (issue === '__copyright__') {
- Linking.openURL(DMCA_LINK)
- return
- }
- setShowDetailsInput(true)
- }
-
- return (
- <>
-
- Report {collectionName}
-
-
- What is the issue with this {collectionName}?
-
-
-
-
- {error ? : undefined}
- {/* If no atUri is present, the report would be for account in which case, we allow sending without specifying a reason */}
- {issue || !atUri ? (
- <>
-
-
-
- Add details to report
-
-
- >
- ) : undefined}
- >
- )
-}
-
-const styles = StyleSheet.create({
- container: {
- paddingHorizontal: 10,
- },
- title: {
- textAlign: 'center',
- fontWeight: 'bold',
- fontSize: 24,
- marginBottom: 12,
- },
- description: {
- textAlign: 'center',
- fontSize: 17,
- paddingHorizontal: 22,
- marginBottom: 10,
- },
- addDetailsBtn: {
- padding: 14,
- alignSelf: 'center',
- },
-})
diff --git a/src/view/com/modals/report/ReasonOptions.tsx b/src/view/com/modals/report/ReasonOptions.tsx
deleted file mode 100644
index 23b49b6648..0000000000
--- a/src/view/com/modals/report/ReasonOptions.tsx
+++ /dev/null
@@ -1,123 +0,0 @@
-import {View} from 'react-native'
-import React, {useMemo} from 'react'
-import {AtUri, ComAtprotoModerationDefs} from '@atproto/api'
-
-import {Text} from '../../util/text/Text'
-import {UsePaletteValue, usePalette} from 'lib/hooks/usePalette'
-import {RadioGroup, RadioGroupItem} from 'view/com/util/forms/RadioGroup'
-import {CollectionId} from './types'
-
-type ReasonMap = Record
-const CommonReasons = {
- [ComAtprotoModerationDefs.REASONRUDE]: {
- title: 'Anti-Social Behavior',
- description: 'Harassment, trolling, or intolerance',
- },
- [ComAtprotoModerationDefs.REASONVIOLATION]: {
- title: 'Illegal and Urgent',
- description: 'Glaring violations of law or terms of service',
- },
- [ComAtprotoModerationDefs.REASONOTHER]: {
- title: 'Other',
- description: 'An issue not included in these options',
- },
-}
-const CollectionToReasonsMap: Record = {
- [CollectionId.Post]: {
- [ComAtprotoModerationDefs.REASONSPAM]: {
- title: 'Spam',
- description: 'Excessive mentions or replies',
- },
- [ComAtprotoModerationDefs.REASONSEXUAL]: {
- title: 'Unwanted Sexual Content',
- description: 'Nudity or pornography not labeled as such',
- },
- __copyright__: {
- title: 'Copyright Violation',
- description: 'Contains copyrighted material',
- },
- ...CommonReasons,
- },
- [CollectionId.List]: {
- ...CommonReasons,
- [ComAtprotoModerationDefs.REASONVIOLATION]: {
- title: 'Name or Description Violates Community Standards',
- description: 'Terms used violate community standards',
- },
- },
-}
-const AccountReportReasons = {
- [ComAtprotoModerationDefs.REASONMISLEADING]: {
- title: 'Misleading Account',
- description: 'Impersonation or false claims about identity or affiliation',
- },
- [ComAtprotoModerationDefs.REASONSPAM]: {
- title: 'Frequently Posts Unwanted Content',
- description: 'Spam; excessive mentions or replies',
- },
- [ComAtprotoModerationDefs.REASONVIOLATION]: {
- title: 'Name or Description Violates Community Standards',
- description: 'Terms used violate community standards',
- },
-}
-
-const Option = ({
- pal,
- title,
- description,
-}: {
- pal: UsePaletteValue
- description: string
- title: string
-}) => {
- return (
-
-
- {title}
-
- {description}
-
- )
-}
-
-// This is mostly just content copy without almost any logic
-// so this may grow over time and it makes sense to split it up into its own file
-// to keep it separate from the actual reporting modal logic
-const useReportRadioOptions = (pal: UsePaletteValue, atUri: AtUri | null) =>
- useMemo(() => {
- let items: ReasonMap = {...CommonReasons}
- // If no atUri is passed, that means the reporting collection is account
- if (!atUri) {
- items = {...AccountReportReasons}
- }
-
- if (atUri?.collection && CollectionToReasonsMap[atUri.collection]) {
- items = {...CollectionToReasonsMap[atUri.collection]}
- }
-
- return Object.entries(items).map(([key, {title, description}]) => ({
- key,
- label: ,
- }))
- }, [pal, atUri])
-
-export const ReportReasonOptions = ({
- atUri,
- selectedIssue,
- onSelectIssue,
-}: {
- atUri: AtUri | null
- selectedIssue?: string
- onSelectIssue: (key: string) => void
-}) => {
- const pal = usePalette('default')
- const ITEMS: RadioGroupItem[] = useReportRadioOptions(pal, atUri)
- return (
-
- )
-}
diff --git a/src/view/com/modals/report/SendReportButton.tsx b/src/view/com/modals/report/SendReportButton.tsx
deleted file mode 100644
index 40c239bffe..0000000000
--- a/src/view/com/modals/report/SendReportButton.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-import React from 'react'
-import LinearGradient from 'react-native-linear-gradient'
-import {
- ActivityIndicator,
- StyleSheet,
- TouchableOpacity,
- View,
-} from 'react-native'
-import {Text} from '../../util/text/Text'
-import {s, gradients, colors} from 'lib/styles'
-import {Trans, msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-
-export function SendReportButton({
- onPress,
- isProcessing,
-}: {
- onPress: () => void
- isProcessing: boolean
-}) {
- const {_} = useLingui()
- // loading state
- // =
- if (isProcessing) {
- return (
-
-
-
- )
- }
- return (
-
-
-
- Send Report
-
-
-
- )
-}
-
-const styles = StyleSheet.create({
- btn: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- width: '100%',
- borderRadius: 32,
- padding: 14,
- backgroundColor: colors.gray1,
- },
-})
diff --git a/src/view/com/modals/report/types.ts b/src/view/com/modals/report/types.ts
deleted file mode 100644
index ca947ecbd4..0000000000
--- a/src/view/com/modals/report/types.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-// TODO: ATM, @atproto/api does not export ids but it does have these listed at @atproto/api/client/lexicons
-// once we start exporting the ids from the @atproto/ap package, replace these hardcoded ones
-export enum CollectionId {
- FeedGenerator = 'app.bsky.feed.generator',
- Profile = 'app.bsky.actor.profile',
- List = 'app.bsky.graph.list',
- Post = 'app.bsky.feed.post',
-}
diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx
index a468702659..b165547904 100644
--- a/src/view/com/notifications/FeedItem.tsx
+++ b/src/view/com/notifications/FeedItem.tsx
@@ -11,7 +11,7 @@ import {
AppBskyFeedDefs,
AppBskyFeedPost,
ModerationOpts,
- ProfileModeration,
+ ModerationDecision,
moderateProfile,
AppBskyEmbedRecordWithMedia,
} from '@atproto/api'
@@ -54,7 +54,7 @@ interface Author {
handle: string
displayName?: string
avatar?: string
- moderation: ProfileModeration
+ moderation: ModerationDecision
}
let FeedItem = ({
@@ -336,7 +336,7 @@ function CondensedAuthorsList({
did={authors[0].did}
handle={authors[0].handle}
avatar={authors[0].avatar}
- moderation={authors[0].moderation.avatar}
+ moderation={authors[0].moderation.ui('avatar')}
/>
)
@@ -354,7 +354,7 @@ function CondensedAuthorsList({
))}
@@ -412,7 +412,7 @@ function ExpandedAuthorsList({
diff --git a/src/view/com/post-thread/PostLikedBy.tsx b/src/view/com/post-thread/PostLikedBy.tsx
index 55463dc137..0760ed7ff3 100644
--- a/src/view/com/post-thread/PostLikedBy.tsx
+++ b/src/view/com/post-thread/PostLikedBy.tsx
@@ -8,7 +8,7 @@ import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
import {logger} from '#/logger'
import {LoadingScreen} from '../util/LoadingScreen'
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
-import {usePostLikedByQuery} from '#/state/queries/post-liked-by'
+import {useLikedByQuery} from '#/state/queries/post-liked-by'
import {cleanError} from '#/lib/strings/errors'
export function PostLikedBy({uri}: {uri: string}) {
@@ -28,7 +28,7 @@ export function PostLikedBy({uri}: {uri: string}) {
isError,
error,
refetch,
- } = usePostLikedByQuery(resolvedUri?.uri)
+ } = useLikedByQuery(resolvedUri?.uri)
const likes = useMemo(() => {
if (data?.pages) {
return data.pages.flatMap(page => page.likes)
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index a7ee42a948..bac7018c34 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -106,11 +106,12 @@ export function PostThread({
? moderatePost(rootPost, moderationOpts)
: undefined
- const cause = mod?.content.cause
-
- return cause
- ? cause.type === 'label' && cause.labelDef.id === '!no-unauthenticated'
- : false
+ return !!mod
+ ?.ui('contentList')
+ .blurs.find(
+ cause =>
+ cause.type === 'label' && cause.labelDef.id === '!no-unauthenticated',
+ )
}, [rootPost, moderationOpts])
useSetTitle(
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index cd746f9a91..c073b55a07 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -5,7 +5,7 @@ import {
AppBskyFeedDefs,
AppBskyFeedPost,
RichText as RichTextAPI,
- PostModeration,
+ ModerationDecision,
} from '@atproto/api'
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
@@ -19,14 +19,14 @@ import {niceDate} from 'lib/strings/time'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles'
import {countLines, pluralize} from 'lib/strings/helpers'
-import {isEmbedByEmbedder} from 'lib/embeds'
import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers'
import {PostMeta} from '../util/PostMeta'
import {PostEmbeds} from '../util/post-embeds'
import {PostCtrls} from '../util/post-ctrls/PostCtrls'
-import {PostHider} from '../util/moderation/PostHider'
-import {ContentHider} from '../util/moderation/ContentHider'
-import {PostAlerts} from '../util/moderation/PostAlerts'
+import {PostHider} from '../../../components/moderation/PostHider'
+import {ContentHider} from '../../../components/moderation/ContentHider'
+import {PostAlerts} from '../../../components/moderation/PostAlerts'
+import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {usePalette} from 'lib/hooks/usePalette'
import {formatCount} from '../util/numeric/format'
@@ -147,7 +147,7 @@ let PostThreadItemLoaded = ({
post: Shadow
record: AppBskyFeedPost.Record
richText: RichTextAPI
- moderation: PostModeration
+ moderation: ModerationDecision
treeView: boolean
depth: number
prevPost: ThreadPost | undefined
@@ -175,7 +175,6 @@ let PostThreadItemLoaded = ({
const itemTitle = _(msg`Post by ${post.author.handle}`)
const authorHref = makeProfileLink(post.author)
const authorTitle = post.author.handle
- const isAuthorMuted = post.author.viewer?.muted
const likesHref = React.useMemo(() => {
const urip = new AtUri(post.uri)
return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by')
@@ -256,7 +255,7 @@ let PostThreadItemLoaded = ({
did={post.author.did}
handle={post.author.handle}
avatar={post.author.avatar}
- moderation={moderation.avatar}
+ moderation={moderation.ui('avatar')}
/>
@@ -271,35 +270,12 @@ let PostThreadItemLoaded = ({
{sanitizeDisplayName(
post.author.displayName ||
sanitizeHandle(post.author.handle),
+ moderation.ui('displayName'),
)}
- {isAuthorMuted && (
-
-
-
- Muted
-
-
- )}
{sanitizeHandle(post.author.handle, '@')}
@@ -312,15 +288,16 @@ let PostThreadItemLoaded = ({
)}
+
{richText?.text ? (
) : undefined}
{post.embed && (
-
-
-
+
+
+
)}
{showChildReplyLine && (
@@ -508,19 +477,21 @@ let PostThreadItemLoaded = ({
}>
+
{richText?.text ? (
@@ -542,18 +513,9 @@ let PostThreadItemLoaded = ({
/>
) : undefined}
{post.embed && (
-
-
-
+
+
+
)}
record: AppBskyFeedPost.Record
richText: RichTextAPI
- moderation: PostModeration
+ moderation: ModerationDecision
showReplyLine?: boolean
style?: StyleProp
}) {
@@ -142,12 +143,13 @@ function PostInner({
did={post.author.did}
handle={post.author.handle}
avatar={post.author.avatar}
- moderation={moderation.avatar}
+ moderation={moderation.ui('avatar')}
/>
)}
+
-
+
{richText.text ? (
) : undefined}
{post.embed ? (
-
-
-
+
) : null}
{
const urip = new AtUri(post.uri)
return makeProfileLink(post.author, 'post', urip.rkey)
}, [post.uri, post.author])
- const isModeratedPost =
- moderation.decisions.post.cause?.type === 'label' &&
- moderation.decisions.post.cause.label.src !== currentAccount?.did
const replyAuthorDid = useMemo(() => {
if (!record?.reply) {
@@ -148,7 +143,7 @@ let FeedItemInner = ({
borderColor: pal.colors.border,
paddingBottom:
isThreadLastChild || (!isThreadChild && !isThreadParent)
- ? 6
+ ? 8
: undefined,
},
isThreadChild ? styles.outerSmallTop : undefined,
@@ -229,6 +224,7 @@ let FeedItemInner = ({
numberOfLines={1}
text={sanitizeDisplayName(
reason.by.displayName || sanitizeHandle(reason.by.handle),
+ moderation.ui('displayName'),
)}
href={makeProfileLink(reason.by)}
/>
@@ -246,7 +242,7 @@ let FeedItemInner = ({
did={post.author.did}
handle={post.author.handle}
avatar={post.author.avatar}
- moderation={moderation.avatar}
+ moderation={moderation.ui('avatar')}
/>
{isThreadParent && (
)}
+
@@ -324,7 +319,7 @@ let PostContent = ({
postEmbed,
postAuthor,
}: {
- moderation: PostModeration
+ moderation: ModerationDecision
richText: RichTextAPI
postEmbed: AppBskyFeedDefs.PostView['embed']
postAuthor: AppBskyFeedDefs.PostView['author']
@@ -342,10 +337,10 @@ let PostContent = ({
return (
-
+
{richText.text ? (
) : undefined}
{postEmbed ? (
-
-
-
+
+
+
) : null}
)
diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx
index 019e6c10e7..d909bda858 100644
--- a/src/view/com/profile/ProfileCard.tsx
+++ b/src/view/com/profile/ProfileCard.tsx
@@ -3,7 +3,8 @@ import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
import {
AppBskyActorDefs,
moderateProfile,
- ProfileModeration,
+ ModerationCause,
+ ModerationDecision,
} from '@atproto/api'
import {Link} from '../util/Link'
import {Text} from '../util/text/Text'
@@ -14,16 +15,13 @@ import {FollowButton} from './FollowButton'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles'
import {makeProfileLink} from 'lib/routes/links'
-import {
- describeModerationCause,
- getProfileModerationCauses,
- getModerationCauseKey,
-} from 'lib/moderation'
+import {getModerationCauseKey, isJustAMute} from 'lib/moderation'
import {Shadow} from '#/state/cache/types'
import {useModerationOpts} from '#/state/queries/preferences'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useSession} from '#/state/session'
import {Trans} from '@lingui/macro'
+import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
export function ProfileCard({
testID,
@@ -33,6 +31,7 @@ export function ProfileCard({
noBorder,
followers,
renderButton,
+ onPress,
style,
}: {
testID?: string
@@ -44,6 +43,7 @@ export function ProfileCard({
renderButton?: (
profile: Shadow,
) => React.ReactNode
+ onPress?: () => void
style?: StyleProp
}) {
const pal = usePalette('default')
@@ -53,11 +53,8 @@ export function ProfileCard({
return null
}
const moderation = moderateProfile(profile, moderationOpts)
- if (
- !noModFilter &&
- moderation.account.filter &&
- moderation.account.cause?.type !== 'muted'
- ) {
+ const modui = moderation.ui('profileList')
+ if (!noModFilter && modui.filter && !isJustAMute(modui)) {
return null
}
@@ -73,6 +70,7 @@ export function ProfileCard({
]}
href={makeProfileLink(profile)}
title={profile.handle}
+ onBeforePress={onPress}
asAnchor
anchorNoUnderline>
@@ -80,7 +78,7 @@ export function ProfileCard({
@@ -91,7 +89,7 @@ export function ProfileCard({
lineHeight={1.2}>
{sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
- moderation.profile,
+ moderation.ui('displayName'),
)}
@@ -119,17 +117,17 @@ export function ProfileCard({
)
}
-function ProfileCardPills({
+export function ProfileCardPills({
followedBy,
moderation,
}: {
followedBy: boolean
- moderation: ProfileModeration
+ moderation: ModerationDecision
}) {
const pal = usePalette('default')
- const causes = getProfileModerationCauses(moderation)
- if (!followedBy && !causes.length) {
+ const modui = moderation.ui('profileList')
+ if (!followedBy && !modui.inform && !modui.alert) {
return null
}
@@ -142,19 +140,41 @@ function ProfileCardPills({
)}
- {causes.map(cause => {
- const desc = describeModerationCause(cause, 'account')
- return (
-
-
- {cause?.type === 'label' ? '⚠' : ''}
- {desc.name}
-
-
- )
- })}
+ {modui.alerts.map(alert => (
+
+ ))}
+ {modui.informs.map(inform => (
+
+ ))}
+
+ )
+}
+
+function ProfileCardPillModerationCause({
+ cause,
+ severity,
+}: {
+ cause: ModerationCause
+ severity: 'alert' | 'inform'
+}) {
+ const pal = usePalette('default')
+ const {name} = useModerationCauseDescription(cause)
+ return (
+
+
+ {severity === 'alert' ? '⚠ ' : ''}
+ {name}
+
)
}
@@ -177,7 +197,7 @@ function FollowersList({
f,
mod: moderateProfile(f, moderationOpts),
}))
- .filter(({mod}) => !mod.account.filter)
+ .filter(({mod}) => !mod.ui('profileList').filter)
}, [followers, moderationOpts])
if (!followersWithMods?.length) {
@@ -199,7 +219,11 @@ function FollowersList({
{followersWithMods.slice(0, 3).map(({f, mod}) => (
-
+
))}
@@ -212,11 +236,13 @@ export function ProfileCardWithFollowBtn({
noBg,
noBorder,
followers,
+ onPress,
}: {
profile: AppBskyActorDefs.ProfileViewBasic
noBg?: boolean
noBorder?: boolean
followers?: AppBskyActorDefs.ProfileView[] | undefined
+ onPress?: () => void
}) {
const {currentAccount} = useSession()
const isMe = profile.did === currentAccount?.did
@@ -234,6 +260,7 @@ export function ProfileCardWithFollowBtn({
)
}
+ onPress={onPress}
/>
)
}
diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx
deleted file mode 100644
index 17dc5ce1bd..0000000000
--- a/src/view/com/profile/ProfileHeader.tsx
+++ /dev/null
@@ -1,598 +0,0 @@
-import React, {memo, useMemo} from 'react'
-import {
- StyleSheet,
- TouchableOpacity,
- TouchableWithoutFeedback,
- View,
-} from 'react-native'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {useNavigation} from '@react-navigation/native'
-import {
- AppBskyActorDefs,
- ModerationOpts,
- moderateProfile,
- RichText as RichTextAPI,
-} from '@atproto/api'
-import {Trans, msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {NavigationProp} from 'lib/routes/types'
-import {isNative} from 'platform/detection'
-import {BlurView} from '../util/BlurView'
-import * as Toast from '../util/Toast'
-import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
-import {Text} from '../util/text/Text'
-import {ThemedText} from '../util/text/ThemedText'
-import {RichText} from '#/components/RichText'
-import {UserAvatar} from '../util/UserAvatar'
-import {UserBanner} from '../util/UserBanner'
-import {ProfileHeaderAlerts} from '../util/moderation/ProfileHeaderAlerts'
-import {formatCount} from '../util/numeric/format'
-import {Link} from '../util/Link'
-import {ProfileHeaderSuggestedFollows} from './ProfileHeaderSuggestedFollows'
-import {useModalControls} from '#/state/modals'
-import {useLightboxControls, ProfileImageLightbox} from '#/state/lightbox'
-import {
- useProfileBlockMutationQueue,
- useProfileFollowMutationQueue,
-} from '#/state/queries/profile'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useAnalytics} from 'lib/analytics/analytics'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {BACK_HITSLOP} from 'lib/constants'
-import {isInvalidHandle, sanitizeHandle} from 'lib/strings/handles'
-import {makeProfileLink} from 'lib/routes/links'
-import {pluralize} from 'lib/strings/helpers'
-import {sanitizeDisplayName} from 'lib/strings/display-names'
-import {s, colors} from 'lib/styles'
-import {logger} from '#/logger'
-import {useSession} from '#/state/session'
-import {Shadow} from '#/state/cache/types'
-import {useRequireAuth} from '#/state/session'
-import {LabelInfo} from '../util/moderation/LabelInfo'
-import {useProfileShadow} from 'state/cache/profile-shadow'
-import {atoms as a} from '#/alf'
-import {ProfileMenu} from 'view/com/profile/ProfileMenu'
-import * as Prompt from '#/components/Prompt'
-
-let ProfileHeaderLoading = (_props: {}): React.ReactNode => {
- const pal = usePalette('default')
- return (
-
-
-
-
-
-
-
-
-
-
-
- )
-}
-ProfileHeaderLoading = memo(ProfileHeaderLoading)
-export {ProfileHeaderLoading}
-
-interface Props {
- profile: AppBskyActorDefs.ProfileViewDetailed
- descriptionRT: RichTextAPI | null
- moderationOpts: ModerationOpts
- hideBackButton?: boolean
- isPlaceholderProfile?: boolean
-}
-
-let ProfileHeader = ({
- profile: profileUnshadowed,
- descriptionRT,
- moderationOpts,
- hideBackButton = false,
- isPlaceholderProfile,
-}: Props): React.ReactNode => {
- const profile: Shadow =
- useProfileShadow(profileUnshadowed)
- const pal = usePalette('default')
- const palInverted = usePalette('inverted')
- const {currentAccount, hasSession} = useSession()
- const requireAuth = useRequireAuth()
- const {_} = useLingui()
- const {openModal} = useModalControls()
- const {openLightbox} = useLightboxControls()
- const navigation = useNavigation()
- const {track} = useAnalytics()
- const invalidHandle = isInvalidHandle(profile.handle)
- const {isDesktop} = useWebMediaQueries()
- const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false)
- const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
- profile,
- 'ProfileHeader',
- )
- const [__, queueUnblock] = useProfileBlockMutationQueue(profile)
- const unblockPromptControl = Prompt.usePromptControl()
- const moderation = useMemo(
- () => moderateProfile(profile, moderationOpts),
- [profile, moderationOpts],
- )
-
- const onPressBack = React.useCallback(() => {
- if (navigation.canGoBack()) {
- navigation.goBack()
- } else {
- navigation.navigate('Home')
- }
- }, [navigation])
-
- const onPressAvi = React.useCallback(() => {
- if (
- profile.avatar &&
- !(moderation.avatar.blur && moderation.avatar.noOverride)
- ) {
- openLightbox(new ProfileImageLightbox(profile))
- }
- }, [openLightbox, profile, moderation])
-
- const onPressFollow = () => {
- requireAuth(async () => {
- try {
- track('ProfileHeader:FollowButtonClicked')
- await queueFollow()
- Toast.show(
- _(
- msg`Following ${sanitizeDisplayName(
- profile.displayName || profile.handle,
- )}`,
- ),
- )
- } catch (e: any) {
- if (e?.name !== 'AbortError') {
- logger.error('Failed to follow', {message: String(e)})
- Toast.show(_(msg`There was an issue! ${e.toString()}`))
- }
- }
- })
- }
-
- const onPressUnfollow = () => {
- requireAuth(async () => {
- try {
- track('ProfileHeader:UnfollowButtonClicked')
- await queueUnfollow()
- Toast.show(
- _(
- msg`No longer following ${sanitizeDisplayName(
- profile.displayName || profile.handle,
- )}`,
- ),
- )
- } catch (e: any) {
- if (e?.name !== 'AbortError') {
- logger.error('Failed to unfollow', {message: String(e)})
- Toast.show(_(msg`There was an issue! ${e.toString()}`))
- }
- }
- })
- }
-
- const onPressEditProfile = React.useCallback(() => {
- track('ProfileHeader:EditProfileButtonClicked')
- openModal({
- name: 'edit-profile',
- profile,
- })
- }, [track, openModal, profile])
-
- const unblockAccount = React.useCallback(async () => {
- track('ProfileHeader:UnblockAccountButtonClicked')
- try {
- await queueUnblock()
- Toast.show(_(msg`Account unblocked`))
- } catch (e: any) {
- if (e?.name !== 'AbortError') {
- logger.error('Failed to unblock account', {message: e})
- Toast.show(_(msg`There was an issue! ${e.toString()}`))
- }
- }
- }, [_, queueUnblock, track])
-
- const isMe = React.useMemo(
- () => currentAccount?.did === profile.did,
- [currentAccount, profile],
- )
-
- const blockHide =
- !isMe && (profile.viewer?.blocking || profile.viewer?.blockedBy)
- const following = formatCount(profile.followsCount || 0)
- const followers = formatCount(profile.followersCount || 0)
- const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower')
-
- return (
-
-
- {isPlaceholderProfile ? (
-
- ) : (
-
- )}
-
-
-
- {isMe ? (
-
-
- Edit Profile
-
-
- ) : profile.viewer?.blocking ? (
- profile.viewer?.blockingByList ? null : (
- unblockPromptControl.open()}
- style={[styles.btn, styles.mainBtn, pal.btn]}
- accessibilityRole="button"
- accessibilityLabel={_(msg`Unblock`)}
- accessibilityHint="">
-
- Unblock
-
-
- )
- ) : !profile.viewer?.blockedBy ? (
- <>
- {hasSession && (
- setShowSuggestedFollows(!showSuggestedFollows)}
- style={[
- styles.btn,
- styles.mainBtn,
- pal.btn,
- {
- paddingHorizontal: 10,
- backgroundColor: showSuggestedFollows
- ? pal.colors.text
- : pal.colors.backgroundLight,
- },
- ]}
- accessibilityRole="button"
- accessibilityLabel={_(
- msg`Show follows similar to ${profile.handle}`,
- )}
- accessibilityHint={_(
- msg`Shows a list of users similar to this user.`,
- )}>
-
-
- )}
-
- {profile.viewer?.following ? (
-
-
-
- Following
-
-
- ) : (
-
-
-
- Follow
-
-
- )}
- >
- ) : null}
-
-
-
-
- {sanitizeDisplayName(
- profile.displayName || sanitizeHandle(profile.handle),
- moderation.profile,
- )}
-
-
-
- {profile.viewer?.followedBy && !blockHide ? (
-
-
- Follows you
-
-
- ) : undefined}
-
- {invalidHandle ? _(msg`⚠Invalid Handle`) : `@${profile.handle}`}
-
-
- {!isPlaceholderProfile && !blockHide && (
- <>
-
-
- track(`ProfileHeader:FollowersButtonClicked`, {
- handle: profile.handle,
- })
- }
- asAnchor
- accessibilityLabel={`${followers} ${pluralizedFollowers}`}
- accessibilityHint={_(msg`Opens followers list`)}>
-
- {followers}{' '}
-
-
- {pluralizedFollowers}
-
-
-
- track(`ProfileHeader:FollowsButtonClicked`, {
- handle: profile.handle,
- })
- }
- asAnchor
- accessibilityLabel={_(msg`${following} following`)}
- accessibilityHint={_(msg`Opens following list`)}>
-
-
- {following}{' '}
-
-
- following
-
-
-
-
- {formatCount(profile.postsCount || 0)}{' '}
-
- {pluralize(profile.postsCount || 0, 'post')}
-
-
-
- {descriptionRT && !moderation.profile.blur ? (
-
-
-
- ) : undefined}
- >
- )}
-
- {isMe && (
-
- )}
-
-
- {showSuggestedFollows && (
- {
- if (showSuggestedFollows) {
- setShowSuggestedFollows(false)
- } else {
- track('ProfileHeader:SuggestedFollowsOpened')
- setShowSuggestedFollows(true)
- }
- }}
- />
- )}
-
- {!isDesktop && !hideBackButton && (
-
-
-
-
-
-
-
- )}
-
-
-
-
-
-
-
- )
-}
-ProfileHeader = memo(ProfileHeader)
-export {ProfileHeader}
-
-const styles = StyleSheet.create({
- banner: {
- width: '100%',
- height: 120,
- },
- backBtnWrapper: {
- position: 'absolute',
- top: 10,
- left: 10,
- width: 30,
- height: 30,
- overflow: 'hidden',
- borderRadius: 15,
- // @ts-ignore web only
- cursor: 'pointer',
- },
- backBtn: {
- width: 30,
- height: 30,
- borderRadius: 15,
- alignItems: 'center',
- justifyContent: 'center',
- },
- avi: {
- position: 'absolute',
- top: 110,
- left: 10,
- width: 84,
- height: 84,
- borderRadius: 42,
- borderWidth: 2,
- },
- content: {
- paddingTop: 8,
- paddingHorizontal: 14,
- paddingBottom: 4,
- },
-
- buttonsLine: {
- flexDirection: 'row',
- marginLeft: 'auto',
- marginBottom: 12,
- },
- primaryBtn: {
- backgroundColor: colors.blue3,
- paddingHorizontal: 24,
- paddingVertical: 6,
- },
- mainBtn: {
- paddingHorizontal: 24,
- },
- secondaryBtn: {
- paddingHorizontal: 14,
- },
- btn: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- paddingVertical: 7,
- borderRadius: 50,
- marginLeft: 6,
- },
- title: {lineHeight: 38},
-
- // Word wrapping appears fine on
- // mobile but overflows on desktop
- handle: isNative
- ? {}
- : {
- // @ts-ignore web only -prf
- wordBreak: 'break-all',
- },
- invalidHandle: {
- borderWidth: 1,
- borderRadius: 4,
- paddingHorizontal: 4,
- },
-
- handleLine: {
- flexDirection: 'row',
- marginBottom: 8,
- },
-
- metricsLine: {
- flexDirection: 'row',
- marginBottom: 8,
- },
-
- description: {
- marginBottom: 8,
- },
-
- detailLine: {
- flexDirection: 'row',
- alignItems: 'center',
- marginBottom: 5,
- },
-
- pill: {
- borderRadius: 4,
- paddingHorizontal: 6,
- paddingVertical: 2,
- },
-
- br40: {borderRadius: 40},
- br50: {borderRadius: 50},
-})
diff --git a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx
index 8f2c894994..947d6e9cc6 100644
--- a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx
+++ b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx
@@ -217,7 +217,7 @@ function SuggestedFollow({
@@ -227,7 +227,7 @@ function SuggestedFollow({
numberOfLines={1}>
{sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
- moderation.profile,
+ moderation.ui('displayName'),
)}
{
+ track('ProfileHeader:FollowButtonClicked')
+ try {
+ await queueFollow()
+ Toast.show(_(msg`Account followed`))
+ } catch (e: any) {
+ if (e?.name !== 'AbortError') {
+ logger.error('Failed to follow account', {message: e})
+ Toast.show(_(msg`There was an issue! ${e.toString()}`))
+ }
+ }
+ }, [_, queueFollow, track])
+
const onPressUnfollowAccount = React.useCallback(async () => {
track('ProfileHeader:UnfollowButtonClicked')
try {
@@ -154,11 +174,8 @@ let ProfileMenu = ({
const onPressReportAccount = React.useCallback(() => {
track('ProfileHeader:ReportAccountButtonClicked')
- openModal({
- name: 'report',
- did: profile.did,
- })
- }, [track, openModal, profile])
+ reportDialogControl.open()
+ }, [track, reportDialogControl])
return (
@@ -175,10 +192,9 @@ let ProfileMenu = ({
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
- paddingVertical: 7,
+ paddingVertical: 10,
borderRadius: 50,
- marginLeft: 6,
- paddingHorizontal: 14,
+ paddingHorizontal: 16,
},
pal.btn,
]}>
@@ -210,10 +226,38 @@ let ProfileMenu = ({
+
{hasSession && (
<>
+ {!isSelf && (
+ <>
+ {(isLabelerAndNotBlocked || isFollowingBlockedAccount) && (
+
+
+ {isFollowing ? (
+ Unfollow Account
+ ) : (
+ Follow Account
+ )}
+
+
+
+ )}
+ >
+ )}
{!isSelf && (
<>
- {profile.viewer?.following &&
- (profile.viewer.blocking || profile.viewer.blockedBy) && (
-
-
- Unfollow Account
-
-
-
- )}
{!profile.viewer?.blocking &&
!profile.viewer?.mutedByList && (
+
+
void) | undefined,
): React.FC {
const CustomBackdrop = ({animatedIndex, style}: BottomSheetBackdropProps) => {
+ const {_} = useLingui()
+
// animated variables
const opacity = useAnimatedStyle(() => ({
opacity: interpolate(
@@ -30,7 +33,7 @@ export function createCustomBackdrop(
return (
{
if (onClose !== undefined) {
diff --git a/src/view/com/util/ErrorBoundary.tsx b/src/view/com/util/ErrorBoundary.tsx
index 5ec1d00144..22fdd606e4 100644
--- a/src/view/com/util/ErrorBoundary.tsx
+++ b/src/view/com/util/ErrorBoundary.tsx
@@ -1,8 +1,9 @@
import React, {Component, ErrorInfo, ReactNode} from 'react'
import {ErrorScreen} from './error/ErrorScreen'
import {CenteredView} from './Views'
-import {t} from '@lingui/macro'
+import {msg} from '@lingui/macro'
import {logger} from '#/logger'
+import {useLingui} from '@lingui/react'
interface Props {
children?: ReactNode
@@ -31,11 +32,7 @@ export class ErrorBoundary extends Component {
if (this.state.hasError) {
return (
-
+
)
}
@@ -43,3 +40,17 @@ export class ErrorBoundary extends Component {
return this.props.children
}
}
+
+function TranslatedErrorScreen({details}: {details?: string}) {
+ const {_} = useLingui()
+
+ return (
+
+ )
+}
diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx
index 7468111b5a..b6c512b09e 100644
--- a/src/view/com/util/Link.tsx
+++ b/src/view/com/util/Link.tsx
@@ -47,6 +47,7 @@ interface Props extends ComponentProps {
anchorNoUnderline?: boolean
navigationAction?: 'push' | 'replace' | 'navigate'
onPointerEnter?: () => void
+ onBeforePress?: () => void
}
export const Link = memo(function Link({
@@ -60,6 +61,7 @@ export const Link = memo(function Link({
accessible,
anchorNoUnderline,
navigationAction,
+ onBeforePress,
...props
}: Props) {
const t = useTheme()
@@ -70,6 +72,7 @@ export const Link = memo(function Link({
const onPress = React.useCallback(
(e?: Event) => {
+ onBeforePress?.()
if (typeof href === 'string') {
return onPressInner(
closeModal,
@@ -81,7 +84,7 @@ export const Link = memo(function Link({
)
}
},
- [closeModal, navigation, navigationAction, href, openLink],
+ [closeModal, navigation, navigationAction, href, openLink, onBeforePress],
)
if (noFeedback) {
@@ -262,6 +265,7 @@ interface TextLinkOnWebOnlyProps extends TextProps {
accessibilityHint?: string
title?: string
navigationAction?: 'push' | 'replace' | 'navigate'
+ disableMismatchWarning?: boolean
onPointerEnter?: () => void
}
export const TextLinkOnWebOnly = memo(function DesktopWebTextLink({
@@ -273,6 +277,7 @@ export const TextLinkOnWebOnly = memo(function DesktopWebTextLink({
numberOfLines,
lineHeight,
navigationAction,
+ disableMismatchWarning,
...props
}: TextLinkOnWebOnlyProps) {
if (isWeb) {
@@ -287,6 +292,7 @@ export const TextLinkOnWebOnly = memo(function DesktopWebTextLink({
lineHeight={lineHeight}
title={props.title}
navigationAction={navigationAction}
+ disableMismatchWarning={disableMismatchWarning}
{...props}
/>
)
diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx
index 3795dcf136..53dc20e714 100644
--- a/src/view/com/util/PostMeta.tsx
+++ b/src/view/com/util/PostMeta.tsx
@@ -11,7 +11,7 @@ import {sanitizeHandle} from 'lib/strings/handles'
import {isAndroid, isWeb} from 'platform/detection'
import {TimeElapsed} from './TimeElapsed'
import {makeProfileLink} from 'lib/routes/links'
-import {ModerationUI} from '@atproto/api'
+import {ModerationDecision, ModerationUI} from '@atproto/api'
import {usePrefetchProfileQuery} from '#/state/queries/profile'
interface PostMetaOpts {
@@ -21,6 +21,7 @@ interface PostMetaOpts {
handle: string
displayName?: string | undefined
}
+ moderation: ModerationDecision | undefined
authorHasWarning: boolean
postHref: string
timestamp: string
@@ -55,9 +56,14 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
style={[pal.text, opts.displayNameStyle]}
numberOfLines={1}
lineHeight={1.2}
+ disableMismatchWarning
text={
<>
- {sanitizeDisplayName(displayName)}
+ {sanitizeDisplayName(
+ displayName,
+ opts.moderation?.ui('displayName'),
+ )}
+
)
}
+ if (type === 'labeler') {
+ return (
+
+ )
+ }
return (
)}
-
- {showAppealLabelItem && (
- <>
-
-
- {
- openModal({
- name: 'appeal-label',
- uri: postUri,
- cid: postCid,
- })
- }}>
-
- {_(msg`Appeal content warning`)}
-
-
-
- >
- )}
@@ -359,6 +328,15 @@ let PostDropdownBtn = ({
confirmButtonCta={_(msg`Hide`)}
/>
+
+
- childContainerStyle?: StyleProp
-}>) {
- const pal = usePalette('default')
- const {_} = useLingui()
- const [override, setOverride] = React.useState(false)
- const {openModal} = useModalControls()
-
- if (
- !moderation.blur ||
- (ignoreMute && moderation.cause?.type === 'muted') ||
- shouldIgnoreQuote(moderationDecisions, ignoreQuoteDecisions)
- ) {
- return (
-
- {children}
-
- )
- }
-
- const isMute = ['muted', 'muted-word'].includes(moderation.cause?.type || '')
- const desc = describeModerationCause(moderation.cause, 'content')
- return (
-
- {
- if (!moderation.noOverride) {
- setOverride(v => !v)
- } else {
- openModal({
- name: 'moderation-details',
- context: 'content',
- moderation,
- })
- }
- }}
- accessibilityRole="button"
- accessibilityHint={
- override ? _(msg`Hide the content`) : _(msg`Show the content`)
- }
- accessibilityLabel=""
- style={[
- styles.cover,
- moderation.noOverride
- ? {borderWidth: 1, borderColor: pal.colors.borderDark}
- : pal.viewLight,
- ]}>
- {
- openModal({
- name: 'moderation-details',
- context: 'content',
- moderation,
- })
- }}
- accessibilityRole="button"
- accessibilityLabel={_(msg`Learn more about this warning`)}
- accessibilityHint="">
- {isMute ? (
-
- ) : (
-
- )}
-
-
- {desc.name}
-
-
-
- {moderation.noOverride ? (
- Learn more
- ) : override ? (
- Hide
- ) : (
- Show
- )}
-
-
-
- {override && {children}}
-
- )
-}
-
-function shouldIgnoreQuote(
- decisions: PostModeration['decisions'] | undefined,
- ignore: boolean | undefined,
-): boolean {
- if (!decisions || !ignore) {
- return false
- }
- return !isPostMediaBlurred(decisions)
-}
-
-const styles = StyleSheet.create({
- outer: {
- overflow: 'hidden',
- },
- cover: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: 6,
- borderRadius: 8,
- marginTop: 4,
- paddingVertical: 14,
- paddingLeft: 14,
- paddingRight: 18,
- },
- showBtn: {
- marginLeft: 'auto',
- alignSelf: 'center',
- },
-})
diff --git a/src/view/com/util/moderation/LabelInfo.tsx b/src/view/com/util/moderation/LabelInfo.tsx
deleted file mode 100644
index 970338752c..0000000000
--- a/src/view/com/util/moderation/LabelInfo.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-import React from 'react'
-import {Pressable, StyleProp, View, ViewStyle} from 'react-native'
-import {ComAtprotoLabelDefs} from '@atproto/api'
-import {Text} from '../text/Text'
-import {usePalette} from 'lib/hooks/usePalette'
-import {msg, Trans} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {useModalControls} from '#/state/modals'
-
-export function LabelInfo({
- details,
- labels,
- style,
-}: {
- details: {did: string} | {uri: string; cid: string}
- labels: ComAtprotoLabelDefs.Label[] | undefined
- style?: StyleProp
-}) {
- const pal = usePalette('default')
- const {_} = useLingui()
- const {openModal} = useModalControls()
-
- if (!labels) {
- return null
- }
- labels = labels.filter(l => !l.val.startsWith('!'))
- if (!labels.length) {
- return null
- }
-
- return (
-
-
-
- A content warning has been applied to this{' '}
- {'did' in details ? 'account' : 'post'}.
- {' '}
-
- openModal({name: 'appeal-label', ...details})}>
-
- Appeal this decision.
-
-
-
- )
-}
diff --git a/src/view/com/util/moderation/PostAlerts.tsx b/src/view/com/util/moderation/PostAlerts.tsx
deleted file mode 100644
index bc5bf9b325..0000000000
--- a/src/view/com/util/moderation/PostAlerts.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-import React from 'react'
-import {Pressable, StyleProp, StyleSheet, ViewStyle} from 'react-native'
-import {ModerationUI} from '@atproto/api'
-import {Text} from '../text/Text'
-import {usePalette} from 'lib/hooks/usePalette'
-import {ShieldExclamation} from 'lib/icons'
-import {describeModerationCause} from 'lib/moderation'
-import {Trans, msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {useModalControls} from '#/state/modals'
-
-export function PostAlerts({
- moderation,
- style,
-}: {
- moderation: ModerationUI
- includeMute?: boolean
- style?: StyleProp
-}) {
- const pal = usePalette('default')
- const {_} = useLingui()
- const {openModal} = useModalControls()
-
- const shouldAlert = !!moderation.cause && moderation.alert
- if (!shouldAlert) {
- return null
- }
-
- const desc = describeModerationCause(moderation.cause, 'content')
- return (
- {
- openModal({
- name: 'moderation-details',
- context: 'content',
- moderation,
- })
- }}
- accessibilityRole="button"
- accessibilityLabel={_(msg`Learn more about this warning`)}
- accessibilityHint=""
- style={[styles.container, pal.viewLight, style]}>
-
-
- {desc.name}{' '}
-
- Learn More
-
-
-
- )
-}
-
-const styles = StyleSheet.create({
- container: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: 4,
- paddingVertical: 8,
- paddingLeft: 14,
- paddingHorizontal: 16,
- borderRadius: 8,
- },
- learnMoreBtn: {
- marginLeft: 'auto',
- },
-})
diff --git a/src/view/com/util/moderation/ProfileHeaderAlerts.tsx b/src/view/com/util/moderation/ProfileHeaderAlerts.tsx
deleted file mode 100644
index 0f07b679ba..0000000000
--- a/src/view/com/util/moderation/ProfileHeaderAlerts.tsx
+++ /dev/null
@@ -1,89 +0,0 @@
-import React from 'react'
-import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
-import {ProfileModeration} from '@atproto/api'
-import {Text} from '../text/Text'
-import {usePalette} from 'lib/hooks/usePalette'
-import {ShieldExclamation} from 'lib/icons'
-import {
- describeModerationCause,
- getProfileModerationCauses,
-} from 'lib/moderation'
-import {msg, Trans} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {useModalControls} from '#/state/modals'
-
-export function ProfileHeaderAlerts({
- moderation,
- style,
-}: {
- moderation: ProfileModeration
- style?: StyleProp
-}) {
- const pal = usePalette('default')
- const {_} = useLingui()
- const {openModal} = useModalControls()
-
- const causes = getProfileModerationCauses(moderation)
- if (!causes.length) {
- return null
- }
-
- return (
-
- {causes.map(cause => {
- const isMute = cause.type === 'muted'
- const desc = describeModerationCause(cause, 'account')
- return (
- {
- openModal({
- name: 'moderation-details',
- context: 'content',
- moderation: {cause},
- })
- }}
- accessibilityRole="button"
- accessibilityLabel={_(msg`Learn more about this warning`)}
- accessibilityHint=""
- style={[styles.container, pal.viewLight, style]}>
- {isMute ? (
-
- ) : (
-
- )}
-
- {desc.name}
-
-
- Learn More
-
-
- )
- })}
-
- )
-}
-
-const styles = StyleSheet.create({
- grid: {
- gap: 4,
- },
- container: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: 8,
- paddingVertical: 12,
- paddingHorizontal: 16,
- borderRadius: 8,
- },
- learnMoreBtn: {
- marginLeft: 'auto',
- },
-})
diff --git a/src/view/com/util/moderation/ScreenHider.tsx b/src/view/com/util/moderation/ScreenHider.tsx
deleted file mode 100644
index 86f0cbf7bf..0000000000
--- a/src/view/com/util/moderation/ScreenHider.tsx
+++ /dev/null
@@ -1,180 +0,0 @@
-import React from 'react'
-import {
- TouchableWithoutFeedback,
- StyleProp,
- StyleSheet,
- View,
- ViewStyle,
-} from 'react-native'
-import {
- FontAwesomeIcon,
- FontAwesomeIconStyle,
-} from '@fortawesome/react-native-fontawesome'
-import {useNavigation} from '@react-navigation/native'
-import {ModerationUI} from '@atproto/api'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {NavigationProp} from 'lib/routes/types'
-import {Text} from '../text/Text'
-import {Button} from '../forms/Button'
-import {describeModerationCause} from 'lib/moderation'
-import {Trans, msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {useModalControls} from '#/state/modals'
-import {s} from '#/lib/styles'
-import {CenteredView} from '../Views'
-
-export function ScreenHider({
- testID,
- screenDescription,
- moderation,
- style,
- containerStyle,
- children,
-}: React.PropsWithChildren<{
- testID?: string
- screenDescription: string
- moderation: ModerationUI
- style?: StyleProp
- containerStyle?: StyleProp
-}>) {
- const pal = usePalette('default')
- const palInverted = usePalette('inverted')
- const {_} = useLingui()
- const [override, setOverride] = React.useState(false)
- const navigation = useNavigation()
- const {isMobile} = useWebMediaQueries()
- const {openModal} = useModalControls()
-
- if (!moderation.blur || override) {
- return (
-
- {children}
-
- )
- }
-
- const isNoPwi =
- moderation.cause?.type === 'label' &&
- moderation.cause?.labelDef.id === '!no-unauthenticated'
- const desc = describeModerationCause(moderation.cause, 'account')
- return (
-
-
-
-
-
-
-
- {isNoPwi ? (
- Sign-in Required
- ) : (
- Content Warning
- )}
-
-
- {isNoPwi ? (
-
- This account has requested that users sign in to view their profile.
-
- ) : (
- <>
- This {screenDescription} has been flagged:
-
- {desc.name}.
-
- {
- openModal({
- name: 'moderation-details',
- context: 'account',
- moderation,
- })
- }}
- accessibilityRole="button"
- accessibilityLabel={_(msg`Learn more about this warning`)}
- accessibilityHint="">
-
- Learn More
-
-
- >
- )}{' '}
-
- {isMobile && }
-
-
- {!moderation.noOverride && (
-
- )}
-
-
- )
-}
-
-const styles = StyleSheet.create({
- spacer: {
- flex: 1,
- },
- container: {
- flex: 1,
- paddingTop: 100,
- paddingBottom: 150,
- },
- iconContainer: {
- alignItems: 'center',
- marginBottom: 10,
- },
- icon: {
- borderRadius: 25,
- width: 50,
- height: 50,
- alignItems: 'center',
- justifyContent: 'center',
- },
- title: {
- textAlign: 'center',
- marginBottom: 10,
- },
- description: {
- marginBottom: 10,
- paddingHorizontal: 20,
- textAlign: 'center',
- },
- btnContainer: {
- flexDirection: 'row',
- justifyContent: 'center',
- marginVertical: 10,
- gap: 10,
- },
- btn: {
- paddingHorizontal: 20,
- paddingVertical: 14,
- },
-})
diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx
index c96954a114..3fa347a6d8 100644
--- a/src/view/com/util/post-ctrls/PostCtrls.tsx
+++ b/src/view/com/util/post-ctrls/PostCtrls.tsx
@@ -41,7 +41,6 @@ let PostCtrls = ({
post,
record,
richText,
- showAppealLabelItem,
style,
onPressReply,
logContext,
@@ -50,7 +49,6 @@ let PostCtrls = ({
post: Shadow
record: AppBskyFeedPost.Record
richText: RichTextAPI
- showAppealLabelItem?: boolean
style?: StyleProp
onPressReply: () => void
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
@@ -232,7 +230,6 @@ let PostCtrls = ({
postUri={post.uri}
record={record}
richText={richText}
- showAppealLabelItem={showAppealLabelItem}
style={styles.btnPad}
hitSlop={big ? HITSLOP_20 : HITSLOP_10}
/>
diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx
index 35b0912698..2b1c3e6179 100644
--- a/src/view/com/util/post-embeds/QuoteEmbed.tsx
+++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx
@@ -1,13 +1,15 @@
import React from 'react'
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
import {
+ AppBskyFeedDefs,
AppBskyEmbedRecord,
AppBskyFeedPost,
AppBskyEmbedImages,
AppBskyEmbedRecordWithMedia,
- ModerationUI,
AppBskyEmbedExternal,
RichText as RichTextAPI,
+ moderatePost,
+ ModerationDecision,
} from '@atproto/api'
import {AtUri} from '@atproto/api'
import {PostMeta} from '../PostMeta'
@@ -16,20 +18,20 @@ import {Text} from '../text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {ComposerOptsQuote} from 'state/shell/composer'
import {PostEmbeds} from '.'
-import {PostAlerts} from '../moderation/PostAlerts'
+import {PostAlerts} from '../../../../components/moderation/PostAlerts'
import {makeProfileLink} from 'lib/routes/links'
import {InfoCircleIcon} from 'lib/icons'
import {Trans} from '@lingui/macro'
+import {useModerationOpts} from '#/state/queries/preferences'
+import {ContentHider} from '../../../../components/moderation/ContentHider'
import {RichText} from '#/components/RichText'
import {atoms as a} from '#/alf'
export function MaybeQuoteEmbed({
embed,
- moderation,
style,
}: {
embed: AppBskyEmbedRecord.View
- moderation: ModerationUI
style?: StyleProp
}) {
const pal = usePalette('default')
@@ -39,17 +41,9 @@ export function MaybeQuoteEmbed({
AppBskyFeedPost.validateRecord(embed.record.value).success
) {
return (
-
)
@@ -75,19 +69,49 @@ export function MaybeQuoteEmbed({
return null
}
+function QuoteEmbedModerated({
+ viewRecord,
+ postRecord,
+ style,
+}: {
+ viewRecord: AppBskyEmbedRecord.ViewRecord
+ postRecord: AppBskyFeedPost.Record
+ style?: StyleProp
+}) {
+ const moderationOpts = useModerationOpts()
+ const moderation = React.useMemo(() => {
+ return moderationOpts
+ ? moderatePost(viewRecordToPostView(viewRecord), moderationOpts)
+ : undefined
+ }, [viewRecord, moderationOpts])
+
+ const quote = {
+ author: viewRecord.author,
+ cid: viewRecord.cid,
+ uri: viewRecord.uri,
+ indexedAt: viewRecord.indexedAt,
+ text: postRecord.text,
+ facets: postRecord.facets,
+ embeds: viewRecord.embeds,
+ }
+
+ return
+}
+
export function QuoteEmbed({
quote,
moderation,
style,
}: {
quote: ComposerOptsQuote
- moderation?: ModerationUI
+ moderation?: ModerationDecision
style?: StyleProp
}) {
const pal = usePalette('default')
const itemUrip = new AtUri(quote.uri)
const itemHref = makeProfileLink(quote.author, 'post', itemUrip.rkey)
const itemTitle = `Post by ${quote.author.handle}`
+
const richText = React.useMemo(
() =>
quote.text.trim()
@@ -95,6 +119,7 @@ export function QuoteEmbed({
: undefined,
[quote.text, quote.facets],
)
+
const embed = React.useMemo(() => {
const e = quote.embeds?.[0]
@@ -108,40 +133,52 @@ export function QuoteEmbed({
return e.media
}
}, [quote.embeds])
+
return (
-
-
-
-
- {moderation ? (
-
- ) : null}
- {richText ? (
-
- ) : null}
- {embed && }
-
+
+
+
+
+
+ {moderation ? (
+
+ ) : null}
+ {richText ? (
+
+ ) : null}
+ {embed && }
+
+
)
}
-export default QuoteEmbed
+function viewRecordToPostView(
+ viewRecord: AppBskyEmbedRecord.ViewRecord,
+): AppBskyFeedDefs.PostView {
+ const {value, embeds, ...rest} = viewRecord
+ return {
+ ...rest,
+ $type: 'app.bsky.feed.defs#postView',
+ record: value,
+ embed: embeds?.[0],
+ }
+}
const styles = StyleSheet.create({
container: {
diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx
index 7e235babb6..47091fbb07 100644
--- a/src/view/com/util/post-embeds/index.tsx
+++ b/src/view/com/util/post-embeds/index.tsx
@@ -15,8 +15,7 @@ import {
AppBskyEmbedRecordWithMedia,
AppBskyFeedDefs,
AppBskyGraphDefs,
- ModerationUI,
- PostModeration,
+ ModerationDecision,
} from '@atproto/api'
import {Link} from '../Link'
import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
@@ -26,9 +25,8 @@ import {ExternalLinkEmbed} from './ExternalLinkEmbed'
import {MaybeQuoteEmbed} from './QuoteEmbed'
import {AutoSizedImage} from '../images/AutoSizedImage'
import {ListEmbed} from './ListEmbed'
-import {isCauseALabelOnUri, isQuoteBlurred} from 'lib/moderation'
import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
-import {ContentHider} from '../moderation/ContentHider'
+import {ContentHider} from '../../../../components/moderation/ContentHider'
import {isNative} from '#/platform/detection'
import {shareUrl} from '#/lib/sharing'
@@ -42,12 +40,10 @@ type Embed =
export function PostEmbeds({
embed,
moderation,
- moderationDecisions,
style,
}: {
embed?: Embed
- moderation: ModerationUI
- moderationDecisions?: PostModeration['decisions']
+ moderation?: ModerationDecision
style?: StyleProp
}) {
const pal = usePalette('default')
@@ -66,18 +62,10 @@ export function PostEmbeds({
// quote post with media
// =
if (AppBskyEmbedRecordWithMedia.isView(embed)) {
- const isModOnQuote =
- (AppBskyEmbedRecord.isViewRecord(embed.record.record) &&
- isCauseALabelOnUri(moderation.cause, embed.record.record.uri)) ||
- (moderationDecisions && isQuoteBlurred(moderationDecisions))
- const mediaModeration = isModOnQuote ? {} : moderation
- const quoteModeration = isModOnQuote ? moderation : {}
return (
-
-
-
-
+
+
)
}
@@ -86,6 +74,7 @@ export function PostEmbeds({
// custom feed embed (i.e. generator view)
// =
if (AppBskyFeedDefs.isGeneratorView(embed.record)) {
+ // TODO moderation
return (
}
// quote post
// =
- return (
-
-
-
- )
+ return
}
// image embed
@@ -132,35 +118,41 @@ export function PostEmbeds({
if (images.length === 1) {
const {alt, thumb, aspectRatio} = images[0]
return (
-
- _openLightbox(0)}
- onPressIn={() => onPressIn(0)}
- style={[styles.singleImage]}>
- {alt === '' ? null : (
-
-
- ALT
-
-
- )}
-
-
+
+
+ _openLightbox(0)}
+ onPressIn={() => onPressIn(0)}
+ style={[styles.singleImage]}>
+ {alt === '' ? null : (
+
+
+ ALT
+
+
+ )}
+
+
+
)
}
return (
-
-
-
+
+
+
+
+
)
}
}
@@ -171,15 +163,17 @@ export function PostEmbeds({
const link = embed.external
return (
-
-
-
+
+
+
+
+
)
}
diff --git a/src/view/screens/DebugMod.tsx b/src/view/screens/DebugMod.tsx
new file mode 100644
index 0000000000..64f2376a43
--- /dev/null
+++ b/src/view/screens/DebugMod.tsx
@@ -0,0 +1,923 @@
+import React from 'react'
+import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
+import {View} from 'react-native'
+import {
+ LABELS,
+ mock,
+ moderatePost,
+ moderateProfile,
+ ModerationOpts,
+ AppBskyActorDefs,
+ AppBskyFeedDefs,
+ AppBskyFeedPost,
+ LabelPreference,
+ ModerationDecision,
+ ModerationBehavior,
+ RichText,
+ ComAtprotoLabelDefs,
+ interpretLabelValueDefinition,
+} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {moderationOptsOverrideContext} from '#/state/queries/preferences'
+import {useSession} from '#/state/session'
+import {FeedNotification} from '#/state/queries/notifications/types'
+import {
+ groupNotifications,
+ shouldFilterNotif,
+} from '#/state/queries/notifications/util'
+
+import {atoms as a, useTheme} from '#/alf'
+import {CenteredView, ScrollView} from '#/view/com/util/Views'
+import {H1, H3, P, Text} from '#/components/Typography'
+import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings'
+import * as Toggle from '#/components/forms/Toggle'
+import * as ToggleButton from '#/components/forms/ToggleButton'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
+import {
+ ChevronBottom_Stroke2_Corner0_Rounded as ChevronBottom,
+ ChevronTop_Stroke2_Corner0_Rounded as ChevronTop,
+} from '#/components/icons/Chevron'
+import {ScreenHider} from '../../components/moderation/ScreenHider'
+import {ProfileHeaderStandard} from '#/screens/Profile/Header/ProfileHeaderStandard'
+import {ProfileCard} from '../com/profile/ProfileCard'
+import {FeedItem} from '../com/posts/FeedItem'
+import {FeedItem as NotifFeedItem} from '../com/notifications/FeedItem'
+import {PostThreadItem} from '../com/post-thread/PostThreadItem'
+import {Divider} from '#/components/Divider'
+
+const LABEL_VALUES: (keyof typeof LABELS)[] = Object.keys(
+ LABELS,
+) as (keyof typeof LABELS)[]
+
+export const DebugModScreen = ({}: NativeStackScreenProps<
+ CommonNavigatorParams,
+ 'DebugMod'
+>) => {
+ const t = useTheme()
+ const [scenario, setScenario] = React.useState(['label'])
+ const [scenarioSwitches, setScenarioSwitches] = React.useState([])
+ const [label, setLabel] = React.useState([LABEL_VALUES[0]])
+ const [target, setTarget] = React.useState(['account'])
+ const [visibility, setVisiblity] = React.useState(['warn'])
+ const [customLabelDef, setCustomLabelDef] =
+ React.useState({
+ identifier: 'custom',
+ blurs: 'content',
+ severity: 'alert',
+ defaultSetting: 'warn',
+ locales: [
+ {
+ lang: 'en',
+ name: 'Custom label',
+ description: 'A custom label created in this test environment',
+ },
+ ],
+ })
+ const [view, setView] = React.useState(['post'])
+ const labelStrings = useGlobalLabelStrings()
+ const {currentAccount} = useSession()
+
+ const isTargetMe =
+ scenario[0] === 'label' && scenarioSwitches.includes('targetMe')
+ const isSelfLabel =
+ scenario[0] === 'label' && scenarioSwitches.includes('selfLabel')
+ const noAdult =
+ scenario[0] === 'label' && scenarioSwitches.includes('noAdult')
+ const isLoggedOut =
+ scenario[0] === 'label' && scenarioSwitches.includes('loggedOut')
+ const isFollowing = scenarioSwitches.includes('following')
+
+ const did =
+ isTargetMe && currentAccount ? currentAccount.did : 'did:web:bob.test'
+
+ const profile = React.useMemo(() => {
+ const mockedProfile = mock.profileViewBasic({
+ handle: `bob.test`,
+ displayName: 'Bob Robertson',
+ description: 'User with this as their bio',
+ labels:
+ scenario[0] === 'label' && target[0] === 'account'
+ ? [
+ mock.label({
+ src: isSelfLabel ? did : undefined,
+ val: label[0],
+ uri: `at://${did}/`,
+ }),
+ ]
+ : scenario[0] === 'label' && target[0] === 'profile'
+ ? [
+ mock.label({
+ src: isSelfLabel ? did : undefined,
+ val: label[0],
+ uri: `at://${did}/app.bsky.actor.profile/self`,
+ }),
+ ]
+ : undefined,
+ viewer: mock.actorViewerState({
+ following: isFollowing
+ ? `at://${currentAccount?.did || ''}/app.bsky.graph.follow/1234`
+ : undefined,
+ muted: scenario[0] === 'mute',
+ mutedByList: undefined,
+ blockedBy: undefined,
+ blocking:
+ scenario[0] === 'block'
+ ? `at://did:web:alice.test/app.bsky.actor.block/fake`
+ : undefined,
+ blockingByList: undefined,
+ }),
+ })
+ mockedProfile.did = did
+ mockedProfile.avatar = 'https://bsky.social/about/images/favicon-32x32.png'
+ mockedProfile.banner =
+ 'https://bsky.social/about/images/social-card-default-gradient.png'
+ return mockedProfile
+ }, [scenario, target, label, isSelfLabel, did, isFollowing, currentAccount])
+
+ const post = React.useMemo(() => {
+ return mock.postView({
+ record: mock.post({
+ text: "This is the body of the post. It's where the text goes. You get the idea.",
+ }),
+ author: profile,
+ labels:
+ scenario[0] === 'label' && target[0] === 'post'
+ ? [
+ mock.label({
+ src: isSelfLabel ? did : undefined,
+ val: label[0],
+ uri: `at://${did}/app.bsky.feed.post/fake`,
+ }),
+ ]
+ : undefined,
+ embed:
+ target[0] === 'embed'
+ ? mock.embedRecordView({
+ record: mock.post({
+ text: 'Embed',
+ }),
+ labels:
+ scenario[0] === 'label' && target[0] === 'embed'
+ ? [
+ mock.label({
+ src: isSelfLabel ? did : undefined,
+ val: label[0],
+ uri: `at://${did}/app.bsky.feed.post/fake`,
+ }),
+ ]
+ : undefined,
+ author: profile,
+ })
+ : {
+ $type: 'app.bsky.embed.images#view',
+ images: [
+ {
+ thumb:
+ 'https://bsky.social/about/images/social-card-default-gradient.png',
+ fullsize:
+ 'https://bsky.social/about/images/social-card-default-gradient.png',
+ alt: '',
+ },
+ ],
+ },
+ })
+ }, [scenario, label, target, profile, isSelfLabel, did])
+
+ const replyNotif = React.useMemo(() => {
+ const notif = mock.replyNotification({
+ record: mock.post({
+ text: "This is the body of the post. It's where the text goes. You get the idea.",
+ reply: {
+ parent: {
+ uri: `at://${did}/app.bsky.feed.post/fake-parent`,
+ cid: 'bafyreiclp443lavogvhj3d2ob2cxbfuscni2k5jk7bebjzg7khl3esabwq',
+ },
+ root: {
+ uri: `at://${did}/app.bsky.feed.post/fake-parent`,
+ cid: 'bafyreiclp443lavogvhj3d2ob2cxbfuscni2k5jk7bebjzg7khl3esabwq',
+ },
+ },
+ }),
+ author: profile,
+ labels:
+ scenario[0] === 'label' && target[0] === 'post'
+ ? [
+ mock.label({
+ src: isSelfLabel ? did : undefined,
+ val: label[0],
+ uri: `at://${did}/app.bsky.feed.post/fake`,
+ }),
+ ]
+ : undefined,
+ })
+ const [item] = groupNotifications([notif])
+ item.subject = mock.postView({
+ record: notif.record as AppBskyFeedPost.Record,
+ author: profile,
+ labels: notif.labels,
+ })
+ return item
+ }, [scenario, label, target, profile, isSelfLabel, did])
+
+ const followNotif = React.useMemo(() => {
+ const notif = mock.followNotification({
+ author: profile,
+ subjectDid: currentAccount?.did || '',
+ })
+ const [item] = groupNotifications([notif])
+ return item
+ }, [profile, currentAccount])
+
+ const modOpts = React.useMemo(() => {
+ return {
+ userDid: isLoggedOut ? '' : isTargetMe ? did : 'did:web:alice.test',
+ prefs: {
+ adultContentEnabled: !noAdult,
+ labels: {
+ [label[0]]: visibility[0] as LabelPreference,
+ },
+ labelers: [
+ {
+ did: 'did:plc:fake-labeler',
+ labels: {[label[0]]: visibility[0] as LabelPreference},
+ },
+ ],
+ mutedWords: [],
+ hiddenPosts: [],
+ },
+ labelDefs: {
+ 'did:plc:fake-labeler': [
+ interpretLabelValueDefinition(customLabelDef, 'did:plc:fake-labeler'),
+ ],
+ },
+ }
+ }, [label, visibility, noAdult, isLoggedOut, isTargetMe, did, customLabelDef])
+
+ const profileModeration = React.useMemo(() => {
+ return moderateProfile(profile, modOpts)
+ }, [profile, modOpts])
+ const postModeration = React.useMemo(() => {
+ return moderatePost(post, modOpts)
+ }, [post, modOpts])
+
+ return (
+
+
+
+ Moderation states
+
+
+
+
+ Label
+
+
+ Block
+
+
+ Mute
+
+
+
+ {scenario[0] === 'label' && (
+ <>
+
+
+
+ {LABEL_VALUES.map(labelValue => {
+ let targetFixed = target[0]
+ if (
+ targetFixed !== 'account' &&
+ targetFixed !== 'profile'
+ ) {
+ targetFixed = 'content'
+ }
+ const disabled =
+ isSelfLabel &&
+ LABELS[labelValue].flags.includes('no-self')
+ return (
+
+
+ {labelValue}
+
+ )
+ })}
+
+
+ Custom label
+
+
+
+
+ {label[0] === 'custom' ? (
+
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+
+
+
+
+
+
+ Target is me
+
+
+
+ Following target
+
+
+
+ Self label
+
+
+
+ Adult disabled
+
+
+
+ Logged out
+
+
+
+
+ {LABELS[label[0] as keyof typeof LABELS]?.configurable !==
+ false && (
+
+
+ Preference
+
+
+
+
+
+ Hide
+
+
+
+ Warn
+
+
+
+ Ignore
+
+
+
+
+ )}
+
+
+
+
+
+
+ Target
+
+
+
+
+
+
+ Account
+
+
+
+ Profile
+
+
+
+ Post
+
+
+
+ Embed
+
+
+
+
+
+
+ >
+ )}
+
+
+
+
+
+
+
+ Post
+
+
+ Notifications
+
+
+ Account
+
+
+ Data
+
+
+
+
+ {view[0] === 'post' && (
+ <>
+
+
+
+
+
+
+
+
+ >
+ )}
+
+ {view[0] === 'notifications' && (
+ <>
+
+
+
+
+
+ >
+ )}
+
+ {view[0] === 'account' && (
+ <>
+
+
+
+
+
+ >
+ )}
+
+ {view[0] === 'data' && (
+ <>
+
+
+
+
+
+ >
+ )}
+
+
+
+
+
+
+ )
+}
+
+function Heading({title, subtitle}: {title: string; subtitle?: string}) {
+ const t = useTheme()
+ return (
+
+ {title}{' '}
+ {!!subtitle && (
+ {subtitle}
+ )}
+
+ )
+}
+
+function CustomLabelForm({
+ def,
+ setDef,
+}: {
+ def: ComAtprotoLabelDefs.LabelValueDefinition
+ setDef: React.Dispatch<
+ React.SetStateAction
+ >
+}) {
+ const t = useTheme()
+ return (
+
+
+
+ Blurs
+
+
+ setDef(v => ({...v, blurs: values[0]}))}>
+
+
+
+ Content
+
+
+
+ Media
+
+
+
+ None
+
+
+
+
+
+
+
+ Severity
+
+
+ setDef(v => ({...v, severity: values[0]}))}>
+
+
+
+ Alert
+
+
+
+ Inform
+
+
+
+ None
+
+
+
+
+
+
+ )
+}
+
+function Toggler({label, children}: React.PropsWithChildren<{label: string}>) {
+ const t = useTheme()
+ const [show, setShow] = React.useState(false)
+ return (
+
+
+
+ {show && children}
+
+
+ )
+}
+
+function SmallToggler({
+ label,
+ children,
+}: React.PropsWithChildren<{label: string}>) {
+ const [show, setShow] = React.useState(false)
+ return (
+
+
+
+
+ {show && children}
+
+ )
+}
+
+function DataView({label, data}: {label: string; data: any}) {
+ return (
+
+
+ {JSON.stringify(data, null, 2)}
+
+
+ )
+}
+
+function ModerationUIView({
+ mod,
+ label,
+}: {
+ mod: ModerationDecision
+ label: string
+}) {
+ return (
+
+
+ {[
+ 'profileList',
+ 'profileView',
+ 'avatar',
+ 'banner',
+ 'displayName',
+ 'contentList',
+ 'contentView',
+ 'contentMedia',
+ ].map(key => {
+ const ui = mod.ui(key as keyof ModerationBehavior)
+ return (
+
+ {key}
+
+
+
+
+
+
+ )
+ })}
+
+
+ )
+}
+
+function Spacer() {
+ return
+}
+
+function MockPostFeedItem({
+ post,
+ moderation,
+}: {
+ post: AppBskyFeedDefs.PostView
+ moderation: ModerationDecision
+}) {
+ const t = useTheme()
+ if (moderation.ui('contentList').filter) {
+ return (
+
+ Filtered from the feed
+
+ )
+ }
+ return (
+
+ )
+}
+
+function MockPostThreadItem({
+ post,
+ reply,
+}: {
+ post: AppBskyFeedDefs.PostView
+ moderation: ModerationDecision
+ reply?: boolean
+}) {
+ return (
+ {}}
+ />
+ )
+}
+
+function MockNotifItem({
+ notif,
+ moderationOpts,
+}: {
+ notif: FeedNotification
+ moderationOpts: ModerationOpts
+}) {
+ const t = useTheme()
+ if (shouldFilterNotif(notif.notification, moderationOpts)) {
+ return (
+
+ Filtered from the feed
+
+ )
+ }
+ return
+}
+
+function MockAccountCard({
+ profile,
+ moderation,
+}: {
+ profile: AppBskyActorDefs.ProfileViewBasic
+ moderation: ModerationDecision
+}) {
+ const t = useTheme()
+
+ if (moderation.ui('profileList').filter) {
+ return (
+
+ Filtered from the listing
+
+ )
+ }
+
+ return
+}
+
+function MockAccountScreen({
+ profile,
+ moderation,
+ moderationOpts,
+}: {
+ profile: AppBskyActorDefs.ProfileViewBasic
+ moderation: ModerationDecision
+ moderationOpts: ModerationOpts
+}) {
+ const t = useTheme()
+ const {_} = useLingui()
+ return (
+
+
+
+
+
+ )
+}
+
+function Flag({v, label}: {v: boolean | undefined; label: string}) {
+ const t = useTheme()
+ return (
+
+
+ {v && }
+
+ {label}
+
+ )
+}
diff --git a/src/view/screens/Moderation.tsx b/src/view/screens/Moderation.tsx
deleted file mode 100644
index 928766c304..0000000000
--- a/src/view/screens/Moderation.tsx
+++ /dev/null
@@ -1,304 +0,0 @@
-import React from 'react'
-import {
- ActivityIndicator,
- StyleSheet,
- TouchableOpacity,
- View,
-} from 'react-native'
-import {useFocusEffect} from '@react-navigation/native'
-import {
- FontAwesomeIcon,
- FontAwesomeIconStyle,
-} from '@fortawesome/react-native-fontawesome'
-import {ComAtprotoLabelDefs} from '@atproto/api'
-import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
-import {s} from 'lib/styles'
-import {CenteredView} from '../com/util/Views'
-import {ViewHeader} from '../com/util/ViewHeader'
-import {Link, TextLink} from '../com/util/Link'
-import {Text} from '../com/util/text/Text'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useAnalytics} from 'lib/analytics/analytics'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {useSetMinimalShellMode} from '#/state/shell'
-import {useModalControls} from '#/state/modals'
-import {Trans, msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {ToggleButton} from '../com/util/forms/ToggleButton'
-import {useSession} from '#/state/session'
-import {
- useProfileQuery,
- useProfileUpdateMutation,
-} from '#/state/queries/profile'
-import {ScrollView} from '../com/util/Views'
-import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
-
-type Props = NativeStackScreenProps
-export function ModerationScreen({}: Props) {
- const pal = usePalette('default')
- const {_} = useLingui()
- const setMinimalShellMode = useSetMinimalShellMode()
- const {screen, track} = useAnalytics()
- const {isTabletOrDesktop} = useWebMediaQueries()
- const {openModal} = useModalControls()
- const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
-
- useFocusEffect(
- React.useCallback(() => {
- screen('Moderation')
- setMinimalShellMode(false)
- }, [screen, setMinimalShellMode]),
- )
-
- const onPressContentFiltering = React.useCallback(() => {
- track('Moderation:ContentfilteringButtonClicked')
- openModal({name: 'content-filtering-settings'})
- }, [track, openModal])
-
- return (
-
-
-
-
-
-
-
-
-
- Content filtering
-
-
- mutedWordsDialogControl.open()}
- accessibilityRole="tab"
- accessibilityHint=""
- accessibilityLabel={_(msg`Open muted words settings`)}>
-
-
-
-
- Muted words & tags
-
-
-
-
-
-
-
- Moderation lists
-
-
-
-
-
-
-
- Muted accounts
-
-
-
-
-
-
-
- Blocked accounts
-
-
-
- Logged-out visibility
-
-
-
-
- )
-}
-
-function PwiOptOut() {
- const pal = usePalette('default')
- const {_} = useLingui()
- const {currentAccount} = useSession()
- const {data: profile} = useProfileQuery({did: currentAccount?.did})
- const updateProfile = useProfileUpdateMutation()
-
- const isOptedOut =
- profile?.labels?.some(l => l.val === '!no-unauthenticated') || false
- const canToggle = profile && !updateProfile.isPending
-
- const onToggleOptOut = React.useCallback(() => {
- if (!profile) {
- return
- }
- let wasAdded = false
- updateProfile.mutate({
- profile,
- updates: existing => {
- // create labels attr if needed
- existing.labels = ComAtprotoLabelDefs.isSelfLabels(existing.labels)
- ? existing.labels
- : {
- $type: 'com.atproto.label.defs#selfLabels',
- values: [],
- }
-
- // toggle the label
- const hasLabel = existing.labels.values.some(
- l => l.val === '!no-unauthenticated',
- )
- if (hasLabel) {
- wasAdded = false
- existing.labels.values = existing.labels.values.filter(
- l => l.val !== '!no-unauthenticated',
- )
- } else {
- wasAdded = true
- existing.labels.values.push({val: '!no-unauthenticated'})
- }
-
- // delete if no longer needed
- if (existing.labels.values.length === 0) {
- delete existing.labels
- }
- return existing
- },
- checkCommitted: res => {
- const exists = !!res.data.labels?.some(
- l => l.val === '!no-unauthenticated',
- )
- return exists === wasAdded
- },
- })
- }, [updateProfile, profile])
-
- return (
-
-
-
- {updateProfile.isPending && }
-
-
-
-
- Bluesky will not show your profile and posts to logged-out users.
- Other apps may not honor this request. This does not make your
- account private.
-
-
-
-
- Note: Bluesky is an open and public network. This setting only
- limits the visibility of your content on the Bluesky app and
- website, and other apps may not respect this setting. Your content
- may still be shown to logged-out users by other apps and websites.
-
-
-
-
-
- )
-}
-
-const styles = StyleSheet.create({
- desktopContainer: {
- borderLeftWidth: 1,
- borderRightWidth: 1,
- },
- spacer: {
- height: 6,
- },
- linkCard: {
- flexDirection: 'row',
- alignItems: 'center',
- paddingVertical: 12,
- paddingHorizontal: 18,
- marginBottom: 1,
- },
- toggleCard: {
- paddingVertical: 8,
- paddingTop: 2,
- paddingHorizontal: 6,
- marginBottom: 1,
- },
- iconContainer: {
- alignItems: 'center',
- justifyContent: 'center',
- width: 40,
- height: 40,
- borderRadius: 30,
- marginRight: 12,
- },
- noBorder: {
- borderBottomWidth: 0,
- borderRightWidth: 0,
- borderLeftWidth: 0,
- borderTopWidth: 0,
- },
-})
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index b30b4491bd..d5a46c5c98 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/Profile.tsx
@@ -1,5 +1,5 @@
import React, {useMemo} from 'react'
-import {StyleSheet, View} from 'react-native'
+import {StyleSheet} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import {
AppBskyActorDefs,
@@ -7,48 +7,39 @@ import {
ModerationOpts,
RichText as RichTextAPI,
} from '@atproto/api'
-import {msg, Trans} from '@lingui/macro'
+import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {CenteredView} from '../com/util/Views'
import {ListRef} from '../com/util/List'
-import {ScreenHider} from 'view/com/util/moderation/ScreenHider'
-import {Feed} from 'view/com/posts/Feed'
+import {ScreenHider} from '#/components/moderation/ScreenHider'
import {ProfileLists} from '../com/lists/ProfileLists'
import {ProfileFeedgens} from '../com/feeds/ProfileFeedgens'
-import {ProfileHeader, ProfileHeaderLoading} from '../com/profile/ProfileHeader'
import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
import {ErrorScreen} from '../com/util/error/ErrorScreen'
-import {EmptyState} from '../com/util/EmptyState'
import {FAB} from '../com/util/fab/FAB'
import {s, colors} from 'lib/styles'
import {useAnalytics} from 'lib/analytics/analytics'
import {ComposeIcon2} from 'lib/icons'
import {useSetTitle} from 'lib/hooks/useSetTitle'
import {combinedDisplayName} from 'lib/strings/display-names'
-import {
- FeedDescriptor,
- resetProfilePostsQueries,
-} from '#/state/queries/post-feed'
+import {resetProfilePostsQueries} from '#/state/queries/post-feed'
import {useResolveDidQuery} from '#/state/queries/resolve-uri'
import {useProfileQuery} from '#/state/queries/profile'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useSession, getAgent} from '#/state/session'
import {useModerationOpts} from '#/state/queries/preferences'
-import {useProfileExtraInfoQuery} from '#/state/queries/profile-extra-info'
-import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
+import {useLabelerInfoQuery} from '#/state/queries/labeler'
import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell'
import {cleanError} from '#/lib/strings/errors'
-import {LoadLatestBtn} from '../com/util/load-latest/LoadLatestBtn'
-import {useQueryClient} from '@tanstack/react-query'
import {useComposerControls} from '#/state/shell/composer'
import {listenSoftReset} from '#/state/events'
-import {truncateAndInvalidate} from '#/state/queries/util'
-import {Text} from '#/view/com/util/text/Text'
-import {usePalette} from 'lib/hooks/usePalette'
-import {isNative} from '#/platform/detection'
import {isInvalidHandle} from '#/lib/strings/handles'
+import {ProfileFeedSection} from '#/screens/Profile/Sections/Feed'
+import {ProfileLabelsSection} from '#/screens/Profile/Sections/Labels'
+import {ProfileHeader, ProfileHeaderLoading} from '#/screens/Profile/Header'
+
interface SectionRef {
scrollToTop: () => void
}
@@ -148,16 +139,24 @@ function ProfileScreenLoaded({
const setMinimalShellMode = useSetMinimalShellMode()
const {openComposer} = useComposerControls()
const {screen, track} = useAnalytics()
+ const {
+ data: labelerInfo,
+ error: labelerError,
+ isLoading: isLabelerLoading,
+ } = useLabelerInfoQuery({
+ did: profile.did,
+ enabled: !!profile.associated?.labeler,
+ })
const [currentPage, setCurrentPage] = React.useState(0)
const {_} = useLingui()
const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
- const extraInfoQuery = useProfileExtraInfoQuery(profile.did)
const postsSectionRef = React.useRef(null)
const repliesSectionRef = React.useRef(null)
const mediaSectionRef = React.useRef(null)
const likesSectionRef = React.useRef(null)
const feedsSectionRef = React.useRef(null)
const listsSectionRef = React.useRef(null)
+ const labelsSectionRef = React.useRef(null)
useSetTitle(combinedDisplayName(profile))
@@ -171,44 +170,75 @@ function ProfileScreenLoaded({
)
const isMe = profile.did === currentAccount?.did
+ const hasLabeler = !!profile.associated?.labeler
+ const showFiltersTab = hasLabeler
+ const showPostsTab = true
const showRepliesTab = hasSession
+ const showMediaTab = !hasLabeler
const showLikesTab = isMe
- const showFeedsTab = hasSession && (isMe || extraInfoQuery.data?.hasFeedgens)
- const showListsTab = hasSession && (isMe || extraInfoQuery.data?.hasLists)
+ const showFeedsTab =
+ hasSession && (isMe || (profile.associated?.feedgens || 0) > 0)
+ const showListsTab =
+ hasSession && (isMe || (profile.associated?.lists || 0) > 0)
+
const sectionTitles = useMemo(() => {
return [
- _(msg`Posts`),
+ showFiltersTab ? _(msg`Labels`) : undefined,
+ showListsTab && hasLabeler ? _(msg`Lists`) : undefined,
+ showPostsTab ? _(msg`Posts`) : undefined,
showRepliesTab ? _(msg`Replies`) : undefined,
- _(msg`Media`),
+ showMediaTab ? _(msg`Media`) : undefined,
showLikesTab ? _(msg`Likes`) : undefined,
showFeedsTab ? _(msg`Feeds`) : undefined,
- showListsTab ? _(msg`Lists`) : undefined,
+ showListsTab && !hasLabeler ? _(msg`Lists`) : undefined,
].filter(Boolean) as string[]
- }, [showRepliesTab, showLikesTab, showFeedsTab, showListsTab, _])
+ }, [
+ showPostsTab,
+ showRepliesTab,
+ showMediaTab,
+ showLikesTab,
+ showFeedsTab,
+ showListsTab,
+ showFiltersTab,
+ hasLabeler,
+ _,
+ ])
let nextIndex = 0
- const postsIndex = nextIndex++
+ let filtersIndex: number | null = null
+ let postsIndex: number | null = null
let repliesIndex: number | null = null
+ let mediaIndex: number | null = null
+ let likesIndex: number | null = null
+ let feedsIndex: number | null = null
+ let listsIndex: number | null = null
+ if (showFiltersTab) {
+ filtersIndex = nextIndex++
+ }
+ if (showPostsTab) {
+ postsIndex = nextIndex++
+ }
if (showRepliesTab) {
repliesIndex = nextIndex++
}
- const mediaIndex = nextIndex++
- let likesIndex: number | null = null
+ if (showMediaTab) {
+ mediaIndex = nextIndex++
+ }
if (showLikesTab) {
likesIndex = nextIndex++
}
- let feedsIndex: number | null = null
if (showFeedsTab) {
feedsIndex = nextIndex++
}
- let listsIndex: number | null = null
if (showListsTab) {
listsIndex = nextIndex++
}
const scrollSectionToTop = React.useCallback(
(index: number) => {
- if (index === postsIndex) {
+ if (index === filtersIndex) {
+ labelsSectionRef.current?.scrollToTop()
+ } else if (index === postsIndex) {
postsSectionRef.current?.scrollToTop()
} else if (index === repliesIndex) {
repliesSectionRef.current?.scrollToTop()
@@ -222,7 +252,15 @@ function ProfileScreenLoaded({
listsSectionRef.current?.scrollToTop()
}
},
- [postsIndex, repliesIndex, mediaIndex, likesIndex, feedsIndex, listsIndex],
+ [
+ filtersIndex,
+ postsIndex,
+ repliesIndex,
+ mediaIndex,
+ likesIndex,
+ feedsIndex,
+ listsIndex,
+ ],
)
useFocusEffect(
@@ -278,6 +316,7 @@ function ProfileScreenLoaded({
return (
+ screenDescription={_(msg`profile`)}
+ modui={moderation.ui('profileView')}>
- {({headerHeight, isFocused, scrollElRef}) => (
-
- )}
+ {showFiltersTab
+ ? ({headerHeight, scrollElRef}) => (
+
+ )
+ : null}
+ {showListsTab && !!profile.associated?.labeler
+ ? ({headerHeight, isFocused, scrollElRef}) => (
+
+ )
+ : null}
+ {showPostsTab
+ ? ({headerHeight, isFocused, scrollElRef}) => (
+
+ )
+ : null}
{showRepliesTab
? ({headerHeight, isFocused, scrollElRef}) => (
-
)
: null}
- {({headerHeight, isFocused, scrollElRef}) => (
-
- )}
+ {showMediaTab
+ ? ({headerHeight, isFocused, scrollElRef}) => (
+
+ )
+ : null}
{showLikesTab
? ({headerHeight, isFocused, scrollElRef}) => (
-
)
: null}
- {showListsTab
+ {showListsTab && !profile.associated?.labeler
? ({headerHeight, isFocused, scrollElRef}) => (
(
- function FeedSectionImpl(
- {feed, headerHeight, isFocused, scrollElRef, ignoreFilterFor},
- ref,
- ) {
- const {_} = useLingui()
- const queryClient = useQueryClient()
- const [hasNew, setHasNew] = React.useState(false)
- const [isScrolledDown, setIsScrolledDown] = React.useState(false)
-
- const onScrollToTop = React.useCallback(() => {
- scrollElRef.current?.scrollToOffset({
- animated: isNative,
- offset: -headerHeight,
- })
- truncateAndInvalidate(queryClient, FEED_RQKEY(feed))
- setHasNew(false)
- }, [scrollElRef, headerHeight, queryClient, feed, setHasNew])
- React.useImperativeHandle(ref, () => ({
- scrollToTop: onScrollToTop,
- }))
-
- const renderPostsEmpty = React.useCallback(() => {
- return
- }, [_])
-
- return (
-
-
- {(isScrolledDown || hasNew) && (
-
- )}
-
- )
- },
-)
-
-function ProfileEndOfFeed() {
- const pal = usePalette('default')
-
- return (
-
-
- End of feed
-
-
- )
-}
-
function useRichText(text: string): [RichTextAPI, boolean] {
const [prevText, setPrevText] = React.useState(text)
const [rawRT, setRawRT] = React.useState(() => new RichTextAPI({text}))
diff --git a/src/view/screens/ProfileFeed.tsx b/src/view/screens/ProfileFeed.tsx
index b3a7328c17..416bbc30e2 100644
--- a/src/view/screens/ProfileFeed.tsx
+++ b/src/view/screens/ProfileFeed.tsx
@@ -35,7 +35,7 @@ import {ComposeIcon2} from 'lib/icons'
import {logger} from '#/logger'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {useModalControls} from '#/state/modals'
+import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog'
import {useFeedSourceInfoQuery, FeedSourceFeedInfo} from '#/state/queries/feed'
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
import {
@@ -155,7 +155,7 @@ export function ProfileFeedScreenInner({
const {_} = useLingui()
const t = useTheme()
const {hasSession, currentAccount} = useSession()
- const {openModal} = useModalControls()
+ const reportDialogControl = useReportDialogControl()
const {openComposer} = useComposerControls()
const {track} = useAnalytics()
const feedSectionRef = React.useRef(null)
@@ -253,13 +253,8 @@ export function ProfileFeedScreenInner({
}, [feedInfo, track])
const onPressReport = React.useCallback(() => {
- if (!feedInfo) return
- openModal({
- name: 'report',
- uri: feedInfo.uri,
- cid: feedInfo.cid,
- })
- }, [openModal, feedInfo])
+ reportDialogControl.open()
+ }, [reportDialogControl])
const onCurrentPageSelected = React.useCallback(
(index: number) => {
@@ -400,6 +395,14 @@ export function ProfileFeedScreenInner({
return (
+
()
const {currentAccount} = useSession()
+ const reportDialogControl = useReportDialogControl()
const {openModal} = useModalControls()
const listMuteMutation = useListMuteMutation()
const listBlockMutation = useListBlockMutation()
@@ -370,12 +372,8 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
])
const onPressReport = useCallback(() => {
- openModal({
- name: 'report',
- uri: list.uri,
- cid: list.cid,
- })
- }, [openModal, list])
+ reportDialogControl.open()
+ }, [reportDialogControl])
const onPressShare = useCallback(() => {
const url = toShareUrl(`/profile/${list.creator.did}/lists/${rkey}`)
@@ -550,6 +548,14 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
isOwner={list.creator.did === currentAccount?.did}
creator={list.creator}
avatarType="list">
+
{isCurateList || isPinned ? (
- openModal({name: 'birth-date-settings'})}>
+
Show
@@ -807,6 +825,16 @@ export function SettingsScreen({}: Props) {
Storybook
+
+
+ Debug Moderation
+
+
Link out
+
+
@@ -148,6 +157,14 @@ export function Buttons() {
label="Link out">
+
+
@@ -183,6 +208,14 @@ export function Buttons() {
label="Link out">
+
+
)
diff --git a/src/view/screens/Storybook/index.tsx b/src/view/screens/Storybook/index.tsx
index e43d756de5..3a2e2f3696 100644
--- a/src/view/screens/Storybook/index.tsx
+++ b/src/view/screens/Storybook/index.tsx
@@ -67,6 +67,7 @@ export function Storybook() {
+
diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx
index 4a94837338..8933324eed 100644
--- a/src/view/shell/desktop/Search.tsx
+++ b/src/view/shell/desktop/Search.tsx
@@ -11,7 +11,7 @@ import {useNavigation, StackActions} from '@react-navigation/native'
import {
AppBskyActorDefs,
moderateProfile,
- ProfileModeration,
+ ModerationDecision,
} from '@atproto/api'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -86,7 +86,7 @@ export function SearchProfileCard({
moderation,
}: {
profile: AppBskyActorDefs.ProfileViewBasic
- moderation: ProfileModeration
+ moderation: ModerationDecision
}) {
const pal = usePalette('default')
@@ -111,7 +111,7 @@ export function SearchProfileCard({
{sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
- moderation.profile,
+ moderation.ui('displayName'),
)}
diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx
index 76a7f8fb3a..f29183095a 100644
--- a/src/view/shell/index.tsx
+++ b/src/view/shell/index.tsx
@@ -101,8 +101,8 @@ function ShellInner() {
-
+
>
)
}
diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx
index 71dccb8c48..02993ac462 100644
--- a/src/view/shell/index.web.tsx
+++ b/src/view/shell/index.web.tsx
@@ -1,5 +1,9 @@
import React, {useEffect} from 'react'
import {View, StyleSheet, TouchableOpacity} from 'react-native'
+import {useNavigation} from '@react-navigation/native'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
import {ErrorBoundary} from '../com/util/ErrorBoundary'
import {Lightbox} from '../com/lightbox/Lightbox'
import {ModalsContainer} from '../com/modals/Modal'
@@ -9,9 +13,7 @@ import {s, colors} from 'lib/styles'
import {RoutesContainer, FlatNavigator} from '../../Navigation'
import {DrawerContent} from './Drawer'
import {useWebMediaQueries} from '../../lib/hooks/useWebMediaQueries'
-import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from 'lib/routes/types'
-import {t} from '@lingui/macro'
import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell'
import {useCloseAllActiveElements} from '#/state/util'
import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
@@ -24,6 +26,7 @@ function ShellInner() {
const {isDesktop} = useWebMediaQueries()
const navigator = useNavigation()
const closeAllActiveElements = useCloseAllActiveElements()
+ const {_} = useLingui()
useWebBodyScrollLock(isDrawerOpen)
@@ -42,14 +45,15 @@ function ShellInner() {
-
+
+
{!isDesktop && isDrawerOpen && (
setDrawerOpen(false)}
style={styles.drawerMask}
- accessibilityLabel={t`Close navigation footer`}
- accessibilityHint={t`Closes bottom navigation bar`}>
+ accessibilityLabel={_(msg`Close navigation footer`)}
+ accessibilityHint={_(msg`Closes bottom navigation bar`)}>
diff --git a/yarn.lock b/yarn.lock
index 28d8210285..266734cdc1 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -34,19 +34,17 @@
jsonpointer "^5.0.0"
leven "^3.1.0"
-"@atproto/api@^0.10.5":
- version "0.10.5"
- resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.10.5.tgz#e778e2843d08690df8df81f24028a7578e9b3cb4"
- integrity sha512-GYdST5sPKU2JnPmm8x3KqjOSlDiYXrp4GkW7bpQTVLPabnUNq5NLN6HJEoJABjjOAsaLF12rBoV+JpRb1UjNsQ==
+"@atproto/api@^0.12.0":
+ version "0.12.0"
+ resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.0.tgz#69e52f8761dc7d76c675fa7284bd49240bb0df64"
+ integrity sha512-nSWiad1Z6IC/oVFSVxD5gZLhkD+J4EW2CFqAqIhklJNc0cjFKdmf8D56Pac6Ktm1sJoM6TVZ8GEeuEG6bJS/aQ==
dependencies:
- "@atproto/common-web" "^0.2.3"
- "@atproto/lexicon" "^0.3.2"
- "@atproto/syntax" "^0.2.0"
- "@atproto/xrpc" "^0.4.2"
+ "@atproto/common-web" "^0.3.0"
+ "@atproto/lexicon" "^0.4.0"
+ "@atproto/syntax" "^0.3.0"
+ "@atproto/xrpc" "^0.5.0"
multiformats "^9.9.0"
tlds "^1.234.0"
- typed-emitter "^2.1.0"
- zod "^3.21.4"
"@atproto/api@^0.9.5":
version "0.9.5"
@@ -144,6 +142,16 @@
uint8arrays "3.0.0"
zod "^3.21.4"
+"@atproto/common-web@^0.3.0":
+ version "0.3.0"
+ resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.3.0.tgz#36da8c2c31d8cf8a140c3c8f03223319bf4430bb"
+ integrity sha512-67VnV6JJyX+ZWyjV7xFQMypAgDmjVaR9ZCuU/QW+mqlqI7fex2uL4Fv+7/jHadgzhuJHVd6OHOvNn0wR5WZYtA==
+ dependencies:
+ graphemer "^1.4.0"
+ multiformats "^9.9.0"
+ uint8arrays "3.0.0"
+ zod "^3.21.4"
+
"@atproto/common@0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.1.0.tgz#4216a8fef5b985ab62ac21252a0f8ca0f4a0f210"
@@ -245,13 +253,13 @@
multiformats "^9.9.0"
zod "^3.21.4"
-"@atproto/lexicon@^0.3.2":
- version "0.3.2"
- resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.3.2.tgz#0085a3acd3a77867b8efe188297a1bbacc55ce5c"
- integrity sha512-kmGCkrRwpWIqmn/KO4BZwUf8Nmfndk3XvFC06V0ygCWc42g6+t4QP/6ywNW4PgqfZY0Q5aW4EuDfD7KjAFkFtQ==
+"@atproto/lexicon@^0.4.0":
+ version "0.4.0"
+ resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.0.tgz#63e8829945d80c25524882caa8ed27b1151cc576"
+ integrity sha512-RvCBKdSI4M8qWm5uTNz1z3R2yIvIhmOsMuleOj8YR6BwRD+QbtUBy3l+xQ7iXf4M5fdfJFxaUNa6Ty0iRwdKqQ==
dependencies:
- "@atproto/common-web" "^0.2.3"
- "@atproto/syntax" "^0.2.0"
+ "@atproto/common-web" "^0.3.0"
+ "@atproto/syntax" "^0.3.0"
iso-datestring-validator "^2.2.2"
multiformats "^9.9.0"
zod "^3.21.4"
@@ -351,12 +359,10 @@
dependencies:
"@atproto/common-web" "^0.2.3"
-"@atproto/syntax@^0.2.0":
- version "0.2.0"
- resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.2.0.tgz#4bab724c02e11f8943b8ec101251082cf55067e9"
- integrity sha512-K+9jl6mtxC9ytlR7msSiP9jVNqtdxEBSt0kOfsC924lqGwuD8nlUAMi1GSMgAZJGg/Rd+0MKXh789heTdeL3HQ==
- dependencies:
- "@atproto/common-web" "^0.2.3"
+"@atproto/syntax@^0.3.0":
+ version "0.3.0"
+ resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.3.0.tgz#fafa2dbea9add37253005cb663e7373e05e618b3"
+ integrity sha512-Weq0ZBxffGHDXHl9U7BQc2BFJi/e23AL+k+i5+D9hUq/bzT4yjGsrCejkjq0xt82xXDjmhhvQSZ0LqxyZ5woxA==
"@atproto/xrpc-server@^0.4.2":
version "0.4.2"
@@ -383,12 +389,12 @@
"@atproto/lexicon" "^0.3.1"
zod "^3.21.4"
-"@atproto/xrpc@^0.4.2":
- version "0.4.2"
- resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.4.2.tgz#57812e0624be597b85f21471acf336513f35ccda"
- integrity sha512-x4x2QB4nWmLjIpz2Ue9n/QVbVyJkk6tQMhvmDQaVFF89E3FcVI4rxF4uhzSxaLpbNtyVQBNEEmNHOr5EJLeHVA==
+"@atproto/xrpc@^0.5.0":
+ version "0.5.0"
+ resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.5.0.tgz#dacbfd8f7b13f0ab5bd56f8fdd4b460e132a6032"
+ integrity sha512-swu+wyOLvYW4l3n+VAuJbHcPcES+tin2Lsrp8Bw5aIXIICiuFn1YMFlwK9JwVUzTH21Py1s1nHEjr4CJeElJog==
dependencies:
- "@atproto/lexicon" "^0.3.2"
+ "@atproto/lexicon" "^0.4.0"
zod "^3.21.4"
"@aws-crypto/crc32@3.0.0":
@@ -7244,11 +7250,37 @@
dependencies:
"@tamagui/constants" "1.84.1"
+"@tanstack/query-async-storage-persister@^5.25.0":
+ version "5.25.0"
+ resolved "https://registry.yarnpkg.com/@tanstack/query-async-storage-persister/-/query-async-storage-persister-5.25.0.tgz#0e8a2a781b8e32a81a5d02a688d6fcdfd055235b"
+ integrity sha512-58UTp1CuLr2mehsJRMOd8IZPtYGHFeL+uHnHyRd1kmbwo7wDaa8HXstiBdTRq5KokxIXy9FiFbA06LtKuOiwMQ==
+ dependencies:
+ "@tanstack/query-persist-client-core" "5.25.0"
+
+"@tanstack/query-core@5.25.0":
+ version "5.25.0"
+ resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.25.0.tgz#e08ed0a9fad34c8005d1a282e57280031ac50cdc"
+ integrity sha512-vlobHP64HTuSE68lWF1mEhwSRC5Q7gaT+a/m9S+ItuN+ruSOxe1rFnR9j0ACWQ314BPhBEVKfBQ6mHL0OWfdbQ==
+
"@tanstack/query-core@5.8.1":
version "5.8.1"
resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.8.1.tgz#5215a028370d9b2f32e83787a0ea119e2f977996"
integrity sha512-Y0enatz2zQXBAsd7XmajlCs+WaitdR7dIFkqz9Xd7HL4KV04JOigWVreYseTmNH7YFSBSC/BJ9uuNp1MAf+GfA==
+"@tanstack/query-persist-client-core@5.25.0":
+ version "5.25.0"
+ resolved "https://registry.yarnpkg.com/@tanstack/query-persist-client-core/-/query-persist-client-core-5.25.0.tgz#52fa634a8067d7b965854a532a33077fd4df0eff"
+ integrity sha512-sEUsEZ/XWkOosO45CDBI5nj5woCS+DUd9Dk8pGpU8MkeH0EVd3p4N5CdbjNhrreyy5Krf3rpNaiRN9ygLX/rWA==
+ dependencies:
+ "@tanstack/query-core" "5.25.0"
+
+"@tanstack/react-query-persist-client@^5.25.0":
+ version "5.25.0"
+ resolved "https://registry.yarnpkg.com/@tanstack/react-query-persist-client/-/react-query-persist-client-5.25.0.tgz#ecbd1362cd6fd94e723d54f5af477d0812852dab"
+ integrity sha512-j1+GyFj4UQGWuiFZoDUVJZS+wxqKd9SGvPlyHG619zzYNN+QQu4B5uvvHc6U8MroM377EOBOuLKK3W6qsAdahQ==
+ dependencies:
+ "@tanstack/query-persist-client-core" "5.25.0"
+
"@tanstack/react-query@^5.8.1":
version "5.8.1"
resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.8.1.tgz#22a122016e23a39acd90341954a895980ec21ade"