Bluesky app fork with some witchin' additions 💫 witchsky.app
bluesky fork client

Merge remote-tracking branch 'origin/main' into samuel/alf-login

+7668 -5295
+2 -2
.github/workflows/build-and-push-bskyweb-aws.yaml
··· 3 3 push: 4 4 branches: 5 5 - main 6 - - traffic-reduction 7 - - respect-optout-for-embeds 6 + - 3p-moderators 7 + 8 8 env: 9 9 REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }} 10 10 USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }}
+185
.github/workflows/pull-request-commit.yml
··· 1 + # Credit https://github.com/expo/expo 2 + # https://github.com/expo/expo/blob/main/.github/workflows/pr-labeler.yml 3 + --- 4 + name: PR labeler 5 + 6 + on: 7 + push: 8 + branches: [main] 9 + pull_request: 10 + types: [opened, synchronize] 11 + 12 + concurrency: 13 + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} 14 + cancel-in-progress: true 15 + 16 + jobs: 17 + test-suite-fingerprint: 18 + runs-on: ubuntu-22.04 19 + if: ${{ github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'push' }} 20 + # REQUIRED: limit concurrency when pushing main(default) branch to prevent conflict for this action to update its fingerprint database 21 + concurrency: fingerprint-${{ github.event_name != 'pull_request' && 'main' || github.run_id }} 22 + permissions: 23 + # REQUIRED: Allow comments of PRs 24 + pull-requests: write 25 + # REQUIRED: Allow updating fingerprint in acton caches 26 + actions: write 27 + steps: 28 + - name: ⬇️ Checkout 29 + uses: actions/checkout@v4 30 + with: 31 + fetch-depth: 100 32 + 33 + - name: ⬇️ Fetch commits from base branch 34 + run: git fetch origin main:main --depth 100 35 + if: github.event_name == 'pull_request' 36 + 37 + - name: 🔧 Setup Node 38 + uses: actions/setup-node@v4 39 + with: 40 + node-version-file: .nvmrc 41 + cache: yarn 42 + 43 + - name: ⚙️ Install Dependencies 44 + run: yarn install 45 + 46 + - name: Get the base commit 47 + id: base-commit 48 + run: | 49 + # Since we limit this pr-labeler workflow only triggered from limited paths, we should use custom base commit 50 + echo base-commit=$(git log -n 1 main --pretty=format:'%H') >> "$GITHUB_OUTPUT" 51 + 52 + - name: 📷 Check fingerprint 53 + id: fingerprint 54 + uses: expo/expo-github-action/fingerprint@main 55 + with: 56 + previous-git-commit: ${{ steps.base-commit.outputs.base-commit }} 57 + 58 + - name: 👀 Debug fingerprint 59 + run: | 60 + echo "previousGitCommit=${{ steps.fingerprint.outputs.previous-git-commit }} currentGitCommit=${{ steps.fingerprint.outputs.current-git-commit }}" 61 + echo "isPreviousFingerprintEmpty=${{ steps.fingerprint.outputs.previous-fingerprint == '' }}" 62 + 63 + - name: 🏷️ Labeling PR 64 + uses: actions/github-script@v6 65 + if: ${{ github.event_name == 'pull_request' && steps.fingerprint.outputs.fingerprint-diff == '[]' }} 66 + with: 67 + script: | 68 + try { 69 + await github.rest.issues.removeLabel({ 70 + issue_number: context.issue.number, 71 + owner: context.repo.owner, 72 + repo: context.repo.repo, 73 + name: ['bot: fingerprint changed'] 74 + }) 75 + } catch (e) { 76 + if (e.status != 404) { 77 + throw e; 78 + } 79 + } 80 + github.rest.issues.addLabels({ 81 + issue_number: context.issue.number, 82 + owner: context.repo.owner, 83 + repo: context.repo.repo, 84 + labels: ['bot: fingerprint compatible'] 85 + }) 86 + 87 + - name: 🏷️ Labeling PR 88 + uses: actions/github-script@v6 89 + if: ${{ github.event_name == 'pull_request' && steps.fingerprint.outputs.fingerprint-diff != '[]' }} 90 + with: 91 + script: | 92 + try { 93 + await github.rest.issues.removeLabel({ 94 + issue_number: context.issue.number, 95 + owner: context.repo.owner, 96 + repo: context.repo.repo, 97 + name: ['bot: fingerprint compatible'] 98 + }) 99 + } catch (e) { 100 + if (e.status != 404) { 101 + throw e; 102 + } 103 + } 104 + github.rest.issues.addLabels({ 105 + issue_number: context.issue.number, 106 + owner: context.repo.owner, 107 + repo: context.repo.repo, 108 + labels: ['bot: fingerprint changed'] 109 + }) 110 + 111 + - name: 🔍 Find old comment if it exists 112 + uses: peter-evans/find-comment@v2 113 + if: ${{ github.event_name == 'pull_request' }} 114 + id: old_comment 115 + with: 116 + issue-number: ${{ github.event.pull_request.number }} 117 + comment-author: 'github-actions[bot]' 118 + body-includes: <!-- pr-labeler comment --> 119 + 120 + - name: 💬 Add comment with fingerprint 121 + if: ${{ github.event_name == 'pull_request' && steps.fingerprint.outputs.fingerprint-diff != '[]' && steps.old_comment.outputs.comment-id == '' }} 122 + uses: actions/github-script@v6 123 + with: 124 + script: | 125 + const diff = JSON.stringify(${{ steps.fingerprint.outputs.fingerprint-diff}}, null, 2); 126 + const body = `<!-- pr-labeler comment --> 127 + The Pull Request introduced fingerprint changes against the base commit: ${{ steps.fingerprint.outputs.previous-git-commit }} 128 + <details><summary>Fingerprint diff</summary> 129 + 130 + \`\`\`json 131 + ${diff} 132 + \`\`\` 133 + 134 + </details> 135 + 136 + --- 137 + *Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖* 138 + `; 139 + 140 + github.rest.issues.createComment({ 141 + issue_number: context.issue.number, 142 + owner: context.repo.owner, 143 + repo: context.repo.repo, 144 + body: body, 145 + }); 146 + 147 + - name: 💬 Update comment with fingerprint 148 + if: ${{ github.event_name == 'pull_request' && steps.fingerprint.outputs.fingerprint-diff != '[]' && steps.old_comment.outputs.comment-id != '' }} 149 + uses: actions/github-script@v6 150 + with: 151 + script: | 152 + const diff = JSON.stringify(${{ steps.fingerprint.outputs.fingerprint-diff}}, null, 2); 153 + const body = `<!-- pr-labeler comment --> 154 + The Pull Request introduced fingerprint changes against the base commit: ${{ steps.fingerprint.outputs.previous-git-commit }} 155 + <details><summary>Fingerprint diff</summary> 156 + 157 + \`\`\`json 158 + ${diff} 159 + \`\`\` 160 + 161 + </details> 162 + 163 + --- 164 + *Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖* 165 + `; 166 + 167 + github.rest.issues.updateComment({ 168 + issue_number: context.issue.number, 169 + comment_id: '${{ steps.old_comment.outputs.comment-id }}', 170 + owner: context.repo.owner, 171 + repo: context.repo.repo, 172 + body: body, 173 + }); 174 + 175 + - name: 💬 Delete comment with fingerprint 176 + if: ${{ github.event_name == 'pull_request' && steps.fingerprint.outputs.fingerprint-diff == '[]' && steps.old_comment.outputs.comment-id != '' }} 177 + uses: actions/github-script@v6 178 + with: 179 + script: | 180 + github.rest.issues.deleteComment({ 181 + issue_number: context.issue.number, 182 + comment_id: '${{ steps.old_comment.outputs.comment-id }}', 183 + owner: context.repo.owner, 184 + repo: context.repo.repo, 185 + });
+1
assets/icons/arrowTriangleBottom_stroke2_corner1_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="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"/></svg>
+1
assets/icons/bars3_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>
+1
assets/icons/chevronBottom_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>
+1
assets/icons/chevronTop_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>
+1
assets/icons/circleBanSign_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>
+1
assets/icons/group3_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M8 5a2 2 0 1 0 0 4 2 2 0 0 0 0-4ZM4 7a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm13-1a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm-3.5 1.5a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0Zm5.826 7.376c-.919-.779-2.052-1.03-3.1-.787a1 1 0 0 1-.451-1.949c1.671-.386 3.45.028 4.844 1.211 1.397 1.185 2.348 3.084 2.524 5.579a1 1 0 0 1-.997 1.07H18a1 1 0 1 1 0-2h3.007c-.29-1.47-.935-2.49-1.681-3.124ZM3.126 19h9.747c-.61-3.495-2.867-5-4.873-5-2.006 0-4.263 1.505-4.873 5ZM8 12c3.47 0 6.64 2.857 6.998 7.93A1 1 0 0 1 14 21H2a1 1 0 0 1-.998-1.07C1.36 14.857 4.53 12 8 12Z" clip-rule="evenodd"/></svg>
+1
assets/icons/heart2_filled_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12.489 21.372c8.528-4.78 10.626-10.47 9.022-14.47-.779-1.941-2.414-3.333-4.342-3.763-1.697-.378-3.552.003-5.169 1.287-1.617-1.284-3.472-1.665-5.17-1.287-1.927.43-3.562 1.822-4.34 3.764-1.605 4 .493 9.69 9.021 14.47a1 1 0 0 0 .978 0Z"/></svg>
+1
assets/icons/heart2_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M16.734 5.091c-1.238-.276-2.708.047-4.022 1.38a1 1 0 0 1-1.424 0C9.974 5.137 8.504 4.814 7.266 5.09c-1.263.282-2.379 1.206-2.92 2.556C3.33 10.18 4.252 14.84 12 19.348c7.747-4.508 8.67-9.168 7.654-11.7-.541-1.351-1.657-2.275-2.92-2.557Zm4.777 1.812c1.604 4-.494 9.69-9.022 14.47a1 1 0 0 1-.978 0C2.983 16.592.885 10.902 2.49 6.902c.779-1.942 2.414-3.334 4.342-3.764 1.697-.378 3.552.003 5.169 1.286 1.617-1.283 3.472-1.664 5.17-1.286 1.927.43 3.562 1.822 4.34 3.764Z" clip-rule="evenodd"/></svg>
+1
assets/icons/person_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.678 19h12.644c-.71-2.909-3.092-5-6.322-5s-5.613 2.091-6.322 5Zm-2.174.906C3.917 15.521 7.242 12 12 12c4.758 0 8.083 3.521 8.496 7.906A1 1 0 0 1 19.5 21h-15a1 1 0 0 1-.996-1.094Z" clip-rule="evenodd"/></svg>
+1
assets/icons/raisingHand4Finger_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>
+1
assets/icons/settingsGear2_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>
+1
assets/icons/shield_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>
+1
assets/icons/squareArrowTopRight_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>
+1
assets/icons/squareBehindSquare4_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>
+1
assets/icons/triangleExclamation_stroke2_corner2_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12.86 4.494a.995.995 0 0 0-1.72 0L4.14 16.502A.996.996 0 0 0 4.999 18h14.003a.996.996 0 0 0 .86-1.498L12.86 4.494ZM9.413 3.487c1.155-1.983 4.019-1.983 5.174 0l7.002 12.007C22.753 17.491 21.314 20 19.002 20H4.998c-2.312 0-3.751-2.509-2.587-4.506L9.413 3.487ZM12 8.019a1 1 0 0 1 1 1v2.994a1 1 0 1 1-2 0V9.02a1 1 0 0 1 1-1Z" clip-rule="evenodd"/><rect width="2.5" height="2.5" x="10.75" y="13.75" fill="#000" rx="1.25"/></svg>
+2
bskyweb/cmd/bskyweb/server.go
··· 188 188 e.GET("/settings/threads", server.WebGeneric) 189 189 e.GET("/settings/external-embeds", server.WebGeneric) 190 190 e.GET("/sys/debug", server.WebGeneric) 191 + e.GET("/sys/debug-mod", server.WebGeneric) 191 192 e.GET("/sys/log", server.WebGeneric) 192 193 e.GET("/support", server.WebGeneric) 193 194 e.GET("/support/privacy", server.WebGeneric) ··· 203 204 e.GET("/profile/:handleOrDID/lists/:rkey", server.WebGeneric) 204 205 e.GET("/profile/:handleOrDID/feed/:rkey", server.WebGeneric) 205 206 e.GET("/profile/:handleOrDID/feed/:rkey/liked-by", server.WebGeneric) 207 + e.GET("/profile/:handleOrDID/labeler/liked-by", server.WebGeneric) 206 208 207 209 // profile RSS feed (DID not handle) 208 210 e.GET("/profile/:ident/rss", server.WebProfileRSS)
+5 -7
bskyweb/static/iframe/youtube.html
··· 5 5 } 6 6 .container { 7 7 position: relative; 8 - width: 100%; 9 - height: 0; 10 - padding-bottom: 56.25%; 8 + overflow: hidden; 9 + width: 100vw; 10 + height: 100vh; 11 11 } 12 12 .video { 13 13 position: absolute; 14 - top: 0; 15 - left: 0; 16 - width: 100%; 17 - height: 100%; 14 + width: 100vw; 15 + height: 100vh; 18 16 } 19 17 </style> 20 18 <div class="container"><div class="video" id="player"></div></div>
+2
jest/jestSetup.js
··· 88 88 ReactNavigationInstrumentation: jest.fn(), 89 89 }, 90 90 })) 91 + 92 + jest.mock('crypto', () => ({}))
+3 -1
package.json
··· 44 44 "update-extensions": "scripts/updateExtensions.sh" 45 45 }, 46 46 "dependencies": { 47 - "@atproto/api": "^0.10.5", 47 + "@atproto/api": "^0.12.0", 48 48 "@bam.tech/react-native-image-resizer": "^3.0.4", 49 49 "@braintree/sanitize-url": "^6.0.2", 50 50 "@emoji-mart/react": "^1.1.1", ··· 76 76 "@segment/sovran-react-native": "^0.4.5", 77 77 "@sentry/react-native": "5.5.0", 78 78 "@tamagui/focus-scope": "^1.84.1", 79 + "@tanstack/query-async-storage-persister": "^5.25.0", 79 80 "@tanstack/react-query": "^5.8.1", 81 + "@tanstack/react-query-persist-client": "^5.25.0", 80 82 "@tiptap/core": "^2.0.0-beta.220", 81 83 "@tiptap/extension-document": "^2.0.0-beta.220", 82 84 "@tiptap/extension-hard-break": "^2.0.3",
+28 -19
src/App.native.tsx
··· 5 5 import {RootSiblingParent} from 'react-native-root-siblings' 6 6 import * as SplashScreen from 'expo-splash-screen' 7 7 import {GestureHandlerRootView} from 'react-native-gesture-handler' 8 - import {QueryClientProvider} from '@tanstack/react-query' 8 + import {PersistQueryClientProvider} from '@tanstack/react-query-persist-client' 9 9 import { 10 10 SafeAreaProvider, 11 11 initialWindowMetrics, ··· 22 22 import {Shell} from 'view/shell' 23 23 import * as notifications from 'lib/notifications/notifications' 24 24 import * as Toast from 'view/com/util/Toast' 25 - import {queryClient} from 'lib/react-query' 25 + import { 26 + queryClient, 27 + asyncStoragePersister, 28 + dehydrateOptions, 29 + } from 'lib/react-query' 26 30 import {TestCtrls} from 'view/com/testing/TestCtrls' 27 31 import {Provider as ShellStateProvider} from 'state/shell' 28 32 import {Provider as ModalStateProvider} from 'state/modals' ··· 33 37 import {Provider as PrefsStateProvider} from 'state/preferences' 34 38 import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out' 35 39 import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed' 40 + import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs' 36 41 import I18nProvider from './locale/i18nProvider' 37 42 import { 38 43 Provider as SessionProvider, ··· 79 84 // Resets the entire tree below when it changes: 80 85 key={currentAccount?.did}> 81 86 <StatsigProvider> 82 - <LoggedOutViewProvider> 83 - <SelectedFeedProvider> 84 - <UnreadNotifsProvider> 85 - <ThemeProvider theme={theme}> 86 - {/* All components should be within this provider */} 87 - <RootSiblingParent> 88 - <GestureHandlerRootView style={s.h100pct}> 89 - <TestCtrls /> 90 - <Shell /> 91 - </GestureHandlerRootView> 92 - </RootSiblingParent> 93 - </ThemeProvider> 94 - </UnreadNotifsProvider> 95 - </SelectedFeedProvider> 96 - </LoggedOutViewProvider> 87 + <LabelDefsProvider> 88 + <LoggedOutViewProvider> 89 + <SelectedFeedProvider> 90 + <UnreadNotifsProvider> 91 + <ThemeProvider theme={theme}> 92 + {/* All components should be within this provider */} 93 + <RootSiblingParent> 94 + <GestureHandlerRootView style={s.h100pct}> 95 + <TestCtrls /> 96 + <Shell /> 97 + </GestureHandlerRootView> 98 + </RootSiblingParent> 99 + </ThemeProvider> 100 + </UnreadNotifsProvider> 101 + </SelectedFeedProvider> 102 + </LoggedOutViewProvider> 103 + </LabelDefsProvider> 97 104 </StatsigProvider> 98 105 </React.Fragment> 99 106 </Splash> ··· 118 125 * that is set up in the InnerApp component above. 119 126 */ 120 127 return ( 121 - <QueryClientProvider client={queryClient}> 128 + <PersistQueryClientProvider 129 + client={queryClient} 130 + persistOptions={{persister: asyncStoragePersister, dehydrateOptions}}> 122 131 <SessionProvider> 123 132 <ShellStateProvider> 124 133 <PrefsStateProvider> ··· 140 149 </PrefsStateProvider> 141 150 </ShellStateProvider> 142 151 </SessionProvider> 143 - </QueryClientProvider> 152 + </PersistQueryClientProvider> 144 153 ) 145 154 } 146 155
+28 -19
src/App.web.tsx
··· 1 1 import 'lib/sentry' // must be near top 2 2 3 3 import React, {useState, useEffect} from 'react' 4 - import {QueryClientProvider} from '@tanstack/react-query' 4 + import {PersistQueryClientProvider} from '@tanstack/react-query-persist-client' 5 5 import {SafeAreaProvider} from 'react-native-safe-area-context' 6 6 import {RootSiblingParent} from 'react-native-root-siblings' 7 7 ··· 13 13 import {Shell} from 'view/shell/index' 14 14 import {ToastContainer} from 'view/com/util/Toast.web' 15 15 import {ThemeProvider} from 'lib/ThemeContext' 16 - import {queryClient} from 'lib/react-query' 16 + import { 17 + queryClient, 18 + asyncStoragePersister, 19 + dehydrateOptions, 20 + } from 'lib/react-query' 17 21 import {Provider as ShellStateProvider} from 'state/shell' 18 22 import {Provider as ModalStateProvider} from 'state/modals' 19 23 import {Provider as DialogStateProvider} from 'state/dialogs' ··· 23 27 import {Provider as PrefsStateProvider} from 'state/preferences' 24 28 import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out' 25 29 import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed' 30 + import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs' 26 31 import I18nProvider from './locale/i18nProvider' 27 32 import { 28 33 Provider as SessionProvider, ··· 56 61 // Resets the entire tree below when it changes: 57 62 key={currentAccount?.did}> 58 63 <StatsigProvider> 59 - <LoggedOutViewProvider> 60 - <SelectedFeedProvider> 61 - <UnreadNotifsProvider> 62 - <ThemeProvider theme={theme}> 63 - {/* All components should be within this provider */} 64 - <RootSiblingParent> 65 - <SafeAreaProvider> 66 - <Shell /> 67 - </SafeAreaProvider> 68 - </RootSiblingParent> 69 - <ToastContainer /> 70 - </ThemeProvider> 71 - </UnreadNotifsProvider> 72 - </SelectedFeedProvider> 73 - </LoggedOutViewProvider> 64 + <LabelDefsProvider> 65 + <LoggedOutViewProvider> 66 + <SelectedFeedProvider> 67 + <UnreadNotifsProvider> 68 + <ThemeProvider theme={theme}> 69 + {/* All components should be within this provider */} 70 + <RootSiblingParent> 71 + <SafeAreaProvider> 72 + <Shell /> 73 + </SafeAreaProvider> 74 + </RootSiblingParent> 75 + <ToastContainer /> 76 + </ThemeProvider> 77 + </UnreadNotifsProvider> 78 + </SelectedFeedProvider> 79 + </LoggedOutViewProvider> 80 + </LabelDefsProvider> 74 81 </StatsigProvider> 75 82 </React.Fragment> 76 83 </Alf> ··· 93 100 * that is set up in the InnerApp component above. 94 101 */ 95 102 return ( 96 - <QueryClientProvider client={queryClient}> 103 + <PersistQueryClientProvider 104 + client={queryClient} 105 + persistOptions={{persister: asyncStoragePersister, dehydrateOptions}}> 97 106 <SessionProvider> 98 107 <ShellStateProvider> 99 108 <PrefsStateProvider> ··· 115 124 </PrefsStateProvider> 116 125 </ShellStateProvider> 117 126 </SessionProvider> 118 - </QueryClientProvider> 127 + </PersistQueryClientProvider> 119 128 ) 120 129 } 121 130
+13 -1
src/Navigation.tsx
··· 46 46 import {FeedsScreen} from './view/screens/Feeds' 47 47 import {NotificationsScreen} from './view/screens/Notifications' 48 48 import {ListsScreen} from './view/screens/Lists' 49 - import {ModerationScreen} from './view/screens/Moderation' 49 + import {ModerationScreen} from '#/screens/Moderation' 50 50 import {ModerationModlistsScreen} from './view/screens/ModerationModlists' 51 51 import {NotFoundScreen} from './view/screens/NotFound' 52 52 import {SettingsScreen} from './view/screens/Settings' ··· 61 61 import {PostLikedByScreen} from './view/screens/PostLikedBy' 62 62 import {PostRepostedByScreen} from './view/screens/PostRepostedBy' 63 63 import {Storybook} from './view/screens/Storybook' 64 + import {DebugModScreen} from './view/screens/DebugMod' 64 65 import {LogScreen} from './view/screens/Log' 65 66 import {SupportScreen} from './view/screens/Support' 66 67 import {PrivacyPolicyScreen} from './view/screens/PrivacyPolicy' ··· 78 79 import {msg} from '@lingui/macro' 79 80 import {i18n, MessageDescriptor} from '@lingui/core' 80 81 import HashtagScreen from '#/screens/Hashtag' 82 + import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy' 81 83 import {logEvent, attachRouteToLogEvents} from './lib/statsig/statsig' 82 84 83 85 const navigationRef = createNavigationContainerRef<AllNavigatorParams>() ··· 199 201 options={{title: title(msg`Liked by`)}} 200 202 /> 201 203 <Stack.Screen 204 + name="ProfileLabelerLikedBy" 205 + getComponent={() => ProfileLabelerLikedByScreen} 206 + options={{title: title(msg`Liked by`)}} 207 + /> 208 + <Stack.Screen 202 209 name="Debug" 203 210 getComponent={() => Storybook} 204 211 options={{title: title(msg`Storybook`), requireAuth: true}} 212 + /> 213 + <Stack.Screen 214 + name="DebugMod" 215 + getComponent={() => DebugModScreen} 216 + options={{title: title(msg`Moderation states`), requireAuth: true}} 205 217 /> 206 218 <Stack.Screen 207 219 name="Log"
+6
src/alf/atoms.ts
··· 50 50 h_full: { 51 51 height: '100%', 52 52 }, 53 + h_full_vh: web({ 54 + height: '100vh', 55 + }), 53 56 54 57 /* 55 58 * Border radius ··· 248 251 }, 249 252 font_normal: { 250 253 fontWeight: tokens.fontWeight.normal, 254 + }, 255 + font_semibold: { 256 + fontWeight: '500', 251 257 }, 252 258 font_bold: { 253 259 fontWeight: tokens.fontWeight.semibold,
+3
src/alf/tokens.ts
··· 12 12 export const color = { 13 13 trueBlack: '#000000', 14 14 15 + temp_purple: 'rgb(105 0 255)', 16 + temp_purple_dark: 'rgb(83 0 202)', 17 + 15 18 gray_0: `hsl(${BLUE_HUE}, 20%, ${scale[14]}%)`, 16 19 gray_25: `hsl(${BLUE_HUE}, 20%, ${scale[13]}%)`, 17 20 gray_50: `hsl(${BLUE_HUE}, 20%, ${scale[12]}%)`,
+14 -14
src/components/Button.tsx
··· 15 15 16 16 import {useTheme, atoms as a, tokens, android, flatten} from '#/alf' 17 17 import {Props as SVGIconProps} from '#/components/icons/common' 18 + import {normalizeTextStyles} from '#/components/Typography' 18 19 19 20 export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'gradient' 20 21 export type ButtonColor = ··· 139 140 })) 140 141 }, [setState]) 141 142 142 - const {baseStyles, hoverStyles, focusStyles} = React.useMemo(() => { 143 + const {baseStyles, hoverStyles} = React.useMemo(() => { 143 144 const baseStyles: ViewStyle[] = [] 144 145 const hoverStyles: ViewStyle[] = [] 145 146 const light = t.name === 'light' ··· 191 192 if (variant === 'solid') { 192 193 if (!disabled) { 193 194 baseStyles.push({ 194 - backgroundColor: t.palette.contrast_50, 195 + backgroundColor: t.palette.contrast_25, 195 196 }) 196 197 hoverStyles.push({ 197 - backgroundColor: t.palette.contrast_100, 198 + backgroundColor: t.palette.contrast_50, 198 199 }) 199 200 } else { 200 201 baseStyles.push({ 201 - backgroundColor: t.palette.contrast_200, 202 + backgroundColor: t.palette.contrast_100, 202 203 }) 203 204 } 204 205 } else if (variant === 'outline') { ··· 308 309 return { 309 310 baseStyles, 310 311 hoverStyles, 311 - focusStyles: [ 312 - ...hoverStyles, 313 - { 314 - outline: 0, 315 - } as ViewStyle, 316 - ], 317 312 } 318 313 }, [t, variant, color, size, shape, disabled]) 319 314 ··· 376 371 a.flex_row, 377 372 a.align_center, 378 373 a.justify_center, 379 - a.justify_center, 380 374 flattenedBaseStyles, 381 375 ...(state.hovered || state.pressed ? hoverStyles : []), 382 - ...(state.focused ? focusStyles : []), 383 376 flatten(style), 384 377 ]} 385 378 onPressIn={onPressIn} ··· 398 391 ]}> 399 392 <LinearGradient 400 393 colors={ 401 - state.hovered || state.pressed || state.focused 394 + state.hovered || state.pressed 402 395 ? gradientHoverColors 403 396 : gradientColors 404 397 } ··· 527 520 const textStyles = useSharedButtonTextStyles() 528 521 529 522 return ( 530 - <Text {...rest} style={[a.font_bold, a.text_center, textStyles, style]}> 523 + <Text 524 + {...rest} 525 + style={normalizeTextStyles([ 526 + a.font_bold, 527 + a.text_center, 528 + textStyles, 529 + style, 530 + ])}> 531 531 {children} 532 532 </Text> 533 533 )
+3 -1
src/components/Dialog/index.tsx
··· 23 23 DialogInnerProps, 24 24 } from '#/components/Dialog/types' 25 25 import {Context} from '#/components/Dialog/context' 26 + import {isNative} from 'platform/detection' 26 27 27 28 export {useDialogControl, useDialogContext} from '#/components/Dialog/context' 28 29 export * from '#/components/Dialog/types' ··· 221 222 borderTopRightRadius: 40, 222 223 }, 223 224 flatten(style), 224 - ]}> 225 + ]} 226 + contentContainerStyle={isNative ? a.pb_4xl : undefined}> 225 227 {children} 226 228 <View style={{height: insets.bottom + a.pt_5xl.paddingTop}} /> 227 229 </BottomSheetScrollView>
+1 -1
src/components/Dialog/index.web.tsx
··· 99 99 style={[ 100 100 web(a.fixed), 101 101 a.inset_0, 102 - {opacity: 0.5, backgroundColor: t.palette.black}, 102 + {opacity: 0.8, backgroundColor: t.palette.black}, 103 103 ]} 104 104 /> 105 105 )}
+27
src/components/GradientFill.tsx
··· 1 + import React from 'react' 2 + import LinearGradient from 'react-native-linear-gradient' 3 + 4 + import {atoms as a, tokens} from '#/alf' 5 + 6 + export function GradientFill({ 7 + gradient, 8 + }: { 9 + gradient: 10 + | typeof tokens.gradients.sky 11 + | typeof tokens.gradients.midnight 12 + | typeof tokens.gradients.sunrise 13 + | typeof tokens.gradients.sunset 14 + | typeof tokens.gradients.bonfire 15 + | typeof tokens.gradients.summer 16 + | typeof tokens.gradients.nordic 17 + }) { 18 + return ( 19 + <LinearGradient 20 + colors={gradient.values.map(c => c[1])} 21 + locations={gradient.values.map(c => c[0])} 22 + start={{x: 0, y: 0}} 23 + end={{x: 1, y: 1}} 24 + style={[a.absolute, a.inset_0]} 25 + /> 26 + ) 27 + }
+182
src/components/LabelingServiceCard/index.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + import {AppBskyLabelerDefs} from '@atproto/api' 6 + 7 + import {getLabelingServiceTitle} from '#/lib/moderation' 8 + import {Link as InternalLink, LinkProps} from '#/components/Link' 9 + import {Text} from '#/components/Typography' 10 + import {useLabelerInfoQuery} from '#/state/queries/labeler' 11 + import {atoms as a, useTheme, ViewStyleProp} from '#/alf' 12 + import {RichText} from '#/components/RichText' 13 + import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '../icons/Chevron' 14 + import {UserAvatar} from '#/view/com/util/UserAvatar' 15 + import {sanitizeHandle} from '#/lib/strings/handles' 16 + import {pluralize} from '#/lib/strings/helpers' 17 + 18 + type LabelingServiceProps = { 19 + labeler: AppBskyLabelerDefs.LabelerViewDetailed 20 + } 21 + 22 + export function Outer({ 23 + children, 24 + style, 25 + }: React.PropsWithChildren<ViewStyleProp>) { 26 + return ( 27 + <View 28 + style={[ 29 + a.flex_row, 30 + a.gap_md, 31 + a.w_full, 32 + a.p_lg, 33 + a.pr_md, 34 + a.overflow_hidden, 35 + style, 36 + ]}> 37 + {children} 38 + </View> 39 + ) 40 + } 41 + 42 + export function Avatar({avatar}: {avatar?: string}) { 43 + return <UserAvatar type="labeler" size={40} avatar={avatar} /> 44 + } 45 + 46 + export function Title({value}: {value: string}) { 47 + return <Text style={[a.text_md, a.font_bold]}>{value}</Text> 48 + } 49 + 50 + export function Description({value, handle}: {value?: string; handle: string}) { 51 + return value ? ( 52 + <Text numberOfLines={2}> 53 + <RichText value={value} style={[]} /> 54 + </Text> 55 + ) : ( 56 + <Text> 57 + <Trans>By {sanitizeHandle(handle, '@')}</Trans> 58 + </Text> 59 + ) 60 + } 61 + 62 + export function LikeCount({count}: {count: number}) { 63 + const t = useTheme() 64 + return ( 65 + <Text 66 + style={[ 67 + a.mt_sm, 68 + a.text_sm, 69 + t.atoms.text_contrast_medium, 70 + {fontWeight: '500'}, 71 + ]}> 72 + <Trans> 73 + Liked by {count} {pluralize(count, 'user')} 74 + </Trans> 75 + </Text> 76 + ) 77 + } 78 + 79 + export function Content({children}: React.PropsWithChildren<{}>) { 80 + const t = useTheme() 81 + 82 + return ( 83 + <View 84 + style={[ 85 + a.flex_1, 86 + a.flex_row, 87 + a.gap_md, 88 + a.align_center, 89 + a.justify_between, 90 + ]}> 91 + <View style={[a.gap_xs, a.flex_1]}>{children}</View> 92 + 93 + <ChevronRight size="md" style={[a.z_10, t.atoms.text_contrast_low]} /> 94 + </View> 95 + ) 96 + } 97 + 98 + /** 99 + * The canonical view for a labeling service. Use this or compose your own. 100 + */ 101 + export function Default({ 102 + labeler, 103 + style, 104 + }: LabelingServiceProps & ViewStyleProp) { 105 + return ( 106 + <Outer style={style}> 107 + <Avatar /> 108 + <Content> 109 + <Title 110 + value={getLabelingServiceTitle({ 111 + displayName: labeler.creator.displayName, 112 + handle: labeler.creator.handle, 113 + })} 114 + /> 115 + <Description 116 + value={labeler.creator.description} 117 + handle={labeler.creator.handle} 118 + /> 119 + {labeler.likeCount ? <LikeCount count={labeler.likeCount} /> : null} 120 + </Content> 121 + </Outer> 122 + ) 123 + } 124 + 125 + export function Link({ 126 + children, 127 + labeler, 128 + }: LabelingServiceProps & Pick<LinkProps, 'children'>) { 129 + const {_} = useLingui() 130 + 131 + return ( 132 + <InternalLink 133 + to={{ 134 + screen: 'Profile', 135 + params: { 136 + name: labeler.creator.handle, 137 + }, 138 + }} 139 + label={_( 140 + msg`View the labeling service provided by @${labeler.creator.handle}`, 141 + )}> 142 + {children} 143 + </InternalLink> 144 + ) 145 + } 146 + 147 + // TODO not finished yet 148 + export function DefaultSkeleton() { 149 + return ( 150 + <View> 151 + <Text>Loading</Text> 152 + </View> 153 + ) 154 + } 155 + 156 + export function Loader({ 157 + did, 158 + loading: LoadingComponent = DefaultSkeleton, 159 + error: ErrorComponent, 160 + component: Component, 161 + }: { 162 + did: string 163 + loading?: React.ComponentType<{}> 164 + error?: React.ComponentType<{error: string}> 165 + component: React.ComponentType<{ 166 + labeler: AppBskyLabelerDefs.LabelerViewDetailed 167 + }> 168 + }) { 169 + const {isLoading, data, error} = useLabelerInfoQuery({did}) 170 + 171 + return isLoading ? ( 172 + LoadingComponent ? ( 173 + <LoadingComponent /> 174 + ) : null 175 + ) : error || !data ? ( 176 + ErrorComponent ? ( 177 + <ErrorComponent error={error?.message || 'Unknown error'} /> 178 + ) : null 179 + ) : ( 180 + <Component labeler={data} /> 181 + ) 182 + }
+109
src/components/LikedByList.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api' 4 + import {Trans} from '@lingui/macro' 5 + 6 + import {logger} from '#/logger' 7 + import {List} from '#/view/com/util/List' 8 + import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard' 9 + import {useResolveUriQuery} from '#/state/queries/resolve-uri' 10 + import {useLikedByQuery} from '#/state/queries/post-liked-by' 11 + import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' 12 + import {ListFooter} from '#/components/Lists' 13 + 14 + import {atoms as a, useTheme} from '#/alf' 15 + import {Loader} from '#/components/Loader' 16 + import {Text} from '#/components/Typography' 17 + 18 + export function LikedByList({uri}: {uri: string}) { 19 + const t = useTheme() 20 + const [isPTRing, setIsPTRing] = React.useState(false) 21 + const { 22 + data: resolvedUri, 23 + error: resolveError, 24 + isFetching: isFetchingResolvedUri, 25 + } = useResolveUriQuery(uri) 26 + const { 27 + data, 28 + isFetching, 29 + isFetched, 30 + isRefetching, 31 + hasNextPage, 32 + fetchNextPage, 33 + isError, 34 + error: likedByError, 35 + refetch, 36 + } = useLikedByQuery(resolvedUri?.uri) 37 + const likes = React.useMemo(() => { 38 + if (data?.pages) { 39 + return data.pages.flatMap(page => page.likes) 40 + } 41 + return [] 42 + }, [data]) 43 + const initialNumToRender = useInitialNumToRender() 44 + const error = resolveError || likedByError 45 + 46 + const onRefresh = React.useCallback(async () => { 47 + setIsPTRing(true) 48 + try { 49 + await refetch() 50 + } catch (err) { 51 + logger.error('Failed to refresh likes', {message: err}) 52 + } 53 + setIsPTRing(false) 54 + }, [refetch, setIsPTRing]) 55 + 56 + const onEndReached = React.useCallback(async () => { 57 + if (isFetching || !hasNextPage || isError) return 58 + try { 59 + await fetchNextPage() 60 + } catch (err) { 61 + logger.error('Failed to load more likes', {message: err}) 62 + } 63 + }, [isFetching, hasNextPage, isError, fetchNextPage]) 64 + 65 + const renderItem = React.useCallback(({item}: {item: GetLikes.Like}) => { 66 + return ( 67 + <ProfileCardWithFollowBtn key={item.actor.did} profile={item.actor} /> 68 + ) 69 + }, []) 70 + 71 + if (isFetchingResolvedUri || !isFetched) { 72 + return ( 73 + <View style={[a.w_full, a.align_center, a.p_lg]}> 74 + <Loader size="xl" /> 75 + </View> 76 + ) 77 + } 78 + 79 + return likes.length ? ( 80 + <List 81 + data={likes} 82 + keyExtractor={item => item.actor.did} 83 + refreshing={isPTRing} 84 + onRefresh={onRefresh} 85 + onEndReached={onEndReached} 86 + onEndReachedThreshold={3} 87 + renderItem={renderItem} 88 + initialNumToRender={initialNumToRender} 89 + ListFooterComponent={() => ( 90 + <ListFooter 91 + isFetching={isFetching && !isRefetching} 92 + isError={isError} 93 + error={error ? error.toString() : undefined} 94 + onRetry={fetchNextPage} 95 + /> 96 + )} 97 + /> 98 + ) : ( 99 + <View style={[a.p_lg]}> 100 + <View style={[a.p_lg, a.rounded_sm, t.atoms.bg_contrast_25]}> 101 + <Text style={[a.text_md, a.leading_snug]}> 102 + <Trans> 103 + Nobody has liked this yet. Maybe you should be the first! 104 + </Trans> 105 + </Text> 106 + </View> 107 + </View> 108 + ) 109 + }
+131
src/components/LikesDialog.tsx
··· 1 + import React, {useMemo, useCallback} from 'react' 2 + import {ActivityIndicator, FlatList, View} from 'react-native' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api' 6 + 7 + import {useResolveUriQuery} from '#/state/queries/resolve-uri' 8 + import {useLikedByQuery} from '#/state/queries/post-liked-by' 9 + import {cleanError} from '#/lib/strings/errors' 10 + import {logger} from '#/logger' 11 + 12 + import {atoms as a, useTheme} from '#/alf' 13 + import {Text} from '#/components/Typography' 14 + import * as Dialog from '#/components/Dialog' 15 + import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' 16 + import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard' 17 + import {Loader} from '#/components/Loader' 18 + 19 + interface LikesDialogProps { 20 + control: Dialog.DialogOuterProps['control'] 21 + uri: string 22 + } 23 + 24 + export function LikesDialog(props: LikesDialogProps) { 25 + return ( 26 + <Dialog.Outer control={props.control}> 27 + <Dialog.Handle /> 28 + 29 + <LikesDialogInner {...props} /> 30 + </Dialog.Outer> 31 + ) 32 + } 33 + 34 + export function LikesDialogInner({control, uri}: LikesDialogProps) { 35 + const {_} = useLingui() 36 + const t = useTheme() 37 + 38 + const { 39 + data: resolvedUri, 40 + error: resolveError, 41 + isFetched: hasFetchedResolvedUri, 42 + } = useResolveUriQuery(uri) 43 + const { 44 + data, 45 + isFetching: isFetchingLikedBy, 46 + isFetched: hasFetchedLikedBy, 47 + isFetchingNextPage, 48 + hasNextPage, 49 + fetchNextPage, 50 + isError, 51 + error: likedByError, 52 + } = useLikedByQuery(resolvedUri?.uri) 53 + 54 + const isLoading = !hasFetchedResolvedUri || !hasFetchedLikedBy 55 + const likes = useMemo(() => { 56 + if (data?.pages) { 57 + return data.pages.flatMap(page => page.likes) 58 + } 59 + return [] 60 + }, [data]) 61 + 62 + const onEndReached = useCallback(async () => { 63 + if (isFetchingLikedBy || !hasNextPage || isError) return 64 + try { 65 + await fetchNextPage() 66 + } catch (err) { 67 + logger.error('Failed to load more likes', {message: err}) 68 + } 69 + }, [isFetchingLikedBy, hasNextPage, isError, fetchNextPage]) 70 + 71 + const renderItem = useCallback( 72 + ({item}: {item: GetLikes.Like}) => { 73 + return ( 74 + <ProfileCardWithFollowBtn 75 + key={item.actor.did} 76 + profile={item.actor} 77 + onPress={() => control.close()} 78 + /> 79 + ) 80 + }, 81 + [control], 82 + ) 83 + 84 + return ( 85 + <Dialog.Inner label={_(msg`Users that have liked this content or profile`)}> 86 + <Text style={[a.text_2xl, a.font_bold, a.leading_tight, a.pb_lg]}> 87 + <Trans>Liked by</Trans> 88 + </Text> 89 + 90 + {isLoading ? ( 91 + <View style={{minHeight: 300}}> 92 + <Loader size="xl" /> 93 + </View> 94 + ) : resolveError || likedByError || !data ? ( 95 + <ErrorMessage message={cleanError(resolveError || likedByError)} /> 96 + ) : likes.length === 0 ? ( 97 + <View style={[t.atoms.bg_contrast_50, a.px_md, a.py_xl, a.rounded_md]}> 98 + <Text style={[a.text_center]}> 99 + <Trans> 100 + Nobody has liked this yet. Maybe you should be the first! 101 + </Trans> 102 + </Text> 103 + </View> 104 + ) : ( 105 + <FlatList 106 + data={likes} 107 + keyExtractor={item => item.actor.did} 108 + onEndReached={onEndReached} 109 + renderItem={renderItem} 110 + initialNumToRender={15} 111 + ListFooterComponent={ 112 + <ListFooterComponent isFetching={isFetchingNextPage} /> 113 + } 114 + /> 115 + )} 116 + 117 + <Dialog.Close /> 118 + </Dialog.Inner> 119 + ) 120 + } 121 + 122 + function ListFooterComponent({isFetching}: {isFetching: boolean}) { 123 + if (isFetching) { 124 + return ( 125 + <View style={a.pt_lg}> 126 + <ActivityIndicator /> 127 + </View> 128 + ) 129 + } 130 + return null 131 + }
+1 -1
src/components/Link.tsx
··· 251 251 onIn: onPressIn, 252 252 onOut: onPressOut, 253 253 } = useInteractionState() 254 - const flattenedStyle = flatten(style) 254 + const flattenedStyle = flatten(style) || {} 255 255 256 256 return ( 257 257 <Text
+1 -1
src/components/Lists.tsx
··· 33 33 a.border_t, 34 34 a.pb_lg, 35 35 t.atoms.border_contrast_low, 36 - {height: 100}, 36 + {height: 180}, 37 37 ]}> 38 38 {isFetching ? ( 39 39 <Loader size="xl" />
+1 -1
src/components/Menu/index.web.tsx
··· 223 223 style={flatten([ 224 224 a.flex_row, 225 225 a.align_center, 226 - a.gap_sm, 226 + a.gap_lg, 227 227 a.py_sm, 228 228 a.rounded_xs, 229 229 {minHeight: 32, paddingHorizontal: 10},
-2
src/components/Prompt.tsx
··· 3 3 import {msg} from '@lingui/macro' 4 4 import {useLingui} from '@lingui/react' 5 5 6 - import {isNative} from '#/platform/detection' 7 6 import {useTheme, atoms as a, useBreakpoints} from '#/alf' 8 7 import {Text} from '#/components/Typography' 9 8 import {Button, ButtonColor, ButtonText} from '#/components/Button' ··· 86 85 gtMobile 87 86 ? [a.flex_row, a.flex_row_reverse, a.justify_start] 88 87 : [a.flex_col], 89 - isNative && [a.pb_4xl], 90 88 ]}> 91 89 {children} 92 90 </View>
+115
src/components/ReportDialog/SelectLabelerView.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + import {AppBskyLabelerDefs} from '@atproto/api' 6 + 7 + export {useDialogControl as useReportDialogControl} from '#/components/Dialog' 8 + 9 + import {atoms as a, useTheme} from '#/alf' 10 + import {Text} from '#/components/Typography' 11 + import {Button, useButtonContext} from '#/components/Button' 12 + import {Divider} from '#/components/Divider' 13 + import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' 14 + 15 + import {ReportDialogProps} from './types' 16 + 17 + export function SelectLabelerView({ 18 + ...props 19 + }: ReportDialogProps & { 20 + labelers: AppBskyLabelerDefs.LabelerViewDetailed[] 21 + onSelectLabeler: (v: string) => void 22 + }) { 23 + const t = useTheme() 24 + const {_} = useLingui() 25 + 26 + return ( 27 + <View style={[a.gap_lg]}> 28 + <View style={[a.justify_center, a.gap_sm]}> 29 + <Text style={[a.text_2xl, a.font_bold]}> 30 + <Trans>Select moderation service</Trans> 31 + </Text> 32 + <Text style={[a.text_md, t.atoms.text_contrast_medium]}> 33 + <Trans>Who do you want to send this report to?</Trans> 34 + </Text> 35 + </View> 36 + 37 + <Divider /> 38 + 39 + <View style={[a.gap_sm, {marginHorizontal: a.p_md.padding * -1}]}> 40 + {props.labelers.map(labeler => { 41 + return ( 42 + <Button 43 + key={labeler.creator.did} 44 + label={_(msg`Send report to ${labeler.creator.displayName}`)} 45 + onPress={() => props.onSelectLabeler(labeler.creator.did)}> 46 + <LabelerButton 47 + title={labeler.creator.displayName || labeler.creator.handle} 48 + description={labeler.creator.description || ''} 49 + /> 50 + </Button> 51 + ) 52 + })} 53 + </View> 54 + </View> 55 + ) 56 + } 57 + 58 + function LabelerButton({ 59 + title, 60 + description, 61 + }: { 62 + title: string 63 + description: string 64 + }) { 65 + const t = useTheme() 66 + const {hovered, pressed} = useButtonContext() 67 + const interacted = hovered || pressed 68 + 69 + const styles = React.useMemo(() => { 70 + return { 71 + interacted: { 72 + backgroundColor: t.palette.contrast_50, 73 + }, 74 + } 75 + }, [t]) 76 + 77 + return ( 78 + <View 79 + style={[ 80 + a.w_full, 81 + a.flex_row, 82 + a.align_center, 83 + a.justify_between, 84 + a.p_md, 85 + a.rounded_md, 86 + {paddingRight: 70}, 87 + interacted && styles.interacted, 88 + ]}> 89 + <View style={[a.flex_1, a.gap_xs]}> 90 + <Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}> 91 + {title} 92 + </Text> 93 + <Text style={[a.leading_tight, {maxWidth: 400}]} numberOfLines={3}> 94 + {description} 95 + </Text> 96 + </View> 97 + 98 + <View 99 + style={[ 100 + a.absolute, 101 + a.inset_0, 102 + a.justify_center, 103 + a.pr_md, 104 + {left: 'auto'}, 105 + ]}> 106 + <ChevronRight 107 + size="md" 108 + fill={ 109 + hovered ? t.palette.primary_500 : t.atoms.text_contrast_low.color 110 + } 111 + /> 112 + </View> 113 + </View> 114 + ) 115 + }
+199
src/components/ReportDialog/SelectReportOptionView.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + import {AppBskyLabelerDefs} from '@atproto/api' 6 + 7 + import {useReportOptions, ReportOption} from '#/lib/moderation/useReportOptions' 8 + import {DMCA_LINK} from '#/components/ReportDialog/const' 9 + import {Link} from '#/components/Link' 10 + export {useDialogControl as useReportDialogControl} from '#/components/Dialog' 11 + 12 + import {atoms as a, useTheme} from '#/alf' 13 + import {Text} from '#/components/Typography' 14 + import { 15 + Button, 16 + ButtonIcon, 17 + ButtonText, 18 + useButtonContext, 19 + } from '#/components/Button' 20 + import {Divider} from '#/components/Divider' 21 + import { 22 + ChevronRight_Stroke2_Corner0_Rounded as ChevronRight, 23 + ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft, 24 + } from '#/components/icons/Chevron' 25 + import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRight} from '#/components/icons/SquareArrowTopRight' 26 + 27 + import {ReportDialogProps} from './types' 28 + 29 + export function SelectReportOptionView({ 30 + ...props 31 + }: ReportDialogProps & { 32 + labelers: AppBskyLabelerDefs.LabelerViewDetailed[] 33 + onSelectReportOption: (reportOption: ReportOption) => void 34 + goBack: () => void 35 + }) { 36 + const t = useTheme() 37 + const {_} = useLingui() 38 + const allReportOptions = useReportOptions() 39 + const reportOptions = allReportOptions[props.params.type] 40 + 41 + const i18n = React.useMemo(() => { 42 + let title = _(msg`Report this content`) 43 + let description = _(msg`Why should this content be reviewed?`) 44 + 45 + if (props.params.type === 'account') { 46 + title = _(msg`Report this user`) 47 + description = _(msg`Why should this user be reviewed?`) 48 + } else if (props.params.type === 'post') { 49 + title = _(msg`Report this post`) 50 + description = _(msg`Why should this post be reviewed?`) 51 + } else if (props.params.type === 'list') { 52 + title = _(msg`Report this list`) 53 + description = _(msg`Why should this list be reviewed?`) 54 + } else if (props.params.type === 'feedgen') { 55 + title = _(msg`Report this feed`) 56 + description = _(msg`Why should this feed be reviewed?`) 57 + } 58 + 59 + return { 60 + title, 61 + description, 62 + } 63 + }, [_, props.params.type]) 64 + 65 + return ( 66 + <View style={[a.gap_lg]}> 67 + {props.labelers?.length > 1 ? ( 68 + <Button 69 + size="small" 70 + variant="solid" 71 + color="secondary" 72 + shape="round" 73 + label={_(msg`Go back to previous step`)} 74 + onPress={props.goBack}> 75 + <ButtonIcon icon={ChevronLeft} /> 76 + </Button> 77 + ) : null} 78 + 79 + <View style={[a.justify_center, a.gap_sm]}> 80 + <Text style={[a.text_2xl, a.font_bold]}>{i18n.title}</Text> 81 + <Text style={[a.text_md, t.atoms.text_contrast_medium]}> 82 + {i18n.description} 83 + </Text> 84 + </View> 85 + 86 + <Divider /> 87 + 88 + <View style={[a.gap_sm, {marginHorizontal: a.p_md.padding * -1}]}> 89 + {reportOptions.map(reportOption => { 90 + return ( 91 + <Button 92 + key={reportOption.reason} 93 + label={_(msg`Create report for ${reportOption.title}`)} 94 + onPress={() => props.onSelectReportOption(reportOption)}> 95 + <ReportOptionButton 96 + title={reportOption.title} 97 + description={reportOption.description} 98 + /> 99 + </Button> 100 + ) 101 + })} 102 + 103 + {(props.params.type === 'post' || props.params.type === 'account') && ( 104 + <View style={[a.pt_md, a.px_md]}> 105 + <View 106 + style={[ 107 + a.flex_row, 108 + a.align_center, 109 + a.justify_between, 110 + a.gap_lg, 111 + a.p_md, 112 + a.pl_lg, 113 + a.rounded_md, 114 + t.atoms.bg_contrast_900, 115 + ]}> 116 + <Text 117 + style={[ 118 + a.flex_1, 119 + t.atoms.text_inverted, 120 + a.italic, 121 + a.leading_snug, 122 + ]}> 123 + <Trans>Need to report a copyright violation?</Trans> 124 + </Text> 125 + <Link 126 + to={DMCA_LINK} 127 + label={_(msg`View details for reporting a copyright violation`)} 128 + size="small" 129 + variant="solid" 130 + color="secondary"> 131 + <ButtonText> 132 + <Trans>View details</Trans> 133 + </ButtonText> 134 + <ButtonIcon position="right" icon={SquareArrowTopRight} /> 135 + </Link> 136 + </View> 137 + </View> 138 + )} 139 + </View> 140 + </View> 141 + ) 142 + } 143 + 144 + function ReportOptionButton({ 145 + title, 146 + description, 147 + }: { 148 + title: string 149 + description: string 150 + }) { 151 + const t = useTheme() 152 + const {hovered, pressed} = useButtonContext() 153 + const interacted = hovered || pressed 154 + 155 + const styles = React.useMemo(() => { 156 + return { 157 + interacted: { 158 + backgroundColor: t.palette.contrast_50, 159 + }, 160 + } 161 + }, [t]) 162 + 163 + return ( 164 + <View 165 + style={[ 166 + a.w_full, 167 + a.flex_row, 168 + a.align_center, 169 + a.justify_between, 170 + a.p_md, 171 + a.rounded_md, 172 + {paddingRight: 70}, 173 + interacted && styles.interacted, 174 + ]}> 175 + <View style={[a.flex_1, a.gap_xs]}> 176 + <Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}> 177 + {title} 178 + </Text> 179 + <Text style={[a.leading_tight, {maxWidth: 400}]}>{description}</Text> 180 + </View> 181 + 182 + <View 183 + style={[ 184 + a.absolute, 185 + a.inset_0, 186 + a.justify_center, 187 + a.pr_md, 188 + {left: 'auto'}, 189 + ]}> 190 + <ChevronRight 191 + size="md" 192 + fill={ 193 + hovered ? t.palette.primary_500 : t.atoms.text_contrast_low.color 194 + } 195 + /> 196 + </View> 197 + </View> 198 + ) 199 + }
+264
src/components/ReportDialog/SubmitView.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + import {AppBskyLabelerDefs} from '@atproto/api' 6 + 7 + import {getLabelingServiceTitle} from '#/lib/moderation' 8 + import {ReportOption} from '#/lib/moderation/useReportOptions' 9 + 10 + import {atoms as a, useTheme, native} from '#/alf' 11 + import {Text} from '#/components/Typography' 12 + import * as Dialog from '#/components/Dialog' 13 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 14 + import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron' 15 + import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 16 + import * as Toggle from '#/components/forms/Toggle' 17 + import {CharProgress} from '#/view/com/composer/char-progress/CharProgress' 18 + import {Loader} from '#/components/Loader' 19 + import * as Toast from '#/view/com/util/Toast' 20 + 21 + import {ReportDialogProps} from './types' 22 + import {getAgent} from '#/state/session' 23 + 24 + export function SubmitView({ 25 + params, 26 + labelers, 27 + selectedLabeler, 28 + selectedReportOption, 29 + goBack, 30 + onSubmitComplete, 31 + }: ReportDialogProps & { 32 + labelers: AppBskyLabelerDefs.LabelerViewDetailed[] 33 + selectedLabeler: string 34 + selectedReportOption: ReportOption 35 + goBack: () => void 36 + onSubmitComplete: () => void 37 + }) { 38 + const t = useTheme() 39 + const {_} = useLingui() 40 + const [details, setDetails] = React.useState<string>('') 41 + const [submitting, setSubmitting] = React.useState<boolean>(false) 42 + const [selectedServices, setSelectedServices] = React.useState<string[]>([ 43 + selectedLabeler, 44 + ]) 45 + const [error, setError] = React.useState('') 46 + 47 + const submit = React.useCallback(async () => { 48 + setSubmitting(true) 49 + setError('') 50 + 51 + const $type = 52 + params.type === 'account' 53 + ? 'com.atproto.admin.defs#repoRef' 54 + : 'com.atproto.repo.strongRef' 55 + const report = { 56 + reasonType: selectedReportOption.reason, 57 + subject: { 58 + $type, 59 + ...params, 60 + }, 61 + reason: details, 62 + } 63 + const results = await Promise.all( 64 + selectedServices.map(did => 65 + getAgent() 66 + .withProxy('atproto_labeler', did) 67 + .createModerationReport(report) 68 + .then( 69 + _ => true, 70 + _ => false, 71 + ), 72 + ), 73 + ) 74 + 75 + setSubmitting(false) 76 + 77 + if (results.includes(true)) { 78 + Toast.show(_(msg`Thank you. Your report has been sent.`)) 79 + onSubmitComplete() 80 + } else { 81 + setError( 82 + _( 83 + msg`There was an issue sending your report. Please check your internet connection.`, 84 + ), 85 + ) 86 + } 87 + }, [ 88 + _, 89 + params, 90 + details, 91 + selectedReportOption, 92 + selectedServices, 93 + onSubmitComplete, 94 + setError, 95 + ]) 96 + 97 + return ( 98 + <View style={[a.gap_2xl]}> 99 + <Button 100 + size="small" 101 + variant="solid" 102 + color="secondary" 103 + shape="round" 104 + label={_(msg`Go back to previous step`)} 105 + onPress={goBack}> 106 + <ButtonIcon icon={ChevronLeft} /> 107 + </Button> 108 + 109 + <View 110 + style={[ 111 + a.w_full, 112 + a.flex_row, 113 + a.align_center, 114 + a.justify_between, 115 + a.gap_lg, 116 + a.p_md, 117 + a.rounded_md, 118 + a.border, 119 + t.atoms.border_contrast_low, 120 + ]}> 121 + <View style={[a.flex_1, a.gap_xs]}> 122 + <Text style={[a.text_md, a.font_bold]}> 123 + {selectedReportOption.title} 124 + </Text> 125 + <Text style={[a.leading_tight, {maxWidth: 400}]}> 126 + {selectedReportOption.description} 127 + </Text> 128 + </View> 129 + 130 + <Check size="md" style={[a.pr_sm, t.atoms.text_contrast_low]} /> 131 + </View> 132 + 133 + <View style={[a.gap_md]}> 134 + <Text style={[t.atoms.text_contrast_medium]}> 135 + <Trans>Select the moderation service(s) to report to</Trans> 136 + </Text> 137 + 138 + <Toggle.Group 139 + label="Select mod services" 140 + values={selectedServices} 141 + onChange={setSelectedServices}> 142 + <View style={[a.flex_row, a.gap_md, a.flex_wrap]}> 143 + {labelers.map(labeler => { 144 + const title = getLabelingServiceTitle({ 145 + displayName: labeler.creator.displayName, 146 + handle: labeler.creator.handle, 147 + }) 148 + return ( 149 + <Toggle.Item 150 + key={labeler.creator.did} 151 + name={labeler.creator.did} 152 + label={title}> 153 + <LabelerToggle title={title} /> 154 + </Toggle.Item> 155 + ) 156 + })} 157 + </View> 158 + </Toggle.Group> 159 + </View> 160 + <View style={[a.gap_md]}> 161 + <Text style={[t.atoms.text_contrast_medium]}> 162 + <Trans>Optionally provide additional information below:</Trans> 163 + </Text> 164 + 165 + <View style={[a.relative, a.w_full]}> 166 + <Dialog.Input 167 + multiline 168 + value={details} 169 + onChangeText={setDetails} 170 + label="Text field" 171 + style={{paddingRight: 60}} 172 + numberOfLines={6} 173 + /> 174 + 175 + <View 176 + style={[ 177 + a.absolute, 178 + a.flex_row, 179 + a.align_center, 180 + a.pr_md, 181 + a.pb_sm, 182 + { 183 + bottom: 0, 184 + right: 0, 185 + }, 186 + ]}> 187 + <CharProgress count={details?.length || 0} /> 188 + </View> 189 + </View> 190 + </View> 191 + 192 + <View style={[a.flex_row, a.align_center, a.justify_end, a.gap_lg]}> 193 + {!selectedServices.length || 194 + (error && ( 195 + <Text 196 + style={[ 197 + a.flex_1, 198 + a.italic, 199 + a.leading_snug, 200 + t.atoms.text_contrast_medium, 201 + ]}> 202 + {error ? ( 203 + error 204 + ) : ( 205 + <Trans>You must select at least one labeler for a report</Trans> 206 + )} 207 + </Text> 208 + ))} 209 + 210 + <Button 211 + size="large" 212 + variant="solid" 213 + color="negative" 214 + label={_(msg`Send report`)} 215 + onPress={submit} 216 + disabled={!selectedServices.length}> 217 + <ButtonText> 218 + <Trans>Send report</Trans> 219 + </ButtonText> 220 + {submitting && <ButtonIcon icon={Loader} />} 221 + </Button> 222 + </View> 223 + </View> 224 + ) 225 + } 226 + 227 + function LabelerToggle({title}: {title: string}) { 228 + const t = useTheme() 229 + const ctx = Toggle.useItemContext() 230 + 231 + return ( 232 + <View 233 + style={[ 234 + a.flex_row, 235 + a.align_center, 236 + a.gap_md, 237 + a.p_md, 238 + a.pr_lg, 239 + a.rounded_sm, 240 + a.overflow_hidden, 241 + t.atoms.bg_contrast_25, 242 + ctx.selected && [t.atoms.bg_contrast_50], 243 + ]}> 244 + <Toggle.Checkbox /> 245 + <View 246 + style={[ 247 + a.flex_row, 248 + a.align_center, 249 + a.justify_between, 250 + a.gap_lg, 251 + a.z_10, 252 + ]}> 253 + <Text 254 + style={[ 255 + native({marginTop: 2}), 256 + t.atoms.text_contrast_medium, 257 + ctx.selected && t.atoms.text, 258 + ]}> 259 + {title} 260 + </Text> 261 + </View> 262 + </View> 263 + ) 264 + }
+1
src/components/ReportDialog/const.ts
··· 1 + export const DMCA_LINK = 'https://bsky.social/about/support/copyright'
+95
src/components/ReportDialog/index.tsx
··· 1 + import React from 'react' 2 + import {View, Pressable} from 'react-native' 3 + import {Trans} from '@lingui/macro' 4 + 5 + import {useMyLabelersQuery} from '#/state/queries/preferences' 6 + import {ReportOption} from '#/lib/moderation/useReportOptions' 7 + export {useDialogControl as useReportDialogControl} from '#/components/Dialog' 8 + 9 + import {atoms as a} from '#/alf' 10 + import {Loader} from '#/components/Loader' 11 + import * as Dialog from '#/components/Dialog' 12 + import {Text} from '#/components/Typography' 13 + 14 + import {ReportDialogProps} from './types' 15 + import {SelectLabelerView} from './SelectLabelerView' 16 + import {SelectReportOptionView} from './SelectReportOptionView' 17 + import {SubmitView} from './SubmitView' 18 + import {useDelayedLoading} from '#/components/hooks/useDelayedLoading' 19 + import {AppBskyLabelerDefs} from '@atproto/api' 20 + 21 + export function ReportDialog(props: ReportDialogProps) { 22 + return ( 23 + <Dialog.Outer control={props.control}> 24 + <Dialog.Handle /> 25 + 26 + <ReportDialogInner {...props} /> 27 + </Dialog.Outer> 28 + ) 29 + } 30 + 31 + function ReportDialogInner(props: ReportDialogProps) { 32 + const { 33 + isLoading: isLabelerLoading, 34 + data: labelers, 35 + error, 36 + } = useMyLabelersQuery() 37 + const isLoading = useDelayedLoading(500, isLabelerLoading) 38 + 39 + return ( 40 + <Dialog.ScrollableInner label="Report Dialog"> 41 + {isLoading ? ( 42 + <View style={[a.align_center, {height: 100}]}> 43 + <Loader size="xl" /> 44 + {/* Here to capture focus for a hot sec to prevent flash */} 45 + <Pressable accessible={false} /> 46 + </View> 47 + ) : error || !labelers ? ( 48 + <View> 49 + <Text style={[a.text_md]}> 50 + <Trans>Something went wrong, please try again.</Trans> 51 + </Text> 52 + </View> 53 + ) : ( 54 + <ReportDialogLoaded labelers={labelers} {...props} /> 55 + )} 56 + 57 + <Dialog.Close /> 58 + </Dialog.ScrollableInner> 59 + ) 60 + } 61 + 62 + function ReportDialogLoaded( 63 + props: ReportDialogProps & { 64 + labelers: AppBskyLabelerDefs.LabelerViewDetailed[] 65 + }, 66 + ) { 67 + const [selectedLabeler, setSelectedLabeler] = React.useState< 68 + string | undefined 69 + >(props.labelers.length === 1 ? props.labelers[0].creator.did : undefined) 70 + const [selectedReportOption, setSelectedReportOption] = React.useState< 71 + ReportOption | undefined 72 + >() 73 + 74 + if (selectedReportOption && selectedLabeler) { 75 + return ( 76 + <SubmitView 77 + {...props} 78 + selectedLabeler={selectedLabeler} 79 + selectedReportOption={selectedReportOption} 80 + goBack={() => setSelectedReportOption(undefined)} 81 + onSubmitComplete={() => props.control.close()} 82 + /> 83 + ) 84 + } 85 + if (selectedLabeler) { 86 + return ( 87 + <SelectReportOptionView 88 + {...props} 89 + goBack={() => setSelectedLabeler(undefined)} 90 + onSelectReportOption={setSelectedReportOption} 91 + /> 92 + ) 93 + } 94 + return <SelectLabelerView {...props} onSelectLabeler={setSelectedLabeler} /> 95 + }
+15
src/components/ReportDialog/types.ts
··· 1 + import * as Dialog from '#/components/Dialog' 2 + 3 + export type ReportDialogProps = { 4 + control: Dialog.DialogOuterProps['control'] 5 + params: 6 + | { 7 + type: 'post' | 'list' | 'feedgen' | 'other' 8 + uri: string 9 + cid: string 10 + } 11 + | { 12 + type: 'account' 13 + did: string 14 + } 15 + }
+1 -1
src/components/TagMenu/index.tsx
··· 59 59 const displayTag = '#' + tag 60 60 61 61 const isMuted = Boolean( 62 - (preferences?.mutedWords?.find( 62 + (preferences?.moderationPrefs.mutedWords?.find( 63 63 m => m.value === tag && m.targets.includes('tag'), 64 64 ) ?? 65 65 optimisticUpsert?.find(
+1 -1
src/components/TagMenu/index.web.tsx
··· 50 50 const {mutateAsync: removeMutedWord, variables: optimisticRemove} = 51 51 useRemoveMutedWordMutation() 52 52 const isMuted = Boolean( 53 - (preferences?.mutedWords?.find( 53 + (preferences?.moderationPrefs.mutedWords?.find( 54 54 m => m.value === tag && m.targets.includes('tag'), 55 55 ) ?? 56 56 optimisticUpsert?.find(
+7 -2
src/components/Typography.tsx
··· 1 1 import React from 'react' 2 - import {Text as RNText, TextStyle, TextProps as RNTextProps} from 'react-native' 2 + import { 3 + Text as RNText, 4 + StyleProp, 5 + TextStyle, 6 + TextProps as RNTextProps, 7 + } from 'react-native' 3 8 import {UITextView} from 'react-native-ui-text-view' 4 9 5 10 import {useTheme, atoms, web, flatten} from '#/alf' ··· 34 39 * If the `lineHeight` value is > 2, we assume it's an absolute value and 35 40 * returns it as-is. 36 41 */ 37 - function normalizeTextStyles(styles: TextStyle[]) { 42 + export function normalizeTextStyles(styles: StyleProp<TextStyle>) { 38 43 const s = flatten(styles) 39 44 // should always be defined on these components 40 45 const fontSize = s.fontSize || atoms.text_md.fontSize
+124
src/components/dialogs/BirthDateSettings.tsx
··· 1 + import React from 'react' 2 + import {useLingui} from '@lingui/react' 3 + import {Trans, msg} from '@lingui/macro' 4 + 5 + import * as Dialog from '#/components/Dialog' 6 + import {Text} from '../Typography' 7 + import {DateInput} from '#/view/com/util/forms/DateInput' 8 + import {logger} from '#/logger' 9 + import { 10 + usePreferencesSetBirthDateMutation, 11 + UsePreferencesQueryResponse, 12 + } from '#/state/queries/preferences' 13 + import {Button, ButtonText} from '../Button' 14 + import {atoms as a, useTheme} from '#/alf' 15 + import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' 16 + import {cleanError} from '#/lib/strings/errors' 17 + import {ActivityIndicator, View} from 'react-native' 18 + import {isIOS, isWeb} from '#/platform/detection' 19 + 20 + export function BirthDateSettingsDialog({ 21 + control, 22 + preferences, 23 + }: { 24 + control: Dialog.DialogControlProps 25 + preferences: UsePreferencesQueryResponse | undefined 26 + }) { 27 + const {_} = useLingui() 28 + const {isPending, isError, error, mutateAsync} = 29 + usePreferencesSetBirthDateMutation() 30 + 31 + return ( 32 + <Dialog.Outer control={control}> 33 + <Dialog.Handle /> 34 + <Dialog.ScrollableInner label={_(msg`My Birthday`)}> 35 + {preferences && !isPending ? ( 36 + <BirthdayInner 37 + control={control} 38 + preferences={preferences} 39 + isError={isError} 40 + error={error} 41 + setBirthDate={mutateAsync} 42 + /> 43 + ) : ( 44 + <ActivityIndicator size="large" style={a.my_5xl} /> 45 + )} 46 + </Dialog.ScrollableInner> 47 + </Dialog.Outer> 48 + ) 49 + } 50 + 51 + function BirthdayInner({ 52 + control, 53 + preferences, 54 + isError, 55 + error, 56 + setBirthDate, 57 + }: { 58 + control: Dialog.DialogControlProps 59 + preferences: UsePreferencesQueryResponse 60 + isError: boolean 61 + error: unknown 62 + setBirthDate: (args: {birthDate: Date}) => Promise<unknown> 63 + }) { 64 + const {_} = useLingui() 65 + const [date, setDate] = React.useState(preferences.birthDate || new Date()) 66 + const t = useTheme() 67 + 68 + const hasChanged = date !== preferences.birthDate 69 + 70 + const onSave = React.useCallback(async () => { 71 + try { 72 + // skip if date is the same 73 + if (hasChanged) { 74 + await setBirthDate({birthDate: date}) 75 + } 76 + control.close() 77 + } catch (e) { 78 + logger.error(`setBirthDate failed`, {message: e}) 79 + } 80 + }, [date, setBirthDate, control, hasChanged]) 81 + 82 + return ( 83 + <View style={a.gap_lg} testID="birthDateSettingsDialog"> 84 + <View style={[a.gap_sm]}> 85 + <Text style={[a.text_2xl, a.font_bold]}> 86 + <Trans>My Birthday</Trans> 87 + </Text> 88 + <Text style={t.atoms.text_contrast_medium}> 89 + <Trans>This information is not shared with other users.</Trans> 90 + </Text> 91 + </View> 92 + <View style={isIOS && [a.w_full, a.align_center]}> 93 + <DateInput 94 + handleAsUTC 95 + testID="birthdayInput" 96 + value={date} 97 + onChange={setDate} 98 + buttonType="default-light" 99 + buttonStyle={[a.rounded_sm]} 100 + buttonLabelType="lg" 101 + accessibilityLabel={_(msg`Birthday`)} 102 + accessibilityHint={_(msg`Enter your birth date`)} 103 + accessibilityLabelledBy="birthDate" 104 + /> 105 + </View> 106 + {isError ? ( 107 + <ErrorMessage message={cleanError(error)} style={[a.rounded_sm]} /> 108 + ) : undefined} 109 + 110 + <View style={isWeb && [a.flex_row, a.justify_end]}> 111 + <Button 112 + label={hasChanged ? _(msg`Save birthday`) : _(msg`Done`)} 113 + size={isWeb ? 'small' : 'medium'} 114 + onPress={onSave} 115 + variant="solid" 116 + color="primary"> 117 + <ButtonText> 118 + {hasChanged ? <Trans>Save</Trans> : <Trans>Done</Trans>} 119 + </ButtonText> 120 + </Button> 121 + </View> 122 + </View> 123 + ) 124 + }
+1 -1
src/components/dialogs/Context.tsx
··· 18 18 19 19 export function Provider({children}: React.PropsWithChildren<{}>) { 20 20 const mutedWordsDialogControl = Dialog.useDialogControl() 21 - const ctx = React.useMemo( 21 + const ctx = React.useMemo<ControlsContext>( 22 22 () => ({mutedWordsDialogControl}), 23 23 [mutedWordsDialogControl], 24 24 )
+2 -2
src/components/dialogs/MutedWords.tsx
··· 233 233 </Trans> 234 234 </Text> 235 235 </View> 236 - ) : preferences.mutedWords.length ? ( 237 - [...preferences.mutedWords] 236 + ) : preferences.moderationPrefs.mutedWords.length ? ( 237 + [...preferences.moderationPrefs.mutedWords] 238 238 .reverse() 239 239 .map((word, i) => ( 240 240 <MutedWordRow
+23 -17
src/components/forms/Toggle.tsx
··· 2 2 import {Pressable, View, ViewStyle} from 'react-native' 3 3 4 4 import {HITSLOP_10} from 'lib/constants' 5 - import {useTheme, atoms as a, web, native, flatten, ViewStyleProp} from '#/alf' 5 + import { 6 + useTheme, 7 + atoms as a, 8 + native, 9 + flatten, 10 + ViewStyleProp, 11 + TextStyleProp, 12 + } from '#/alf' 6 13 import {Text} from '#/components/Typography' 7 14 import {useInteractionState} from '#/components/hooks/useInteractionState' 8 15 import {CheckThick_Stroke2_Corner0_Rounded as Checkmark} from '#/components/icons/Check' ··· 220 227 onPressOut={onPressOut} 221 228 onFocus={onFocus} 222 229 onBlur={onBlur} 223 - style={[ 224 - a.flex_row, 225 - a.align_center, 226 - a.gap_sm, 227 - focused ? web({outline: 'none'}) : {}, 228 - flatten(style), 229 - ]}> 230 + style={[a.flex_row, a.align_center, a.gap_sm, flatten(style)]}> 230 231 {typeof children === 'function' ? children(state) : children} 231 232 </Pressable> 232 233 </ItemContext.Provider> 233 234 ) 234 235 } 235 236 236 - export function Label({children}: React.PropsWithChildren<{}>) { 237 + export function Label({ 238 + children, 239 + style, 240 + }: React.PropsWithChildren<TextStyleProp>) { 237 241 const t = useTheme() 238 242 const {disabled} = useItemContext() 239 243 return ( ··· 242 246 a.font_bold, 243 247 { 244 248 userSelect: 'none', 245 - color: disabled ? t.palette.contrast_400 : t.palette.contrast_600, 249 + color: disabled 250 + ? t.atoms.text_contrast_low.color 251 + : t.atoms.text_contrast_high.color, 246 252 }, 247 253 native({ 248 254 paddingTop: 3, 249 255 }), 256 + flatten(style), 250 257 ]}> 251 258 {children} 252 259 </Text> ··· 257 264 export function createSharedToggleStyles({ 258 265 theme: t, 259 266 hovered, 260 - focused, 261 267 selected, 262 268 disabled, 263 269 isInvalid, ··· 280 286 borderColor: t.palette.primary_500, 281 287 }) 282 288 283 - if (hovered || focused) { 289 + if (hovered) { 284 290 baseHover.push({ 285 291 backgroundColor: 286 292 t.name === 'light' ? t.palette.primary_100 : t.palette.primary_800, ··· 289 295 }) 290 296 } 291 297 } else { 292 - if (hovered || focused) { 298 + if (hovered) { 293 299 baseHover.push({ 294 300 backgroundColor: 295 301 t.name === 'light' ? t.palette.contrast_50 : t.palette.contrast_100, ··· 306 312 t.name === 'light' ? t.palette.negative_300 : t.palette.negative_800, 307 313 }) 308 314 309 - if (hovered || focused) { 315 + if (hovered) { 310 316 baseHover.push({ 311 317 backgroundColor: 312 318 t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900, ··· 353 359 width: 20, 354 360 }, 355 361 baseStyles, 356 - hovered || focused ? baseHoverStyles : {}, 362 + hovered ? baseHoverStyles : {}, 357 363 ]}> 358 364 {selected ? <Checkmark size="xs" fill={t.palette.primary_500} /> : null} 359 365 </View> ··· 385 391 width: 30, 386 392 }, 387 393 baseStyles, 388 - hovered || focused ? baseHoverStyles : {}, 394 + hovered ? baseHoverStyles : {}, 389 395 ]}> 390 396 <View 391 397 style={[ ··· 437 443 width: 20, 438 444 }, 439 445 baseStyles, 440 - hovered || focused ? baseHoverStyles : {}, 446 + hovered ? baseHoverStyles : {}, 441 447 ]}> 442 448 {selected ? ( 443 449 <View
+5 -3
src/components/forms/ToggleButton.tsx
··· 8 8 9 9 export type ItemProps = Omit<Toggle.ItemProps, 'style' | 'role' | 'children'> & 10 10 AccessibilityProps & 11 - React.PropsWithChildren<{testID?: string}> 11 + React.PropsWithChildren<{ 12 + testID?: string 13 + }> 12 14 13 15 export type GroupProps = Omit<Toggle.GroupProps, 'style' | 'type'> & { 14 16 multiple?: boolean ··· 101 103 native({ 102 104 paddingBottom: 10, 103 105 }), 104 - a.px_sm, 106 + a.px_md, 105 107 t.atoms.bg, 106 108 t.atoms.border_contrast_low, 107 109 baseStyles, 108 110 activeStyles, 109 - (state.hovered || state.focused || state.pressed) && hoverStyles, 111 + (state.hovered || state.pressed) && hoverStyles, 110 112 ]}> 111 113 {typeof children === 'string' ? ( 112 114 <Text
+15
src/components/hooks/useDelayedLoading.ts
··· 1 + import React from 'react' 2 + 3 + export function useDelayedLoading(delay: number, initialState: boolean = true) { 4 + const [isLoading, setIsLoading] = React.useState(initialState) 5 + 6 + React.useEffect(() => { 7 + let timeout: NodeJS.Timeout 8 + // on initial load, show a loading spinner for a hot sec to prevent flash 9 + if (isLoading) timeout = setTimeout(() => setIsLoading(false), delay) 10 + 11 + return () => timeout && clearTimeout(timeout) 12 + }, [isLoading, delay]) 13 + 14 + return isLoading 15 + }
+5
src/components/icons/ArrowTriangle.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const ArrowTriangleBottom_Stroke2_Corner1_Rounded = createSinglePathSVG({ 4 + 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', 5 + })
+5
src/components/icons/Bars.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const Bars3_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + 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', 5 + })
+8
src/components/icons/Chevron.tsx
··· 7 7 export const ChevronRight_Stroke2_Corner0_Rounded = createSinglePathSVG({ 8 8 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', 9 9 }) 10 + 11 + export const ChevronTop_Stroke2_Corner0_Rounded = createSinglePathSVG({ 12 + 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', 13 + }) 14 + 15 + export const ChevronBottom_Stroke2_Corner0_Rounded = createSinglePathSVG({ 16 + 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', 17 + })
+5
src/components/icons/CircleBanSign.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const CircleBanSign_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + 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', 5 + })
+5
src/components/icons/Gear.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const SettingsGear2_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + 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', 5 + })
src/components/icons/Group3.tsx src/components/icons/Group.tsx
+5
src/components/icons/RaisingHand.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const RaisingHande4Finger_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + 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', 5 + })
+5
src/components/icons/Shield.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const Shield_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + 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', 5 + })
+5
src/components/icons/SquareArrowTopRight.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const SquareArrowTopRight_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + 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', 5 + })
+5
src/components/icons/SquareBehindSquare4.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const SquareBehindSquare4_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + 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', 5 + })
+182
src/components/moderation/ContentHider.tsx
··· 1 + import React from 'react' 2 + import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' 3 + import {ModerationUI} from '@atproto/api' 4 + import {useLingui} from '@lingui/react' 5 + import {msg, Trans} from '@lingui/macro' 6 + 7 + import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription' 8 + import {isJustAMute} from '#/lib/moderation' 9 + import {sanitizeDisplayName} from '#/lib/strings/display-names' 10 + 11 + import {atoms as a, useTheme, useBreakpoints, web} from '#/alf' 12 + import {Button} from '#/components/Button' 13 + import {Text} from '#/components/Typography' 14 + import { 15 + ModerationDetailsDialog, 16 + useModerationDetailsDialogControl, 17 + } from '#/components/moderation/ModerationDetailsDialog' 18 + 19 + export function ContentHider({ 20 + testID, 21 + modui, 22 + ignoreMute, 23 + style, 24 + childContainerStyle, 25 + children, 26 + }: React.PropsWithChildren<{ 27 + testID?: string 28 + modui: ModerationUI | undefined 29 + ignoreMute?: boolean 30 + style?: StyleProp<ViewStyle> 31 + childContainerStyle?: StyleProp<ViewStyle> 32 + }>) { 33 + const t = useTheme() 34 + const {_} = useLingui() 35 + const {gtMobile} = useBreakpoints() 36 + const [override, setOverride] = React.useState(false) 37 + const control = useModerationDetailsDialogControl() 38 + 39 + const blur = modui?.blurs[0] 40 + const desc = useModerationCauseDescription(blur) 41 + 42 + if (!blur || (ignoreMute && isJustAMute(modui))) { 43 + return ( 44 + <View testID={testID} style={[styles.outer, style]}> 45 + {children} 46 + </View> 47 + ) 48 + } 49 + 50 + return ( 51 + <View testID={testID} style={[a.overflow_hidden, style]}> 52 + <ModerationDetailsDialog control={control} modcause={blur} /> 53 + 54 + <Button 55 + onPress={() => { 56 + if (!modui.noOverride) { 57 + setOverride(v => !v) 58 + } else { 59 + control.open() 60 + } 61 + }} 62 + label={desc.name} 63 + accessibilityHint={ 64 + modui.noOverride 65 + ? _(msg`Learn more about the moderation applied to this content.`) 66 + : override 67 + ? _(msg`Hide the content`) 68 + : _(msg`Show the content`) 69 + }> 70 + {state => ( 71 + <View 72 + style={[ 73 + a.flex_row, 74 + a.w_full, 75 + a.justify_start, 76 + a.align_center, 77 + a.py_md, 78 + a.px_lg, 79 + a.gap_xs, 80 + a.rounded_sm, 81 + t.atoms.bg_contrast_25, 82 + gtMobile && [a.gap_sm, a.py_lg, a.mt_xs, a.px_xl], 83 + (state.hovered || state.pressed) && t.atoms.bg_contrast_50, 84 + ]}> 85 + <desc.icon 86 + size="md" 87 + fill={t.atoms.text_contrast_medium.color} 88 + style={{marginLeft: -2}} 89 + /> 90 + <Text 91 + style={[ 92 + a.flex_1, 93 + a.text_left, 94 + a.font_bold, 95 + a.leading_snug, 96 + gtMobile && [a.font_semibold], 97 + t.atoms.text_contrast_medium, 98 + web({ 99 + marginBottom: 1, 100 + }), 101 + ]}> 102 + {desc.name} 103 + </Text> 104 + {!modui.noOverride && ( 105 + <Text 106 + style={[ 107 + a.font_bold, 108 + a.leading_snug, 109 + gtMobile && [a.font_semibold], 110 + t.atoms.text_contrast_high, 111 + web({ 112 + marginBottom: 1, 113 + }), 114 + ]}> 115 + {override ? <Trans>Hide</Trans> : <Trans>Show</Trans>} 116 + </Text> 117 + )} 118 + </View> 119 + )} 120 + </Button> 121 + 122 + {desc.source && blur.type === 'label' && !override && ( 123 + <Button 124 + onPress={() => { 125 + control.open() 126 + }} 127 + label={_( 128 + msg`Learn more about the moderation applied to this content.`, 129 + )} 130 + style={[a.pt_sm]}> 131 + {state => ( 132 + <Text 133 + style={[ 134 + a.flex_1, 135 + a.text_sm, 136 + a.font_normal, 137 + a.leading_snug, 138 + t.atoms.text_contrast_medium, 139 + a.text_left, 140 + ]}> 141 + {desc.sourceType === 'user' ? ( 142 + <Trans>Labeled by the author.</Trans> 143 + ) : ( 144 + <Trans>Labeled by {sanitizeDisplayName(desc.source!)}.</Trans> 145 + )}{' '} 146 + <Text 147 + style={[ 148 + {color: t.palette.primary_500}, 149 + a.text_sm, 150 + state.hovered && [web({textDecoration: 'underline'})], 151 + ]}> 152 + <Trans>Learn more.</Trans> 153 + </Text> 154 + </Text> 155 + )} 156 + </Button> 157 + )} 158 + 159 + {override && <View style={childContainerStyle}>{children}</View>} 160 + </View> 161 + ) 162 + } 163 + 164 + const styles = StyleSheet.create({ 165 + outer: { 166 + overflow: 'hidden', 167 + }, 168 + cover: { 169 + flexDirection: 'row', 170 + alignItems: 'center', 171 + gap: 6, 172 + borderRadius: 8, 173 + marginTop: 4, 174 + paddingVertical: 14, 175 + paddingLeft: 14, 176 + paddingRight: 18, 177 + }, 178 + showBtn: { 179 + marginLeft: 'auto', 180 + alignSelf: 'center', 181 + }, 182 + })
+93
src/components/moderation/GlobalModerationLabelPref.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {InterpretedLabelValueDefinition, LabelPreference} from '@atproto/api' 4 + import {useLingui} from '@lingui/react' 5 + import {msg} from '@lingui/macro' 6 + 7 + import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings' 8 + import { 9 + usePreferencesQuery, 10 + usePreferencesSetContentLabelMutation, 11 + } from '#/state/queries/preferences' 12 + 13 + import {useTheme, atoms as a} from '#/alf' 14 + import {Text} from '#/components/Typography' 15 + import * as ToggleButton from '#/components/forms/ToggleButton' 16 + 17 + export function GlobalModerationLabelPref({ 18 + labelValueDefinition, 19 + disabled, 20 + }: { 21 + labelValueDefinition: InterpretedLabelValueDefinition 22 + disabled?: boolean 23 + }) { 24 + const {_} = useLingui() 25 + const t = useTheme() 26 + 27 + const {identifier} = labelValueDefinition 28 + const {data: preferences} = usePreferencesQuery() 29 + const {mutate, variables} = usePreferencesSetContentLabelMutation() 30 + const savedPref = preferences?.moderationPrefs.labels[identifier] 31 + const pref = variables?.visibility ?? savedPref ?? 'warn' 32 + 33 + const allLabelStrings = useGlobalLabelStrings() 34 + const labelStrings = 35 + labelValueDefinition.identifier in allLabelStrings 36 + ? allLabelStrings[labelValueDefinition.identifier] 37 + : { 38 + name: labelValueDefinition.identifier, 39 + description: `Labeled "${labelValueDefinition.identifier}"`, 40 + } 41 + 42 + const labelOptions = { 43 + hide: _(msg`Hide`), 44 + warn: _(msg`Warn`), 45 + ignore: _(msg`Show`), 46 + } 47 + 48 + return ( 49 + <View 50 + style={[ 51 + a.flex_row, 52 + a.justify_between, 53 + a.gap_sm, 54 + a.py_md, 55 + a.pl_lg, 56 + a.pr_md, 57 + a.align_center, 58 + ]}> 59 + <View style={[a.gap_xs, a.flex_1]}> 60 + <Text style={[a.font_bold]}>{labelStrings.name}</Text> 61 + <Text style={[t.atoms.text_contrast_medium, a.leading_snug]}> 62 + {labelStrings.description} 63 + </Text> 64 + </View> 65 + <View style={[a.justify_center, {minHeight: 35}]}> 66 + {!disabled && ( 67 + <ToggleButton.Group 68 + label={_( 69 + msg`Configure content filtering setting for category: ${labelStrings.name.toLowerCase()}`, 70 + )} 71 + values={[pref]} 72 + onChange={newPref => 73 + mutate({ 74 + label: identifier, 75 + visibility: newPref[0] as LabelPreference, 76 + labelerDid: undefined, 77 + }) 78 + }> 79 + <ToggleButton.Button name="ignore" label={labelOptions.ignore}> 80 + {labelOptions.ignore} 81 + </ToggleButton.Button> 82 + <ToggleButton.Button name="warn" label={labelOptions.warn}> 83 + {labelOptions.warn} 84 + </ToggleButton.Button> 85 + <ToggleButton.Button name="hide" label={labelOptions.hide}> 86 + {labelOptions.hide} 87 + </ToggleButton.Button> 88 + </ToggleButton.Group> 89 + )} 90 + </View> 91 + </View> 92 + ) 93 + }
+83
src/components/moderation/LabelsOnMe.tsx
··· 1 + import React from 'react' 2 + import {StyleProp, View, ViewStyle} from 'react-native' 3 + import {AppBskyFeedDefs, ComAtprotoLabelDefs} from '@atproto/api' 4 + import {msg, Trans} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + import {useSession} from '#/state/session' 7 + 8 + import {atoms as a} from '#/alf' 9 + import {Button, ButtonText, ButtonIcon, ButtonSize} from '#/components/Button' 10 + import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 11 + import { 12 + LabelsOnMeDialog, 13 + useLabelsOnMeDialogControl, 14 + } from '#/components/moderation/LabelsOnMeDialog' 15 + 16 + export function LabelsOnMe({ 17 + details, 18 + labels, 19 + size, 20 + style, 21 + }: { 22 + details: {did: string} | {uri: string; cid: string} 23 + labels: ComAtprotoLabelDefs.Label[] | undefined 24 + size?: ButtonSize 25 + style?: StyleProp<ViewStyle> 26 + }) { 27 + const {_} = useLingui() 28 + const {currentAccount} = useSession() 29 + const isAccount = 'did' in details 30 + const control = useLabelsOnMeDialogControl() 31 + 32 + if (!labels || !currentAccount) { 33 + return null 34 + } 35 + labels = labels.filter( 36 + l => !l.val.startsWith('!') && l.src !== currentAccount.did, 37 + ) 38 + if (!labels.length) { 39 + return null 40 + } 41 + 42 + const labelTarget = isAccount ? _(msg`account`) : _(msg`content`) 43 + return ( 44 + <View style={[a.flex_row, style]}> 45 + <LabelsOnMeDialog control={control} subject={details} labels={labels} /> 46 + 47 + <Button 48 + variant="solid" 49 + color="secondary" 50 + size={size || 'small'} 51 + label={_(msg`View information about these labels`)} 52 + onPress={() => { 53 + control.open() 54 + }}> 55 + <ButtonIcon position="left" icon={CircleInfo} /> 56 + <ButtonText style={[a.leading_snug]}> 57 + {labels.length}{' '} 58 + {labels.length === 1 ? ( 59 + <Trans>label has been placed on this {labelTarget}</Trans> 60 + ) : ( 61 + <Trans>labels have been placed on this {labelTarget}</Trans> 62 + )} 63 + </ButtonText> 64 + </Button> 65 + </View> 66 + ) 67 + } 68 + 69 + export function LabelsOnMyPost({ 70 + post, 71 + style, 72 + }: { 73 + post: AppBskyFeedDefs.PostView 74 + style?: StyleProp<ViewStyle> 75 + }) { 76 + const {currentAccount} = useSession() 77 + if (post.author.did !== currentAccount?.did) { 78 + return null 79 + } 80 + return ( 81 + <LabelsOnMe details={post} labels={post.labels} size="tiny" style={style} /> 82 + ) 83 + }
+262
src/components/moderation/LabelsOnMeDialog.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + import {ComAtprotoLabelDefs, ComAtprotoModerationDefs} from '@atproto/api' 6 + 7 + import {useLabelInfo} from '#/lib/moderation/useLabelInfo' 8 + import {makeProfileLink} from '#/lib/routes/links' 9 + import {sanitizeHandle} from '#/lib/strings/handles' 10 + import {getAgent} from '#/state/session' 11 + 12 + import {atoms as a, useBreakpoints, useTheme} from '#/alf' 13 + import {Text} from '#/components/Typography' 14 + import * as Dialog from '#/components/Dialog' 15 + import {Button, ButtonText} from '#/components/Button' 16 + import {InlineLink} from '#/components/Link' 17 + import * as Toast from '#/view/com/util/Toast' 18 + import {Divider} from '../Divider' 19 + 20 + export {useDialogControl as useLabelsOnMeDialogControl} from '#/components/Dialog' 21 + 22 + type Subject = 23 + | { 24 + uri: string 25 + cid: string 26 + } 27 + | { 28 + did: string 29 + } 30 + 31 + export interface LabelsOnMeDialogProps { 32 + control: Dialog.DialogOuterProps['control'] 33 + subject: Subject 34 + labels: ComAtprotoLabelDefs.Label[] 35 + } 36 + 37 + export function LabelsOnMeDialogInner(props: LabelsOnMeDialogProps) { 38 + const {_} = useLingui() 39 + const [appealingLabel, setAppealingLabel] = React.useState< 40 + ComAtprotoLabelDefs.Label | undefined 41 + >(undefined) 42 + const {subject, labels} = props 43 + const isAccount = 'did' in subject 44 + 45 + return ( 46 + <Dialog.ScrollableInner 47 + label={ 48 + isAccount 49 + ? _(msg`The following labels were applied to your account.`) 50 + : _(msg`The following labels were applied to your content.`) 51 + }> 52 + {appealingLabel ? ( 53 + <AppealForm 54 + label={appealingLabel} 55 + subject={subject} 56 + control={props.control} 57 + onPressBack={() => setAppealingLabel(undefined)} 58 + /> 59 + ) : ( 60 + <> 61 + <Text style={[a.text_2xl, a.font_bold, a.pb_xs, a.leading_tight]}> 62 + {isAccount ? ( 63 + <Trans>Labels on your account</Trans> 64 + ) : ( 65 + <Trans>Labels on your content</Trans> 66 + )} 67 + </Text> 68 + <Text style={[a.text_md, a.leading_snug]}> 69 + <Trans> 70 + You may appeal these labels if you feel they were placed in error. 71 + </Trans> 72 + </Text> 73 + 74 + <View style={[a.py_lg, a.gap_md]}> 75 + {labels.map(label => ( 76 + <Label 77 + key={`${label.val}-${label.src}`} 78 + label={label} 79 + control={props.control} 80 + onPressAppeal={label => setAppealingLabel(label)} 81 + /> 82 + ))} 83 + </View> 84 + </> 85 + )} 86 + 87 + <Dialog.Close /> 88 + </Dialog.ScrollableInner> 89 + ) 90 + } 91 + 92 + export function LabelsOnMeDialog(props: LabelsOnMeDialogProps) { 93 + return ( 94 + <Dialog.Outer control={props.control}> 95 + <Dialog.Handle /> 96 + 97 + <LabelsOnMeDialogInner {...props} /> 98 + </Dialog.Outer> 99 + ) 100 + } 101 + 102 + function Label({ 103 + label, 104 + control, 105 + onPressAppeal, 106 + }: { 107 + label: ComAtprotoLabelDefs.Label 108 + control: Dialog.DialogOuterProps['control'] 109 + onPressAppeal: (label: ComAtprotoLabelDefs.Label) => void 110 + }) { 111 + const t = useTheme() 112 + const {_} = useLingui() 113 + const {labeler, strings} = useLabelInfo(label) 114 + return ( 115 + <View 116 + style={[ 117 + a.border, 118 + t.atoms.border_contrast_low, 119 + a.rounded_sm, 120 + a.overflow_hidden, 121 + ]}> 122 + <View style={[a.p_md, a.gap_sm, a.flex_row]}> 123 + <View style={[a.flex_1, a.gap_xs]}> 124 + <Text style={[a.font_bold, a.text_md]}>{strings.name}</Text> 125 + <Text style={[t.atoms.text_contrast_medium, a.leading_snug]}> 126 + {strings.description} 127 + </Text> 128 + </View> 129 + <View> 130 + <Button 131 + variant="solid" 132 + color="secondary" 133 + size="small" 134 + label={_(msg`Appeal`)} 135 + onPress={() => onPressAppeal(label)}> 136 + <ButtonText> 137 + <Trans>Appeal</Trans> 138 + </ButtonText> 139 + </Button> 140 + </View> 141 + </View> 142 + 143 + <Divider /> 144 + 145 + <View style={[a.px_md, a.py_sm, t.atoms.bg_contrast_25]}> 146 + <Text style={[t.atoms.text_contrast_medium]}> 147 + <Trans>Source:</Trans>{' '} 148 + <InlineLink 149 + to={makeProfileLink( 150 + labeler ? labeler.creator : {did: label.src, handle: ''}, 151 + )} 152 + onPress={() => control.close()}> 153 + {labeler ? sanitizeHandle(labeler.creator.handle, '@') : label.src} 154 + </InlineLink> 155 + </Text> 156 + </View> 157 + </View> 158 + ) 159 + } 160 + 161 + function AppealForm({ 162 + label, 163 + subject, 164 + control, 165 + onPressBack, 166 + }: { 167 + label: ComAtprotoLabelDefs.Label 168 + subject: Subject 169 + control: Dialog.DialogOuterProps['control'] 170 + onPressBack: () => void 171 + }) { 172 + const {_} = useLingui() 173 + const {labeler, strings} = useLabelInfo(label) 174 + const {gtMobile} = useBreakpoints() 175 + const [details, setDetails] = React.useState('') 176 + const isAccountReport = 'did' in subject 177 + 178 + const onSubmit = async () => { 179 + try { 180 + const $type = !isAccountReport 181 + ? 'com.atproto.repo.strongRef' 182 + : 'com.atproto.admin.defs#repoRef' 183 + await getAgent() 184 + .withProxy('atproto_labeler', label.src) 185 + .createModerationReport({ 186 + reasonType: ComAtprotoModerationDefs.REASONAPPEAL, 187 + subject: { 188 + $type, 189 + ...subject, 190 + }, 191 + reason: details, 192 + }) 193 + Toast.show(_(msg`Appeal submitted.`)) 194 + } finally { 195 + control.close() 196 + } 197 + } 198 + 199 + return ( 200 + <> 201 + <Text style={[a.text_2xl, a.font_bold, a.pb_xs, a.leading_tight]}> 202 + <Trans>Appeal "{strings.name}" label</Trans> 203 + </Text> 204 + <Text style={[a.text_md, a.leading_snug]}> 205 + <Trans> 206 + This appeal will be sent to{' '} 207 + <InlineLink 208 + to={makeProfileLink( 209 + labeler ? labeler.creator : {did: label.src, handle: ''}, 210 + )} 211 + onPress={() => control.close()} 212 + style={[a.text_md, a.leading_snug]}> 213 + {labeler ? sanitizeHandle(labeler.creator.handle, '@') : label.src} 214 + </InlineLink> 215 + . 216 + </Trans> 217 + </Text> 218 + <View style={[a.my_md]}> 219 + <Dialog.Input 220 + label={_(msg`Text input field`)} 221 + placeholder={_( 222 + msg`Please explain why you think this label was incorrectly applied by ${ 223 + labeler ? sanitizeHandle(labeler.creator.handle, '@') : label.src 224 + }`, 225 + )} 226 + value={details} 227 + onChangeText={setDetails} 228 + autoFocus={true} 229 + numberOfLines={3} 230 + multiline 231 + maxLength={300} 232 + /> 233 + </View> 234 + 235 + <View 236 + style={ 237 + gtMobile 238 + ? [a.flex_row, a.justify_between] 239 + : [{flexDirection: 'column-reverse'}, a.gap_sm] 240 + }> 241 + <Button 242 + testID="backBtn" 243 + variant="solid" 244 + color="secondary" 245 + size="medium" 246 + onPress={onPressBack} 247 + label={_(msg`Back`)}> 248 + {_(msg`Back`)} 249 + </Button> 250 + <Button 251 + testID="submitBtn" 252 + variant="solid" 253 + color="primary" 254 + size="medium" 255 + onPress={onSubmit} 256 + label={_(msg`Submit`)}> 257 + {_(msg`Submit`)} 258 + </Button> 259 + </View> 260 + </> 261 + ) 262 + }
+148
src/components/moderation/ModerationDetailsDialog.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + import {ModerationCause} from '@atproto/api' 6 + 7 + import {listUriToHref} from '#/lib/strings/url-helpers' 8 + import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription' 9 + import {makeProfileLink} from '#/lib/routes/links' 10 + 11 + import {isNative} from '#/platform/detection' 12 + import {useTheme, atoms as a} from '#/alf' 13 + import {Text} from '#/components/Typography' 14 + import * as Dialog from '#/components/Dialog' 15 + import {InlineLink} from '#/components/Link' 16 + import {Divider} from '#/components/Divider' 17 + 18 + export {useDialogControl as useModerationDetailsDialogControl} from '#/components/Dialog' 19 + 20 + export interface ModerationDetailsDialogProps { 21 + control: Dialog.DialogOuterProps['control'] 22 + modcause: ModerationCause 23 + } 24 + 25 + export function ModerationDetailsDialog(props: ModerationDetailsDialogProps) { 26 + return ( 27 + <Dialog.Outer control={props.control}> 28 + <Dialog.Handle /> 29 + <ModerationDetailsDialogInner {...props} /> 30 + </Dialog.Outer> 31 + ) 32 + } 33 + 34 + function ModerationDetailsDialogInner({ 35 + modcause, 36 + control, 37 + }: ModerationDetailsDialogProps & { 38 + control: Dialog.DialogOuterProps['control'] 39 + }) { 40 + const t = useTheme() 41 + const {_} = useLingui() 42 + const desc = useModerationCauseDescription(modcause) 43 + 44 + let name 45 + let description 46 + if (!modcause) { 47 + name = _(msg`Content Warning`) 48 + description = _( 49 + msg`Moderator has chosen to set a general warning on the content.`, 50 + ) 51 + } else if (modcause.type === 'blocking') { 52 + if (modcause.source.type === 'list') { 53 + const list = modcause.source.list 54 + name = _(msg`User Blocked by List`) 55 + description = ( 56 + <Trans> 57 + This user is included in the{' '} 58 + <InlineLink to={listUriToHref(list.uri)} style={[a.text_sm]}> 59 + {list.name} 60 + </InlineLink>{' '} 61 + list which you have blocked. 62 + </Trans> 63 + ) 64 + } else { 65 + name = _(msg`User Blocked`) 66 + description = _( 67 + msg`You have blocked this user. You cannot view their content.`, 68 + ) 69 + } 70 + } else if (modcause.type === 'blocked-by') { 71 + name = _(msg`User Blocks You`) 72 + description = _( 73 + msg`This user has blocked you. You cannot view their content.`, 74 + ) 75 + } else if (modcause.type === 'block-other') { 76 + name = _(msg`Content Not Available`) 77 + description = _( 78 + msg`This content is not available because one of the users involved has blocked the other.`, 79 + ) 80 + } else if (modcause.type === 'muted') { 81 + if (modcause.source.type === 'list') { 82 + const list = modcause.source.list 83 + name = _(msg`Account Muted by List`) 84 + description = ( 85 + <Trans> 86 + This user is included in the{' '} 87 + <InlineLink to={listUriToHref(list.uri)} style={[a.text_sm]}> 88 + {list.name} 89 + </InlineLink>{' '} 90 + list which you have muted. 91 + </Trans> 92 + ) 93 + } else { 94 + name = _(msg`Account Muted`) 95 + description = _(msg`You have muted this account.`) 96 + } 97 + } else if (modcause.type === 'mute-word') { 98 + name = _(msg`Post Hidden by Muted Word`) 99 + description = _(msg`You've chosen to hide a word or tag within this post.`) 100 + } else if (modcause.type === 'hidden') { 101 + name = _(msg`Post Hidden by You`) 102 + description = _(msg`You have hidden this post.`) 103 + } else if (modcause.type === 'label') { 104 + name = desc.name 105 + description = desc.description 106 + } else { 107 + // should never happen 108 + name = '' 109 + description = '' 110 + } 111 + 112 + return ( 113 + <Dialog.ScrollableInner label={_(msg`Moderation details`)}> 114 + <Text style={[t.atoms.text, a.text_2xl, a.font_bold, a.mb_sm]}> 115 + {name} 116 + </Text> 117 + <Text style={[t.atoms.text, a.text_md, a.mb_lg, a.leading_snug]}> 118 + {description} 119 + </Text> 120 + 121 + {modcause.type === 'label' && ( 122 + <> 123 + <Divider /> 124 + <Text style={[t.atoms.text, a.text_md, a.leading_snug, a.mt_lg]}> 125 + <Trans> 126 + This label was applied by{' '} 127 + {modcause.source.type === 'user' ? ( 128 + <Trans>the author</Trans> 129 + ) : ( 130 + <InlineLink 131 + to={makeProfileLink({did: modcause.label.src, handle: ''})} 132 + onPress={() => control.close()} 133 + style={a.text_md}> 134 + {desc.source} 135 + </InlineLink> 136 + )} 137 + . 138 + </Trans> 139 + </Text> 140 + </> 141 + )} 142 + 143 + {isNative && <View style={{height: 40}} />} 144 + 145 + <Dialog.Close /> 146 + </Dialog.ScrollableInner> 147 + ) 148 + }
+154
src/components/moderation/ModerationLabelPref.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {InterpretedLabelValueDefinition, LabelPreference} from '@atproto/api' 4 + import {useLingui} from '@lingui/react' 5 + import {msg, Trans} from '@lingui/macro' 6 + 7 + import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings' 8 + import {useLabelBehaviorDescription} from '#/lib/moderation/useLabelBehaviorDescription' 9 + import { 10 + usePreferencesQuery, 11 + usePreferencesSetContentLabelMutation, 12 + } from '#/state/queries/preferences' 13 + import {getLabelStrings} from '#/lib/moderation/useLabelInfo' 14 + 15 + import {useTheme, atoms as a} from '#/alf' 16 + import {Text} from '#/components/Typography' 17 + import {InlineLink} from '#/components/Link' 18 + import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '../icons/CircleInfo' 19 + import * as ToggleButton from '#/components/forms/ToggleButton' 20 + 21 + export function ModerationLabelPref({ 22 + labelValueDefinition, 23 + labelerDid, 24 + disabled, 25 + }: { 26 + labelValueDefinition: InterpretedLabelValueDefinition 27 + labelerDid: string | undefined 28 + disabled?: boolean 29 + }) { 30 + const {_, i18n} = useLingui() 31 + const t = useTheme() 32 + 33 + const isGlobalLabel = !labelValueDefinition.definedBy 34 + const {identifier} = labelValueDefinition 35 + const {data: preferences} = usePreferencesQuery() 36 + const {mutate, variables} = usePreferencesSetContentLabelMutation() 37 + const savedPref = 38 + labelerDid && !isGlobalLabel 39 + ? preferences?.moderationPrefs.labelers.find(l => l.did === labelerDid) 40 + ?.labels[identifier] 41 + : preferences?.moderationPrefs.labels[identifier] 42 + const pref = 43 + variables?.visibility ?? 44 + savedPref ?? 45 + labelValueDefinition.defaultSetting ?? 46 + 'warn' 47 + 48 + // does the 'warn' setting make sense for this label? 49 + const canWarn = !( 50 + labelValueDefinition.blurs === 'none' && 51 + labelValueDefinition.severity === 'none' 52 + ) 53 + // is this label adult only? 54 + const adultOnly = labelValueDefinition.flags.includes('adult') 55 + // is this label disabled because it's adult only? 56 + const adultDisabled = 57 + adultOnly && !preferences?.moderationPrefs.adultContentEnabled 58 + // are there any reasons we cant configure this label here? 59 + const cantConfigure = isGlobalLabel || adultDisabled 60 + 61 + // adjust the pref based on whether warn is available 62 + let prefAdjusted = pref 63 + if (adultDisabled) { 64 + prefAdjusted = 'hide' 65 + } else if (!canWarn && pref === 'warn') { 66 + prefAdjusted = 'ignore' 67 + } 68 + 69 + // grab localized descriptions of the label and its settings 70 + const currentPrefLabel = useLabelBehaviorDescription( 71 + labelValueDefinition, 72 + prefAdjusted, 73 + ) 74 + const hideLabel = useLabelBehaviorDescription(labelValueDefinition, 'hide') 75 + const warnLabel = useLabelBehaviorDescription(labelValueDefinition, 'warn') 76 + const ignoreLabel = useLabelBehaviorDescription( 77 + labelValueDefinition, 78 + 'ignore', 79 + ) 80 + const globalLabelStrings = useGlobalLabelStrings() 81 + const labelStrings = getLabelStrings( 82 + i18n.locale, 83 + globalLabelStrings, 84 + labelValueDefinition, 85 + ) 86 + 87 + return ( 88 + <View style={[a.flex_row, a.gap_sm, a.px_lg, a.py_lg, a.justify_between]}> 89 + <View style={[a.gap_xs, a.flex_1]}> 90 + <Text style={[a.font_bold]}>{labelStrings.name}</Text> 91 + <Text style={[t.atoms.text_contrast_medium, a.leading_snug]}> 92 + {labelStrings.description} 93 + </Text> 94 + 95 + {cantConfigure && ( 96 + <View style={[a.flex_row, a.gap_xs, a.align_center, a.mt_xs]}> 97 + <CircleInfo size="sm" fill={t.atoms.text_contrast_high.color} /> 98 + 99 + <Text 100 + style={[t.atoms.text_contrast_medium, a.font_semibold, a.italic]}> 101 + {adultDisabled ? ( 102 + <Trans>Adult content is disabled.</Trans> 103 + ) : isGlobalLabel ? ( 104 + <Trans> 105 + Configured in{' '} 106 + <InlineLink to="/moderation" style={a.text_sm}> 107 + moderation settings 108 + </InlineLink> 109 + . 110 + </Trans> 111 + ) : null} 112 + </Text> 113 + </View> 114 + )} 115 + </View> 116 + {disabled ? ( 117 + <></> 118 + ) : cantConfigure ? ( 119 + <View style={[{minHeight: 35}, a.px_sm, a.py_md]}> 120 + <Text style={[a.font_bold, t.atoms.text_contrast_medium]}> 121 + {currentPrefLabel} 122 + </Text> 123 + </View> 124 + ) : ( 125 + <View style={[{minHeight: 35}]}> 126 + <ToggleButton.Group 127 + label={_( 128 + msg`Configure content filtering setting for category: ${labelStrings.name.toLowerCase()}`, 129 + )} 130 + values={[prefAdjusted]} 131 + onChange={newPref => 132 + mutate({ 133 + label: identifier, 134 + visibility: newPref[0] as LabelPreference, 135 + labelerDid, 136 + }) 137 + }> 138 + <ToggleButton.Button name="ignore" label={ignoreLabel}> 139 + {ignoreLabel} 140 + </ToggleButton.Button> 141 + {canWarn && ( 142 + <ToggleButton.Button name="warn" label={warnLabel}> 143 + {warnLabel} 144 + </ToggleButton.Button> 145 + )} 146 + <ToggleButton.Button name="hide" label={hideLabel}> 147 + {hideLabel} 148 + </ToggleButton.Button> 149 + </ToggleButton.Group> 150 + </View> 151 + )} 152 + </View> 153 + ) 154 + }
+66
src/components/moderation/PostAlerts.tsx
··· 1 + import React from 'react' 2 + import {StyleProp, View, ViewStyle} from 'react-native' 3 + import {ModerationUI, ModerationCause} from '@atproto/api' 4 + 5 + import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription' 6 + import {getModerationCauseKey} from '#/lib/moderation' 7 + 8 + import {atoms as a} from '#/alf' 9 + import {Button, ButtonText, ButtonIcon} from '#/components/Button' 10 + import { 11 + ModerationDetailsDialog, 12 + useModerationDetailsDialogControl, 13 + } from '#/components/moderation/ModerationDetailsDialog' 14 + 15 + export function PostAlerts({ 16 + modui, 17 + style, 18 + }: { 19 + modui: ModerationUI 20 + includeMute?: boolean 21 + style?: StyleProp<ViewStyle> 22 + }) { 23 + if (!modui.alert && !modui.inform) { 24 + return null 25 + } 26 + 27 + return ( 28 + <View style={[a.flex_col, a.gap_xs, style]}> 29 + <View style={[a.flex_row, a.flex_wrap, a.gap_xs]}> 30 + {modui.alerts.map(cause => ( 31 + <PostLabel key={getModerationCauseKey(cause)} cause={cause} /> 32 + ))} 33 + {modui.informs.map(cause => ( 34 + <PostLabel key={getModerationCauseKey(cause)} cause={cause} /> 35 + ))} 36 + </View> 37 + </View> 38 + ) 39 + } 40 + 41 + function PostLabel({cause}: {cause: ModerationCause}) { 42 + const control = useModerationDetailsDialogControl() 43 + const desc = useModerationCauseDescription(cause) 44 + 45 + return ( 46 + <> 47 + <Button 48 + label={desc.name} 49 + variant="solid" 50 + color="secondary" 51 + size="small" 52 + shape="default" 53 + onPress={() => { 54 + control.open() 55 + }} 56 + style={[a.px_sm, a.py_xs, a.gap_xs]}> 57 + <ButtonIcon icon={desc.icon} position="left" /> 58 + <ButtonText style={[a.text_left, a.leading_snug]}> 59 + {desc.name} 60 + </ButtonText> 61 + </Button> 62 + 63 + <ModerationDetailsDialog control={control} modcause={cause} /> 64 + </> 65 + ) 66 + }
+129
src/components/moderation/PostHider.tsx
··· 1 + import React, {ComponentProps} from 'react' 2 + import {StyleSheet, Pressable, View, ViewStyle, StyleProp} from 'react-native' 3 + import {ModerationUI} from '@atproto/api' 4 + import {useLingui} from '@lingui/react' 5 + import {Trans, msg} from '@lingui/macro' 6 + 7 + import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription' 8 + import {addStyle} from 'lib/styles' 9 + 10 + import {useTheme, atoms as a} from '#/alf' 11 + import { 12 + ModerationDetailsDialog, 13 + useModerationDetailsDialogControl, 14 + } from '#/components/moderation/ModerationDetailsDialog' 15 + import {Text} from '#/components/Typography' 16 + // import {Link} from '#/components/Link' TODO this imposes some styles that screw things up 17 + import {Link} from '#/view/com/util/Link' 18 + 19 + interface Props extends ComponentProps<typeof Link> { 20 + iconSize: number 21 + iconStyles: StyleProp<ViewStyle> 22 + modui: ModerationUI 23 + } 24 + 25 + export function PostHider({ 26 + testID, 27 + href, 28 + modui, 29 + style, 30 + children, 31 + iconSize, 32 + iconStyles, 33 + ...props 34 + }: Props) { 35 + const t = useTheme() 36 + const {_} = useLingui() 37 + const [override, setOverride] = React.useState(false) 38 + const control = useModerationDetailsDialogControl() 39 + const blur = modui.blurs[0] 40 + const desc = useModerationCauseDescription(blur) 41 + 42 + if (!blur) { 43 + return ( 44 + <Link 45 + testID={testID} 46 + style={style} 47 + href={href} 48 + accessible={false} 49 + {...props}> 50 + {children} 51 + </Link> 52 + ) 53 + } 54 + 55 + return !override ? ( 56 + <Pressable 57 + onPress={() => { 58 + if (!modui.noOverride) { 59 + setOverride(v => !v) 60 + } 61 + }} 62 + accessibilityRole="button" 63 + accessibilityHint={ 64 + override ? _(msg`Hide the content`) : _(msg`Show the content`) 65 + } 66 + accessibilityLabel="" 67 + style={[ 68 + a.flex_row, 69 + a.align_center, 70 + a.gap_sm, 71 + a.py_md, 72 + { 73 + paddingLeft: 6, 74 + paddingRight: 18, 75 + }, 76 + override ? {paddingBottom: 0} : undefined, 77 + t.atoms.bg, 78 + ]}> 79 + <ModerationDetailsDialog control={control} modcause={blur} /> 80 + <Pressable 81 + onPress={() => { 82 + control.open() 83 + }} 84 + accessibilityRole="button" 85 + accessibilityLabel={_(msg`Learn more about this warning`)} 86 + accessibilityHint=""> 87 + <View 88 + style={[ 89 + t.atoms.bg_contrast_25, 90 + a.align_center, 91 + a.justify_center, 92 + { 93 + width: iconSize, 94 + height: iconSize, 95 + borderRadius: iconSize, 96 + }, 97 + iconStyles, 98 + ]}> 99 + <desc.icon size="sm" fill={t.atoms.text_contrast_medium.color} /> 100 + </View> 101 + </Pressable> 102 + <Text style={[t.atoms.text_contrast_medium, a.flex_1]} numberOfLines={1}> 103 + {desc.name} 104 + </Text> 105 + {!modui.noOverride && ( 106 + <Text style={[{color: t.palette.primary_500}]}> 107 + {override ? <Trans>Hide</Trans> : <Trans>Show</Trans>} 108 + </Text> 109 + )} 110 + </Pressable> 111 + ) : ( 112 + <Link 113 + testID={testID} 114 + style={addStyle(style, styles.child)} 115 + href={href} 116 + accessible={false} 117 + {...props}> 118 + {children} 119 + </Link> 120 + ) 121 + } 122 + 123 + const styles = StyleSheet.create({ 124 + child: { 125 + borderWidth: 0, 126 + borderTopWidth: 0, 127 + borderRadius: 8, 128 + }, 129 + })
+66
src/components/moderation/ProfileHeaderAlerts.tsx
··· 1 + import React from 'react' 2 + import {StyleProp, View, ViewStyle} from 'react-native' 3 + import {ModerationCause, ModerationDecision} from '@atproto/api' 4 + 5 + import {getModerationCauseKey} from 'lib/moderation' 6 + import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription' 7 + 8 + import {atoms as a} from '#/alf' 9 + import {Button, ButtonText, ButtonIcon} from '#/components/Button' 10 + import { 11 + ModerationDetailsDialog, 12 + useModerationDetailsDialogControl, 13 + } from '#/components/moderation/ModerationDetailsDialog' 14 + 15 + export function ProfileHeaderAlerts({ 16 + moderation, 17 + style, 18 + }: { 19 + moderation: ModerationDecision 20 + style?: StyleProp<ViewStyle> 21 + }) { 22 + const modui = moderation.ui('profileView') 23 + if (!modui.alert && !modui.inform) { 24 + return null 25 + } 26 + 27 + return ( 28 + <View style={[a.flex_col, a.gap_xs, style]}> 29 + <View style={[a.flex_row, a.flex_wrap, a.gap_xs]}> 30 + {modui.alerts.map(cause => ( 31 + <ProfileLabel key={getModerationCauseKey(cause)} cause={cause} /> 32 + ))} 33 + {modui.informs.map(cause => ( 34 + <ProfileLabel key={getModerationCauseKey(cause)} cause={cause} /> 35 + ))} 36 + </View> 37 + </View> 38 + ) 39 + } 40 + 41 + function ProfileLabel({cause}: {cause: ModerationCause}) { 42 + const control = useModerationDetailsDialogControl() 43 + const desc = useModerationCauseDescription(cause) 44 + 45 + return ( 46 + <> 47 + <Button 48 + label={desc.name} 49 + variant="solid" 50 + color="secondary" 51 + size="small" 52 + shape="default" 53 + onPress={() => { 54 + control.open() 55 + }} 56 + style={[a.px_sm, a.py_xs, a.gap_xs]}> 57 + <ButtonIcon icon={desc.icon} position="left" /> 58 + <ButtonText style={[a.text_left, a.leading_snug]}> 59 + {desc.name} 60 + </ButtonText> 61 + </Button> 62 + 63 + <ModerationDetailsDialog control={control} modcause={cause} /> 64 + </> 65 + ) 66 + }
+171
src/components/moderation/ScreenHider.tsx
··· 1 + import React from 'react' 2 + import { 3 + TouchableWithoutFeedback, 4 + StyleProp, 5 + View, 6 + ViewStyle, 7 + } from 'react-native' 8 + import {useNavigation} from '@react-navigation/native' 9 + import {ModerationUI} from '@atproto/api' 10 + import {Trans, msg} from '@lingui/macro' 11 + import {useLingui} from '@lingui/react' 12 + 13 + import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 14 + import {NavigationProp} from 'lib/routes/types' 15 + import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription' 16 + 17 + import {useTheme, atoms as a} from '#/alf' 18 + import {CenteredView} from '#/view/com/util/Views' 19 + import {Text} from '#/components/Typography' 20 + import {Button, ButtonText} from '#/components/Button' 21 + import { 22 + ModerationDetailsDialog, 23 + useModerationDetailsDialogControl, 24 + } from '#/components/moderation/ModerationDetailsDialog' 25 + 26 + export function ScreenHider({ 27 + testID, 28 + screenDescription, 29 + modui, 30 + style, 31 + containerStyle, 32 + children, 33 + }: React.PropsWithChildren<{ 34 + testID?: string 35 + screenDescription: string 36 + modui: ModerationUI 37 + style?: StyleProp<ViewStyle> 38 + containerStyle?: StyleProp<ViewStyle> 39 + }>) { 40 + const t = useTheme() 41 + const {_} = useLingui() 42 + const [override, setOverride] = React.useState(false) 43 + const navigation = useNavigation<NavigationProp>() 44 + const {isMobile} = useWebMediaQueries() 45 + const control = useModerationDetailsDialogControl() 46 + const blur = modui.blurs[0] 47 + const desc = useModerationCauseDescription(blur) 48 + 49 + if (!blur || override) { 50 + return ( 51 + <View testID={testID} style={style}> 52 + {children} 53 + </View> 54 + ) 55 + } 56 + 57 + const isNoPwi = !!modui.blurs.find( 58 + cause => 59 + cause.type === 'label' && cause.labelDef.id === '!no-unauthenticated', 60 + ) 61 + return ( 62 + <CenteredView 63 + style={[ 64 + a.flex_1, 65 + { 66 + paddingTop: 100, 67 + paddingBottom: 150, 68 + }, 69 + t.atoms.bg, 70 + containerStyle, 71 + ]} 72 + sideBorders> 73 + <View style={[a.align_center, a.mb_md]}> 74 + <View 75 + style={[ 76 + t.atoms.bg_contrast_975, 77 + a.align_center, 78 + a.justify_center, 79 + { 80 + borderRadius: 25, 81 + width: 50, 82 + height: 50, 83 + }, 84 + ]}> 85 + <desc.icon width={24} fill={t.atoms.bg.backgroundColor} /> 86 + </View> 87 + </View> 88 + <Text 89 + style={[ 90 + a.text_4xl, 91 + a.font_semibold, 92 + a.text_center, 93 + a.mb_md, 94 + t.atoms.text, 95 + ]}> 96 + {isNoPwi ? ( 97 + <Trans>Sign-in Required</Trans> 98 + ) : ( 99 + <Trans>Content Warning</Trans> 100 + )} 101 + </Text> 102 + <Text 103 + style={[ 104 + a.text_lg, 105 + a.mb_md, 106 + a.px_lg, 107 + a.text_center, 108 + t.atoms.text_contrast_medium, 109 + ]}> 110 + {isNoPwi ? ( 111 + <Trans> 112 + This account has requested that users sign in to view their profile. 113 + </Trans> 114 + ) : ( 115 + <> 116 + <Trans>This {screenDescription} has been flagged:</Trans> 117 + <Text style={[a.text_lg, a.font_semibold, t.atoms.text, a.ml_xs]}> 118 + {desc.name}.{' '} 119 + </Text> 120 + <TouchableWithoutFeedback 121 + onPress={() => { 122 + control.open() 123 + }} 124 + accessibilityRole="button" 125 + accessibilityLabel={_(msg`Learn more about this warning`)} 126 + accessibilityHint=""> 127 + <Text style={[a.text_lg, {color: t.palette.primary_500}]}> 128 + <Trans>Learn More</Trans> 129 + </Text> 130 + </TouchableWithoutFeedback> 131 + 132 + <ModerationDetailsDialog control={control} modcause={blur} /> 133 + </> 134 + )}{' '} 135 + </Text> 136 + {isMobile && <View style={a.flex_1} />} 137 + <View style={[a.flex_row, a.justify_center, a.my_md, a.gap_md]}> 138 + <Button 139 + variant="solid" 140 + color="primary" 141 + size="large" 142 + style={[a.rounded_full]} 143 + label={_(msg`Go back`)} 144 + onPress={() => { 145 + if (navigation.canGoBack()) { 146 + navigation.goBack() 147 + } else { 148 + navigation.navigate('Home') 149 + } 150 + }}> 151 + <ButtonText> 152 + <Trans>Go back</Trans> 153 + </ButtonText> 154 + </Button> 155 + {!modui.noOverride && ( 156 + <Button 157 + variant="solid" 158 + color="secondary" 159 + size="large" 160 + style={[a.rounded_full]} 161 + label={_(msg`Show anyway`)} 162 + onPress={() => setOverride(v => !v)}> 163 + <ButtonText> 164 + <Trans>Show anyway</Trans> 165 + </ButtonText> 166 + </Button> 167 + )} 168 + </View> 169 + </CenteredView> 170 + ) 171 + }
-692
src/lib/__tests__/moderatePost_wrapped.test.ts
··· 1 - import {describe, it, expect} from '@jest/globals' 2 - import {RichText} from '@atproto/api' 3 - 4 - import {hasMutedWord} from '../moderatePost_wrapped' 5 - 6 - describe(`hasMutedWord`, () => { 7 - describe(`tags`, () => { 8 - it(`match: outline tag`, () => { 9 - const rt = new RichText({ 10 - text: `This is a post #inlineTag`, 11 - }) 12 - rt.detectFacetsWithoutResolution() 13 - 14 - const match = hasMutedWord({ 15 - mutedWords: [{value: 'outlineTag', targets: ['tag']}], 16 - text: rt.text, 17 - facets: rt.facets, 18 - outlineTags: ['outlineTag'], 19 - isOwnPost: false, 20 - }) 21 - 22 - expect(match).toBe(true) 23 - }) 24 - 25 - it(`match: inline tag`, () => { 26 - const rt = new RichText({ 27 - text: `This is a post #inlineTag`, 28 - }) 29 - rt.detectFacetsWithoutResolution() 30 - 31 - const match = hasMutedWord({ 32 - mutedWords: [{value: 'inlineTag', targets: ['tag']}], 33 - text: rt.text, 34 - facets: rt.facets, 35 - outlineTags: ['outlineTag'], 36 - isOwnPost: false, 37 - }) 38 - 39 - expect(match).toBe(true) 40 - }) 41 - 42 - it(`match: content target matches inline tag`, () => { 43 - const rt = new RichText({ 44 - text: `This is a post #inlineTag`, 45 - }) 46 - rt.detectFacetsWithoutResolution() 47 - 48 - const match = hasMutedWord({ 49 - mutedWords: [{value: 'inlineTag', targets: ['content']}], 50 - text: rt.text, 51 - facets: rt.facets, 52 - outlineTags: ['outlineTag'], 53 - isOwnPost: false, 54 - }) 55 - 56 - expect(match).toBe(true) 57 - }) 58 - 59 - it(`no match: only tag targets`, () => { 60 - const rt = new RichText({ 61 - text: `This is a post`, 62 - }) 63 - rt.detectFacetsWithoutResolution() 64 - 65 - const match = hasMutedWord({ 66 - mutedWords: [{value: 'inlineTag', targets: ['tag']}], 67 - text: rt.text, 68 - facets: rt.facets, 69 - outlineTags: [], 70 - isOwnPost: false, 71 - }) 72 - 73 - expect(match).toBe(false) 74 - }) 75 - }) 76 - 77 - describe(`early exits`, () => { 78 - it(`match: single character 希`, () => { 79 - /** 80 - * @see https://bsky.app/profile/mukuuji.bsky.social/post/3klji4fvsdk2c 81 - */ 82 - const rt = new RichText({ 83 - text: `改善希望です`, 84 - }) 85 - rt.detectFacetsWithoutResolution() 86 - 87 - const match = hasMutedWord({ 88 - mutedWords: [{value: '希', targets: ['content']}], 89 - text: rt.text, 90 - facets: rt.facets, 91 - outlineTags: [], 92 - isOwnPost: false, 93 - }) 94 - 95 - expect(match).toBe(true) 96 - }) 97 - 98 - it(`no match: long muted word, short post`, () => { 99 - const rt = new RichText({ 100 - text: `hey`, 101 - }) 102 - rt.detectFacetsWithoutResolution() 103 - 104 - const match = hasMutedWord({ 105 - mutedWords: [{value: 'politics', targets: ['content']}], 106 - text: rt.text, 107 - facets: rt.facets, 108 - outlineTags: [], 109 - isOwnPost: false, 110 - }) 111 - 112 - expect(match).toBe(false) 113 - }) 114 - 115 - it(`match: exact text`, () => { 116 - const rt = new RichText({ 117 - text: `javascript`, 118 - }) 119 - rt.detectFacetsWithoutResolution() 120 - 121 - const match = hasMutedWord({ 122 - mutedWords: [{value: 'javascript', targets: ['content']}], 123 - text: rt.text, 124 - facets: rt.facets, 125 - outlineTags: [], 126 - isOwnPost: false, 127 - }) 128 - 129 - expect(match).toBe(true) 130 - }) 131 - }) 132 - 133 - describe(`general content`, () => { 134 - it(`match: word within post`, () => { 135 - const rt = new RichText({ 136 - text: `This is a post about javascript`, 137 - }) 138 - rt.detectFacetsWithoutResolution() 139 - 140 - const match = hasMutedWord({ 141 - mutedWords: [{value: 'javascript', targets: ['content']}], 142 - text: rt.text, 143 - facets: rt.facets, 144 - outlineTags: [], 145 - isOwnPost: false, 146 - }) 147 - 148 - expect(match).toBe(true) 149 - }) 150 - 151 - it(`no match: partial word`, () => { 152 - const rt = new RichText({ 153 - text: `Use your brain, Eric`, 154 - }) 155 - rt.detectFacetsWithoutResolution() 156 - 157 - const match = hasMutedWord({ 158 - mutedWords: [{value: 'ai', targets: ['content']}], 159 - text: rt.text, 160 - facets: rt.facets, 161 - outlineTags: [], 162 - isOwnPost: false, 163 - }) 164 - 165 - expect(match).toBe(false) 166 - }) 167 - 168 - it(`match: multiline`, () => { 169 - const rt = new RichText({ 170 - text: `Use your\n\tbrain, Eric`, 171 - }) 172 - rt.detectFacetsWithoutResolution() 173 - 174 - const match = hasMutedWord({ 175 - mutedWords: [{value: 'brain', targets: ['content']}], 176 - text: rt.text, 177 - facets: rt.facets, 178 - outlineTags: [], 179 - isOwnPost: false, 180 - }) 181 - 182 - expect(match).toBe(true) 183 - }) 184 - 185 - it(`match: :)`, () => { 186 - const rt = new RichText({ 187 - text: `So happy :)`, 188 - }) 189 - rt.detectFacetsWithoutResolution() 190 - 191 - const match = hasMutedWord({ 192 - mutedWords: [{value: `:)`, targets: ['content']}], 193 - text: rt.text, 194 - facets: rt.facets, 195 - outlineTags: [], 196 - isOwnPost: false, 197 - }) 198 - 199 - expect(match).toBe(true) 200 - }) 201 - }) 202 - 203 - describe(`punctuation semi-fuzzy`, () => { 204 - describe(`yay!`, () => { 205 - const rt = new RichText({ 206 - text: `We're federating, yay!`, 207 - }) 208 - rt.detectFacetsWithoutResolution() 209 - 210 - it(`match: yay!`, () => { 211 - const match = hasMutedWord({ 212 - mutedWords: [{value: 'yay!', targets: ['content']}], 213 - text: rt.text, 214 - facets: rt.facets, 215 - outlineTags: [], 216 - isOwnPost: false, 217 - }) 218 - 219 - expect(match).toBe(true) 220 - }) 221 - 222 - it(`match: yay`, () => { 223 - const match = hasMutedWord({ 224 - mutedWords: [{value: 'yay', targets: ['content']}], 225 - text: rt.text, 226 - facets: rt.facets, 227 - outlineTags: [], 228 - isOwnPost: false, 229 - }) 230 - 231 - expect(match).toBe(true) 232 - }) 233 - }) 234 - 235 - describe(`y!ppee!!`, () => { 236 - const rt = new RichText({ 237 - text: `We're federating, y!ppee!!`, 238 - }) 239 - rt.detectFacetsWithoutResolution() 240 - 241 - it(`match: y!ppee`, () => { 242 - const match = hasMutedWord({ 243 - mutedWords: [{value: 'y!ppee', targets: ['content']}], 244 - text: rt.text, 245 - facets: rt.facets, 246 - outlineTags: [], 247 - isOwnPost: false, 248 - }) 249 - 250 - expect(match).toBe(true) 251 - }) 252 - 253 - // single exclamation point, source has double 254 - it(`no match: y!ppee!`, () => { 255 - const match = hasMutedWord({ 256 - mutedWords: [{value: 'y!ppee!', targets: ['content']}], 257 - text: rt.text, 258 - facets: rt.facets, 259 - outlineTags: [], 260 - isOwnPost: false, 261 - }) 262 - 263 - expect(match).toBe(true) 264 - }) 265 - }) 266 - 267 - describe(`Why so S@assy?`, () => { 268 - const rt = new RichText({ 269 - text: `Why so S@assy?`, 270 - }) 271 - rt.detectFacetsWithoutResolution() 272 - 273 - it(`match: S@assy`, () => { 274 - const match = hasMutedWord({ 275 - mutedWords: [{value: 'S@assy', targets: ['content']}], 276 - text: rt.text, 277 - facets: rt.facets, 278 - outlineTags: [], 279 - isOwnPost: false, 280 - }) 281 - 282 - expect(match).toBe(true) 283 - }) 284 - 285 - it(`match: s@assy`, () => { 286 - const match = hasMutedWord({ 287 - mutedWords: [{value: 's@assy', targets: ['content']}], 288 - text: rt.text, 289 - facets: rt.facets, 290 - outlineTags: [], 291 - isOwnPost: false, 292 - }) 293 - 294 - expect(match).toBe(true) 295 - }) 296 - }) 297 - 298 - describe(`New York Times`, () => { 299 - const rt = new RichText({ 300 - text: `New York Times`, 301 - }) 302 - rt.detectFacetsWithoutResolution() 303 - 304 - // case insensitive 305 - it(`match: new york times`, () => { 306 - const match = hasMutedWord({ 307 - mutedWords: [{value: 'new york times', targets: ['content']}], 308 - text: rt.text, 309 - facets: rt.facets, 310 - outlineTags: [], 311 - isOwnPost: false, 312 - }) 313 - 314 - expect(match).toBe(true) 315 - }) 316 - }) 317 - 318 - describe(`!command`, () => { 319 - const rt = new RichText({ 320 - text: `Idk maybe a bot !command`, 321 - }) 322 - rt.detectFacetsWithoutResolution() 323 - 324 - it(`match: !command`, () => { 325 - const match = hasMutedWord({ 326 - mutedWords: [{value: `!command`, targets: ['content']}], 327 - text: rt.text, 328 - facets: rt.facets, 329 - outlineTags: [], 330 - isOwnPost: false, 331 - }) 332 - 333 - expect(match).toBe(true) 334 - }) 335 - 336 - it(`match: command`, () => { 337 - const match = hasMutedWord({ 338 - mutedWords: [{value: `command`, targets: ['content']}], 339 - text: rt.text, 340 - facets: rt.facets, 341 - outlineTags: [], 342 - isOwnPost: false, 343 - }) 344 - 345 - expect(match).toBe(true) 346 - }) 347 - 348 - it(`no match: !command`, () => { 349 - const rt = new RichText({ 350 - text: `Idk maybe a bot command`, 351 - }) 352 - rt.detectFacetsWithoutResolution() 353 - 354 - const match = hasMutedWord({ 355 - mutedWords: [{value: `!command`, targets: ['content']}], 356 - text: rt.text, 357 - facets: rt.facets, 358 - outlineTags: [], 359 - isOwnPost: false, 360 - }) 361 - 362 - expect(match).toBe(false) 363 - }) 364 - }) 365 - 366 - describe(`e/acc`, () => { 367 - const rt = new RichText({ 368 - text: `I'm e/acc pilled`, 369 - }) 370 - rt.detectFacetsWithoutResolution() 371 - 372 - it(`match: e/acc`, () => { 373 - const match = hasMutedWord({ 374 - mutedWords: [{value: `e/acc`, targets: ['content']}], 375 - text: rt.text, 376 - facets: rt.facets, 377 - outlineTags: [], 378 - isOwnPost: false, 379 - }) 380 - 381 - expect(match).toBe(true) 382 - }) 383 - 384 - it(`match: acc`, () => { 385 - const match = hasMutedWord({ 386 - mutedWords: [{value: `acc`, targets: ['content']}], 387 - text: rt.text, 388 - facets: rt.facets, 389 - outlineTags: [], 390 - isOwnPost: false, 391 - }) 392 - 393 - expect(match).toBe(true) 394 - }) 395 - }) 396 - 397 - describe(`super-bad`, () => { 398 - const rt = new RichText({ 399 - text: `I'm super-bad`, 400 - }) 401 - rt.detectFacetsWithoutResolution() 402 - 403 - it(`match: super-bad`, () => { 404 - const match = hasMutedWord({ 405 - mutedWords: [{value: `super-bad`, targets: ['content']}], 406 - text: rt.text, 407 - facets: rt.facets, 408 - outlineTags: [], 409 - isOwnPost: false, 410 - }) 411 - 412 - expect(match).toBe(true) 413 - }) 414 - 415 - it(`match: super`, () => { 416 - const match = hasMutedWord({ 417 - mutedWords: [{value: `super`, targets: ['content']}], 418 - text: rt.text, 419 - facets: rt.facets, 420 - outlineTags: [], 421 - isOwnPost: false, 422 - }) 423 - 424 - expect(match).toBe(true) 425 - }) 426 - 427 - it(`match: super bad`, () => { 428 - const match = hasMutedWord({ 429 - mutedWords: [{value: `super bad`, targets: ['content']}], 430 - text: rt.text, 431 - facets: rt.facets, 432 - outlineTags: [], 433 - isOwnPost: false, 434 - }) 435 - 436 - expect(match).toBe(true) 437 - }) 438 - 439 - it(`match: superbad`, () => { 440 - const match = hasMutedWord({ 441 - mutedWords: [{value: `superbad`, targets: ['content']}], 442 - text: rt.text, 443 - facets: rt.facets, 444 - outlineTags: [], 445 - isOwnPost: false, 446 - }) 447 - 448 - expect(match).toBe(false) 449 - }) 450 - }) 451 - 452 - describe(`idk_what_this_would_be`, () => { 453 - const rt = new RichText({ 454 - text: `Weird post with idk_what_this_would_be`, 455 - }) 456 - rt.detectFacetsWithoutResolution() 457 - 458 - it(`match: idk what this would be`, () => { 459 - const match = hasMutedWord({ 460 - mutedWords: [{value: `idk what this would be`, targets: ['content']}], 461 - text: rt.text, 462 - facets: rt.facets, 463 - outlineTags: [], 464 - isOwnPost: false, 465 - }) 466 - 467 - expect(match).toBe(true) 468 - }) 469 - 470 - it(`no match: idk what this would be for`, () => { 471 - // extra word 472 - const match = hasMutedWord({ 473 - mutedWords: [ 474 - {value: `idk what this would be for`, targets: ['content']}, 475 - ], 476 - text: rt.text, 477 - facets: rt.facets, 478 - outlineTags: [], 479 - isOwnPost: false, 480 - }) 481 - 482 - expect(match).toBe(false) 483 - }) 484 - 485 - it(`match: idk`, () => { 486 - // extra word 487 - const match = hasMutedWord({ 488 - mutedWords: [{value: `idk`, targets: ['content']}], 489 - text: rt.text, 490 - facets: rt.facets, 491 - outlineTags: [], 492 - isOwnPost: false, 493 - }) 494 - 495 - expect(match).toBe(true) 496 - }) 497 - 498 - it(`match: idkwhatthiswouldbe`, () => { 499 - const match = hasMutedWord({ 500 - mutedWords: [{value: `idkwhatthiswouldbe`, targets: ['content']}], 501 - text: rt.text, 502 - facets: rt.facets, 503 - outlineTags: [], 504 - isOwnPost: false, 505 - }) 506 - 507 - expect(match).toBe(false) 508 - }) 509 - }) 510 - 511 - describe(`parentheses`, () => { 512 - const rt = new RichText({ 513 - text: `Post with context(iykyk)`, 514 - }) 515 - rt.detectFacetsWithoutResolution() 516 - 517 - it(`match: context(iykyk)`, () => { 518 - const match = hasMutedWord({ 519 - mutedWords: [{value: `context(iykyk)`, targets: ['content']}], 520 - text: rt.text, 521 - facets: rt.facets, 522 - outlineTags: [], 523 - isOwnPost: false, 524 - }) 525 - 526 - expect(match).toBe(true) 527 - }) 528 - 529 - it(`match: context`, () => { 530 - const match = hasMutedWord({ 531 - mutedWords: [{value: `context`, targets: ['content']}], 532 - text: rt.text, 533 - facets: rt.facets, 534 - outlineTags: [], 535 - isOwnPost: false, 536 - }) 537 - 538 - expect(match).toBe(true) 539 - }) 540 - 541 - it(`match: iykyk`, () => { 542 - const match = hasMutedWord({ 543 - mutedWords: [{value: `iykyk`, targets: ['content']}], 544 - text: rt.text, 545 - facets: rt.facets, 546 - outlineTags: [], 547 - isOwnPost: false, 548 - }) 549 - 550 - expect(match).toBe(true) 551 - }) 552 - 553 - it(`match: (iykyk)`, () => { 554 - const match = hasMutedWord({ 555 - mutedWords: [{value: `(iykyk)`, targets: ['content']}], 556 - text: rt.text, 557 - facets: rt.facets, 558 - outlineTags: [], 559 - isOwnPost: false, 560 - }) 561 - 562 - expect(match).toBe(true) 563 - }) 564 - }) 565 - 566 - describe(`🦋`, () => { 567 - const rt = new RichText({ 568 - text: `Post with 🦋`, 569 - }) 570 - rt.detectFacetsWithoutResolution() 571 - 572 - it(`match: 🦋`, () => { 573 - const match = hasMutedWord({ 574 - mutedWords: [{value: `🦋`, targets: ['content']}], 575 - text: rt.text, 576 - facets: rt.facets, 577 - outlineTags: [], 578 - isOwnPost: false, 579 - }) 580 - 581 - expect(match).toBe(true) 582 - }) 583 - }) 584 - }) 585 - 586 - describe(`phrases`, () => { 587 - describe(`I like turtles, or how I learned to stop worrying and love the internet.`, () => { 588 - const rt = new RichText({ 589 - text: `I like turtles, or how I learned to stop worrying and love the internet.`, 590 - }) 591 - rt.detectFacetsWithoutResolution() 592 - 593 - it(`match: stop worrying`, () => { 594 - const match = hasMutedWord({ 595 - mutedWords: [{value: 'stop worrying', targets: ['content']}], 596 - text: rt.text, 597 - facets: rt.facets, 598 - outlineTags: [], 599 - isOwnPost: false, 600 - }) 601 - 602 - expect(match).toBe(true) 603 - }) 604 - 605 - it(`match: turtles, or how`, () => { 606 - const match = hasMutedWord({ 607 - mutedWords: [{value: 'turtles, or how', targets: ['content']}], 608 - text: rt.text, 609 - facets: rt.facets, 610 - outlineTags: [], 611 - isOwnPost: false, 612 - }) 613 - 614 - expect(match).toBe(true) 615 - }) 616 - }) 617 - }) 618 - 619 - describe(`languages without spaces`, () => { 620 - // I love turtles, or how I learned to stop worrying and love the internet 621 - describe(`私はカメが好きです、またはどのようにして心配するのをやめてインターネットを愛するようになったのか`, () => { 622 - const rt = new RichText({ 623 - text: `私はカメが好きです、またはどのようにして心配するのをやめてインターネットを愛するようになったのか`, 624 - }) 625 - rt.detectFacetsWithoutResolution() 626 - 627 - // internet 628 - it(`match: インターネット`, () => { 629 - const match = hasMutedWord({ 630 - mutedWords: [{value: 'インターネット', targets: ['content']}], 631 - text: rt.text, 632 - facets: rt.facets, 633 - outlineTags: [], 634 - languages: ['ja'], 635 - isOwnPost: false, 636 - }) 637 - 638 - expect(match).toBe(true) 639 - }) 640 - }) 641 - }) 642 - 643 - describe(`doesn't mute own post`, () => { 644 - it(`does mute if it isn't own post`, () => { 645 - const rt = new RichText({ 646 - text: `Mute words!`, 647 - }) 648 - 649 - const match = hasMutedWord({ 650 - mutedWords: [{value: 'words', targets: ['content']}], 651 - text: rt.text, 652 - facets: rt.facets, 653 - outlineTags: [], 654 - isOwnPost: false, 655 - }) 656 - 657 - expect(match).toBe(true) 658 - }) 659 - 660 - it(`doesn't mute own post when muted word is in text`, () => { 661 - const rt = new RichText({ 662 - text: `Mute words!`, 663 - }) 664 - 665 - const match = hasMutedWord({ 666 - mutedWords: [{value: 'words', targets: ['content']}], 667 - text: rt.text, 668 - facets: rt.facets, 669 - outlineTags: [], 670 - isOwnPost: true, 671 - }) 672 - 673 - expect(match).toBe(false) 674 - }) 675 - 676 - it(`doesn't mute own post when muted word is in tags`, () => { 677 - const rt = new RichText({ 678 - text: `Mute #words!`, 679 - }) 680 - 681 - const match = hasMutedWord({ 682 - mutedWords: [{value: 'words', targets: ['tags']}], 683 - text: rt.text, 684 - facets: rt.facets, 685 - outlineTags: [], 686 - isOwnPost: true, 687 - }) 688 - 689 - expect(match).toBe(false) 690 - }) 691 - }) 692 - })
+4
src/lib/constants.ts
··· 35 35 // but increasing limit per user feedback 36 36 export const MAX_ALT_TEXT = 1000 37 37 38 + export function IS_TEST_USER(handle?: string) { 39 + return handle && handle?.endsWith('.test') 40 + } 41 + 38 42 export function IS_PROD_SERVICE(url?: string) { 39 43 return url && url !== STAGING_SERVICE && url !== LOCAL_DEV_SERVICE 40 44 }
+17 -367
src/lib/moderatePost_wrapped.ts
··· 1 - import { 2 - AppBskyEmbedRecord, 3 - AppBskyEmbedRecordWithMedia, 4 - moderatePost, 5 - AppBskyActorDefs, 6 - AppBskyFeedPost, 7 - AppBskyRichtextFacet, 8 - AppBskyEmbedImages, 9 - AppBskyEmbedExternal, 10 - } from '@atproto/api' 1 + import {moderatePost, BSKY_LABELER_DID} from '@atproto/api' 11 2 12 3 type ModeratePost = typeof moderatePost 13 - type Options = Parameters<ModeratePost>[1] & { 14 - hiddenPosts?: string[] 15 - mutedWords?: AppBskyActorDefs.MutedWord[] 16 - } 17 - 18 - const REGEX = { 19 - LEADING_TRAILING_PUNCTUATION: /(?:^\p{P}+|\p{P}+$)/gu, 20 - ESCAPE: /[[\]{}()*+?.\\^$|\s]/g, 21 - SEPARATORS: /[\/\-\–\—\(\)\[\]\_]+/g, 22 - WORD_BOUNDARY: /[\s\n\t\r\f\v]+?/g, 23 - } 24 - 25 - /** 26 - * List of 2-letter lang codes for languages that either don't use spaces, or 27 - * don't use spaces in a way conducive to word-based filtering. 28 - * 29 - * For these, we use a simple `String.includes` to check for a match. 30 - */ 31 - const LANGUAGE_EXCEPTIONS = [ 32 - 'ja', // Japanese 33 - 'zh', // Chinese 34 - 'ko', // Korean 35 - 'th', // Thai 36 - 'vi', // Vietnamese 37 - ] 38 - 39 - export function hasMutedWord({ 40 - mutedWords, 41 - text, 42 - facets, 43 - outlineTags, 44 - languages, 45 - isOwnPost, 46 - }: { 47 - mutedWords: AppBskyActorDefs.MutedWord[] 48 - text: string 49 - facets?: AppBskyRichtextFacet.Main[] 50 - outlineTags?: string[] 51 - languages?: string[] 52 - isOwnPost: boolean 53 - }) { 54 - if (isOwnPost) return false 55 - 56 - const exception = LANGUAGE_EXCEPTIONS.includes(languages?.[0] || '') 57 - const tags = ([] as string[]) 58 - .concat(outlineTags || []) 59 - .concat( 60 - facets 61 - ?.filter(facet => { 62 - return facet.features.find(feature => 63 - AppBskyRichtextFacet.isTag(feature), 64 - ) 65 - }) 66 - .map(t => t.features[0].tag as string) || [], 67 - ) 68 - .map(t => t.toLowerCase()) 69 - 70 - for (const mute of mutedWords) { 71 - const mutedWord = mute.value.toLowerCase() 72 - const postText = text.toLowerCase() 73 - 74 - // `content` applies to tags as well 75 - if (tags.includes(mutedWord)) return true 76 - // rest of the checks are for `content` only 77 - if (!mute.targets.includes('content')) continue 78 - // single character or other exception, has to use includes 79 - if ((mutedWord.length === 1 || exception) && postText.includes(mutedWord)) 80 - return true 81 - // too long 82 - if (mutedWord.length > postText.length) continue 83 - // exact match 84 - if (mutedWord === postText) return true 85 - // any muted phrase with space or punctuation 86 - if (/(?:\s|\p{P})+?/u.test(mutedWord) && postText.includes(mutedWord)) 87 - return true 88 - 89 - // check individual character groups 90 - const words = postText.split(REGEX.WORD_BOUNDARY) 91 - for (const word of words) { 92 - if (word === mutedWord) return true 93 - 94 - // compare word without leading/trailing punctuation, but allow internal 95 - // punctuation (such as `s@ssy`) 96 - const wordTrimmedPunctuation = word.replace( 97 - REGEX.LEADING_TRAILING_PUNCTUATION, 98 - '', 99 - ) 100 - 101 - if (mutedWord === wordTrimmedPunctuation) return true 102 - if (mutedWord.length > wordTrimmedPunctuation.length) continue 103 - 104 - // handle hyphenated, slash separated words, etc 105 - if (REGEX.SEPARATORS.test(wordTrimmedPunctuation)) { 106 - // check against full normalized phrase 107 - const wordNormalizedSeparators = wordTrimmedPunctuation.replace( 108 - REGEX.SEPARATORS, 109 - ' ', 110 - ) 111 - const mutedWordNormalizedSeparators = mutedWord.replace( 112 - REGEX.SEPARATORS, 113 - ' ', 114 - ) 115 - // hyphenated (or other sep) to spaced words 116 - if (wordNormalizedSeparators === mutedWordNormalizedSeparators) 117 - return true 118 - 119 - /* Disabled for now e.g. `super-cool` to `supercool` 120 - const wordNormalizedCompressed = wordNormalizedSeparators.replace( 121 - REGEX.WORD_BOUNDARY, 122 - '', 123 - ) 124 - const mutedWordNormalizedCompressed = 125 - mutedWordNormalizedSeparators.replace(/\s+?/g, '') 126 - // hyphenated (or other sep) to non-hyphenated contiguous word 127 - if (mutedWordNormalizedCompressed === wordNormalizedCompressed) 128 - return true 129 - */ 130 - 131 - // then individual parts of separated phrases/words 132 - const wordParts = wordTrimmedPunctuation.split(REGEX.SEPARATORS) 133 - for (const wp of wordParts) { 134 - // still retain internal punctuation 135 - if (wp === mutedWord) return true 136 - } 137 - } 138 - } 139 - } 140 - 141 - return false 142 - } 4 + type Options = Parameters<ModeratePost>[1] 143 5 144 6 export function moderatePost_wrapped( 145 7 subject: Parameters<ModeratePost>[0], 146 8 opts: Options, 147 9 ) { 148 - const {hiddenPosts = [], mutedWords = [], ...options} = opts 149 - const moderations = moderatePost(subject, options) 150 - const isOwnPost = subject.author.did === opts.userDid 151 - 152 - if (hiddenPosts.includes(subject.uri)) { 153 - moderations.content.filter = true 154 - moderations.content.blur = true 155 - if (!moderations.content.cause) { 156 - moderations.content.cause = { 157 - // @ts-ignore Temporary extension to the moderation system -prf 158 - type: 'post-hidden', 159 - source: {type: 'user'}, 160 - priority: 1, 161 - } 162 - } 163 - } 164 - 165 - if (AppBskyFeedPost.isRecord(subject.record)) { 166 - let muted = hasMutedWord({ 167 - mutedWords, 168 - text: subject.record.text, 169 - facets: subject.record.facets || [], 170 - outlineTags: subject.record.tags || [], 171 - languages: subject.record.langs, 172 - isOwnPost, 173 - }) 174 - 175 - if ( 176 - subject.record.embed && 177 - AppBskyEmbedImages.isMain(subject.record.embed) 178 - ) { 179 - for (const image of subject.record.embed.images) { 180 - muted = 181 - muted || 182 - hasMutedWord({ 183 - mutedWords, 184 - text: image.alt, 185 - facets: [], 186 - outlineTags: [], 187 - languages: subject.record.langs, 188 - isOwnPost, 189 - }) 190 - } 191 - } 192 - 193 - if (muted) { 194 - moderations.content.filter = true 195 - moderations.content.blur = true 196 - if (!moderations.content.cause) { 197 - moderations.content.cause = { 198 - // @ts-ignore Temporary extension to the moderation system -prf 199 - type: 'muted-word', 200 - source: {type: 'user'}, 201 - priority: 1, 202 - } 203 - } 204 - } 205 - } 206 - 207 - if (subject.embed) { 208 - let embedHidden = false 209 - let embedMuted = false 210 - let externalMuted = false 211 - 212 - if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) { 213 - embedHidden = hiddenPosts.includes(subject.embed.record.uri) 214 - } 215 - if ( 216 - AppBskyEmbedRecordWithMedia.isView(subject.embed) && 217 - AppBskyEmbedRecord.isViewRecord(subject.embed.record.record) 218 - ) { 219 - embedHidden = hiddenPosts.includes(subject.embed.record.record.uri) 220 - } 221 - 222 - if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) { 223 - if (AppBskyFeedPost.isRecord(subject.embed.record.value)) { 224 - const embeddedPost = subject.embed.record.value 225 - 226 - embedMuted = 227 - embedMuted || 228 - hasMutedWord({ 229 - mutedWords, 230 - text: embeddedPost.text, 231 - facets: embeddedPost.facets, 232 - outlineTags: embeddedPost.tags, 233 - languages: embeddedPost.langs, 234 - isOwnPost, 235 - }) 236 - 237 - if (AppBskyEmbedImages.isMain(embeddedPost.embed)) { 238 - for (const image of embeddedPost.embed.images) { 239 - embedMuted = 240 - embedMuted || 241 - hasMutedWord({ 242 - mutedWords, 243 - text: image.alt, 244 - facets: [], 245 - outlineTags: [], 246 - languages: embeddedPost.langs, 247 - isOwnPost, 248 - }) 249 - } 250 - } 251 - 252 - if (AppBskyEmbedExternal.isMain(embeddedPost.embed)) { 253 - const {external} = embeddedPost.embed 254 - 255 - embedMuted = 256 - embedMuted || 257 - hasMutedWord({ 258 - mutedWords, 259 - text: external.title + ' ' + external.description, 260 - facets: [], 261 - outlineTags: [], 262 - languages: [], 263 - isOwnPost, 264 - }) 265 - } 266 - 267 - if (AppBskyEmbedRecordWithMedia.isMain(embeddedPost.embed)) { 268 - if (AppBskyEmbedExternal.isMain(embeddedPost.embed.media)) { 269 - const {external} = embeddedPost.embed.media 270 - 271 - embedMuted = 272 - embedMuted || 273 - hasMutedWord({ 274 - mutedWords, 275 - text: external.title + ' ' + external.description, 276 - facets: [], 277 - outlineTags: [], 278 - languages: [], 279 - isOwnPost, 280 - }) 281 - } 282 - 283 - if (AppBskyEmbedImages.isMain(embeddedPost.embed.media)) { 284 - for (const image of embeddedPost.embed.media.images) { 285 - embedMuted = 286 - embedMuted || 287 - hasMutedWord({ 288 - mutedWords, 289 - text: image.alt, 290 - facets: [], 291 - outlineTags: [], 292 - languages: AppBskyFeedPost.isRecord(embeddedPost.record) 293 - ? embeddedPost.langs 294 - : [], 295 - isOwnPost, 296 - }) 297 - } 298 - } 299 - } 300 - } 301 - } 302 - 303 - if (AppBskyEmbedExternal.isView(subject.embed)) { 304 - const {external} = subject.embed 305 - 306 - externalMuted = 307 - externalMuted || 308 - hasMutedWord({ 309 - mutedWords, 310 - text: external.title + ' ' + external.description, 311 - facets: [], 312 - outlineTags: [], 313 - languages: [], 314 - isOwnPost, 315 - }) 316 - } 317 - 318 - if ( 319 - AppBskyEmbedRecordWithMedia.isView(subject.embed) && 320 - AppBskyEmbedRecord.isViewRecord(subject.embed.record.record) 321 - ) { 322 - if (AppBskyFeedPost.isRecord(subject.embed.record.record.value)) { 323 - const post = subject.embed.record.record.value 324 - embedMuted = 325 - embedMuted || 326 - hasMutedWord({ 327 - mutedWords, 328 - text: post.text, 329 - facets: post.facets, 330 - outlineTags: post.tags, 331 - languages: post.langs, 332 - isOwnPost, 333 - }) 334 - } 10 + // HACK 11 + // temporarily translate 'gore' into 'graphic-media' during the transition period 12 + // can remove this in a few months 13 + // -prf 14 + translateOldLabels(subject) 335 15 336 - if (AppBskyEmbedImages.isView(subject.embed.media)) { 337 - for (const image of subject.embed.media.images) { 338 - embedMuted = 339 - embedMuted || 340 - hasMutedWord({ 341 - mutedWords, 342 - text: image.alt, 343 - facets: [], 344 - outlineTags: [], 345 - languages: AppBskyFeedPost.isRecord(subject.record) 346 - ? subject.record.langs 347 - : [], 348 - isOwnPost, 349 - }) 350 - } 351 - } 352 - } 16 + return moderatePost(subject, opts) 17 + } 353 18 354 - if (embedHidden) { 355 - moderations.embed.filter = true 356 - moderations.embed.blur = true 357 - if (!moderations.embed.cause) { 358 - moderations.embed.cause = { 359 - // @ts-ignore Temporary extension to the moderation system -prf 360 - type: 'post-hidden', 361 - source: {type: 'user'}, 362 - priority: 1, 363 - } 364 - } 365 - } else if (externalMuted || embedMuted) { 366 - moderations.content.filter = true 367 - moderations.content.blur = true 368 - if (!moderations.content.cause) { 369 - moderations.content.cause = { 370 - // @ts-ignore Temporary extension to the moderation system -prf 371 - type: 'muted-word', 372 - source: {type: 'user'}, 373 - priority: 1, 374 - } 19 + function translateOldLabels(subject: Parameters<ModeratePost>[0]) { 20 + if (subject.labels) { 21 + for (const label of subject.labels) { 22 + if ( 23 + label.val === 'gore' && 24 + (!label.src || label.src === BSKY_LABELER_DID) 25 + ) { 26 + label.val = 'graphic-media' 375 27 } 376 28 } 377 29 } 378 - 379 - return moderations 380 30 }
+64 -132
src/lib/moderation.ts
··· 1 - import {ModerationCause, ProfileModeration, PostModeration} from '@atproto/api' 1 + import { 2 + ModerationCause, 3 + ModerationUI, 4 + InterpretedLabelValueDefinition, 5 + LABELS, 6 + AppBskyLabelerDefs, 7 + BskyAgent, 8 + ModerationOpts, 9 + } from '@atproto/api' 2 10 3 - export interface ModerationCauseDescription { 4 - name: string 5 - description: string 6 - } 11 + import {sanitizeDisplayName} from '#/lib/strings/display-names' 12 + import {sanitizeHandle} from '#/lib/strings/handles' 7 13 8 - export function describeModerationCause( 9 - cause: ModerationCause | undefined, 10 - context: 'account' | 'content', 11 - ): ModerationCauseDescription { 12 - if (!cause) { 13 - return { 14 - name: 'Content Warning', 15 - description: 16 - 'Moderator has chosen to set a general warning on the content.', 17 - } 18 - } 19 - if (cause.type === 'blocking') { 20 - if (cause.source.type === 'list') { 21 - return { 22 - name: `User Blocked by "${cause.source.list.name}"`, 23 - description: 24 - 'You have blocked this user. You cannot view their content.', 25 - } 26 - } else { 27 - return { 28 - name: 'User Blocked', 29 - description: 30 - 'You have blocked this user. You cannot view their content.', 31 - } 32 - } 33 - } 34 - if (cause.type === 'blocked-by') { 35 - return { 36 - name: 'User Blocking You', 37 - description: 'This user has blocked you. You cannot view their content.', 38 - } 39 - } 40 - if (cause.type === 'block-other') { 41 - return { 42 - name: 'Content Not Available', 43 - description: 44 - 'This content is not available because one of the users involved has blocked the other.', 45 - } 14 + export function getModerationCauseKey(cause: ModerationCause): string { 15 + const source = 16 + cause.source.type === 'labeler' 17 + ? cause.source.did 18 + : cause.source.type === 'list' 19 + ? cause.source.list.uri 20 + : 'user' 21 + if (cause.type === 'label') { 22 + return `label:${cause.label.val}:${source}` 46 23 } 47 - if (cause.type === 'muted') { 48 - if (cause.source.type === 'list') { 49 - return { 50 - name: 51 - context === 'account' 52 - ? `Muted by "${cause.source.list.name}"` 53 - : `Post by muted user ("${cause.source.list.name}")`, 54 - description: 'You have muted this user', 55 - } 56 - } else { 57 - return { 58 - name: context === 'account' ? 'Muted User' : 'Post by muted user', 59 - description: 'You have muted this user', 60 - } 61 - } 62 - } 63 - // @ts-ignore Temporary extension to the moderation system -prf 64 - if (cause.type === 'post-hidden') { 65 - return { 66 - name: 'Post Hidden by You', 67 - description: 'You have hidden this post', 68 - } 69 - } 70 - // @ts-ignore Temporary extension to the moderation system -prf 71 - if (cause.type === 'muted-word') { 72 - return { 73 - name: 'Post hidden by muted word', 74 - description: `You've chosen to hide a word or tag within this post.`, 75 - } 76 - } 77 - return cause.labelDef.strings[context].en 24 + return `${cause.type}:${source}` 78 25 } 79 26 80 - export function getProfileModerationCauses( 81 - moderation: ProfileModeration, 82 - ): ModerationCause[] { 83 - /* 84 - Gather everything on profile and account that blurs or alerts 85 - */ 86 - return [ 87 - moderation.decisions.profile.cause, 88 - ...moderation.decisions.profile.additionalCauses, 89 - moderation.decisions.account.cause, 90 - ...moderation.decisions.account.additionalCauses, 91 - ].filter(cause => { 92 - if (!cause) { 93 - return false 94 - } 95 - if (cause?.type === 'label') { 96 - if ( 97 - cause.labelDef.onwarn === 'blur' || 98 - cause.labelDef.onwarn === 'alert' 99 - ) { 100 - return true 101 - } else { 102 - return false 103 - } 104 - } 105 - return true 106 - }) as ModerationCause[] 27 + export function isJustAMute(modui: ModerationUI): boolean { 28 + return modui.filters.length === 1 && modui.filters[0].type === 'muted' 107 29 } 108 30 109 - export function isPostMediaBlurred( 110 - decisions: PostModeration['decisions'], 111 - ): boolean { 112 - return decisions.post.blurMedia 31 + export function getLabelingServiceTitle({ 32 + displayName, 33 + handle, 34 + }: { 35 + displayName?: string 36 + handle: string 37 + }) { 38 + return displayName 39 + ? sanitizeDisplayName(displayName) 40 + : sanitizeHandle(handle, '@') 113 41 } 114 42 115 - export function isQuoteBlurred( 116 - decisions: PostModeration['decisions'], 117 - ): boolean { 118 - return ( 119 - decisions.quote?.blur || 120 - decisions.quote?.blurMedia || 121 - decisions.quote?.filter || 122 - decisions.quotedAccount?.blur || 123 - decisions.quotedAccount?.filter || 124 - false 125 - ) 43 + export function lookupLabelValueDefinition( 44 + labelValue: string, 45 + customDefs: InterpretedLabelValueDefinition[] | undefined, 46 + ): InterpretedLabelValueDefinition | undefined { 47 + let def 48 + if (!labelValue.startsWith('!') && customDefs) { 49 + def = customDefs.find(d => d.identifier === labelValue) 50 + } 51 + if (!def) { 52 + def = LABELS[labelValue as keyof typeof LABELS] 53 + } 54 + return def 126 55 } 127 56 128 - export function isCauseALabelOnUri( 129 - cause: ModerationCause | undefined, 130 - uri: string, 57 + export function isAppLabeler( 58 + labeler: 59 + | string 60 + | AppBskyLabelerDefs.LabelerView 61 + | AppBskyLabelerDefs.LabelerViewDetailed, 131 62 ): boolean { 132 - if (cause?.type !== 'label') { 133 - return false 63 + if (typeof labeler === 'string') { 64 + return BskyAgent.appLabelers.includes(labeler) 134 65 } 135 - return cause.label.uri === uri 66 + return BskyAgent.appLabelers.includes(labeler.creator.did) 136 67 } 137 68 138 - export function getModerationCauseKey(cause: ModerationCause): string { 139 - const source = 140 - cause.source.type === 'labeler' 141 - ? cause.source.labeler.did 142 - : cause.source.type === 'list' 143 - ? cause.source.list.uri 144 - : 'user' 145 - if (cause.type === 'label') { 146 - return `label:${cause.label.val}:${source}` 69 + export function isLabelerSubscribed( 70 + labeler: 71 + | string 72 + | AppBskyLabelerDefs.LabelerView 73 + | AppBskyLabelerDefs.LabelerViewDetailed, 74 + modOpts: ModerationOpts, 75 + ) { 76 + labeler = typeof labeler === 'string' ? labeler : labeler.creator.did 77 + if (isAppLabeler(labeler)) { 78 + return true 147 79 } 148 - return `${cause.type}:${source}` 80 + return modOpts.prefs.labelers.find(l => l.did === labeler) 149 81 }
+52
src/lib/moderation/useGlobalLabelStrings.ts
··· 1 + import {msg} from '@lingui/macro' 2 + import {useLingui} from '@lingui/react' 3 + import {useMemo} from 'react' 4 + 5 + export type GlobalLabelStrings = Record< 6 + string, 7 + { 8 + name: string 9 + description: string 10 + } 11 + > 12 + 13 + export function useGlobalLabelStrings(): GlobalLabelStrings { 14 + const {_} = useLingui() 15 + return useMemo( 16 + () => ({ 17 + '!hide': { 18 + name: _(msg`Content Blocked`), 19 + description: _(msg`This content has been hidden by the moderators.`), 20 + }, 21 + '!warn': { 22 + name: _(msg`Content Warning`), 23 + description: _( 24 + msg`This content has received a general warning from moderators.`, 25 + ), 26 + }, 27 + '!no-unauthenticated': { 28 + name: _(msg`Sign-in Required`), 29 + description: _( 30 + msg`This user has requested that their content only be shown to signed-in users.`, 31 + ), 32 + }, 33 + porn: { 34 + name: _(msg`Pornography`), 35 + description: _(msg`Explicit sexual images.`), 36 + }, 37 + sexual: { 38 + name: _(msg`Sexually Suggestive`), 39 + description: _(msg`Does not include nudity.`), 40 + }, 41 + nudity: { 42 + name: _(msg`Non-sexual Nudity`), 43 + description: _(msg`E.g. artistic nudes.`), 44 + }, 45 + 'graphic-media': { 46 + name: _(msg`Graphic Media`), 47 + description: _(msg`Explicit or potentially disturbing media.`), 48 + }, 49 + }), 50 + [_], 51 + ) 52 + }
+70
src/lib/moderation/useLabelBehaviorDescription.ts
··· 1 + import {InterpretedLabelValueDefinition, LabelPreference} from '@atproto/api' 2 + import {useLingui} from '@lingui/react' 3 + import {msg} from '@lingui/macro' 4 + 5 + export function useLabelBehaviorDescription( 6 + labelValueDef: InterpretedLabelValueDefinition, 7 + pref: LabelPreference, 8 + ) { 9 + const {_} = useLingui() 10 + if (pref === 'ignore') { 11 + return _(msg`Off`) 12 + } 13 + if (labelValueDef.blurs === 'content' || labelValueDef.blurs === 'media') { 14 + if (pref === 'hide') { 15 + return _(msg`Hide`) 16 + } 17 + return _(msg`Warn`) 18 + } else if (labelValueDef.severity === 'alert') { 19 + if (pref === 'hide') { 20 + return _(msg`Hide`) 21 + } 22 + return _(msg`Warn`) 23 + } else if (labelValueDef.severity === 'inform') { 24 + if (pref === 'hide') { 25 + return _(msg`Hide`) 26 + } 27 + return _(msg`Show badge`) 28 + } else { 29 + if (pref === 'hide') { 30 + return _(msg`Hide`) 31 + } 32 + return _(msg`Disabled`) 33 + } 34 + } 35 + 36 + export function useLabelLongBehaviorDescription( 37 + labelValueDef: InterpretedLabelValueDefinition, 38 + pref: LabelPreference, 39 + ) { 40 + const {_} = useLingui() 41 + if (pref === 'ignore') { 42 + return _(msg`Disabled`) 43 + } 44 + if (labelValueDef.blurs === 'content') { 45 + if (pref === 'hide') { 46 + return _(msg`Warn content and filter from feeds`) 47 + } 48 + return _(msg`Warn content`) 49 + } else if (labelValueDef.blurs === 'media') { 50 + if (pref === 'hide') { 51 + return _(msg`Blur images and filter from feeds`) 52 + } 53 + return _(msg`Blur images`) 54 + } else if (labelValueDef.severity === 'alert') { 55 + if (pref === 'hide') { 56 + return _(msg`Show warning and filter from feeds`) 57 + } 58 + return _(msg`Show warning`) 59 + } else if (labelValueDef.severity === 'inform') { 60 + if (pref === 'hide') { 61 + return _(msg`Show badge and filter from feeds`) 62 + } 63 + return _(msg`Show badge`) 64 + } else { 65 + if (pref === 'hide') { 66 + return _(msg`Filter from feeds`) 67 + } 68 + return _(msg`Disabled`) 69 + } 70 + }
+100
src/lib/moderation/useLabelInfo.ts
··· 1 + import { 2 + ComAtprotoLabelDefs, 3 + AppBskyLabelerDefs, 4 + LABELS, 5 + interpretLabelValueDefinition, 6 + InterpretedLabelValueDefinition, 7 + } from '@atproto/api' 8 + import {useLingui} from '@lingui/react' 9 + import * as bcp47Match from 'bcp-47-match' 10 + 11 + import { 12 + GlobalLabelStrings, 13 + useGlobalLabelStrings, 14 + } from '#/lib/moderation/useGlobalLabelStrings' 15 + import {useLabelDefinitions} from '#/state/preferences' 16 + 17 + export interface LabelInfo { 18 + label: ComAtprotoLabelDefs.Label 19 + def: InterpretedLabelValueDefinition 20 + strings: ComAtprotoLabelDefs.LabelValueDefinitionStrings 21 + labeler: AppBskyLabelerDefs.LabelerViewDetailed | undefined 22 + } 23 + 24 + export function useLabelInfo(label: ComAtprotoLabelDefs.Label): LabelInfo { 25 + const {i18n} = useLingui() 26 + const {labelDefs, labelers} = useLabelDefinitions() 27 + const globalLabelStrings = useGlobalLabelStrings() 28 + const def = getDefinition(labelDefs, label) 29 + return { 30 + label, 31 + def, 32 + strings: getLabelStrings(i18n.locale, globalLabelStrings, def), 33 + labeler: labelers.find(labeler => label.src === labeler.creator.did), 34 + } 35 + } 36 + 37 + export function getDefinition( 38 + labelDefs: Record<string, InterpretedLabelValueDefinition[]>, 39 + label: ComAtprotoLabelDefs.Label, 40 + ): InterpretedLabelValueDefinition { 41 + // check local definitions 42 + const customDef = 43 + !label.val.startsWith('!') && 44 + labelDefs[label.src]?.find( 45 + def => def.identifier === label.val && def.definedBy === label.src, 46 + ) 47 + if (customDef) { 48 + return customDef 49 + } 50 + 51 + // check global definitions 52 + const globalDef = LABELS[label.val as keyof typeof LABELS] 53 + if (globalDef) { 54 + return globalDef 55 + } 56 + 57 + // fallback to a noop definition 58 + return interpretLabelValueDefinition( 59 + { 60 + identifier: label.val, 61 + severity: 'none', 62 + blurs: 'none', 63 + defaultSetting: 'ignore', 64 + locales: [], 65 + }, 66 + label.src, 67 + ) 68 + } 69 + 70 + export function getLabelStrings( 71 + locale: string, 72 + globalLabelStrings: GlobalLabelStrings, 73 + def: InterpretedLabelValueDefinition, 74 + ): ComAtprotoLabelDefs.LabelValueDefinitionStrings { 75 + if (!def.definedBy) { 76 + // global definition, look up strings 77 + if (def.identifier in globalLabelStrings) { 78 + return globalLabelStrings[ 79 + def.identifier 80 + ] as ComAtprotoLabelDefs.LabelValueDefinitionStrings 81 + } 82 + } else { 83 + // try to find locale match in the definition's strings 84 + const localeMatch = def.locales.find( 85 + strings => bcp47Match.basicFilter(locale, strings.lang).length > 0, 86 + ) 87 + if (localeMatch) { 88 + return localeMatch 89 + } 90 + // fall back to the zero item if no match 91 + if (def.locales[0]) { 92 + return def.locales[0] 93 + } 94 + } 95 + return { 96 + lang: locale, 97 + name: def.identifier, 98 + description: `Labeled "${def.identifier}"`, 99 + } 100 + }
+146
src/lib/moderation/useModerationCauseDescription.ts
··· 1 + import React from 'react' 2 + import { 3 + BSKY_LABELER_DID, 4 + ModerationCause, 5 + ModerationCauseSource, 6 + } from '@atproto/api' 7 + import {msg} from '@lingui/macro' 8 + import {useLingui} from '@lingui/react' 9 + import {getDefinition, getLabelStrings} from './useLabelInfo' 10 + import {useLabelDefinitions} from '#/state/preferences' 11 + import {useGlobalLabelStrings} from './useGlobalLabelStrings' 12 + 13 + import {Props as SVGIconProps} from '#/components/icons/common' 14 + import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' 15 + import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 16 + import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash' 17 + import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign' 18 + 19 + export interface ModerationCauseDescription { 20 + icon: React.ComponentType<SVGIconProps> 21 + name: string 22 + description: string 23 + source?: string 24 + sourceType?: ModerationCauseSource['type'] 25 + } 26 + 27 + export function useModerationCauseDescription( 28 + cause: ModerationCause | undefined, 29 + ): ModerationCauseDescription { 30 + const {_, i18n} = useLingui() 31 + const {labelDefs, labelers} = useLabelDefinitions() 32 + const globalLabelStrings = useGlobalLabelStrings() 33 + 34 + return React.useMemo(() => { 35 + if (!cause) { 36 + return { 37 + icon: Warning, 38 + name: _(msg`Content Warning`), 39 + description: _( 40 + msg`Moderator has chosen to set a general warning on the content.`, 41 + ), 42 + } 43 + } 44 + if (cause.type === 'blocking') { 45 + if (cause.source.type === 'list') { 46 + return { 47 + icon: CircleBanSign, 48 + name: _(msg`User Blocked by "${cause.source.list.name}"`), 49 + description: _( 50 + msg`You have blocked this user. You cannot view their content.`, 51 + ), 52 + } 53 + } else { 54 + return { 55 + icon: CircleBanSign, 56 + name: _(msg`User Blocked`), 57 + description: _( 58 + msg`You have blocked this user. You cannot view their content.`, 59 + ), 60 + } 61 + } 62 + } 63 + if (cause.type === 'blocked-by') { 64 + return { 65 + icon: CircleBanSign, 66 + name: _(msg`User Blocking You`), 67 + description: _( 68 + msg`This user has blocked you. You cannot view their content.`, 69 + ), 70 + } 71 + } 72 + if (cause.type === 'block-other') { 73 + return { 74 + icon: CircleBanSign, 75 + name: _(msg`Content Not Available`), 76 + description: _( 77 + msg`This content is not available because one of the users involved has blocked the other.`, 78 + ), 79 + } 80 + } 81 + if (cause.type === 'muted') { 82 + if (cause.source.type === 'list') { 83 + return { 84 + icon: EyeSlash, 85 + name: _(msg`Muted by "${cause.source.list.name}"`), 86 + description: _(msg`You have muted this user`), 87 + } 88 + } else { 89 + return { 90 + icon: EyeSlash, 91 + name: _(msg`Account Muted`), 92 + description: _(msg`You have muted this account.`), 93 + } 94 + } 95 + } 96 + if (cause.type === 'mute-word') { 97 + return { 98 + icon: EyeSlash, 99 + name: _(msg`Post Hidden by Muted Word`), 100 + description: _( 101 + msg`You've chosen to hide a word or tag within this post.`, 102 + ), 103 + } 104 + } 105 + if (cause.type === 'hidden') { 106 + return { 107 + icon: EyeSlash, 108 + name: _(msg`Post Hidden by You`), 109 + description: _(msg`You have hidden this post`), 110 + } 111 + } 112 + if (cause.type === 'label') { 113 + const def = cause.labelDef || getDefinition(labelDefs, cause.label) 114 + const strings = getLabelStrings(i18n.locale, globalLabelStrings, def) 115 + const labeler = labelers.find(l => l.creator.did === cause.label.src) 116 + let source = 117 + labeler?.creator.displayName || 118 + (labeler?.creator.handle ? '@' + labeler?.creator.handle : undefined) 119 + if (!source) { 120 + if (cause.label.src === BSKY_LABELER_DID) { 121 + source = 'Bluesky Moderation' 122 + } else { 123 + source = cause.label.src 124 + } 125 + } 126 + return { 127 + icon: 128 + def.identifier === '!no-unauthenticated' 129 + ? EyeSlash 130 + : def.severity === 'alert' 131 + ? Warning 132 + : CircleInfo, 133 + name: strings.name, 134 + description: strings.description, 135 + source, 136 + sourceType: cause.source.type, 137 + } 138 + } 139 + // should never happen 140 + return { 141 + icon: CircleInfo, 142 + name: '', 143 + description: ``, 144 + } 145 + }, [labelDefs, labelers, globalLabelStrings, cause, _, i18n.locale]) 146 + }
+94
src/lib/moderation/useReportOptions.ts
··· 1 + import {msg} from '@lingui/macro' 2 + import {useLingui} from '@lingui/react' 3 + import {useMemo} from 'react' 4 + import {ComAtprotoModerationDefs} from '@atproto/api' 5 + 6 + export interface ReportOption { 7 + reason: string 8 + title: string 9 + description: string 10 + } 11 + 12 + interface ReportOptions { 13 + account: ReportOption[] 14 + post: ReportOption[] 15 + list: ReportOption[] 16 + feedgen: ReportOption[] 17 + other: ReportOption[] 18 + } 19 + 20 + export function useReportOptions(): ReportOptions { 21 + const {_} = useLingui() 22 + return useMemo(() => { 23 + const other = { 24 + reason: ComAtprotoModerationDefs.REASONOTHER, 25 + title: _(msg`Other`), 26 + description: _(msg`An issue not included in these options`), 27 + } 28 + const common = [ 29 + { 30 + reason: ComAtprotoModerationDefs.REASONRUDE, 31 + title: _(msg`Anti-Social Behavior`), 32 + description: _(msg`Harassment, trolling, or intolerance`), 33 + }, 34 + { 35 + reason: ComAtprotoModerationDefs.REASONVIOLATION, 36 + title: _(msg`Illegal and Urgent`), 37 + description: _(msg`Glaring violations of law or terms of service`), 38 + }, 39 + other, 40 + ] 41 + return { 42 + account: [ 43 + { 44 + reason: ComAtprotoModerationDefs.REASONMISLEADING, 45 + title: _(msg`Misleading Account`), 46 + description: _( 47 + msg`Impersonation or false claims about identity or affiliation`, 48 + ), 49 + }, 50 + { 51 + reason: ComAtprotoModerationDefs.REASONSPAM, 52 + title: _(msg`Frequently Posts Unwanted Content`), 53 + description: _(msg`Spam; excessive mentions or replies`), 54 + }, 55 + { 56 + reason: ComAtprotoModerationDefs.REASONVIOLATION, 57 + title: _(msg`Name or Description Violates Community Standards`), 58 + description: _(msg`Terms used violate community standards`), 59 + }, 60 + other, 61 + ], 62 + post: [ 63 + { 64 + reason: ComAtprotoModerationDefs.REASONSPAM, 65 + title: _(msg`Spam`), 66 + description: _(msg`Excessive mentions or replies`), 67 + }, 68 + { 69 + reason: ComAtprotoModerationDefs.REASONSEXUAL, 70 + title: _(msg`Unwanted Sexual Content`), 71 + description: _(msg`Nudity or pornography not labeled as such`), 72 + }, 73 + ...common, 74 + ], 75 + list: [ 76 + { 77 + reason: ComAtprotoModerationDefs.REASONVIOLATION, 78 + title: _(msg`Name or Description Violates Community Standards`), 79 + description: _(msg`Terms used violate community standards`), 80 + }, 81 + ...common, 82 + ], 83 + feedgen: [ 84 + { 85 + reason: ComAtprotoModerationDefs.REASONVIOLATION, 86 + title: _(msg`Name or Description Violates Community Standards`), 87 + description: _(msg`Terms used violate community standards`), 88 + }, 89 + ...common, 90 + ], 91 + other: common, 92 + } 93 + }, [_]) 94 + }
+20
src/lib/react-query.ts
··· 1 1 import {AppState, AppStateStatus} from 'react-native' 2 2 import {QueryClient, focusManager} from '@tanstack/react-query' 3 + import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister' 4 + import AsyncStorage from '@react-native-async-storage/async-storage' 5 + import {PersistQueryClientProviderProps} from '@tanstack/react-query-persist-client' 6 + 3 7 import {isNative} from '#/platform/detection' 8 + 9 + // any query keys in this array will be persisted to AsyncStorage 10 + const STORED_CACHE_QUERY_KEYS = ['labelers-detailed-info'] 4 11 5 12 focusManager.setEventListener(onFocus => { 6 13 if (isNative) { ··· 48 55 }, 49 56 }, 50 57 }) 58 + 59 + export const asyncStoragePersister = createAsyncStoragePersister({ 60 + storage: AsyncStorage, 61 + key: 'queryCache', 62 + }) 63 + 64 + export const dehydrateOptions: PersistQueryClientProviderProps['persistOptions']['dehydrateOptions'] = 65 + { 66 + shouldDehydrateMutation: (_: any) => false, 67 + shouldDehydrateQuery: query => { 68 + return STORED_CACHE_QUERY_KEYS.includes(String(query.queryKey[0])) 69 + }, 70 + }
+2
src/lib/routes/types.ts
··· 21 21 PostRepostedBy: {name: string; rkey: string} 22 22 ProfileFeed: {name: string; rkey: string} 23 23 ProfileFeedLikedBy: {name: string; rkey: string} 24 + ProfileLabelerLikedBy: {name: string} 24 25 Debug: undefined 26 + DebugMod: undefined 25 27 Log: undefined 26 28 Support: undefined 27 29 PrivacyPolicy: undefined
+1 -2
src/lib/strings/display-names.ts
··· 1 1 import {ModerationUI} from '@atproto/api' 2 - import {describeModerationCause} from '../moderation' 3 2 4 3 // \u2705 = ✅ 5 4 // \u2713 = ✓ ··· 14 13 moderation?: ModerationUI, 15 14 ): string { 16 15 if (moderation?.blur) { 17 - return `⚠${describeModerationCause(moderation.cause, 'account').name}` 16 + return '' 18 17 } 19 18 if (typeof str === 'string') { 20 19 return str.replace(CHECK_MARKS_RE, '').replace(CONTROL_CHARS_RE, '').trim()
+11 -2
src/lib/strings/embed-player.ts
··· 2 2 import {isWeb} from 'platform/detection' 3 3 const {height: SCREEN_HEIGHT} = Dimensions.get('window') 4 4 5 + const IFRAME_HOST = isWeb 6 + ? // @ts-ignore only for web 7 + window.location.host === 'localhost:8100' 8 + ? 'http://localhost:8100' 9 + : 'https://bsky.app' 10 + : __DEV__ && !process.env.JEST_WORKER_ID 11 + ? 'http://localhost:8100' 12 + : 'https://bsky.app' 13 + 5 14 export const embedPlayerSources = [ 6 15 'youtube', 7 16 'youtubeShorts', ··· 74 83 return { 75 84 type: 'youtube_video', 76 85 source: 'youtube', 77 - playerUri: `https://bsky.app/iframe/youtube.html?videoId=${videoId}&start=${seek}`, 86 + playerUri: `${IFRAME_HOST}/iframe/youtube.html?videoId=${videoId}&start=${seek}`, 78 87 } 79 88 } 80 89 } ··· 93 102 type: page === 'shorts' ? 'youtube_short' : 'youtube_video', 94 103 source: page === 'shorts' ? 'youtubeShorts' : 'youtube', 95 104 hideDetails: page === 'shorts' ? true : undefined, 96 - playerUri: `https://bsky.app/iframe/youtube.html?videoId=${videoId}&start=${seek}`, 105 + playerUri: `${IFRAME_HOST}/iframe/youtube.html?videoId=${videoId}&start=${seek}`, 97 106 } 98 107 } 99 108 }
+1 -1
src/lib/themes.ts
··· 9 9 palette: { 10 10 default: { 11 11 background: lightPalette.white, 12 - backgroundLight: lightPalette.contrast_50, 12 + backgroundLight: lightPalette.contrast_25, 13 13 text: lightPalette.black, 14 14 textLight: lightPalette.contrast_700, 15 15 textInverted: lightPalette.white,
+2
src/routes.ts
··· 21 21 PostRepostedBy: '/profile/:name/post/:rkey/reposted-by', 22 22 ProfileFeed: '/profile/:name/feed/:rkey', 23 23 ProfileFeedLikedBy: '/profile/:name/feed/:rkey/liked-by', 24 + ProfileLabelerLikedBy: '/profile/:name/labeler/liked-by', 24 25 Debug: '/sys/debug', 26 + DebugMod: '/sys/debug-mod', 25 27 Log: '/sys/log', 26 28 AppPasswords: '/settings/app-passwords', 27 29 PreferencesFollowingFeed: '/settings/following-feed',
+560
src/screens/Moderation/index.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {useFocusEffect} from '@react-navigation/native' 4 + import {ComAtprotoLabelDefs} from '@atproto/api' 5 + import {Trans, msg} from '@lingui/macro' 6 + import {useLingui} from '@lingui/react' 7 + import {LABELS} from '@atproto/api' 8 + import {useSafeAreaFrame} from 'react-native-safe-area-context' 9 + 10 + import {NativeStackScreenProps, CommonNavigatorParams} from '#/lib/routes/types' 11 + import {CenteredView} from '#/view/com/util/Views' 12 + import {ViewHeader} from '#/view/com/util/ViewHeader' 13 + import {useAnalytics} from 'lib/analytics/analytics' 14 + import {useSetMinimalShellMode} from '#/state/shell' 15 + import {useSession} from '#/state/session' 16 + import { 17 + useProfileQuery, 18 + useProfileUpdateMutation, 19 + } from '#/state/queries/profile' 20 + import {ScrollView} from '#/view/com/util/Views' 21 + 22 + import { 23 + UsePreferencesQueryResponse, 24 + useMyLabelersQuery, 25 + usePreferencesQuery, 26 + usePreferencesSetAdultContentMutation, 27 + } from '#/state/queries/preferences' 28 + 29 + import {getLabelingServiceTitle} from '#/lib/moderation' 30 + import {logger} from '#/logger' 31 + import {useTheme, atoms as a, useBreakpoints, ViewStyleProp} from '#/alf' 32 + import {Divider} from '#/components/Divider' 33 + import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign' 34 + import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group' 35 + import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person' 36 + import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' 37 + import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter' 38 + import {Text} from '#/components/Typography' 39 + import * as Toggle from '#/components/forms/Toggle' 40 + import {InlineLink, Link} from '#/components/Link' 41 + import {Button, ButtonText} from '#/components/Button' 42 + import {Loader} from '#/components/Loader' 43 + import * as LabelingService from '#/components/LabelingServiceCard' 44 + import {GlobalModerationLabelPref} from '#/components/moderation/GlobalModerationLabelPref' 45 + import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' 46 + import {Props as SVGIconProps} from '#/components/icons/common' 47 + import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' 48 + import * as Dialog from '#/components/Dialog' 49 + 50 + function ErrorState({error}: {error: string}) { 51 + const t = useTheme() 52 + return ( 53 + <View style={[a.p_xl]}> 54 + <Text 55 + style={[ 56 + a.text_md, 57 + a.leading_normal, 58 + a.pb_md, 59 + t.atoms.text_contrast_medium, 60 + ]}> 61 + <Trans> 62 + Hmmmm, it seems we're having trouble loading this data. See below for 63 + more details. If this issue persists, please contact us. 64 + </Trans> 65 + </Text> 66 + <View 67 + style={[ 68 + a.relative, 69 + a.py_md, 70 + a.px_lg, 71 + a.rounded_md, 72 + a.mb_2xl, 73 + t.atoms.bg_contrast_25, 74 + ]}> 75 + <Text style={[a.text_md, a.leading_normal]}>{error}</Text> 76 + </View> 77 + </View> 78 + ) 79 + } 80 + 81 + export function ModerationScreen( 82 + _props: NativeStackScreenProps<CommonNavigatorParams, 'Moderation'>, 83 + ) { 84 + const t = useTheme() 85 + const {_} = useLingui() 86 + const { 87 + isLoading: isPreferencesLoading, 88 + error: preferencesError, 89 + data: preferences, 90 + } = usePreferencesQuery() 91 + const {gtMobile} = useBreakpoints() 92 + const {height} = useSafeAreaFrame() 93 + 94 + const isLoading = isPreferencesLoading 95 + const error = preferencesError 96 + 97 + return ( 98 + <CenteredView 99 + testID="moderationScreen" 100 + style={[ 101 + t.atoms.border_contrast_low, 102 + t.atoms.bg, 103 + {minHeight: height}, 104 + ...(gtMobile ? [a.border_l, a.border_r] : []), 105 + ]}> 106 + <ViewHeader title={_(msg`Moderation`)} showOnDesktop /> 107 + 108 + {isLoading ? ( 109 + <View style={[a.w_full, a.align_center, a.pt_2xl]}> 110 + <Loader size="xl" fill={t.atoms.text.color} /> 111 + </View> 112 + ) : error || !preferences ? ( 113 + <ErrorState 114 + error={ 115 + preferencesError?.toString() || 116 + _(msg`Something went wrong, please try again.`) 117 + } 118 + /> 119 + ) : ( 120 + <ModerationScreenInner preferences={preferences} /> 121 + )} 122 + </CenteredView> 123 + ) 124 + } 125 + 126 + function SubItem({ 127 + title, 128 + icon: Icon, 129 + style, 130 + }: ViewStyleProp & { 131 + title: string 132 + icon: React.ComponentType<SVGIconProps> 133 + }) { 134 + const t = useTheme() 135 + return ( 136 + <View 137 + style={[ 138 + a.w_full, 139 + a.flex_row, 140 + a.align_center, 141 + a.justify_between, 142 + a.p_lg, 143 + a.gap_sm, 144 + style, 145 + ]}> 146 + <View style={[a.flex_row, a.align_center, a.gap_md]}> 147 + <Icon size="md" style={[t.atoms.text_contrast_medium]} /> 148 + <Text style={[a.text_sm, a.font_bold]}>{title}</Text> 149 + </View> 150 + <ChevronRight 151 + size="sm" 152 + style={[t.atoms.text_contrast_low, a.self_end, {paddingBottom: 2}]} 153 + /> 154 + </View> 155 + ) 156 + } 157 + 158 + export function ModerationScreenInner({ 159 + preferences, 160 + }: { 161 + preferences: UsePreferencesQueryResponse 162 + }) { 163 + const {_} = useLingui() 164 + const t = useTheme() 165 + const setMinimalShellMode = useSetMinimalShellMode() 166 + const {screen} = useAnalytics() 167 + const {gtMobile} = useBreakpoints() 168 + const {mutedWordsDialogControl} = useGlobalDialogsControlContext() 169 + const birthdateDialogControl = Dialog.useDialogControl() 170 + const { 171 + isLoading: isLabelersLoading, 172 + data: labelers, 173 + error: labelersError, 174 + } = useMyLabelersQuery() 175 + 176 + useFocusEffect( 177 + React.useCallback(() => { 178 + screen('Moderation') 179 + setMinimalShellMode(false) 180 + }, [screen, setMinimalShellMode]), 181 + ) 182 + 183 + const {mutateAsync: setAdultContentPref, variables: optimisticAdultContent} = 184 + usePreferencesSetAdultContentMutation() 185 + const adultContentEnabled = !!( 186 + (optimisticAdultContent && optimisticAdultContent.enabled) || 187 + (!optimisticAdultContent && preferences.moderationPrefs.adultContentEnabled) 188 + ) 189 + const ageNotSet = !preferences.userAge 190 + const isUnderage = (preferences.userAge || 0) < 18 191 + 192 + const onToggleAdultContentEnabled = React.useCallback( 193 + async (selected: boolean) => { 194 + try { 195 + await setAdultContentPref({ 196 + enabled: selected, 197 + }) 198 + } catch (e: any) { 199 + logger.error(`Failed to set adult content pref`, { 200 + message: e.message, 201 + }) 202 + } 203 + }, 204 + [setAdultContentPref], 205 + ) 206 + 207 + return ( 208 + <View> 209 + <ScrollView 210 + contentContainerStyle={[ 211 + a.border_0, 212 + a.pt_2xl, 213 + a.px_lg, 214 + gtMobile && a.px_2xl, 215 + ]}> 216 + <Text 217 + style={[a.text_md, a.font_bold, a.pb_md, t.atoms.text_contrast_high]}> 218 + <Trans>Moderation tools</Trans> 219 + </Text> 220 + 221 + <View 222 + style={[ 223 + a.w_full, 224 + a.rounded_md, 225 + a.overflow_hidden, 226 + t.atoms.bg_contrast_25, 227 + ]}> 228 + <Button 229 + testID="mutedWordsBtn" 230 + label={_(msg`Open muted words and tags settings`)} 231 + onPress={() => mutedWordsDialogControl.open()}> 232 + {state => ( 233 + <SubItem 234 + title={_(msg`Muted words & tags`)} 235 + icon={Filter} 236 + style={[ 237 + (state.hovered || state.pressed) && [t.atoms.bg_contrast_50], 238 + ]} 239 + /> 240 + )} 241 + </Button> 242 + <Divider /> 243 + <Link testID="moderationlistsBtn" to="/moderation/modlists"> 244 + {state => ( 245 + <SubItem 246 + title={_(msg`Moderation lists`)} 247 + icon={Group} 248 + style={[ 249 + (state.hovered || state.pressed) && [t.atoms.bg_contrast_50], 250 + ]} 251 + /> 252 + )} 253 + </Link> 254 + <Divider /> 255 + <Link testID="mutedAccountsBtn" to="/moderation/muted-accounts"> 256 + {state => ( 257 + <SubItem 258 + title={_(msg`Muted accounts`)} 259 + icon={Person} 260 + style={[ 261 + (state.hovered || state.pressed) && [t.atoms.bg_contrast_50], 262 + ]} 263 + /> 264 + )} 265 + </Link> 266 + <Divider /> 267 + <Link testID="blockedAccountsBtn" to="/moderation/blocked-accounts"> 268 + {state => ( 269 + <SubItem 270 + title={_(msg`Blocked accounts`)} 271 + icon={CircleBanSign} 272 + style={[ 273 + (state.hovered || state.pressed) && [t.atoms.bg_contrast_50], 274 + ]} 275 + /> 276 + )} 277 + </Link> 278 + </View> 279 + 280 + <Text 281 + style={[ 282 + a.pt_2xl, 283 + a.pb_md, 284 + a.text_md, 285 + a.font_bold, 286 + t.atoms.text_contrast_high, 287 + ]}> 288 + <Trans>Content filters</Trans> 289 + </Text> 290 + 291 + <View style={[a.gap_md]}> 292 + {ageNotSet && ( 293 + <> 294 + <Button 295 + label={_(msg`Confirm your birthdate`)} 296 + size="small" 297 + variant="solid" 298 + color="secondary" 299 + onPress={() => { 300 + birthdateDialogControl.open() 301 + }} 302 + style={[a.justify_between, a.rounded_md, a.px_lg, a.py_lg]}> 303 + <ButtonText> 304 + <Trans>Confirm your age:</Trans> 305 + </ButtonText> 306 + <ButtonText> 307 + <Trans>Set birthdate</Trans> 308 + </ButtonText> 309 + </Button> 310 + 311 + <BirthDateSettingsDialog 312 + control={birthdateDialogControl} 313 + preferences={preferences} 314 + /> 315 + </> 316 + )} 317 + <View 318 + style={[ 319 + a.w_full, 320 + a.rounded_md, 321 + a.overflow_hidden, 322 + t.atoms.bg_contrast_25, 323 + ]}> 324 + {!ageNotSet && !isUnderage && ( 325 + <> 326 + <View 327 + style={[ 328 + a.py_lg, 329 + a.px_lg, 330 + a.flex_row, 331 + a.align_center, 332 + a.justify_between, 333 + ]}> 334 + <Text style={[a.font_semibold, t.atoms.text_contrast_high]}> 335 + <Trans>Enable adult content</Trans> 336 + </Text> 337 + <Toggle.Item 338 + label={_(msg`Toggle to enable or disable adult content`)} 339 + name="adultContent" 340 + value={adultContentEnabled} 341 + onChange={onToggleAdultContentEnabled}> 342 + <View style={[a.flex_row, a.align_center, a.gap_sm]}> 343 + <Text style={[t.atoms.text_contrast_medium]}> 344 + {adultContentEnabled ? ( 345 + <Trans>Enabled</Trans> 346 + ) : ( 347 + <Trans>Disabled</Trans> 348 + )} 349 + </Text> 350 + <Toggle.Switch /> 351 + </View> 352 + </Toggle.Item> 353 + </View> 354 + <Divider /> 355 + </> 356 + )} 357 + {!isUnderage && adultContentEnabled && ( 358 + <> 359 + <GlobalModerationLabelPref labelValueDefinition={LABELS.porn} /> 360 + <Divider /> 361 + <GlobalModerationLabelPref 362 + labelValueDefinition={LABELS.sexual} 363 + /> 364 + <Divider /> 365 + <GlobalModerationLabelPref 366 + labelValueDefinition={LABELS['graphic-media']} 367 + /> 368 + <Divider /> 369 + </> 370 + )} 371 + <GlobalModerationLabelPref labelValueDefinition={LABELS.nudity} /> 372 + </View> 373 + </View> 374 + 375 + <Text 376 + style={[ 377 + a.text_md, 378 + a.font_bold, 379 + a.pt_2xl, 380 + a.pb_md, 381 + t.atoms.text_contrast_high, 382 + ]}> 383 + <Trans>Advanced</Trans> 384 + </Text> 385 + 386 + {isLabelersLoading ? ( 387 + <Loader /> 388 + ) : labelersError || !labelers ? ( 389 + <View style={[a.p_lg, a.rounded_sm, t.atoms.bg_contrast_25]}> 390 + <Text> 391 + <Trans> 392 + We were unable to load your configured labelers at this time. 393 + </Trans> 394 + </Text> 395 + </View> 396 + ) : ( 397 + <View style={[a.rounded_sm, t.atoms.bg_contrast_25]}> 398 + {labelers.map((labeler, i) => { 399 + return ( 400 + <React.Fragment key={labeler.creator.did}> 401 + {i !== 0 && <Divider />} 402 + <LabelingService.Link labeler={labeler}> 403 + {state => ( 404 + <LabelingService.Outer 405 + style={[ 406 + i === 0 && { 407 + borderTopLeftRadius: a.rounded_sm.borderRadius, 408 + borderTopRightRadius: a.rounded_sm.borderRadius, 409 + }, 410 + i === labelers.length - 1 && { 411 + borderBottomLeftRadius: a.rounded_sm.borderRadius, 412 + borderBottomRightRadius: a.rounded_sm.borderRadius, 413 + }, 414 + (state.hovered || state.pressed) && [ 415 + t.atoms.bg_contrast_50, 416 + ], 417 + ]}> 418 + <LabelingService.Avatar /> 419 + <LabelingService.Content> 420 + <LabelingService.Title 421 + value={getLabelingServiceTitle({ 422 + displayName: labeler.creator.displayName, 423 + handle: labeler.creator.handle, 424 + })} 425 + /> 426 + <LabelingService.Description 427 + value={labeler.creator.description} 428 + handle={labeler.creator.handle} 429 + /> 430 + </LabelingService.Content> 431 + </LabelingService.Outer> 432 + )} 433 + </LabelingService.Link> 434 + </React.Fragment> 435 + ) 436 + })} 437 + </View> 438 + )} 439 + 440 + <Text 441 + style={[ 442 + a.text_md, 443 + a.font_bold, 444 + a.pt_2xl, 445 + a.pb_md, 446 + t.atoms.text_contrast_high, 447 + ]}> 448 + <Trans>Logged-out visibility</Trans> 449 + </Text> 450 + 451 + <PwiOptOut /> 452 + 453 + <View style={{height: 200}} /> 454 + </ScrollView> 455 + </View> 456 + ) 457 + } 458 + 459 + function PwiOptOut() { 460 + const t = useTheme() 461 + const {_} = useLingui() 462 + const {currentAccount} = useSession() 463 + const {data: profile} = useProfileQuery({did: currentAccount?.did}) 464 + const updateProfile = useProfileUpdateMutation() 465 + 466 + const isOptedOut = 467 + profile?.labels?.some(l => l.val === '!no-unauthenticated') || false 468 + const canToggle = profile && !updateProfile.isPending 469 + 470 + const onToggleOptOut = React.useCallback(() => { 471 + if (!profile) { 472 + return 473 + } 474 + let wasAdded = false 475 + updateProfile.mutate({ 476 + profile, 477 + updates: existing => { 478 + // create labels attr if needed 479 + existing.labels = ComAtprotoLabelDefs.isSelfLabels(existing.labels) 480 + ? existing.labels 481 + : { 482 + $type: 'com.atproto.label.defs#selfLabels', 483 + values: [], 484 + } 485 + 486 + // toggle the label 487 + const hasLabel = existing.labels.values.some( 488 + l => l.val === '!no-unauthenticated', 489 + ) 490 + if (hasLabel) { 491 + wasAdded = false 492 + existing.labels.values = existing.labels.values.filter( 493 + l => l.val !== '!no-unauthenticated', 494 + ) 495 + } else { 496 + wasAdded = true 497 + existing.labels.values.push({val: '!no-unauthenticated'}) 498 + } 499 + 500 + // delete if no longer needed 501 + if (existing.labels.values.length === 0) { 502 + delete existing.labels 503 + } 504 + return existing 505 + }, 506 + checkCommitted: res => { 507 + const exists = !!res.data.labels?.some( 508 + l => l.val === '!no-unauthenticated', 509 + ) 510 + return exists === wasAdded 511 + }, 512 + }) 513 + }, [updateProfile, profile]) 514 + 515 + return ( 516 + <View style={[a.pt_sm]}> 517 + <View style={[a.flex_row, a.align_center, a.justify_between, a.gap_lg]}> 518 + <Toggle.Item 519 + disabled={!canToggle} 520 + value={isOptedOut} 521 + onChange={onToggleOptOut} 522 + name="logged_out_visibility" 523 + label={_( 524 + msg`Discourage apps from showing my account to logged-out users`, 525 + )}> 526 + <Toggle.Switch /> 527 + <Toggle.Label style={[a.text_md]}> 528 + <Trans> 529 + Discourage apps from showing my account to logged-out users 530 + </Trans> 531 + </Toggle.Label> 532 + </Toggle.Item> 533 + 534 + {updateProfile.isPending && <Loader />} 535 + </View> 536 + 537 + <View style={[a.pt_md, a.gap_md, {paddingLeft: 38}]}> 538 + <Text style={[a.leading_snug, t.atoms.text_contrast_high]}> 539 + <Trans> 540 + Bluesky will not show your profile and posts to logged-out users. 541 + Other apps may not honor this request. This does not make your 542 + account private. 543 + </Trans> 544 + </Text> 545 + <Text style={[a.font_bold, a.leading_snug, t.atoms.text_contrast_high]}> 546 + <Trans> 547 + Note: Bluesky is an open and public network. This setting only 548 + limits the visibility of your content on the Bluesky app and 549 + website, and other apps may not respect this setting. Your content 550 + may still be shown to logged-out users by other apps and websites. 551 + </Trans> 552 + </Text> 553 + 554 + <InlineLink to="https://blueskyweb.zendesk.com/hc/en-us/articles/15835264007693-Data-Privacy"> 555 + <Trans>Learn more about what is public on Bluesky.</Trans> 556 + </InlineLink> 557 + </View> 558 + </View> 559 + ) 560 + }
+7 -2
src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx
··· 56 56 57 57 try { 58 58 mutate({ 59 - enabled: !(variables?.enabled ?? preferences?.adultContentEnabled), 59 + enabled: !( 60 + variables?.enabled ?? preferences?.moderationPrefs.adultContentEnabled 61 + ), 60 62 }) 61 63 } catch (e) { 62 64 Toast.show( ··· 75 77 <Toggle.Item 76 78 name={_(msg`Enable adult content in your feeds`)} 77 79 label={_(msg`Enable adult content in your feeds`)} 78 - value={variables?.enabled ?? preferences?.adultContentEnabled} 80 + value={ 81 + variables?.enabled ?? 82 + preferences?.moderationPrefs.adultContentEnabled 83 + } 79 84 onChange={onToggleAdultContent}> 80 85 <View 81 86 style={[
+53 -38
src/screens/Onboarding/StepModeration/ModerationOption.tsx
··· 1 1 import React from 'react' 2 2 import {View} from 'react-native' 3 - import {LabelPreference} from '@atproto/api' 3 + import {LabelPreference, InterpretedLabelValueDefinition} from '@atproto/api' 4 4 import {useLingui} from '@lingui/react' 5 - import {msg} from '@lingui/macro' 6 - import Animated, {Easing, Layout, FadeIn} from 'react-native-reanimated' 5 + import {msg, Trans} from '@lingui/macro' 7 6 8 7 import { 9 - CONFIGURABLE_LABEL_GROUPS, 10 - ConfigurableLabelGroup, 11 8 usePreferencesQuery, 12 9 usePreferencesSetContentLabelMutation, 13 10 } from '#/state/queries/preferences' 14 11 import {atoms as a, useTheme} from '#/alf' 15 12 import {Text} from '#/components/Typography' 16 13 import * as ToggleButton from '#/components/forms/ToggleButton' 14 + import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings' 17 15 18 16 export function ModerationOption({ 19 - labelGroup, 20 - isMounted, 17 + labelValueDefinition, 18 + disabled, 21 19 }: { 22 - labelGroup: ConfigurableLabelGroup 23 - isMounted: React.MutableRefObject<boolean> 20 + labelValueDefinition: InterpretedLabelValueDefinition 21 + disabled?: boolean 24 22 }) { 25 23 const {_} = useLingui() 26 24 const t = useTheme() 27 - const groupInfo = CONFIGURABLE_LABEL_GROUPS[labelGroup] 28 25 const {data: preferences} = usePreferencesQuery() 29 26 const {mutate, variables} = usePreferencesSetContentLabelMutation() 27 + const label = labelValueDefinition.identifier 30 28 const visibility = 31 - variables?.visibility ?? preferences?.contentLabels?.[labelGroup] 29 + variables?.visibility ?? preferences?.moderationPrefs.labels?.[label] 30 + 31 + const allLabelStrings = useGlobalLabelStrings() 32 + const labelStrings = 33 + labelValueDefinition.identifier in allLabelStrings 34 + ? allLabelStrings[labelValueDefinition.identifier] 35 + : { 36 + name: labelValueDefinition.identifier, 37 + description: `Labeled "${labelValueDefinition.identifier}"`, 38 + } 32 39 33 40 const onChange = React.useCallback( 34 41 (vis: string[]) => { 35 - mutate({labelGroup, visibility: vis[0] as LabelPreference}) 42 + mutate({ 43 + label, 44 + visibility: vis[0] as LabelPreference, 45 + labelerDid: undefined, 46 + }) 36 47 }, 37 - [mutate, labelGroup], 48 + [mutate, label], 38 49 ) 39 50 40 51 const labels = { ··· 44 55 } 45 56 46 57 return ( 47 - <Animated.View 58 + <View 48 59 style={[ 49 60 a.flex_row, 50 61 a.justify_between, ··· 52 63 a.py_xs, 53 64 a.px_xs, 54 65 a.align_center, 55 - ]} 56 - layout={Layout.easing(Easing.ease).duration(200)} 57 - entering={isMounted.current ? FadeIn : undefined}> 58 - <View style={[a.gap_xs, {width: '50%'}]}> 59 - <Text style={[a.font_bold]}>{groupInfo.title}</Text> 66 + ]}> 67 + <View style={[a.gap_xs, a.flex_1]}> 68 + <Text style={[a.font_bold]}>{labelStrings.name}</Text> 60 69 <Text style={[t.atoms.text_contrast_medium, a.leading_snug]}> 61 - {groupInfo.subtitle} 70 + {labelStrings.description} 62 71 </Text> 63 72 </View> 64 - <View style={[a.justify_center, {minHeight: 35}]}> 65 - <ToggleButton.Group 66 - label={_( 67 - msg`Configure content filtering setting for category: ${groupInfo.title.toLowerCase()}`, 68 - )} 69 - values={[visibility ?? 'hide']} 70 - onChange={onChange}> 71 - <ToggleButton.Button name="hide" label={labels.hide}> 72 - {labels.hide} 73 - </ToggleButton.Button> 74 - <ToggleButton.Button name="warn" label={labels.warn}> 75 - {labels.warn} 76 - </ToggleButton.Button> 77 - <ToggleButton.Button name="ignore" label={labels.show}> 78 - {labels.show} 79 - </ToggleButton.Button> 80 - </ToggleButton.Group> 73 + <View style={[a.justify_center, {minHeight: 40}]}> 74 + {disabled ? ( 75 + <Text style={[a.font_bold]}> 76 + <Trans>Hide</Trans> 77 + </Text> 78 + ) : ( 79 + <ToggleButton.Group 80 + label={_( 81 + msg`Configure content filtering setting for category: ${labelStrings.name.toLowerCase()}`, 82 + )} 83 + values={[visibility ?? 'hide']} 84 + onChange={onChange}> 85 + <ToggleButton.Button name="ignore" label={labels.show}> 86 + {labels.show} 87 + </ToggleButton.Button> 88 + <ToggleButton.Button name="warn" label={labels.warn}> 89 + {labels.warn} 90 + </ToggleButton.Button> 91 + <ToggleButton.Button name="hide" label={labels.hide}> 92 + {labels.hide} 93 + </ToggleButton.Button> 94 + </ToggleButton.Group> 95 + )} 81 96 </View> 82 - </Animated.View> 97 + </View> 83 98 ) 84 99 }
+16 -32
src/screens/Onboarding/StepModeration/index.tsx
··· 2 2 import {View} from 'react-native' 3 3 import {useLingui} from '@lingui/react' 4 4 import {msg, Trans} from '@lingui/macro' 5 - import Animated, {Easing, Layout} from 'react-native-reanimated' 5 + import {LABELS} from '@atproto/api' 6 6 7 7 import {atoms as a} from '#/alf' 8 - import { 9 - configurableAdultLabelGroups, 10 - configurableOtherLabelGroups, 11 - usePreferencesSetAdultContentMutation, 12 - } from 'state/queries/preferences' 13 - import {Divider} from '#/components/Divider' 8 + import {usePreferencesSetAdultContentMutation} from 'state/queries/preferences' 14 9 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 15 10 import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' 16 11 import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash' ··· 28 23 import {Context} from '#/screens/Onboarding/state' 29 24 import {IconCircle} from '#/components/IconCircle' 30 25 31 - function AnimatedDivider() { 32 - return ( 33 - <Animated.View layout={Layout.easing(Easing.ease).duration(200)}> 34 - <Divider /> 35 - </Animated.View> 36 - ) 37 - } 38 - 39 26 export function StepModeration() { 40 27 const {_} = useLingui() 41 28 const {track} = useAnalytics() ··· 52 39 53 40 const adultContentEnabled = !!( 54 41 (variables && variables.enabled) || 55 - (!variables && preferences?.adultContentEnabled) 42 + (!variables && preferences?.moderationPrefs.adultContentEnabled) 56 43 ) 57 44 58 45 const onContinue = React.useCallback(() => { ··· 86 73 <AdultContentEnabledPref mutate={mutate} variables={variables} /> 87 74 88 75 <View style={[a.gap_sm, a.w_full]}> 89 - {adultContentEnabled && 90 - configurableAdultLabelGroups.map((g, index) => ( 91 - <React.Fragment key={index}> 92 - {index === 0 && <AnimatedDivider />} 93 - <ModerationOption labelGroup={g} isMounted={isMounted} /> 94 - <AnimatedDivider /> 95 - </React.Fragment> 96 - ))} 97 - 98 - {configurableOtherLabelGroups.map((g, index) => ( 99 - <React.Fragment key={index}> 100 - {!adultContentEnabled && index === 0 && <AnimatedDivider />} 101 - <ModerationOption labelGroup={g} isMounted={isMounted} /> 102 - <AnimatedDivider /> 103 - </React.Fragment> 104 - ))} 76 + <ModerationOption 77 + labelValueDefinition={LABELS.porn} 78 + disabled={!adultContentEnabled} 79 + /> 80 + <ModerationOption 81 + labelValueDefinition={LABELS.sexual} 82 + disabled={!adultContentEnabled} 83 + /> 84 + <ModerationOption 85 + labelValueDefinition={LABELS['graphic-media']} 86 + disabled={!adultContentEnabled} 87 + /> 88 + <ModerationOption labelValueDefinition={LABELS.nudity} /> 105 89 </View> 106 90 </> 107 91 )}
+1 -1
src/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard.tsx
··· 88 88 <UserAvatar 89 89 size={48} 90 90 avatar={profile.avatar} 91 - moderation={moderation.avatar} 91 + moderation={moderation.ui('avatar')} 92 92 /> 93 93 </View> 94 94 <View style={[a.flex_1]}>
+1 -1
src/screens/Onboarding/StepSuggestedAccounts/index.tsx
··· 76 76 return aggregateInterestItems( 77 77 state.interestsStepResults.selectedInterests, 78 78 state.interestsStepResults.apiResponse.suggestedAccountDids, 79 - state.interestsStepResults.apiResponse.suggestedAccountDids.default, 79 + state.interestsStepResults.apiResponse.suggestedAccountDids.default || [], 80 80 ) 81 81 }, [state.interestsStepResults]) 82 82 const moderationOpts = useModerationOpts()
+4 -4
src/screens/Onboarding/StepTopicalFeeds.tsx
··· 21 21 import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard' 22 22 import {aggregateInterestItems} from '#/screens/Onboarding/util' 23 23 import {IconCircle} from '#/components/IconCircle' 24 - import {IS_PROD_SERVICE} from 'lib/constants' 24 + import {IS_TEST_USER} from 'lib/constants' 25 25 import {useSession} from 'state/session' 26 26 27 27 export function StepTopicalFeeds() { ··· 32 32 const [selectedFeedUris, setSelectedFeedUris] = React.useState<string[]>([]) 33 33 const [saving, setSaving] = React.useState(false) 34 34 const suggestedFeedUris = React.useMemo(() => { 35 - if (!IS_PROD_SERVICE(currentAccount?.service)) return [] 35 + if (IS_TEST_USER(currentAccount?.handle)) return [] 36 36 return aggregateInterestItems( 37 37 state.interestsStepResults.selectedInterests, 38 38 state.interestsStepResults.apiResponse.suggestedFeedUris, 39 - state.interestsStepResults.apiResponse.suggestedFeedUris.default, 39 + state.interestsStepResults.apiResponse.suggestedFeedUris.default || [], 40 40 ).slice(0, 10) 41 41 }, [ 42 - currentAccount?.service, 42 + currentAccount?.handle, 43 43 state.interestsStepResults.apiResponse.suggestedFeedUris, 44 44 state.interestsStepResults.selectedInterests, 45 45 ])
+72
src/screens/Profile/ErrorState.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {Trans, msg} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + import {useNavigation} from '@react-navigation/native' 6 + 7 + import {useTheme, atoms as a} from '#/alf' 8 + import {Text} from '#/components/Typography' 9 + import {Button, ButtonText} from '#/components/Button' 10 + import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 11 + import {NavigationProp} from '#/lib/routes/types' 12 + 13 + export function ErrorState({error}: {error: string}) { 14 + const t = useTheme() 15 + const {_} = useLingui() 16 + const navigation = useNavigation<NavigationProp>() 17 + 18 + const onPressBack = React.useCallback(() => { 19 + if (navigation.canGoBack()) { 20 + navigation.goBack() 21 + } else { 22 + navigation.navigate('Home') 23 + } 24 + }, [navigation]) 25 + 26 + return ( 27 + <View style={[a.px_xl]}> 28 + <CircleInfo width={48} style={[t.atoms.text_contrast_low]} /> 29 + 30 + <Text style={[a.text_xl, a.font_bold, a.pb_md, a.pt_xl]}> 31 + <Trans>Hmmmm, we couldn't load that moderation service.</Trans> 32 + </Text> 33 + <Text 34 + style={[ 35 + a.text_md, 36 + a.leading_normal, 37 + a.pb_md, 38 + t.atoms.text_contrast_medium, 39 + ]}> 40 + <Trans> 41 + This moderation service is unavailable. See below for more details. If 42 + this issue persists, contact us. 43 + </Trans> 44 + </Text> 45 + <View 46 + style={[ 47 + a.relative, 48 + a.py_md, 49 + a.px_lg, 50 + a.rounded_md, 51 + a.mb_2xl, 52 + t.atoms.bg_contrast_25, 53 + ]}> 54 + <Text style={[a.text_md, a.leading_normal]}>{error}</Text> 55 + </View> 56 + 57 + <View style={{flexDirection: 'row'}}> 58 + <Button 59 + size="small" 60 + color="secondary" 61 + variant="solid" 62 + label={_(msg`Go Back`)} 63 + accessibilityHint="Return to previous page" 64 + onPress={onPressBack}> 65 + <ButtonText> 66 + <Trans>Go Back</Trans> 67 + </ButtonText> 68 + </Button> 69 + </View> 70 + </View> 71 + ) 72 + }
+31
src/screens/Profile/Header/DisplayName.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {AppBskyActorDefs, ModerationDecision} from '@atproto/api' 4 + import {sanitizeHandle} from 'lib/strings/handles' 5 + import {sanitizeDisplayName} from 'lib/strings/display-names' 6 + import {Shadow} from '#/state/cache/types' 7 + 8 + import {atoms as a, useTheme} from '#/alf' 9 + import {Text} from '#/components/Typography' 10 + 11 + export function ProfileHeaderDisplayName({ 12 + profile, 13 + moderation, 14 + }: { 15 + profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> 16 + moderation: ModerationDecision 17 + }) { 18 + const t = useTheme() 19 + return ( 20 + <View pointerEvents="none"> 21 + <Text 22 + testID="profileHeaderDisplayName" 23 + style={[t.atoms.text, a.text_4xl, {fontWeight: '500'}]}> 24 + {sanitizeDisplayName( 25 + profile.displayName || sanitizeHandle(profile.handle), 26 + moderation.ui('displayName'), 27 + )} 28 + </Text> 29 + </View> 30 + ) 31 + }
+46
src/screens/Profile/Header/Handle.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {AppBskyActorDefs} from '@atproto/api' 4 + import {isInvalidHandle} from 'lib/strings/handles' 5 + import {Shadow} from '#/state/cache/types' 6 + import {Trans} from '@lingui/macro' 7 + 8 + import {atoms as a, useTheme, web} from '#/alf' 9 + import {Text} from '#/components/Typography' 10 + 11 + export function ProfileHeaderHandle({ 12 + profile, 13 + }: { 14 + profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> 15 + }) { 16 + const t = useTheme() 17 + const invalidHandle = isInvalidHandle(profile.handle) 18 + const blockHide = profile.viewer?.blocking || profile.viewer?.blockedBy 19 + return ( 20 + <View style={[a.flex_row, a.gap_xs, a.align_center]} pointerEvents="none"> 21 + {profile.viewer?.followedBy && !blockHide ? ( 22 + <View style={[t.atoms.bg_contrast_25, a.rounded_xs, a.px_sm, a.py_xs]}> 23 + <Text style={[t.atoms.text, a.text_sm]}> 24 + <Trans>Follows you</Trans> 25 + </Text> 26 + </View> 27 + ) : undefined} 28 + <Text 29 + style={[ 30 + invalidHandle 31 + ? [ 32 + a.border, 33 + a.text_xs, 34 + a.px_sm, 35 + a.py_xs, 36 + a.rounded_xs, 37 + {borderColor: t.palette.contrast_200}, 38 + ] 39 + : [a.text_md, t.atoms.text_contrast_medium], 40 + web({wordBreak: 'break-all'}), 41 + ]}> 42 + {invalidHandle ? <Trans>⚠Invalid Handle</Trans> : `@${profile.handle}`} 43 + </Text> 44 + </View> 45 + ) 46 + }
+61
src/screens/Profile/Header/Metrics.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {AppBskyActorDefs} from '@atproto/api' 4 + import {Trans, msg} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + 7 + import {Shadow} from '#/state/cache/types' 8 + import {pluralize} from '#/lib/strings/helpers' 9 + import {makeProfileLink} from 'lib/routes/links' 10 + import {formatCount} from 'view/com/util/numeric/format' 11 + 12 + import {atoms as a, useTheme} from '#/alf' 13 + import {Text} from '#/components/Typography' 14 + import {InlineLink} from '#/components/Link' 15 + 16 + export function ProfileHeaderMetrics({ 17 + profile, 18 + }: { 19 + profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> 20 + }) { 21 + const t = useTheme() 22 + const {_} = useLingui() 23 + const following = formatCount(profile.followsCount || 0) 24 + const followers = formatCount(profile.followersCount || 0) 25 + const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower') 26 + 27 + return ( 28 + <View 29 + style={[a.flex_row, a.gap_sm, a.align_center, a.pb_md]} 30 + pointerEvents="box-none"> 31 + <InlineLink 32 + testID="profileHeaderFollowersButton" 33 + style={[a.flex_row, t.atoms.text]} 34 + to={makeProfileLink(profile, 'followers')} 35 + label={`${followers} ${pluralizedFollowers}`}> 36 + <Text style={[a.font_bold, a.text_md]}>{followers} </Text> 37 + <Text style={[t.atoms.text_contrast_medium, a.text_md]}> 38 + {pluralizedFollowers} 39 + </Text> 40 + </InlineLink> 41 + <InlineLink 42 + testID="profileHeaderFollowsButton" 43 + style={[a.flex_row, t.atoms.text]} 44 + to={makeProfileLink(profile, 'follows')} 45 + label={_(msg`${following} following`)}> 46 + <Trans> 47 + <Text style={[a.font_bold, a.text_md]}>{following} </Text> 48 + <Text style={[t.atoms.text_contrast_medium, a.text_md]}> 49 + following 50 + </Text> 51 + </Trans> 52 + </InlineLink> 53 + <Text style={[a.font_bold, t.atoms.text, a.text_md]}> 54 + {formatCount(profile.postsCount || 0)}{' '} 55 + <Text style={[t.atoms.text_contrast_medium, a.font_normal, a.text_md]}> 56 + {pluralize(profile.postsCount || 0, 'post')} 57 + </Text> 58 + </Text> 59 + </View> 60 + ) 61 + }
+329
src/screens/Profile/Header/ProfileHeaderLabeler.tsx
··· 1 + import React, {memo, useMemo} from 'react' 2 + import {View} from 'react-native' 3 + import { 4 + AppBskyActorDefs, 5 + AppBskyLabelerDefs, 6 + ModerationOpts, 7 + moderateProfile, 8 + RichText as RichTextAPI, 9 + } from '@atproto/api' 10 + import {Trans, msg} from '@lingui/macro' 11 + import {useLingui} from '@lingui/react' 12 + 13 + import {RichText} from '#/components/RichText' 14 + import {useModalControls} from '#/state/modals' 15 + import {usePreferencesQuery} from '#/state/queries/preferences' 16 + import {useAnalytics} from 'lib/analytics/analytics' 17 + import {useSession} from '#/state/session' 18 + import {Shadow} from '#/state/cache/types' 19 + import {useProfileShadow} from 'state/cache/profile-shadow' 20 + import {useLabelerSubscriptionMutation} from '#/state/queries/labeler' 21 + import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like' 22 + import {logger} from '#/logger' 23 + import {Haptics} from '#/lib/haptics' 24 + import {pluralize} from '#/lib/strings/helpers' 25 + import {isAppLabeler} from '#/lib/moderation' 26 + 27 + import {atoms as a, useTheme, tokens} from '#/alf' 28 + import {Button, ButtonText} from '#/components/Button' 29 + import {Text} from '#/components/Typography' 30 + import * as Toast from '#/view/com/util/Toast' 31 + import {ProfileHeaderShell} from './Shell' 32 + import {ProfileMenu} from '#/view/com/profile/ProfileMenu' 33 + import {ProfileHeaderDisplayName} from './DisplayName' 34 + import {ProfileHeaderHandle} from './Handle' 35 + import {ProfileHeaderMetrics} from './Metrics' 36 + import { 37 + Heart2_Stroke2_Corner0_Rounded as Heart, 38 + Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled, 39 + } from '#/components/icons/Heart2' 40 + import {DialogOuterProps} from '#/components/Dialog' 41 + import * as Prompt from '#/components/Prompt' 42 + import {Link} from '#/components/Link' 43 + 44 + interface Props { 45 + profile: AppBskyActorDefs.ProfileViewDetailed 46 + labeler: AppBskyLabelerDefs.LabelerViewDetailed 47 + descriptionRT: RichTextAPI | null 48 + moderationOpts: ModerationOpts 49 + hideBackButton?: boolean 50 + isPlaceholderProfile?: boolean 51 + } 52 + 53 + let ProfileHeaderLabeler = ({ 54 + profile: profileUnshadowed, 55 + labeler, 56 + descriptionRT, 57 + moderationOpts, 58 + hideBackButton = false, 59 + isPlaceholderProfile, 60 + }: Props): React.ReactNode => { 61 + const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> = 62 + useProfileShadow(profileUnshadowed) 63 + const t = useTheme() 64 + const {_} = useLingui() 65 + const {currentAccount, hasSession} = useSession() 66 + const {openModal} = useModalControls() 67 + const {track} = useAnalytics() 68 + const cantSubscribePrompt = Prompt.usePromptControl() 69 + const isSelf = currentAccount?.did === profile.did 70 + 71 + const moderation = useMemo( 72 + () => moderateProfile(profile, moderationOpts), 73 + [profile, moderationOpts], 74 + ) 75 + const {data: preferences} = usePreferencesQuery() 76 + const {mutateAsync: toggleSubscription, variables} = 77 + useLabelerSubscriptionMutation() 78 + const isSubscribed = 79 + variables?.subscribe ?? 80 + preferences?.moderationPrefs.labelers.find(l => l.did === profile.did) 81 + const canSubscribe = 82 + isSubscribed || 83 + (preferences ? preferences?.moderationPrefs.labelers.length < 9 : false) 84 + const {mutateAsync: likeMod, isPending: isLikePending} = useLikeMutation() 85 + const {mutateAsync: unlikeMod, isPending: isUnlikePending} = 86 + useUnlikeMutation() 87 + const [likeUri, setLikeUri] = React.useState<string>( 88 + labeler.viewer?.like || '', 89 + ) 90 + const [likeCount, setLikeCount] = React.useState(labeler.likeCount || 0) 91 + 92 + const onToggleLiked = React.useCallback(async () => { 93 + if (!labeler) { 94 + return 95 + } 96 + try { 97 + Haptics.default() 98 + 99 + if (likeUri) { 100 + await unlikeMod({uri: likeUri}) 101 + track('CustomFeed:Unlike') 102 + setLikeCount(c => c - 1) 103 + setLikeUri('') 104 + } else { 105 + const res = await likeMod({uri: labeler.uri, cid: labeler.cid}) 106 + track('CustomFeed:Like') 107 + setLikeCount(c => c + 1) 108 + setLikeUri(res.uri) 109 + } 110 + } catch (e: any) { 111 + Toast.show( 112 + _( 113 + msg`There was an an issue contacting the server, please check your internet connection and try again.`, 114 + ), 115 + ) 116 + logger.error(`Failed to toggle labeler like`, {message: e.message}) 117 + } 118 + }, [labeler, likeUri, likeMod, unlikeMod, track, _]) 119 + 120 + const onPressEditProfile = React.useCallback(() => { 121 + track('ProfileHeader:EditProfileButtonClicked') 122 + openModal({ 123 + name: 'edit-profile', 124 + profile, 125 + }) 126 + }, [track, openModal, profile]) 127 + 128 + const onPressSubscribe = React.useCallback(async () => { 129 + if (!canSubscribe) { 130 + cantSubscribePrompt.open() 131 + return 132 + } 133 + try { 134 + await toggleSubscription({ 135 + did: profile.did, 136 + subscribe: !isSubscribed, 137 + }) 138 + } catch (e: any) { 139 + // setSubscriptionError(e.message) 140 + logger.error(`Failed to subscribe to labeler`, {message: e.message}) 141 + } 142 + }, [ 143 + toggleSubscription, 144 + isSubscribed, 145 + profile, 146 + canSubscribe, 147 + cantSubscribePrompt, 148 + ]) 149 + 150 + const isMe = React.useMemo( 151 + () => currentAccount?.did === profile.did, 152 + [currentAccount, profile], 153 + ) 154 + 155 + return ( 156 + <ProfileHeaderShell 157 + profile={profile} 158 + moderation={moderation} 159 + hideBackButton={hideBackButton} 160 + isPlaceholderProfile={isPlaceholderProfile}> 161 + <View style={[a.px_lg, a.pt_md, a.pb_sm]} pointerEvents="box-none"> 162 + <View 163 + style={[a.flex_row, a.justify_end, a.gap_sm, a.pb_lg]} 164 + pointerEvents="box-none"> 165 + {isMe ? ( 166 + <Button 167 + testID="profileHeaderEditProfileButton" 168 + size="small" 169 + color="secondary" 170 + variant="solid" 171 + onPress={onPressEditProfile} 172 + label={_(msg`Edit profile`)} 173 + style={a.rounded_full}> 174 + <ButtonText> 175 + <Trans>Edit Profile</Trans> 176 + </ButtonText> 177 + </Button> 178 + ) : !isAppLabeler(profile.did) ? ( 179 + <> 180 + <Button 181 + testID="toggleSubscribeBtn" 182 + label={ 183 + isSubscribed 184 + ? _(msg`Unsubscribe from this labeler`) 185 + : _(msg`Subscribe to this labeler`) 186 + } 187 + disabled={!hasSession} 188 + onPress={onPressSubscribe}> 189 + {state => ( 190 + <View 191 + style={[ 192 + { 193 + paddingVertical: 12, 194 + backgroundColor: 195 + isSubscribed || !canSubscribe 196 + ? state.hovered || state.pressed 197 + ? t.palette.contrast_50 198 + : t.palette.contrast_25 199 + : state.hovered || state.pressed 200 + ? tokens.color.temp_purple_dark 201 + : tokens.color.temp_purple, 202 + }, 203 + a.px_lg, 204 + a.rounded_sm, 205 + a.gap_sm, 206 + ]}> 207 + <Text 208 + style={[ 209 + { 210 + color: canSubscribe 211 + ? isSubscribed 212 + ? t.palette.contrast_700 213 + : t.palette.white 214 + : t.palette.contrast_400, 215 + }, 216 + a.font_bold, 217 + a.text_center, 218 + ]}> 219 + {isSubscribed ? ( 220 + <Trans>Unsubscribe</Trans> 221 + ) : ( 222 + <Trans>Subscribe to Labeler</Trans> 223 + )} 224 + </Text> 225 + </View> 226 + )} 227 + </Button> 228 + </> 229 + ) : null} 230 + <ProfileMenu profile={profile} /> 231 + </View> 232 + <View style={[a.flex_col, a.gap_xs, a.pb_md]}> 233 + <ProfileHeaderDisplayName profile={profile} moderation={moderation} /> 234 + <ProfileHeaderHandle profile={profile} /> 235 + </View> 236 + {!isPlaceholderProfile && ( 237 + <> 238 + {isSelf && <ProfileHeaderMetrics profile={profile} />} 239 + {descriptionRT && !moderation.ui('profileView').blur ? ( 240 + <View pointerEvents="auto"> 241 + <RichText 242 + testID="profileHeaderDescription" 243 + style={[a.text_md]} 244 + numberOfLines={15} 245 + value={descriptionRT} 246 + /> 247 + </View> 248 + ) : undefined} 249 + {!isAppLabeler(profile.did) && ( 250 + <View style={[a.flex_row, a.gap_xs, a.align_center, a.pt_lg]}> 251 + <Button 252 + testID="toggleLikeBtn" 253 + size="small" 254 + color="secondary" 255 + variant="solid" 256 + shape="round" 257 + label={_(msg`Like this feed`)} 258 + disabled={!hasSession || isLikePending || isUnlikePending} 259 + onPress={onToggleLiked}> 260 + {likeUri ? ( 261 + <HeartFilled fill={t.palette.negative_400} /> 262 + ) : ( 263 + <Heart fill={t.atoms.text_contrast_medium.color} /> 264 + )} 265 + </Button> 266 + 267 + {typeof likeCount === 'number' && ( 268 + <Link 269 + to={{ 270 + screen: 'ProfileLabelerLikedBy', 271 + params: { 272 + name: labeler.creator.handle || labeler.creator.did, 273 + }, 274 + }} 275 + size="tiny" 276 + label={_( 277 + msg`Liked by ${likeCount} ${pluralize( 278 + likeCount, 279 + 'user', 280 + )}`, 281 + )}> 282 + {({hovered, focused, pressed}) => ( 283 + <Text 284 + style={[ 285 + a.font_bold, 286 + a.text_sm, 287 + t.atoms.text_contrast_medium, 288 + (hovered || focused || pressed) && 289 + t.atoms.text_contrast_high, 290 + ]}> 291 + <Trans> 292 + Liked by {likeCount} {pluralize(likeCount, 'user')} 293 + </Trans> 294 + </Text> 295 + )} 296 + </Link> 297 + )} 298 + </View> 299 + )} 300 + </> 301 + )} 302 + </View> 303 + <CantSubscribePrompt control={cantSubscribePrompt} /> 304 + </ProfileHeaderShell> 305 + ) 306 + } 307 + ProfileHeaderLabeler = memo(ProfileHeaderLabeler) 308 + export {ProfileHeaderLabeler} 309 + 310 + function CantSubscribePrompt({ 311 + control, 312 + }: { 313 + control: DialogOuterProps['control'] 314 + }) { 315 + return ( 316 + <Prompt.Outer control={control}> 317 + <Prompt.Title>Unable to subscribe</Prompt.Title> 318 + <Prompt.Description> 319 + <Trans> 320 + We're sorry! You can only subscribe to ten labelers, and you've 321 + reached your limit of ten. 322 + </Trans> 323 + </Prompt.Description> 324 + <Prompt.Actions> 325 + <Prompt.Action onPress={control.close}>OK</Prompt.Action> 326 + </Prompt.Actions> 327 + </Prompt.Outer> 328 + ) 329 + }
+286
src/screens/Profile/Header/ProfileHeaderStandard.tsx
··· 1 + import React, {memo, useMemo} from 'react' 2 + import {View} from 'react-native' 3 + import { 4 + AppBskyActorDefs, 5 + ModerationOpts, 6 + moderateProfile, 7 + RichText as RichTextAPI, 8 + } from '@atproto/api' 9 + import {Trans, msg} from '@lingui/macro' 10 + import {useLingui} from '@lingui/react' 11 + import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 12 + 13 + import {useModalControls} from '#/state/modals' 14 + import {useAnalytics} from 'lib/analytics/analytics' 15 + import {useSession, useRequireAuth} from '#/state/session' 16 + import {Shadow} from '#/state/cache/types' 17 + import {useProfileShadow} from 'state/cache/profile-shadow' 18 + import { 19 + useProfileFollowMutationQueue, 20 + useProfileBlockMutationQueue, 21 + } from '#/state/queries/profile' 22 + import {logger} from '#/logger' 23 + import {sanitizeDisplayName} from 'lib/strings/display-names' 24 + 25 + import {atoms as a, useTheme} from '#/alf' 26 + import {Button, ButtonText, ButtonIcon} from '#/components/Button' 27 + import * as Toast from '#/view/com/util/Toast' 28 + import {ProfileHeaderShell} from './Shell' 29 + import {ProfileMenu} from '#/view/com/profile/ProfileMenu' 30 + import {ProfileHeaderDisplayName} from './DisplayName' 31 + import {ProfileHeaderHandle} from './Handle' 32 + import {ProfileHeaderMetrics} from './Metrics' 33 + import {ProfileHeaderSuggestedFollows} from '#/view/com/profile/ProfileHeaderSuggestedFollows' 34 + import {RichText} from '#/components/RichText' 35 + import * as Prompt from '#/components/Prompt' 36 + import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 37 + import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 38 + 39 + interface Props { 40 + profile: AppBskyActorDefs.ProfileViewDetailed 41 + descriptionRT: RichTextAPI | null 42 + moderationOpts: ModerationOpts 43 + hideBackButton?: boolean 44 + isPlaceholderProfile?: boolean 45 + } 46 + 47 + let ProfileHeaderStandard = ({ 48 + profile: profileUnshadowed, 49 + descriptionRT, 50 + moderationOpts, 51 + hideBackButton = false, 52 + isPlaceholderProfile, 53 + }: Props): React.ReactNode => { 54 + const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> = 55 + useProfileShadow(profileUnshadowed) 56 + const t = useTheme() 57 + const {currentAccount, hasSession} = useSession() 58 + const {_} = useLingui() 59 + const {openModal} = useModalControls() 60 + const {track} = useAnalytics() 61 + const moderation = useMemo( 62 + () => moderateProfile(profile, moderationOpts), 63 + [profile, moderationOpts], 64 + ) 65 + const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false) 66 + const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( 67 + profile, 68 + 'ProfileHeader', 69 + ) 70 + const [_queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) 71 + const unblockPromptControl = Prompt.usePromptControl() 72 + const requireAuth = useRequireAuth() 73 + 74 + const onPressEditProfile = React.useCallback(() => { 75 + track('ProfileHeader:EditProfileButtonClicked') 76 + openModal({ 77 + name: 'edit-profile', 78 + profile, 79 + }) 80 + }, [track, openModal, profile]) 81 + 82 + const onPressFollow = () => { 83 + requireAuth(async () => { 84 + try { 85 + track('ProfileHeader:FollowButtonClicked') 86 + await queueFollow() 87 + Toast.show( 88 + _( 89 + msg`Following ${sanitizeDisplayName( 90 + profile.displayName || profile.handle, 91 + moderation.ui('displayName'), 92 + )}`, 93 + ), 94 + ) 95 + } catch (e: any) { 96 + if (e?.name !== 'AbortError') { 97 + logger.error('Failed to follow', {message: String(e)}) 98 + Toast.show(_(msg`There was an issue! ${e.toString()}`)) 99 + } 100 + } 101 + }) 102 + } 103 + 104 + const onPressUnfollow = () => { 105 + requireAuth(async () => { 106 + try { 107 + track('ProfileHeader:UnfollowButtonClicked') 108 + await queueUnfollow() 109 + Toast.show( 110 + _( 111 + msg`No longer following ${sanitizeDisplayName( 112 + profile.displayName || profile.handle, 113 + moderation.ui('displayName'), 114 + )}`, 115 + ), 116 + ) 117 + } catch (e: any) { 118 + if (e?.name !== 'AbortError') { 119 + logger.error('Failed to unfollow', {message: String(e)}) 120 + Toast.show(_(msg`There was an issue! ${e.toString()}`)) 121 + } 122 + } 123 + }) 124 + } 125 + 126 + const unblockAccount = React.useCallback(async () => { 127 + track('ProfileHeader:UnblockAccountButtonClicked') 128 + try { 129 + await queueUnblock() 130 + Toast.show(_(msg`Account unblocked`)) 131 + } catch (e: any) { 132 + if (e?.name !== 'AbortError') { 133 + logger.error('Failed to unblock account', {message: e}) 134 + Toast.show(_(msg`There was an issue! ${e.toString()}`)) 135 + } 136 + } 137 + }, [_, queueUnblock, track]) 138 + 139 + const isMe = React.useMemo( 140 + () => currentAccount?.did === profile.did, 141 + [currentAccount, profile], 142 + ) 143 + 144 + return ( 145 + <ProfileHeaderShell 146 + profile={profile} 147 + moderation={moderation} 148 + hideBackButton={hideBackButton} 149 + isPlaceholderProfile={isPlaceholderProfile}> 150 + <View style={[a.px_lg, a.pt_md, a.pb_sm]} pointerEvents="box-none"> 151 + <View 152 + style={[a.flex_row, a.justify_end, a.gap_sm, a.pb_sm]} 153 + pointerEvents="box-none"> 154 + {isMe ? ( 155 + <Button 156 + testID="profileHeaderEditProfileButton" 157 + size="small" 158 + color="secondary" 159 + variant="solid" 160 + onPress={onPressEditProfile} 161 + label={_(msg`Edit profile`)} 162 + style={a.rounded_full}> 163 + <ButtonText> 164 + <Trans>Edit Profile</Trans> 165 + </ButtonText> 166 + </Button> 167 + ) : profile.viewer?.blocking ? ( 168 + profile.viewer?.blockingByList ? null : ( 169 + <Button 170 + testID="unblockBtn" 171 + size="small" 172 + color="secondary" 173 + variant="solid" 174 + label={_(msg`Unblock`)} 175 + disabled={!hasSession} 176 + onPress={() => unblockPromptControl.open()} 177 + style={a.rounded_full}> 178 + <ButtonText> 179 + <Trans context="action">Unblock</Trans> 180 + </ButtonText> 181 + </Button> 182 + ) 183 + ) : !profile.viewer?.blockedBy ? ( 184 + <> 185 + {hasSession && ( 186 + <Button 187 + testID="suggestedFollowsBtn" 188 + size="small" 189 + color={showSuggestedFollows ? 'primary' : 'secondary'} 190 + variant="solid" 191 + shape="round" 192 + onPress={() => setShowSuggestedFollows(!showSuggestedFollows)} 193 + label={_(msg`Show follows similar to ${profile.handle}`)}> 194 + <FontAwesomeIcon 195 + icon="user-plus" 196 + style={ 197 + showSuggestedFollows 198 + ? {color: t.palette.white} 199 + : t.atoms.text 200 + } 201 + size={14} 202 + /> 203 + </Button> 204 + )} 205 + 206 + <Button 207 + testID={profile.viewer?.following ? 'unfollowBtn' : 'followBtn'} 208 + size="small" 209 + color={profile.viewer?.following ? 'secondary' : 'primary'} 210 + variant="solid" 211 + label={ 212 + profile.viewer?.following 213 + ? _(msg`Unfollow ${profile.handle}`) 214 + : _(msg`Follow ${profile.handle}`) 215 + } 216 + disabled={!hasSession} 217 + onPress={ 218 + profile.viewer?.following ? onPressUnfollow : onPressFollow 219 + } 220 + style={[a.rounded_full, a.gap_xs]}> 221 + <ButtonIcon 222 + position="left" 223 + icon={profile.viewer?.following ? Check : Plus} 224 + /> 225 + <ButtonText> 226 + {profile.viewer?.following ? ( 227 + <Trans>Following</Trans> 228 + ) : ( 229 + <Trans>Follow</Trans> 230 + )} 231 + </ButtonText> 232 + </Button> 233 + </> 234 + ) : null} 235 + <ProfileMenu profile={profile} /> 236 + </View> 237 + <View style={[a.flex_col, a.gap_xs, a.pb_sm]}> 238 + <ProfileHeaderDisplayName profile={profile} moderation={moderation} /> 239 + <ProfileHeaderHandle profile={profile} /> 240 + </View> 241 + {!isPlaceholderProfile && ( 242 + <> 243 + <ProfileHeaderMetrics profile={profile} /> 244 + {descriptionRT && !moderation.ui('profileView').blur ? ( 245 + <View pointerEvents="auto"> 246 + <RichText 247 + testID="profileHeaderDescription" 248 + style={[a.text_md]} 249 + numberOfLines={15} 250 + value={descriptionRT} 251 + /> 252 + </View> 253 + ) : undefined} 254 + </> 255 + )} 256 + </View> 257 + {showSuggestedFollows && ( 258 + <ProfileHeaderSuggestedFollows 259 + actorDid={profile.did} 260 + requestDismiss={() => { 261 + if (showSuggestedFollows) { 262 + setShowSuggestedFollows(false) 263 + } else { 264 + track('ProfileHeader:SuggestedFollowsOpened') 265 + setShowSuggestedFollows(true) 266 + } 267 + }} 268 + /> 269 + )} 270 + <Prompt.Basic 271 + control={unblockPromptControl} 272 + title={_(msg`Unblock Account?`)} 273 + description={_( 274 + msg`The account will be able to interact with you after unblocking.`, 275 + )} 276 + onConfirm={unblockAccount} 277 + confirmButtonCta={ 278 + profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`) 279 + } 280 + confirmButtonColor="negative" 281 + /> 282 + </ProfileHeaderShell> 283 + ) 284 + } 285 + ProfileHeaderStandard = memo(ProfileHeaderStandard) 286 + export {ProfileHeaderStandard}
+164
src/screens/Profile/Header/Shell.tsx
··· 1 + import React, {memo} from 'react' 2 + import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native' 3 + import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 4 + import {useNavigation} from '@react-navigation/native' 5 + import {AppBskyActorDefs, ModerationDecision} from '@atproto/api' 6 + import {msg} from '@lingui/macro' 7 + import {useLingui} from '@lingui/react' 8 + import {NavigationProp} from 'lib/routes/types' 9 + import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 10 + import {BACK_HITSLOP} from 'lib/constants' 11 + import {useSession} from '#/state/session' 12 + import {Shadow} from '#/state/cache/types' 13 + import {useLightboxControls, ProfileImageLightbox} from '#/state/lightbox' 14 + 15 + import {atoms as a, useTheme} from '#/alf' 16 + import {LabelsOnMe} from '#/components/moderation/LabelsOnMe' 17 + import {BlurView} from 'view/com/util/BlurView' 18 + import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' 19 + import {UserAvatar} from 'view/com/util/UserAvatar' 20 + import {UserBanner} from 'view/com/util/UserBanner' 21 + import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts' 22 + 23 + interface Props { 24 + profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> 25 + moderation: ModerationDecision 26 + hideBackButton?: boolean 27 + isPlaceholderProfile?: boolean 28 + } 29 + 30 + let ProfileHeaderShell = ({ 31 + children, 32 + profile, 33 + moderation, 34 + hideBackButton = false, 35 + isPlaceholderProfile, 36 + }: React.PropsWithChildren<Props>): React.ReactNode => { 37 + const t = useTheme() 38 + const {currentAccount} = useSession() 39 + const {_} = useLingui() 40 + const {openLightbox} = useLightboxControls() 41 + const navigation = useNavigation<NavigationProp>() 42 + const {isDesktop} = useWebMediaQueries() 43 + 44 + const onPressBack = React.useCallback(() => { 45 + if (navigation.canGoBack()) { 46 + navigation.goBack() 47 + } else { 48 + navigation.navigate('Home') 49 + } 50 + }, [navigation]) 51 + 52 + const onPressAvi = React.useCallback(() => { 53 + const modui = moderation.ui('avatar') 54 + if (profile.avatar && !(modui.blur && modui.noOverride)) { 55 + openLightbox(new ProfileImageLightbox(profile)) 56 + } 57 + }, [openLightbox, profile, moderation]) 58 + 59 + const isMe = React.useMemo( 60 + () => currentAccount?.did === profile.did, 61 + [currentAccount, profile], 62 + ) 63 + 64 + return ( 65 + <View style={t.atoms.bg} pointerEvents="box-none"> 66 + <View pointerEvents="none"> 67 + {isPlaceholderProfile ? ( 68 + <LoadingPlaceholder 69 + width="100%" 70 + height={150} 71 + style={{borderRadius: 0}} 72 + /> 73 + ) : ( 74 + <UserBanner 75 + type={profile.associated?.labeler ? 'labeler' : 'default'} 76 + banner={profile.banner} 77 + moderation={moderation.ui('banner')} 78 + /> 79 + )} 80 + </View> 81 + 82 + {children} 83 + 84 + <View style={[a.px_lg, a.pb_sm]} pointerEvents="box-none"> 85 + <ProfileHeaderAlerts moderation={moderation} /> 86 + {isMe && ( 87 + <LabelsOnMe details={{did: profile.did}} labels={profile.labels} /> 88 + )} 89 + </View> 90 + 91 + {!isDesktop && !hideBackButton && ( 92 + <TouchableWithoutFeedback 93 + testID="profileHeaderBackBtn" 94 + onPress={onPressBack} 95 + hitSlop={BACK_HITSLOP} 96 + accessibilityRole="button" 97 + accessibilityLabel={_(msg`Back`)} 98 + accessibilityHint=""> 99 + <View style={styles.backBtnWrapper}> 100 + <BlurView style={styles.backBtn} blurType="dark"> 101 + <FontAwesomeIcon size={18} icon="angle-left" color="white" /> 102 + </BlurView> 103 + </View> 104 + </TouchableWithoutFeedback> 105 + )} 106 + <TouchableWithoutFeedback 107 + testID="profileHeaderAviButton" 108 + onPress={onPressAvi} 109 + accessibilityRole="image" 110 + accessibilityLabel={_(msg`View ${profile.handle}'s avatar`)} 111 + accessibilityHint=""> 112 + <View 113 + style={[ 114 + t.atoms.bg, 115 + {borderColor: t.atoms.bg.backgroundColor}, 116 + styles.avi, 117 + profile.associated?.labeler && styles.aviLabeler, 118 + ]}> 119 + <UserAvatar 120 + type={profile.associated?.labeler ? 'labeler' : 'user'} 121 + size={90} 122 + avatar={profile.avatar} 123 + moderation={moderation.ui('avatar')} 124 + /> 125 + </View> 126 + </TouchableWithoutFeedback> 127 + </View> 128 + ) 129 + } 130 + ProfileHeaderShell = memo(ProfileHeaderShell) 131 + export {ProfileHeaderShell} 132 + 133 + const styles = StyleSheet.create({ 134 + backBtnWrapper: { 135 + position: 'absolute', 136 + top: 10, 137 + left: 10, 138 + width: 30, 139 + height: 30, 140 + overflow: 'hidden', 141 + borderRadius: 15, 142 + // @ts-ignore web only 143 + cursor: 'pointer', 144 + }, 145 + backBtn: { 146 + width: 30, 147 + height: 30, 148 + borderRadius: 15, 149 + alignItems: 'center', 150 + justifyContent: 'center', 151 + }, 152 + avi: { 153 + position: 'absolute', 154 + top: 110, 155 + left: 10, 156 + width: 94, 157 + height: 94, 158 + borderRadius: 47, 159 + borderWidth: 2, 160 + }, 161 + aviLabeler: { 162 + borderRadius: 10, 163 + }, 164 + })
+78
src/screens/Profile/Header/index.tsx
··· 1 + import React, {memo} from 'react' 2 + import {StyleSheet, View} from 'react-native' 3 + import { 4 + AppBskyActorDefs, 5 + AppBskyLabelerDefs, 6 + ModerationOpts, 7 + RichText as RichTextAPI, 8 + } from '@atproto/api' 9 + import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' 10 + import {usePalette} from 'lib/hooks/usePalette' 11 + 12 + import {ProfileHeaderStandard} from './ProfileHeaderStandard' 13 + import {ProfileHeaderLabeler} from './ProfileHeaderLabeler' 14 + 15 + let ProfileHeaderLoading = (_props: {}): React.ReactNode => { 16 + const pal = usePalette('default') 17 + return ( 18 + <View style={pal.view}> 19 + <LoadingPlaceholder width="100%" height={150} style={{borderRadius: 0}} /> 20 + <View 21 + style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}> 22 + <LoadingPlaceholder width={80} height={80} style={styles.br40} /> 23 + </View> 24 + <View style={styles.content}> 25 + <View style={[styles.buttonsLine]}> 26 + <LoadingPlaceholder width={167} height={31} style={styles.br50} /> 27 + </View> 28 + </View> 29 + </View> 30 + ) 31 + } 32 + ProfileHeaderLoading = memo(ProfileHeaderLoading) 33 + export {ProfileHeaderLoading} 34 + 35 + interface Props { 36 + profile: AppBskyActorDefs.ProfileViewDetailed 37 + labeler: AppBskyLabelerDefs.LabelerViewDetailed | undefined 38 + descriptionRT: RichTextAPI | null 39 + moderationOpts: ModerationOpts 40 + hideBackButton?: boolean 41 + isPlaceholderProfile?: boolean 42 + } 43 + 44 + let ProfileHeader = (props: Props): React.ReactNode => { 45 + if (props.profile.associated?.labeler) { 46 + if (!props.labeler) { 47 + return <ProfileHeaderLoading /> 48 + } 49 + return <ProfileHeaderLabeler {...props} labeler={props.labeler} /> 50 + } 51 + return <ProfileHeaderStandard {...props} /> 52 + } 53 + ProfileHeader = memo(ProfileHeader) 54 + export {ProfileHeader} 55 + 56 + const styles = StyleSheet.create({ 57 + avi: { 58 + position: 'absolute', 59 + top: 110, 60 + left: 10, 61 + width: 84, 62 + height: 84, 63 + borderRadius: 42, 64 + borderWidth: 2, 65 + }, 66 + content: { 67 + paddingTop: 8, 68 + paddingHorizontal: 14, 69 + paddingBottom: 4, 70 + }, 71 + buttonsLine: { 72 + flexDirection: 'row', 73 + marginLeft: 'auto', 74 + marginBottom: 12, 75 + }, 76 + br40: {borderRadius: 40}, 77 + br50: {borderRadius: 50}, 78 + })
+46
src/screens/Profile/ProfileLabelerLikedBy.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {msg} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + import {useFocusEffect} from '@react-navigation/native' 6 + 7 + import {NativeStackScreenProps, CommonNavigatorParams} from '#/lib/routes/types' 8 + import {ViewHeader} from '#/view/com/util/ViewHeader' 9 + import {LikedByList} from '#/components/LikedByList' 10 + import {useSetMinimalShellMode} from '#/state/shell' 11 + import {makeRecordUri} from '#/lib/strings/url-helpers' 12 + 13 + import {atoms as a, useBreakpoints} from '#/alf' 14 + 15 + export function ProfileLabelerLikedByScreen({ 16 + route, 17 + }: NativeStackScreenProps<CommonNavigatorParams, 'ProfileLabelerLikedBy'>) { 18 + const setMinimalShellMode = useSetMinimalShellMode() 19 + const {name: handleOrDid} = route.params 20 + const uri = makeRecordUri(handleOrDid, 'app.bsky.labeler.service', 'self') 21 + const {_} = useLingui() 22 + const {gtMobile} = useBreakpoints() 23 + 24 + useFocusEffect( 25 + React.useCallback(() => { 26 + setMinimalShellMode(false) 27 + }, [setMinimalShellMode]), 28 + ) 29 + 30 + return ( 31 + <View 32 + style={[ 33 + a.mx_auto, 34 + a.w_full, 35 + a.h_full_vh, 36 + gtMobile && [ 37 + { 38 + maxWidth: 600, 39 + }, 40 + ], 41 + ]}> 42 + <ViewHeader title={_(msg`Liked By`)} /> 43 + <LikedByList uri={uri} /> 44 + </View> 45 + ) 46 + }
+88
src/screens/Profile/Sections/Feed.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + import {ListRef} from 'view/com/util/List' 6 + import {Feed} from 'view/com/posts/Feed' 7 + import {EmptyState} from 'view/com/util/EmptyState' 8 + import {FeedDescriptor} from '#/state/queries/post-feed' 9 + import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' 10 + import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn' 11 + import {useQueryClient} from '@tanstack/react-query' 12 + import {truncateAndInvalidate} from '#/state/queries/util' 13 + import {Text} from '#/view/com/util/text/Text' 14 + import {usePalette} from 'lib/hooks/usePalette' 15 + import {isNative} from '#/platform/detection' 16 + import {SectionRef} from './types' 17 + 18 + interface FeedSectionProps { 19 + feed: FeedDescriptor 20 + headerHeight: number 21 + isFocused: boolean 22 + scrollElRef: ListRef 23 + ignoreFilterFor?: string 24 + } 25 + export const ProfileFeedSection = React.forwardRef< 26 + SectionRef, 27 + FeedSectionProps 28 + >(function FeedSectionImpl( 29 + {feed, headerHeight, isFocused, scrollElRef, ignoreFilterFor}, 30 + ref, 31 + ) { 32 + const {_} = useLingui() 33 + const queryClient = useQueryClient() 34 + const [hasNew, setHasNew] = React.useState(false) 35 + const [isScrolledDown, setIsScrolledDown] = React.useState(false) 36 + 37 + const onScrollToTop = React.useCallback(() => { 38 + scrollElRef.current?.scrollToOffset({ 39 + animated: isNative, 40 + offset: -headerHeight, 41 + }) 42 + truncateAndInvalidate(queryClient, FEED_RQKEY(feed)) 43 + setHasNew(false) 44 + }, [scrollElRef, headerHeight, queryClient, feed, setHasNew]) 45 + React.useImperativeHandle(ref, () => ({ 46 + scrollToTop: onScrollToTop, 47 + })) 48 + 49 + const renderPostsEmpty = React.useCallback(() => { 50 + return <EmptyState icon="feed" message={_(msg`This feed is empty!`)} /> 51 + }, [_]) 52 + 53 + return ( 54 + <View> 55 + <Feed 56 + testID="postsFeed" 57 + enabled={isFocused} 58 + feed={feed} 59 + scrollElRef={scrollElRef} 60 + onHasNew={setHasNew} 61 + onScrolledDownChange={setIsScrolledDown} 62 + renderEmptyState={renderPostsEmpty} 63 + headerOffset={headerHeight} 64 + renderEndOfFeed={ProfileEndOfFeed} 65 + ignoreFilterFor={ignoreFilterFor} 66 + /> 67 + {(isScrolledDown || hasNew) && ( 68 + <LoadLatestBtn 69 + onPress={onScrollToTop} 70 + label={_(msg`Load new posts`)} 71 + showIndicator={hasNew} 72 + /> 73 + )} 74 + </View> 75 + ) 76 + }) 77 + 78 + function ProfileEndOfFeed() { 79 + const pal = usePalette('default') 80 + 81 + return ( 82 + <View style={[pal.border, {paddingTop: 32, borderTopWidth: 1}]}> 83 + <Text style={[pal.textLight, pal.border, {textAlign: 'center'}]}> 84 + <Trans>End of feed</Trans> 85 + </Text> 86 + </View> 87 + ) 88 + }
+233
src/screens/Profile/Sections/Labels.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import { 4 + AppBskyLabelerDefs, 5 + ModerationOpts, 6 + interpretLabelValueDefinitions, 7 + InterpretedLabelValueDefinition, 8 + } from '@atproto/api' 9 + import {Trans, msg} from '@lingui/macro' 10 + import {useLingui} from '@lingui/react' 11 + import {useSafeAreaFrame} from 'react-native-safe-area-context' 12 + 13 + import {useScrollHandlers} from '#/lib/ScrollContext' 14 + import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' 15 + import {isLabelerSubscribed, lookupLabelValueDefinition} from '#/lib/moderation' 16 + import {ListRef} from '#/view/com/util/List' 17 + import {SectionRef} from './types' 18 + import {isNative} from '#/platform/detection' 19 + 20 + import {useTheme, atoms as a} from '#/alf' 21 + import {Text} from '#/components/Typography' 22 + import {Loader} from '#/components/Loader' 23 + import {Divider} from '#/components/Divider' 24 + import {CenteredView, ScrollView} from '#/view/com/util/Views' 25 + import {ErrorState} from '../ErrorState' 26 + import {ModerationLabelPref} from '#/components/moderation/ModerationLabelPref' 27 + import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 28 + 29 + interface LabelsSectionProps { 30 + isLabelerLoading: boolean 31 + labelerInfo: AppBskyLabelerDefs.LabelerViewDetailed | undefined 32 + labelerError: Error | null 33 + moderationOpts: ModerationOpts 34 + scrollElRef: ListRef 35 + headerHeight: number 36 + } 37 + export const ProfileLabelsSection = React.forwardRef< 38 + SectionRef, 39 + LabelsSectionProps 40 + >(function LabelsSectionImpl( 41 + { 42 + isLabelerLoading, 43 + labelerInfo, 44 + labelerError, 45 + moderationOpts, 46 + scrollElRef, 47 + headerHeight, 48 + }, 49 + ref, 50 + ) { 51 + const t = useTheme() 52 + const {_} = useLingui() 53 + const {height: minHeight} = useSafeAreaFrame() 54 + 55 + const onScrollToTop = React.useCallback(() => { 56 + // @ts-ignore TODO fix this 57 + scrollElRef.current?.scrollTo({ 58 + animated: isNative, 59 + x: 0, 60 + y: -headerHeight, 61 + }) 62 + }, [scrollElRef, headerHeight]) 63 + 64 + React.useImperativeHandle(ref, () => ({ 65 + scrollToTop: onScrollToTop, 66 + })) 67 + 68 + return ( 69 + <CenteredView> 70 + <View 71 + style={[ 72 + a.border_l, 73 + a.border_r, 74 + a.border_t, 75 + t.atoms.border_contrast_low, 76 + { 77 + minHeight, 78 + }, 79 + ]}> 80 + {isLabelerLoading ? ( 81 + <View style={[a.w_full, a.align_center]}> 82 + <Loader size="xl" /> 83 + </View> 84 + ) : labelerError || !labelerInfo ? ( 85 + <ErrorState 86 + error={ 87 + labelerError?.toString() || 88 + _(msg`Something went wrong, please try again.`) 89 + } 90 + /> 91 + ) : ( 92 + <ProfileLabelsSectionInner 93 + moderationOpts={moderationOpts} 94 + labelerInfo={labelerInfo} 95 + scrollElRef={scrollElRef} 96 + headerHeight={headerHeight} 97 + /> 98 + )} 99 + </View> 100 + </CenteredView> 101 + ) 102 + }) 103 + 104 + export function ProfileLabelsSectionInner({ 105 + moderationOpts, 106 + labelerInfo, 107 + scrollElRef, 108 + headerHeight, 109 + }: { 110 + moderationOpts: ModerationOpts 111 + labelerInfo: AppBskyLabelerDefs.LabelerViewDetailed 112 + scrollElRef: ListRef 113 + headerHeight: number 114 + }) { 115 + const t = useTheme() 116 + const contextScrollHandlers = useScrollHandlers() 117 + 118 + const scrollHandler = useAnimatedScrollHandler({ 119 + onBeginDrag(e, ctx) { 120 + contextScrollHandlers.onBeginDrag?.(e, ctx) 121 + }, 122 + onEndDrag(e, ctx) { 123 + contextScrollHandlers.onEndDrag?.(e, ctx) 124 + }, 125 + onScroll(e, ctx) { 126 + contextScrollHandlers.onScroll?.(e, ctx) 127 + }, 128 + }) 129 + 130 + const {labelValues} = labelerInfo.policies 131 + const isSubscribed = isLabelerSubscribed(labelerInfo, moderationOpts) 132 + const labelDefs = React.useMemo(() => { 133 + const customDefs = interpretLabelValueDefinitions(labelerInfo) 134 + return labelValues 135 + .map(val => lookupLabelValueDefinition(val, customDefs)) 136 + .filter( 137 + def => def && def?.configurable, 138 + ) as InterpretedLabelValueDefinition[] 139 + }, [labelerInfo, labelValues]) 140 + 141 + return ( 142 + <ScrollView 143 + // @ts-ignore TODO fix this 144 + ref={scrollElRef} 145 + scrollEventThrottle={1} 146 + contentContainerStyle={{ 147 + paddingTop: headerHeight, 148 + borderWidth: 0, 149 + }} 150 + contentOffset={{x: 0, y: headerHeight * -1}} 151 + onScroll={scrollHandler}> 152 + <View 153 + style={[ 154 + a.pt_xl, 155 + a.px_lg, 156 + isNative && a.border_t, 157 + t.atoms.border_contrast_low, 158 + ]}> 159 + <View> 160 + <Text style={[t.atoms.text_contrast_high, a.leading_snug, a.text_sm]}> 161 + <Trans> 162 + Labels are annotations on users and content. They can be used to 163 + hide, warn, and categorize the network. 164 + </Trans> 165 + </Text> 166 + {labelerInfo.creator.viewer?.blocking ? ( 167 + <View style={[a.flex_row, a.gap_sm, a.align_center, a.mt_md]}> 168 + <CircleInfo size="sm" fill={t.atoms.text_contrast_medium.color} /> 169 + <Text 170 + style={[t.atoms.text_contrast_high, a.leading_snug, a.text_sm]}> 171 + <Trans> 172 + Blocking does not prevent this labeler from placing labels on 173 + your account. 174 + </Trans> 175 + </Text> 176 + </View> 177 + ) : null} 178 + {labelValues.length === 0 ? ( 179 + <Text 180 + style={[ 181 + a.pt_xl, 182 + t.atoms.text_contrast_high, 183 + a.leading_snug, 184 + a.text_sm, 185 + ]}> 186 + <Trans> 187 + This labeler hasn't declared what labels it publishes, and may 188 + not be active. 189 + </Trans> 190 + </Text> 191 + ) : !isSubscribed ? ( 192 + <Text 193 + style={[ 194 + a.pt_xl, 195 + t.atoms.text_contrast_high, 196 + a.leading_snug, 197 + a.text_sm, 198 + ]}> 199 + <Trans> 200 + Subscribe to @{labelerInfo.creator.handle} to use these labels: 201 + </Trans> 202 + </Text> 203 + ) : null} 204 + </View> 205 + {labelDefs.length > 0 && ( 206 + <View 207 + style={[ 208 + a.mt_xl, 209 + a.w_full, 210 + a.rounded_md, 211 + a.overflow_hidden, 212 + t.atoms.bg_contrast_25, 213 + ]}> 214 + {labelDefs.map((labelDef, i) => { 215 + return ( 216 + <React.Fragment key={labelDef.identifier}> 217 + {i !== 0 && <Divider />} 218 + <ModerationLabelPref 219 + disabled={isSubscribed ? undefined : true} 220 + labelValueDefinition={labelDef} 221 + labelerDid={labelerInfo.creator.did} 222 + /> 223 + </React.Fragment> 224 + ) 225 + })} 226 + </View> 227 + )} 228 + 229 + <View style={{height: 400}} /> 230 + </View> 231 + </ScrollView> 232 + ) 233 + }
+3
src/screens/Profile/Sections/types.ts
··· 1 + export interface SectionRef { 2 + scrollToTop: () => void 3 + }
+1 -42
src/state/modals/index.tsx
··· 1 1 import React from 'react' 2 - import {AppBskyActorDefs, AppBskyGraphDefs, ModerationUI} from '@atproto/api' 2 + import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api' 3 3 import {Image as RNImage} from 'react-native-image-crop-picker' 4 4 5 5 import {ImageModel} from '#/state/models/media/image' ··· 14 14 onUpdate?: () => void 15 15 } 16 16 17 - export interface ModerationDetailsModal { 18 - name: 'moderation-details' 19 - context: 'account' | 'content' 20 - moderation: ModerationUI 21 - } 22 - 23 - export type ReportModal = { 24 - name: 'report' 25 - } & ( 26 - | { 27 - uri: string 28 - cid: string 29 - } 30 - | {did: string} 31 - ) 32 - 33 - export type AppealLabelModal = { 34 - name: 'appeal-label' 35 - } & ( 36 - | { 37 - uri: string 38 - cid: string 39 - } 40 - | {did: string} 41 - ) 42 - 43 17 export interface CreateOrEditListModal { 44 18 name: 'create-or-edit-list' 45 19 purpose?: string ··· 123 97 name: 'add-app-password' 124 98 } 125 99 126 - export interface ContentFilteringSettingsModal { 127 - name: 'content-filtering-settings' 128 - } 129 - 130 100 export interface ContentLanguagesSettingsModal { 131 101 name: 'content-languages-settings' 132 102 } 133 103 134 104 export interface PostLanguagesSettingsModal { 135 105 name: 'post-languages-settings' 136 - } 137 - 138 - export interface BirthDateSettingsModal { 139 - name: 'birth-date-settings' 140 106 } 141 107 142 108 export interface VerifyEmailModal { ··· 179 145 | ChangeHandleModal 180 146 | DeleteAccountModal 181 147 | EditProfileModal 182 - | BirthDateSettingsModal 183 148 | VerifyEmailModal 184 149 | ChangeEmailModal 185 150 | ChangePasswordModal 186 151 | SwitchAccountModal 187 152 188 153 // Curation 189 - | ContentFilteringSettingsModal 190 154 | ContentLanguagesSettingsModal 191 155 | PostLanguagesSettingsModal 192 - 193 - // Moderation 194 - | ModerationDetailsModal 195 - | ReportModal 196 - | AppealLabelModal 197 156 198 157 // Lists 199 158 | CreateOrEditListModal
+1
src/state/preferences/index.tsx
··· 15 15 useSetExternalEmbedPref, 16 16 } from './external-embeds-prefs' 17 17 export * from './hidden-posts' 18 + export {useLabelDefinitions} from './label-defs' 18 19 19 20 export function Provider({children}: React.PropsWithChildren<{}>) { 20 21 return (
+25
src/state/preferences/label-defs.tsx
··· 1 + import React from 'react' 2 + import {InterpretedLabelValueDefinition, AppBskyLabelerDefs} from '@atproto/api' 3 + import {useLabelDefinitionsQuery} from '../queries/preferences' 4 + 5 + interface StateContext { 6 + labelDefs: Record<string, InterpretedLabelValueDefinition[]> 7 + labelers: AppBskyLabelerDefs.LabelerViewDetailed[] 8 + } 9 + 10 + const stateContext = React.createContext<StateContext>({ 11 + labelDefs: {}, 12 + labelers: [], 13 + }) 14 + 15 + export function Provider({children}: React.PropsWithChildren<{}>) { 16 + const {labelDefs, labelers} = useLabelDefinitionsQuery() 17 + 18 + const state = {labelDefs, labelers} 19 + 20 + return <stateContext.Provider value={state}>{children}</stateContext.Provider> 21 + } 22 + 23 + export function useLabelDefinitions() { 24 + return React.useContext(stateContext) 25 + }
+8 -11
src/state/queries/actor-autocomplete.ts
··· 6 6 import {getAgent} from '#/state/session' 7 7 import {useMyFollowsQuery} from '#/state/queries/my-follows' 8 8 import {STALE} from '#/state/queries' 9 - import { 10 - DEFAULT_LOGGED_OUT_PREFERENCES, 11 - getModerationOpts, 12 - useModerationOpts, 13 - } from './preferences' 9 + import {DEFAULT_LOGGED_OUT_PREFERENCES, useModerationOpts} from './preferences' 14 10 import {isInvalidHandle} from '#/lib/strings/handles' 11 + import {isJustAMute} from '#/lib/moderation' 15 12 16 - const DEFAULT_MOD_OPTS = getModerationOpts({ 17 - userDid: '', 18 - preferences: DEFAULT_LOGGED_OUT_PREFERENCES, 19 - }) 13 + const DEFAULT_MOD_OPTS = { 14 + userDid: undefined, 15 + prefs: DEFAULT_LOGGED_OUT_PREFERENCES.moderationPrefs, 16 + } 20 17 21 18 export const RQKEY = (prefix: string) => ['actor-autocomplete', prefix] 22 19 ··· 114 111 } 115 112 } 116 113 return items.filter(profile => { 117 - const mod = moderateProfile(profile, moderationOpts) 118 - return !mod.account.filter && mod.account.cause?.type !== 'muted' 114 + const modui = moderateProfile(profile, moderationOpts).ui('profileList') 115 + return !modui.filter || isJustAMute(modui) 119 116 }) 120 117 } 121 118
+89
src/state/queries/labeler.ts
··· 1 + import {z} from 'zod' 2 + import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query' 3 + import {AppBskyLabelerDefs} from '@atproto/api' 4 + 5 + import {getAgent} from '#/state/session' 6 + import {preferencesQueryKey} from '#/state/queries/preferences' 7 + import {STALE} from '#/state/queries' 8 + 9 + export const labelerInfoQueryKey = (did: string) => ['labeler-info', did] 10 + export const labelersInfoQueryKey = (dids: string[]) => [ 11 + 'labelers-info', 12 + dids.sort(), 13 + ] 14 + export const labelersDetailedInfoQueryKey = (dids: string[]) => [ 15 + 'labelers-detailed-info', 16 + dids, 17 + ] 18 + 19 + export function useLabelerInfoQuery({ 20 + did, 21 + enabled, 22 + }: { 23 + did?: string 24 + enabled?: boolean 25 + }) { 26 + return useQuery({ 27 + enabled: !!did && enabled !== false, 28 + queryKey: labelerInfoQueryKey(did as string), 29 + queryFn: async () => { 30 + const res = await getAgent().app.bsky.labeler.getServices({ 31 + dids: [did as string], 32 + detailed: true, 33 + }) 34 + return res.data.views[0] as AppBskyLabelerDefs.LabelerViewDetailed 35 + }, 36 + }) 37 + } 38 + 39 + export function useLabelersInfoQuery({dids}: {dids: string[]}) { 40 + return useQuery({ 41 + enabled: !!dids.length, 42 + queryKey: labelersInfoQueryKey(dids), 43 + queryFn: async () => { 44 + const res = await getAgent().app.bsky.labeler.getServices({dids}) 45 + return res.data.views as AppBskyLabelerDefs.LabelerView[] 46 + }, 47 + }) 48 + } 49 + 50 + export function useLabelersDetailedInfoQuery({dids}: {dids: string[]}) { 51 + return useQuery({ 52 + enabled: !!dids.length, 53 + queryKey: labelersDetailedInfoQueryKey(dids), 54 + gcTime: 1000 * 60 * 60 * 6, // 6 hours 55 + staleTime: STALE.MINUTES.ONE, 56 + queryFn: async () => { 57 + const res = await getAgent().app.bsky.labeler.getServices({ 58 + dids, 59 + detailed: true, 60 + }) 61 + return res.data.views as AppBskyLabelerDefs.LabelerViewDetailed[] 62 + }, 63 + }) 64 + } 65 + 66 + export function useLabelerSubscriptionMutation() { 67 + const queryClient = useQueryClient() 68 + 69 + return useMutation({ 70 + async mutationFn({did, subscribe}: {did: string; subscribe: boolean}) { 71 + // TODO 72 + z.object({ 73 + did: z.string(), 74 + subscribe: z.boolean(), 75 + }).parse({did, subscribe}) 76 + 77 + if (subscribe) { 78 + await getAgent().addLabeler(did) 79 + } else { 80 + await getAgent().removeLabeler(did) 81 + } 82 + }, 83 + onSuccess() { 84 + queryClient.invalidateQueries({ 85 + queryKey: preferencesQueryKey, 86 + }) 87 + }, 88 + }) 89 + }
+6 -24
src/state/queries/notifications/util.ts
··· 1 1 import { 2 2 AppBskyNotificationListNotifications, 3 3 ModerationOpts, 4 - moderateProfile, 4 + moderateNotification, 5 5 AppBskyFeedDefs, 6 6 AppBskyFeedPost, 7 7 AppBskyFeedRepost, 8 8 AppBskyFeedLike, 9 9 AppBskyEmbedRecord, 10 10 } from '@atproto/api' 11 - import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' 12 11 import chunk from 'lodash.chunk' 13 12 import {QueryClient} from '@tanstack/react-query' 14 13 import {getAgent} from '../../session' ··· 88 87 // internal methods 89 88 // = 90 89 91 - // TODO this should be in the sdk as moderateNotification -prf 92 - function shouldFilterNotif( 90 + export function shouldFilterNotif( 93 91 notif: AppBskyNotificationListNotifications.Notification, 94 92 moderationOpts: ModerationOpts | undefined, 95 93 ): boolean { 96 94 if (!moderationOpts) { 97 95 return false 98 96 } 99 - const profile = moderateProfile(notif.author, moderationOpts) 100 - if ( 101 - profile.account.filter || 102 - profile.profile.filter || 103 - notif.author.viewer?.muted 104 - ) { 105 - return true 97 + if (notif.author.viewer?.following) { 98 + return false 106 99 } 107 - if ( 108 - notif.type === 'reply' || 109 - notif.type === 'quote' || 110 - notif.type === 'mention' 111 - ) { 112 - // NOTE: the notification overlaps the post enough for this to work 113 - const post = moderatePost(notif, moderationOpts) 114 - if (post.content.filter) { 115 - return true 116 - } 117 - } 118 - return false 100 + return moderateNotification(notif, moderationOpts).ui('contentList').filter 119 101 } 120 102 121 - function groupNotifications( 103 + export function groupNotifications( 122 104 notifs: AppBskyNotificationListNotifications.Notification[], 123 105 ): FeedNotification[] { 124 106 const groupedNotifs: FeedNotification[] = []
+16 -10
src/state/queries/post-feed.ts
··· 3 3 import { 4 4 AppBskyFeedDefs, 5 5 AppBskyFeedPost, 6 + ModerationDecision, 6 7 AtUri, 7 - PostModeration, 8 8 } from '@atproto/api' 9 9 import { 10 10 useInfiniteQuery, ··· 29 29 import {precacheFeedPostProfiles} from './profile' 30 30 import {getAgent} from '#/state/session' 31 31 import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const' 32 - import {getModerationOpts} from '#/state/queries/preferences/moderation' 33 32 import {KnownError} from '#/view/com/posts/FeedErrorMessage' 34 33 import {embedViewRecordToPostView, getEmbeddedPost} from './util' 35 34 import {useModerationOpts} from './preferences' ··· 69 68 post: AppBskyFeedDefs.PostView 70 69 record: AppBskyFeedPost.Record 71 70 reason?: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource 72 - moderation: PostModeration 71 + moderation: ModerationDecision 73 72 } 74 73 75 74 export interface FeedPostSlice { ··· 250 249 251 250 // apply moderation filter 252 251 for (let i = 0; i < slice.items.length; i++) { 252 + const ignoreFilter = 253 + slice.items[i].post.author.did === ignoreFilterFor 254 + if (ignoreFilter) { 255 + // remove mutes to avoid confused UIs 256 + moderations[i].causes = moderations[i].causes.filter( 257 + cause => cause.type !== 'muted', 258 + ) 259 + } 253 260 if ( 254 - moderations[i]?.content.filter && 255 - slice.items[i].post.author.did !== ignoreFilterFor 261 + !ignoreFilter && 262 + moderations[i]?.ui('contentList').filter 256 263 ) { 257 264 return undefined 258 265 } ··· 435 442 let somePostsPassModeration = false 436 443 437 444 for (const item of feed) { 438 - const moderationOpts = getModerationOpts({ 439 - userDid: '', 440 - preferences: DEFAULT_LOGGED_OUT_PREFERENCES, 445 + const moderation = moderatePost(item.post, { 446 + userDid: undefined, 447 + prefs: DEFAULT_LOGGED_OUT_PREFERENCES.moderationPrefs, 441 448 }) 442 - const moderation = moderatePost(item.post, moderationOpts) 443 449 444 - if (!moderation.content.filter) { 450 + if (!moderation.ui('contentList').filter) { 445 451 // we have a sfw post 446 452 somePostsPassModeration = true 447 453 }
+2 -2
src/state/queries/post-liked-by.ts
··· 12 12 type RQPageParam = string | undefined 13 13 14 14 // TODO refactor invalidate on mutate? 15 - export const RQKEY = (resolvedUri: string) => ['post-liked-by', resolvedUri] 15 + export const RQKEY = (resolvedUri: string) => ['liked-by', resolvedUri] 16 16 17 - export function usePostLikedByQuery(resolvedUri: string | undefined) { 17 + export function useLikedByQuery(resolvedUri: string | undefined) { 18 18 return useInfiniteQuery< 19 19 AppBskyFeedGetLikes.OutputSchema, 20 20 Error,
+6 -12
src/state/queries/preferences/const.ts
··· 29 29 30 30 export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = { 31 31 birthDate: new Date('2022-11-17'), // TODO(pwi) 32 - adultContentEnabled: false, 33 32 feeds: { 34 33 saved: [], 35 34 pinned: [], 36 35 unpinned: [], 37 36 }, 38 - // labels are undefined until set by user 39 - contentLabels: { 40 - nsfw: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.nsfw, 41 - nudity: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.nudity, 42 - suggestive: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.suggestive, 43 - gore: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.gore, 44 - hate: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.hate, 45 - spam: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.spam, 46 - impersonation: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.impersonation, 37 + moderationPrefs: { 38 + adultContentEnabled: false, 39 + labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES, 40 + labelers: [], 41 + mutedWords: [], 42 + hiddenPosts: [], 47 43 }, 48 44 feedViewPrefs: DEFAULT_HOME_FEED_PREFS, 49 45 threadViewPrefs: DEFAULT_THREAD_VIEW_PREFS, 50 46 userAge: 13, // TODO(pwi) 51 47 interests: {tags: []}, 52 - mutedWords: [], 53 - hiddenPosts: [], 54 48 }
+53 -47
src/state/queries/preferences/index.ts
··· 1 - import {useMemo} from 'react' 1 + import {useMemo, createContext, useContext} from 'react' 2 2 import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query' 3 3 import { 4 4 LabelPreference, 5 5 BskyFeedViewPreference, 6 + ModerationOpts, 6 7 AppBskyActorDefs, 7 8 } from '@atproto/api' 8 9 9 10 import {track} from '#/lib/analytics/analytics' 10 11 import {getAge} from '#/lib/strings/time' 11 - import {useSession, getAgent} from '#/state/session' 12 - import {DEFAULT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation' 12 + import {getAgent, useSession} from '#/state/session' 13 13 import { 14 - ConfigurableLabelGroup, 15 14 UsePreferencesQueryResponse, 16 15 ThreadViewPreferences, 17 16 } from '#/state/queries/preferences/types' 18 - import {temp__migrateLabelPref} from '#/state/queries/preferences/util' 19 17 import { 20 18 DEFAULT_HOME_FEED_PREFS, 21 19 DEFAULT_THREAD_VIEW_PREFS, 22 20 DEFAULT_LOGGED_OUT_PREFERENCES, 23 21 } from '#/state/queries/preferences/const' 24 - import {getModerationOpts} from '#/state/queries/preferences/moderation' 25 22 import {STALE} from '#/state/queries' 26 - import {useHiddenPosts} from '#/state/preferences/hidden-posts' 23 + import {useHiddenPosts, useLabelDefinitions} from '#/state/preferences' 24 + import {saveLabelers} from '#/state/session/agent-config' 27 25 28 26 export * from '#/state/queries/preferences/types' 29 27 export * from '#/state/queries/preferences/moderation' ··· 44 42 return DEFAULT_LOGGED_OUT_PREFERENCES 45 43 } else { 46 44 const res = await agent.getPreferences() 45 + 46 + // save to local storage to ensure there are labels on initial requests 47 + saveLabelers( 48 + agent.session.did, 49 + res.moderationPrefs.labelers.map(l => l.did), 50 + ) 51 + 47 52 const preferences: UsePreferencesQueryResponse = { 48 53 ...res, 49 54 feeds: { ··· 54 59 return !res.feeds.pinned?.includes(f) 55 60 }) || [], 56 61 }, 57 - // labels are undefined until set by user 58 - contentLabels: { 59 - nsfw: temp__migrateLabelPref( 60 - res.contentLabels?.nsfw || DEFAULT_LABEL_PREFERENCES.nsfw, 61 - ), 62 - nudity: temp__migrateLabelPref( 63 - res.contentLabels?.nudity || DEFAULT_LABEL_PREFERENCES.nudity, 64 - ), 65 - suggestive: temp__migrateLabelPref( 66 - res.contentLabels?.suggestive || 67 - DEFAULT_LABEL_PREFERENCES.suggestive, 68 - ), 69 - gore: temp__migrateLabelPref( 70 - res.contentLabels?.gore || DEFAULT_LABEL_PREFERENCES.gore, 71 - ), 72 - hate: temp__migrateLabelPref( 73 - res.contentLabels?.hate || DEFAULT_LABEL_PREFERENCES.hate, 74 - ), 75 - spam: temp__migrateLabelPref( 76 - res.contentLabels?.spam || DEFAULT_LABEL_PREFERENCES.spam, 77 - ), 78 - impersonation: temp__migrateLabelPref( 79 - res.contentLabels?.impersonation || 80 - DEFAULT_LABEL_PREFERENCES.impersonation, 81 - ), 82 - }, 83 62 feedViewPrefs: { 84 63 ...DEFAULT_HOME_FEED_PREFS, 85 64 ...(res.feedViewPrefs.home || {}), ··· 95 74 }, 96 75 }) 97 76 } 77 + 78 + // used in the moderation state devtool 79 + export const moderationOptsOverrideContext = createContext< 80 + ModerationOpts | undefined 81 + >(undefined) 98 82 99 83 export function useModerationOpts() { 84 + const override = useContext(moderationOptsOverrideContext) 100 85 const {currentAccount} = useSession() 101 86 const prefs = usePreferencesQuery() 102 - const hiddenPosts = useHiddenPosts() 103 - const opts = useMemo(() => { 87 + const {labelDefs} = useLabelDefinitions() 88 + const hiddenPosts = useHiddenPosts() // TODO move this into pds-stored prefs 89 + const opts = useMemo<ModerationOpts | undefined>(() => { 90 + if (override) { 91 + return override 92 + } 104 93 if (!prefs.data) { 105 94 return 106 95 } 107 - const moderationOpts = getModerationOpts({ 108 - userDid: currentAccount?.did || '', 109 - preferences: prefs.data, 110 - }) 111 - 112 96 return { 113 - ...moderationOpts, 114 - hiddenPosts, 115 - mutedWords: prefs.data.mutedWords || [], 97 + userDid: currentAccount?.did, 98 + prefs: {...prefs.data.moderationPrefs, hiddenPosts: hiddenPosts || []}, 99 + labelDefs, 116 100 } 117 - }, [currentAccount?.did, prefs.data, hiddenPosts]) 101 + }, [override, currentAccount, labelDefs, prefs.data, hiddenPosts]) 118 102 return opts 119 103 } 120 104 ··· 138 122 return useMutation< 139 123 void, 140 124 unknown, 141 - {labelGroup: ConfigurableLabelGroup; visibility: LabelPreference} 125 + {label: string; visibility: LabelPreference; labelerDid: string | undefined} 142 126 >({ 143 - mutationFn: async ({labelGroup, visibility}) => { 144 - await getAgent().setContentLabelPref(labelGroup, visibility) 127 + mutationFn: async ({label, visibility, labelerDid}) => { 128 + await getAgent().setContentLabelPref(label, visibility, labelerDid) 129 + // triggers a refetch 130 + await queryClient.invalidateQueries({ 131 + queryKey: preferencesQueryKey, 132 + }) 133 + }, 134 + }) 135 + } 136 + 137 + export function useSetContentLabelMutation() { 138 + const queryClient = useQueryClient() 139 + 140 + return useMutation({ 141 + mutationFn: async ({ 142 + label, 143 + visibility, 144 + labelerDid, 145 + }: { 146 + label: string 147 + visibility: LabelPreference 148 + labelerDid?: string 149 + }) => { 150 + await getAgent().setContentLabelPref(label, visibility, labelerDid) 145 151 // triggers a refetch 146 152 await queryClient.invalidateQueries({ 147 153 queryKey: preferencesQueryKey,
+42 -170
src/state/queries/preferences/moderation.ts
··· 1 + import React from 'react' 1 2 import { 2 - LabelPreference, 3 - ComAtprotoLabelDefs, 4 - ModerationOpts, 3 + DEFAULT_LABEL_SETTINGS, 4 + BskyAgent, 5 + interpretLabelValueDefinitions, 5 6 } from '@atproto/api' 6 7 7 - import { 8 - LabelGroup, 9 - ConfigurableLabelGroup, 10 - UsePreferencesQueryResponse, 11 - } from '#/state/queries/preferences/types' 12 - 13 - export type Label = ComAtprotoLabelDefs.Label 14 - 15 - export type LabelGroupConfig = { 16 - id: LabelGroup 17 - title: string 18 - isAdultImagery?: boolean 19 - subtitle?: string 20 - warning: string 21 - values: string[] 22 - } 23 - 24 - export const DEFAULT_LABEL_PREFERENCES: Record< 25 - ConfigurableLabelGroup, 26 - LabelPreference 27 - > = { 28 - nsfw: 'hide', 29 - nudity: 'warn', 30 - suggestive: 'warn', 31 - gore: 'warn', 32 - hate: 'hide', 33 - spam: 'hide', 34 - impersonation: 'hide', 35 - } 8 + import {usePreferencesQuery} from './index' 9 + import {useLabelersDetailedInfoQuery} from '../labeler' 36 10 37 11 /** 38 12 * More strict than our default settings for logged in users. 39 - * 40 - * TODO(pwi) 41 13 */ 42 - export const DEFAULT_LOGGED_OUT_LABEL_PREFERENCES: Record< 43 - ConfigurableLabelGroup, 44 - LabelPreference 45 - > = { 46 - nsfw: 'hide', 47 - nudity: 'hide', 48 - suggestive: 'hide', 49 - gore: 'hide', 50 - hate: 'hide', 51 - spam: 'hide', 52 - impersonation: 'hide', 53 - } 54 - 55 - export const ILLEGAL_LABEL_GROUP: LabelGroupConfig = { 56 - id: 'illegal', 57 - title: 'Illegal Content', 58 - warning: 'Illegal Content', 59 - values: ['csam', 'dmca-violation', 'nudity-nonconsensual'], 60 - } 14 + export const DEFAULT_LOGGED_OUT_LABEL_PREFERENCES: typeof DEFAULT_LABEL_SETTINGS = 15 + Object.fromEntries( 16 + Object.entries(DEFAULT_LABEL_SETTINGS).map(([key, _pref]) => [key, 'hide']), 17 + ) 61 18 62 - export const ALWAYS_FILTER_LABEL_GROUP: LabelGroupConfig = { 63 - id: 'always-filter', 64 - title: 'Content Warning', 65 - warning: 'Content Warning', 66 - values: ['!filter'], 67 - } 68 - 69 - export const ALWAYS_WARN_LABEL_GROUP: LabelGroupConfig = { 70 - id: 'always-warn', 71 - title: 'Content Warning', 72 - warning: 'Content Warning', 73 - values: ['!warn', 'account-security'], 74 - } 75 - 76 - export const UNKNOWN_LABEL_GROUP: LabelGroupConfig = { 77 - id: 'unknown', 78 - title: 'Unknown Label', 79 - warning: 'Content Warning', 80 - values: [], 81 - } 82 - 83 - export const CONFIGURABLE_LABEL_GROUPS: Record< 84 - ConfigurableLabelGroup, 85 - LabelGroupConfig 86 - > = { 87 - nsfw: { 88 - id: 'nsfw', 89 - title: 'Explicit Sexual Images', 90 - subtitle: 'i.e. pornography', 91 - warning: 'Sexually Explicit', 92 - values: ['porn', 'nsfl'], 93 - isAdultImagery: true, 94 - }, 95 - nudity: { 96 - id: 'nudity', 97 - title: 'Other Nudity', 98 - subtitle: 'Including non-sexual and artistic', 99 - warning: 'Nudity', 100 - values: ['nudity'], 101 - isAdultImagery: true, 102 - }, 103 - suggestive: { 104 - id: 'suggestive', 105 - title: 'Sexually Suggestive', 106 - subtitle: 'Does not include nudity', 107 - warning: 'Sexually Suggestive', 108 - values: ['sexual'], 109 - isAdultImagery: true, 110 - }, 111 - gore: { 112 - id: 'gore', 113 - title: 'Violent / Bloody', 114 - subtitle: 'Gore, self-harm, torture', 115 - warning: 'Violence', 116 - values: ['gore', 'self-harm', 'torture', 'nsfl', 'corpse'], 117 - isAdultImagery: true, 118 - }, 119 - hate: { 120 - id: 'hate', 121 - title: 'Hate Group Iconography', 122 - subtitle: 'Images of terror groups, articles covering events, etc.', 123 - warning: 'Hate Groups', 124 - values: ['icon-kkk', 'icon-nazi', 'icon-intolerant', 'behavior-intolerant'], 125 - }, 126 - spam: { 127 - id: 'spam', 128 - title: 'Spam', 129 - subtitle: 'Excessive unwanted interactions', 130 - warning: 'Spam', 131 - values: ['spam'], 132 - }, 133 - impersonation: { 134 - id: 'impersonation', 135 - title: 'Impersonation', 136 - subtitle: 'Accounts falsely claiming to be people or orgs', 137 - warning: 'Impersonation', 138 - values: ['impersonation'], 139 - }, 19 + export function useMyLabelersQuery() { 20 + const prefs = usePreferencesQuery() 21 + const dids = Array.from( 22 + new Set( 23 + BskyAgent.appLabelers.concat( 24 + prefs.data?.moderationPrefs.labelers.map(l => l.did) || [], 25 + ), 26 + ), 27 + ) 28 + const labelers = useLabelersDetailedInfoQuery({dids}) 29 + const isLoading = prefs.isLoading || labelers.isLoading 30 + const error = prefs.error || labelers.error 31 + return React.useMemo(() => { 32 + return { 33 + isLoading, 34 + error, 35 + data: labelers.data, 36 + } 37 + }, [labelers, isLoading, error]) 140 38 } 141 39 142 - export function getModerationOpts({ 143 - userDid, 144 - preferences, 145 - }: { 146 - userDid: string 147 - preferences: UsePreferencesQueryResponse 148 - }): ModerationOpts { 149 - return { 150 - userDid: userDid, 151 - adultContentEnabled: preferences.adultContentEnabled, 152 - labels: { 153 - porn: preferences.contentLabels.nsfw, 154 - sexual: preferences.contentLabels.suggestive, 155 - nudity: preferences.contentLabels.nudity, 156 - nsfl: preferences.contentLabels.gore, 157 - corpse: preferences.contentLabels.gore, 158 - gore: preferences.contentLabels.gore, 159 - torture: preferences.contentLabels.gore, 160 - 'self-harm': preferences.contentLabels.gore, 161 - 'intolerant-race': preferences.contentLabels.hate, 162 - 'intolerant-gender': preferences.contentLabels.hate, 163 - 'intolerant-sexual-orientation': preferences.contentLabels.hate, 164 - 'intolerant-religion': preferences.contentLabels.hate, 165 - intolerant: preferences.contentLabels.hate, 166 - 'icon-intolerant': preferences.contentLabels.hate, 167 - spam: preferences.contentLabels.spam, 168 - impersonation: preferences.contentLabels.impersonation, 169 - scam: 'warn', 170 - }, 171 - labelers: [ 172 - { 173 - labeler: { 174 - did: '', 175 - displayName: 'Bluesky Social', 176 - }, 177 - labels: {}, 178 - }, 179 - ], 180 - } 40 + export function useLabelDefinitionsQuery() { 41 + const labelers = useMyLabelersQuery() 42 + return React.useMemo(() => { 43 + return { 44 + labelDefs: Object.fromEntries( 45 + (labelers.data || []).map(labeler => [ 46 + labeler.creator.did, 47 + interpretLabelValueDefinitions(labeler), 48 + ]), 49 + ), 50 + labelers: labelers.data || [], 51 + } 52 + }, [labelers]) 181 53 }
-33
src/state/queries/preferences/types.ts
··· 1 1 import { 2 2 BskyPreferences, 3 - LabelPreference, 4 3 BskyThreadViewPreference, 5 4 BskyFeedViewPreference, 6 5 } from '@atproto/api' 7 6 8 - export const configurableAdultLabelGroups = [ 9 - 'nsfw', 10 - 'nudity', 11 - 'suggestive', 12 - 'gore', 13 - ] as const 14 - 15 - export const configurableOtherLabelGroups = [ 16 - 'hate', 17 - 'spam', 18 - 'impersonation', 19 - ] as const 20 - 21 - export const configurableLabelGroups = [ 22 - ...configurableAdultLabelGroups, 23 - ...configurableOtherLabelGroups, 24 - ] as const 25 - export type ConfigurableLabelGroup = (typeof configurableLabelGroups)[number] 26 - 27 - export type LabelGroup = 28 - | ConfigurableLabelGroup 29 - | 'illegal' 30 - | 'always-filter' 31 - | 'always-warn' 32 - | 'unknown' 33 - 34 7 export type UsePreferencesQueryResponse = Omit< 35 8 BskyPreferences, 36 9 'contentLabels' | 'feedViewPrefs' | 'feeds' 37 10 > & { 38 - /* 39 - * Content labels previously included 'show', which has been deprecated in 40 - * favor of 'ignore'. The API can return legacy data from the database, and 41 - * we clean up the data in `usePreferencesQuery`. 42 - */ 43 - contentLabels: Record<ConfigurableLabelGroup, LabelPreference> 44 11 feedViewPrefs: BskyFeedViewPreference & { 45 12 lab_mergeFeedEnabled?: boolean 46 13 }
-16
src/state/queries/preferences/util.ts
··· 1 - import {LabelPreference} from '@atproto/api' 2 - 3 - /** 4 - * Content labels previously included 'show', which has been deprecated in 5 - * favor of 'ignore'. The API can return legacy data from the database, and 6 - * we clean up the data in `usePreferencesQuery`. 7 - * 8 - * @deprecated 9 - */ 10 - export function temp__migrateLabelPref( 11 - pref: LabelPreference | 'show', 12 - ): LabelPreference { 13 - // @ts-ignore 14 - if (pref === 'show') return 'ignore' 15 - return pref 16 - }
-34
src/state/queries/profile-extra-info.ts
··· 1 - import {useQuery} from '@tanstack/react-query' 2 - 3 - import {getAgent} from '#/state/session' 4 - import {STALE} from '#/state/queries' 5 - 6 - // TODO refactor invalidate on mutate? 7 - export const RQKEY = (did: string) => ['profile-extra-info', did] 8 - 9 - /** 10 - * Fetches some additional information for the profile screen which 11 - * is not available in the API's ProfileView 12 - */ 13 - export function useProfileExtraInfoQuery(did: string) { 14 - return useQuery({ 15 - staleTime: STALE.MINUTES.ONE, 16 - queryKey: RQKEY(did), 17 - async queryFn() { 18 - const [listsRes, feedsRes] = await Promise.all([ 19 - getAgent().app.bsky.graph.getLists({ 20 - actor: did, 21 - limit: 1, 22 - }), 23 - getAgent().app.bsky.feed.getActorFeeds({ 24 - actor: did, 25 - limit: 1, 26 - }), 27 - ]) 28 - return { 29 - hasLists: listsRes.data.lists.length > 0, 30 - hasFeedgens: feedsRes.data.feeds.length > 0, 31 - } 32 - }, 33 - }) 34 - }
+2 -1
src/state/queries/suggested-follows.ts
··· 46 46 47 47 res.data.actors = res.data.actors 48 48 .filter( 49 - actor => !moderateProfile(actor, moderationOpts!).account.filter, 49 + actor => 50 + !moderateProfile(actor, moderationOpts!).ui('profileList').filter, 50 51 ) 51 52 .filter(actor => { 52 53 const viewer = actor.viewer
+12
src/state/session/agent-config.ts
··· 1 + import AsyncStorage from '@react-native-async-storage/async-storage' 2 + 3 + const PREFIX = 'agent-labelers' 4 + 5 + export async function saveLabelers(did: string, value: string[]) { 6 + await AsyncStorage.setItem(`${PREFIX}:${did}`, JSON.stringify(value)) 7 + } 8 + 9 + export async function readLabelers(did: string): Promise<string[] | undefined> { 10 + const rawData = await AsyncStorage.getItem(`${PREFIX}:${did}`) 11 + return rawData ? JSON.parse(rawData) : undefined 12 + }
+40 -1
src/state/session/index.tsx
··· 1 1 import React from 'react' 2 - import {BskyAgent, AtpPersistSessionHandler} from '@atproto/api' 2 + import { 3 + BskyAgent, 4 + AtpPersistSessionHandler, 5 + BSKY_LABELER_DID, 6 + } from '@atproto/api' 3 7 import {useQueryClient} from '@tanstack/react-query' 4 8 import {jwtDecode} from 'jwt-decode' 5 9 10 + import {IS_DEV} from '#/env' 11 + import {IS_TEST_USER} from '#/lib/constants' 12 + import {isWeb} from '#/platform/detection' 6 13 import {networkRetry} from '#/lib/async/retry' 7 14 import {logger} from '#/logger' 8 15 import * as persisted from '#/state/persisted' ··· 12 19 import {useCloseAllActiveElements} from '#/state/util' 13 20 import {track} from '#/lib/analytics/analytics' 14 21 import {hasProp} from '#/lib/type-guards' 22 + import {readLabelers} from './agent-config' 15 23 16 24 let __globalAgent: BskyAgent = PUBLIC_BSKY_AGENT 17 25 ··· 255 263 deactivated, 256 264 } 257 265 266 + await configureModeration(agent, account) 267 + 258 268 agent.setPersistSessionHandler( 259 269 createPersistSessionHandler( 260 270 account, ··· 298 308 deactivated: isSessionDeactivated(agent.session.accessJwt), 299 309 } 300 310 311 + await configureModeration(agent, account) 312 + 301 313 agent.setPersistSessionHandler( 302 314 createPersistSessionHandler( 303 315 account, ··· 309 321 ) 310 322 311 323 __globalAgent = agent 324 + // @ts-ignore 325 + if (IS_DEV && isWeb) window.agent = agent 312 326 queryClient.clear() 313 327 upsertAccount(account) 314 328 ··· 348 362 {networkErrorCallback: clearCurrentAccount}, 349 363 ), 350 364 }) 365 + // @ts-ignore 366 + if (IS_DEV && isWeb) window.agent = agent 367 + await configureModeration(agent, account) 351 368 352 369 let canReusePrevSession = false 353 370 try { ··· 641 658 <ApiContext.Provider value={api}>{children}</ApiContext.Provider> 642 659 </StateContext.Provider> 643 660 ) 661 + } 662 + 663 + async function configureModeration(agent: BskyAgent, account: SessionAccount) { 664 + if (IS_TEST_USER(account.handle)) { 665 + const did = ( 666 + await agent 667 + .resolveHandle({handle: 'mod-authority.test'}) 668 + .catch(_ => undefined) 669 + )?.data.did 670 + if (did) { 671 + console.warn('USING TEST ENV MODERATION') 672 + BskyAgent.configure({appLabelers: [did]}) 673 + } 674 + } else { 675 + BskyAgent.configure({appLabelers: [BSKY_LABELER_DID]}) 676 + const labelerDids = await readLabelers(account.did).catch(_ => {}) 677 + if (labelerDids) { 678 + agent.configureLabelersHeader( 679 + labelerDids.filter(did => did !== BSKY_LABELER_DID), 680 + ) 681 + } 682 + } 644 683 } 645 684 646 685 export function useSession() {
+2 -2
src/state/shell/composer.tsx
··· 2 2 import { 3 3 AppBskyEmbedRecord, 4 4 AppBskyRichtextFacet, 5 - PostModeration, 5 + ModerationDecision, 6 6 } from '@atproto/api' 7 7 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 8 8 ··· 16 16 avatar?: string 17 17 } 18 18 embed?: AppBskyEmbedRecord.ViewRecord['embed'] 19 - moderation?: PostModeration 19 + moderation?: ModerationDecision 20 20 } 21 21 export interface ComposerOptsQuote { 22 22 uri: string
+2 -2
src/view/com/auth/create/state.ts
··· 12 12 import {cleanError} from '#/lib/strings/errors' 13 13 import {useOnboardingDispatch} from '#/state/shell/onboarding' 14 14 import {useSessionApi} from '#/state/session' 15 - import {DEFAULT_SERVICE, IS_PROD_SERVICE} from '#/lib/constants' 15 + import {DEFAULT_SERVICE, IS_TEST_USER} from '#/lib/constants' 16 16 import { 17 17 DEFAULT_PROD_FEEDS, 18 18 usePreferencesSetBirthDateMutation, ··· 147 147 : undefined, 148 148 }) 149 149 setBirthDate({birthDate: uiState.birthDate}) 150 - if (IS_PROD_SERVICE(uiState.serviceUrl)) { 150 + if (!IS_TEST_USER(uiState.handle)) { 151 151 setSavedFeeds(DEFAULT_PROD_FEEDS) 152 152 } 153 153 } catch (e: any) {
+5 -5
src/view/com/auth/onboarding/RecommendedFollowsItem.tsx
··· 1 1 import React from 'react' 2 2 import {View, StyleSheet, ActivityIndicator} from 'react-native' 3 - import {ProfileModeration, AppBskyActorDefs} from '@atproto/api' 3 + import {ModerationDecision, AppBskyActorDefs} from '@atproto/api' 4 4 import {Button} from '#/view/com/util/forms/Button' 5 5 import {usePalette} from 'lib/hooks/usePalette' 6 6 import {sanitizeDisplayName} from 'lib/strings/display-names' ··· 18 18 19 19 type Props = { 20 20 profile: AppBskyActorDefs.ProfileViewBasic 21 - moderation: ProfileModeration 21 + moderation: ModerationDecision 22 22 onFollowStateChange: (props: { 23 23 did: string 24 24 following: boolean ··· 62 62 moderation, 63 63 }: { 64 64 profile: Shadow<AppBskyActorDefs.ProfileViewBasic> 65 - moderation: ProfileModeration 65 + moderation: ModerationDecision 66 66 onFollowStateChange: (props: { 67 67 did: string 68 68 following: boolean ··· 113 113 <UserAvatar 114 114 size={40} 115 115 avatar={profile.avatar} 116 - moderation={moderation.avatar} 116 + moderation={moderation.ui('avatar')} 117 117 /> 118 118 </View> 119 119 <View style={styles.layoutContent}> ··· 124 124 lineHeight={1.2}> 125 125 {sanitizeDisplayName( 126 126 profile.displayName || sanitizeHandle(profile.handle), 127 - moderation.profile, 127 + moderation.ui('displayName'), 128 128 )} 129 129 </Text> 130 130 <Text type="xl" style={[pal.textLight]} numberOfLines={1}>
+1 -1
src/view/com/composer/Composer.tsx
··· 39 39 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 40 40 import {useExternalLinkFetch} from './useExternalLinkFetch' 41 41 import {isWeb, isNative, isAndroid, isIOS} from 'platform/detection' 42 - import QuoteEmbed from '../util/post-embeds/QuoteEmbed' 42 + import {QuoteEmbed} from '../util/post-embeds/QuoteEmbed' 43 43 import {GalleryModel} from 'state/models/media/gallery' 44 44 import {Gallery} from './photos/Gallery' 45 45 import {MAX_GRAPHEME_LENGTH} from 'lib/constants'
+3 -3
src/view/com/composer/ComposerReplyTo.tsx
··· 15 15 import {sanitizeHandle} from 'lib/strings/handles' 16 16 import {UserAvatar} from 'view/com/util/UserAvatar' 17 17 import {Text} from 'view/com/util/text/Text' 18 - import QuoteEmbed from 'view/com/util/post-embeds/QuoteEmbed' 18 + import {QuoteEmbed} from 'view/com/util/post-embeds/QuoteEmbed' 19 19 20 20 export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) { 21 21 const pal = usePalette('default') ··· 86 86 <UserAvatar 87 87 avatar={replyTo.author.avatar} 88 88 size={50} 89 - moderation={replyTo.moderation?.avatar} 89 + moderation={replyTo.moderation?.ui('avatar')} 90 90 /> 91 91 <View style={styles.replyToPost}> 92 92 <Text type="xl-medium" style={[pal.text]}> ··· 103 103 {replyTo.text} 104 104 </Text> 105 105 </View> 106 - {images && !replyTo.moderation?.embed.blur && ( 106 + {images && !replyTo.moderation?.ui('contentMedia').blur && ( 107 107 <ComposerReplyToImages images={images} showFull={showFull} /> 108 108 )} 109 109 </View>
+4 -4
src/view/com/composer/select-language/SelectLangBtn.tsx
··· 20 20 toPostLanguages, 21 21 hasPostLanguage, 22 22 } from '#/state/preferences/languages' 23 - import {t, msg} from '@lingui/macro' 23 + import {msg} from '@lingui/macro' 24 24 import {useLingui} from '@lingui/react' 25 25 26 26 export function SelectLangBtn() { ··· 84 84 } 85 85 86 86 return [ 87 - {heading: true, label: t`Post language`}, 87 + {heading: true, label: _(msg`Post language`)}, 88 88 ...arr.slice(0, 6), 89 89 {sep: true}, 90 90 { 91 - label: t`Other...`, 91 + label: _(msg`Other...`), 92 92 onPress: onPressMore, 93 93 }, 94 94 ] 95 - }, [onPressMore, langPrefs, setLangPrefs, postLanguagesPref]) 95 + }, [onPressMore, langPrefs, setLangPrefs, postLanguagesPref, _]) 96 96 97 97 return ( 98 98 <DropdownButton
+21 -16
src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx
··· 6 6 * 7 7 */ 8 8 import React from 'react' 9 - import {createHitslop} from 'lib/constants' 10 9 import {SafeAreaView, Text, TouchableOpacity, StyleSheet} from 'react-native' 11 - import {t} from '@lingui/macro' 10 + import {msg} from '@lingui/macro' 11 + import {useLingui} from '@lingui/react' 12 + 13 + import {createHitslop} from '#/lib/constants' 12 14 13 15 type Props = { 14 16 onRequestClose: () => void ··· 16 18 17 19 const HIT_SLOP = createHitslop(16) 18 20 19 - const ImageDefaultHeader = ({onRequestClose}: Props) => ( 20 - <SafeAreaView style={styles.root}> 21 - <TouchableOpacity 22 - style={styles.closeButton} 23 - onPress={onRequestClose} 24 - hitSlop={HIT_SLOP} 25 - accessibilityRole="button" 26 - accessibilityLabel={t`Close image`} 27 - accessibilityHint={t`Closes viewer for header image`} 28 - onAccessibilityEscape={onRequestClose}> 29 - <Text style={styles.closeText}>✕</Text> 30 - </TouchableOpacity> 31 - </SafeAreaView> 32 - ) 21 + const ImageDefaultHeader = ({onRequestClose}: Props) => { 22 + const {_} = useLingui() 23 + return ( 24 + <SafeAreaView style={styles.root}> 25 + <TouchableOpacity 26 + style={styles.closeButton} 27 + onPress={onRequestClose} 28 + hitSlop={HIT_SLOP} 29 + accessibilityRole="button" 30 + accessibilityLabel={_(msg`Close image`)} 31 + accessibilityHint={_(msg`Closes viewer for header image`)} 32 + onAccessibilityEscape={onRequestClose}> 33 + <Text style={styles.closeText}>✕</Text> 34 + </TouchableOpacity> 35 + </SafeAreaView> 36 + ) 37 + } 33 38 34 39 const styles = StyleSheet.create({ 35 40 root: {
-139
src/view/com/modals/AppealLabel.tsx
··· 1 - import React, {useState} from 'react' 2 - import {StyleSheet, TouchableOpacity, View} from 'react-native' 3 - import {ComAtprotoModerationDefs} from '@atproto/api' 4 - import {ScrollView, TextInput} from './util' 5 - import {Text} from '../util/text/Text' 6 - import {s, colors} from 'lib/styles' 7 - import {usePalette} from 'lib/hooks/usePalette' 8 - import {Trans, msg} from '@lingui/macro' 9 - import {useLingui} from '@lingui/react' 10 - import {useModalControls} from '#/state/modals' 11 - import {CharProgress} from '../composer/char-progress/CharProgress' 12 - import {getAgent} from '#/state/session' 13 - import * as Toast from '../util/Toast' 14 - import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 15 - 16 - export const snapPoints = ['40%'] 17 - 18 - type ReportComponentProps = 19 - | { 20 - uri: string 21 - cid: string 22 - } 23 - | { 24 - did: string 25 - } 26 - 27 - export function Component(props: ReportComponentProps) { 28 - const pal = usePalette('default') 29 - const [details, setDetails] = useState<string>('') 30 - const {_} = useLingui() 31 - const {closeModal} = useModalControls() 32 - const {isMobile} = useWebMediaQueries() 33 - const isAccountReport = 'did' in props 34 - 35 - const submit = async () => { 36 - try { 37 - const $type = !isAccountReport 38 - ? 'com.atproto.repo.strongRef' 39 - : 'com.atproto.admin.defs#repoRef' 40 - await getAgent().createModerationReport({ 41 - reasonType: ComAtprotoModerationDefs.REASONAPPEAL, 42 - subject: { 43 - $type, 44 - ...props, 45 - }, 46 - reason: details, 47 - }) 48 - Toast.show(_(msg`We'll look into your appeal promptly.`)) 49 - } finally { 50 - closeModal() 51 - } 52 - } 53 - 54 - return ( 55 - <View 56 - style={[ 57 - pal.view, 58 - s.flex1, 59 - isMobile ? {paddingHorizontal: 12} : undefined, 60 - ]} 61 - testID="appealLabelModal"> 62 - <Text 63 - type="2xl-bold" 64 - style={[pal.text, s.textCenter, {paddingBottom: 8}]}> 65 - <Trans>Appeal Content Warning</Trans> 66 - </Text> 67 - <ScrollView> 68 - <View style={[pal.btn, styles.detailsInputContainer]}> 69 - <TextInput 70 - accessibilityLabel={_(msg`Text input field`)} 71 - accessibilityHint={_( 72 - msg`Please tell us why you think this content warning was incorrectly applied!`, 73 - )} 74 - placeholder={_( 75 - msg`Please tell us why you think this content warning was incorrectly applied!`, 76 - )} 77 - placeholderTextColor={pal.textLight.color} 78 - value={details} 79 - onChangeText={setDetails} 80 - autoFocus={true} 81 - numberOfLines={3} 82 - multiline={true} 83 - textAlignVertical="top" 84 - maxLength={300} 85 - style={[styles.detailsInput, pal.text]} 86 - /> 87 - <View style={styles.detailsInputBottomBar}> 88 - <View style={styles.charCounter}> 89 - <CharProgress count={details?.length || 0} /> 90 - </View> 91 - </View> 92 - </View> 93 - <TouchableOpacity 94 - testID="confirmBtn" 95 - onPress={submit} 96 - style={styles.btn} 97 - accessibilityRole="button" 98 - accessibilityLabel={_(msg`Confirm`)} 99 - accessibilityHint=""> 100 - <Text style={[s.white, s.bold, s.f18]}> 101 - <Trans>Submit</Trans> 102 - </Text> 103 - </TouchableOpacity> 104 - </ScrollView> 105 - </View> 106 - ) 107 - } 108 - 109 - const styles = StyleSheet.create({ 110 - detailsInputContainer: { 111 - borderRadius: 8, 112 - marginBottom: 8, 113 - }, 114 - detailsInput: { 115 - paddingHorizontal: 12, 116 - paddingTop: 12, 117 - paddingBottom: 12, 118 - borderRadius: 8, 119 - minHeight: 100, 120 - fontSize: 16, 121 - }, 122 - detailsInputBottomBar: { 123 - alignSelf: 'flex-end', 124 - }, 125 - charCounter: { 126 - flexDirection: 'row', 127 - alignItems: 'center', 128 - paddingRight: 10, 129 - paddingBottom: 8, 130 - }, 131 - btn: { 132 - flexDirection: 'row', 133 - alignItems: 'center', 134 - justifyContent: 'center', 135 - borderRadius: 32, 136 - padding: 14, 137 - backgroundColor: colors.blue3, 138 - }, 139 - })
-151
src/view/com/modals/BirthDateSettings.tsx
··· 1 - import React, {useState} from 'react' 2 - import { 3 - ActivityIndicator, 4 - StyleSheet, 5 - TouchableOpacity, 6 - View, 7 - } from 'react-native' 8 - import {Text} from '../util/text/Text' 9 - import {DateInput} from '../util/forms/DateInput' 10 - import {ErrorMessage} from '../util/error/ErrorMessage' 11 - import {s, colors} from 'lib/styles' 12 - import {usePalette} from 'lib/hooks/usePalette' 13 - import {isWeb} from 'platform/detection' 14 - import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 15 - import {cleanError} from 'lib/strings/errors' 16 - import {Trans, msg} from '@lingui/macro' 17 - import {useLingui} from '@lingui/react' 18 - import {useModalControls} from '#/state/modals' 19 - import { 20 - usePreferencesQuery, 21 - usePreferencesSetBirthDateMutation, 22 - UsePreferencesQueryResponse, 23 - } from '#/state/queries/preferences' 24 - import {logger} from '#/logger' 25 - 26 - export const snapPoints = ['50%', '90%'] 27 - 28 - function Inner({preferences}: {preferences: UsePreferencesQueryResponse}) { 29 - const pal = usePalette('default') 30 - const {isMobile} = useWebMediaQueries() 31 - const {_} = useLingui() 32 - const { 33 - isPending, 34 - isError, 35 - error, 36 - mutateAsync: setBirthDate, 37 - } = usePreferencesSetBirthDateMutation() 38 - const [date, setDate] = useState(preferences.birthDate || new Date()) 39 - const {closeModal} = useModalControls() 40 - 41 - const onSave = React.useCallback(async () => { 42 - try { 43 - await setBirthDate({birthDate: date}) 44 - closeModal() 45 - } catch (e) { 46 - logger.error(`setBirthDate failed`, {message: e}) 47 - } 48 - }, [date, setBirthDate, closeModal]) 49 - 50 - return ( 51 - <View 52 - testID="birthDateSettingsModal" 53 - style={[pal.view, styles.container, isMobile && {paddingHorizontal: 18}]}> 54 - <View style={styles.titleSection}> 55 - <Text type="title-lg" style={[pal.text, styles.title]}> 56 - <Trans>My Birthday</Trans> 57 - </Text> 58 - </View> 59 - 60 - <Text type="lg" style={[pal.textLight, {marginBottom: 10}]}> 61 - <Trans>This information is not shared with other users.</Trans> 62 - </Text> 63 - 64 - <View> 65 - <DateInput 66 - handleAsUTC 67 - testID="birthdayInput" 68 - value={date} 69 - onChange={setDate} 70 - buttonType="default-light" 71 - buttonStyle={[pal.border, styles.dateInputButton]} 72 - buttonLabelType="lg" 73 - accessibilityLabel={_(msg`Birthday`)} 74 - accessibilityHint={_(msg`Enter your birth date`)} 75 - accessibilityLabelledBy="birthDate" 76 - /> 77 - </View> 78 - 79 - {isError ? ( 80 - <ErrorMessage message={cleanError(error)} style={styles.error} /> 81 - ) : undefined} 82 - 83 - <View style={[styles.btnContainer, pal.borderDark]}> 84 - {isPending ? ( 85 - <View style={styles.btn}> 86 - <ActivityIndicator color="#fff" /> 87 - </View> 88 - ) : ( 89 - <TouchableOpacity 90 - testID="confirmBtn" 91 - onPress={onSave} 92 - style={styles.btn} 93 - accessibilityRole="button" 94 - accessibilityLabel={_(msg`Save`)} 95 - accessibilityHint=""> 96 - <Text style={[s.white, s.bold, s.f18]}> 97 - <Trans>Save</Trans> 98 - </Text> 99 - </TouchableOpacity> 100 - )} 101 - </View> 102 - </View> 103 - ) 104 - } 105 - 106 - export function Component({}: {}) { 107 - const {data: preferences} = usePreferencesQuery() 108 - 109 - return !preferences ? ( 110 - <ActivityIndicator /> 111 - ) : ( 112 - <Inner preferences={preferences} /> 113 - ) 114 - } 115 - 116 - const styles = StyleSheet.create({ 117 - container: { 118 - flex: 1, 119 - paddingBottom: isWeb ? 0 : 40, 120 - }, 121 - titleSection: { 122 - paddingTop: isWeb ? 0 : 4, 123 - paddingBottom: isWeb ? 14 : 10, 124 - }, 125 - title: { 126 - textAlign: 'center', 127 - fontWeight: '600', 128 - marginBottom: 5, 129 - }, 130 - error: { 131 - borderRadius: 6, 132 - marginTop: 10, 133 - }, 134 - dateInputButton: { 135 - borderWidth: 1, 136 - borderRadius: 6, 137 - paddingVertical: 14, 138 - }, 139 - btn: { 140 - flexDirection: 'row', 141 - alignItems: 'center', 142 - justifyContent: 'center', 143 - borderRadius: 32, 144 - padding: 14, 145 - backgroundColor: colors.blue3, 146 - }, 147 - btnContainer: { 148 - paddingTop: 20, 149 - paddingHorizontal: 20, 150 - }, 151 - })
-401
src/view/com/modals/ContentFilteringSettings.tsx
··· 1 - import React from 'react' 2 - import {LabelPreference} from '@atproto/api' 3 - import {StyleSheet, Pressable, View, Linking} from 'react-native' 4 - import LinearGradient from 'react-native-linear-gradient' 5 - import {ScrollView} from './util' 6 - import {s, colors, gradients} from 'lib/styles' 7 - import {Text} from '../util/text/Text' 8 - import {TextLink} from '../util/Link' 9 - import {ToggleButton} from '../util/forms/ToggleButton' 10 - import {Button} from '../util/forms/Button' 11 - import {usePalette} from 'lib/hooks/usePalette' 12 - import {isIOS} from 'platform/detection' 13 - import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 14 - import * as Toast from '../util/Toast' 15 - import {logger} from '#/logger' 16 - import {Trans, msg} from '@lingui/macro' 17 - import {useLingui} from '@lingui/react' 18 - import {useModalControls} from '#/state/modals' 19 - import { 20 - usePreferencesQuery, 21 - usePreferencesSetContentLabelMutation, 22 - usePreferencesSetAdultContentMutation, 23 - ConfigurableLabelGroup, 24 - CONFIGURABLE_LABEL_GROUPS, 25 - UsePreferencesQueryResponse, 26 - } from '#/state/queries/preferences' 27 - 28 - export const snapPoints = ['90%'] 29 - 30 - export function Component({}: {}) { 31 - const {isMobile} = useWebMediaQueries() 32 - const pal = usePalette('default') 33 - const {_} = useLingui() 34 - const {closeModal} = useModalControls() 35 - const {data: preferences} = usePreferencesQuery() 36 - 37 - const onPressDone = React.useCallback(() => { 38 - closeModal() 39 - }, [closeModal]) 40 - 41 - return ( 42 - <View testID="contentFilteringModal" style={[pal.view, styles.container]}> 43 - <Text style={[pal.text, styles.title]}> 44 - <Trans>Content Filtering</Trans> 45 - </Text> 46 - 47 - <ScrollView style={styles.scrollContainer}> 48 - <AdultContentEnabledPref /> 49 - <ContentLabelPref 50 - preferences={preferences} 51 - labelGroup="nsfw" 52 - disabled={!preferences?.adultContentEnabled} 53 - /> 54 - <ContentLabelPref 55 - preferences={preferences} 56 - labelGroup="nudity" 57 - disabled={!preferences?.adultContentEnabled} 58 - /> 59 - <ContentLabelPref 60 - preferences={preferences} 61 - labelGroup="suggestive" 62 - disabled={!preferences?.adultContentEnabled} 63 - /> 64 - <ContentLabelPref 65 - preferences={preferences} 66 - labelGroup="gore" 67 - disabled={!preferences?.adultContentEnabled} 68 - /> 69 - <ContentLabelPref preferences={preferences} labelGroup="hate" /> 70 - <ContentLabelPref preferences={preferences} labelGroup="spam" /> 71 - <ContentLabelPref 72 - preferences={preferences} 73 - labelGroup="impersonation" 74 - /> 75 - <View style={{height: isMobile ? 60 : 0}} /> 76 - </ScrollView> 77 - 78 - <View 79 - style={[ 80 - styles.btnContainer, 81 - isMobile && styles.btnContainerMobile, 82 - pal.borderDark, 83 - ]}> 84 - <Pressable 85 - testID="sendReportBtn" 86 - onPress={onPressDone} 87 - accessibilityRole="button" 88 - accessibilityLabel={_(msg`Done`)} 89 - accessibilityHint=""> 90 - <LinearGradient 91 - colors={[gradients.blueLight.start, gradients.blueLight.end]} 92 - start={{x: 0, y: 0}} 93 - end={{x: 1, y: 1}} 94 - style={[styles.btn]}> 95 - <Text style={[s.white, s.bold, s.f18]}> 96 - <Trans>Done</Trans> 97 - </Text> 98 - </LinearGradient> 99 - </Pressable> 100 - </View> 101 - </View> 102 - ) 103 - } 104 - 105 - function AdultContentEnabledPref() { 106 - const pal = usePalette('default') 107 - const {_} = useLingui() 108 - const {data: preferences} = usePreferencesQuery() 109 - const {mutate, variables} = usePreferencesSetAdultContentMutation() 110 - const {openModal} = useModalControls() 111 - 112 - const onSetAge = React.useCallback( 113 - () => openModal({name: 'birth-date-settings'}), 114 - [openModal], 115 - ) 116 - 117 - const onToggleAdultContent = React.useCallback(async () => { 118 - if (isIOS) return 119 - 120 - try { 121 - mutate({ 122 - enabled: !(variables?.enabled ?? preferences?.adultContentEnabled), 123 - }) 124 - } catch (e) { 125 - Toast.show( 126 - _(msg`There was an issue syncing your preferences with the server`), 127 - ) 128 - logger.error('Failed to update preferences with server', {message: e}) 129 - } 130 - }, [variables, preferences, mutate, _]) 131 - 132 - const onAdultContentLinkPress = React.useCallback(() => { 133 - Linking.openURL('https://bsky.app/') 134 - }, []) 135 - 136 - return ( 137 - <View style={s.mb10}> 138 - {isIOS ? ( 139 - preferences?.adultContentEnabled ? null : ( 140 - <Text type="md" style={pal.textLight}> 141 - <Trans> 142 - Adult content can only be enabled via the Web at{' '} 143 - <TextLink 144 - style={pal.link} 145 - href="" 146 - text="bsky.app" 147 - onPress={onAdultContentLinkPress} 148 - /> 149 - . 150 - </Trans> 151 - </Text> 152 - ) 153 - ) : typeof preferences?.birthDate === 'undefined' ? ( 154 - <View style={[pal.viewLight, styles.agePrompt]}> 155 - <Text type="md" style={[pal.text, {flex: 1}]}> 156 - <Trans>Confirm your age to enable adult content.</Trans> 157 - </Text> 158 - <Button 159 - type="primary" 160 - label={_(msg({message: 'Set Age', context: 'action'}))} 161 - onPress={onSetAge} 162 - /> 163 - </View> 164 - ) : (preferences.userAge || 0) >= 18 ? ( 165 - <ToggleButton 166 - type="default-light" 167 - label={_(msg`Enable Adult Content`)} 168 - isSelected={variables?.enabled ?? preferences?.adultContentEnabled} 169 - onPress={onToggleAdultContent} 170 - style={styles.toggleBtn} 171 - /> 172 - ) : ( 173 - <View style={[pal.viewLight, styles.agePrompt]}> 174 - <Text type="md" style={[pal.text, {flex: 1}]}> 175 - <Trans>You must be 18 or older to enable adult content.</Trans> 176 - </Text> 177 - <Button 178 - type="primary" 179 - label={_(msg({message: 'Set Age', context: 'action'}))} 180 - onPress={onSetAge} 181 - /> 182 - </View> 183 - )} 184 - </View> 185 - ) 186 - } 187 - 188 - // TODO: Refactor this component to pass labels down to each tab 189 - function ContentLabelPref({ 190 - preferences, 191 - labelGroup, 192 - disabled, 193 - }: { 194 - preferences?: UsePreferencesQueryResponse 195 - labelGroup: ConfigurableLabelGroup 196 - disabled?: boolean 197 - }) { 198 - const pal = usePalette('default') 199 - const visibility = preferences?.contentLabels?.[labelGroup] 200 - const {mutate, variables} = usePreferencesSetContentLabelMutation() 201 - 202 - const onChange = React.useCallback( 203 - (vis: LabelPreference) => { 204 - mutate({labelGroup, visibility: vis}) 205 - }, 206 - [mutate, labelGroup], 207 - ) 208 - 209 - return ( 210 - <View style={[styles.contentLabelPref, pal.border]}> 211 - <View style={s.flex1}> 212 - <Text type="md-medium" style={[pal.text]}> 213 - {CONFIGURABLE_LABEL_GROUPS[labelGroup].title} 214 - </Text> 215 - {typeof CONFIGURABLE_LABEL_GROUPS[labelGroup].subtitle === 'string' && ( 216 - <Text type="sm" style={[pal.textLight]}> 217 - {CONFIGURABLE_LABEL_GROUPS[labelGroup].subtitle} 218 - </Text> 219 - )} 220 - </View> 221 - 222 - {disabled || !visibility ? ( 223 - <Text type="sm-bold" style={pal.textLight}> 224 - <Trans context="action">Hide</Trans> 225 - </Text> 226 - ) : ( 227 - <SelectGroup 228 - current={variables?.visibility || visibility} 229 - onChange={onChange} 230 - labelGroup={labelGroup} 231 - /> 232 - )} 233 - </View> 234 - ) 235 - } 236 - 237 - interface SelectGroupProps { 238 - current: LabelPreference 239 - onChange: (v: LabelPreference) => void 240 - labelGroup: ConfigurableLabelGroup 241 - } 242 - 243 - function SelectGroup({current, onChange, labelGroup}: SelectGroupProps) { 244 - const {_} = useLingui() 245 - 246 - return ( 247 - <View style={styles.selectableBtns}> 248 - <SelectableBtn 249 - current={current} 250 - value="hide" 251 - label={_(msg`Hide`)} 252 - left 253 - onChange={onChange} 254 - labelGroup={labelGroup} 255 - /> 256 - <SelectableBtn 257 - current={current} 258 - value="warn" 259 - label={_(msg`Warn`)} 260 - onChange={onChange} 261 - labelGroup={labelGroup} 262 - /> 263 - <SelectableBtn 264 - current={current} 265 - value="ignore" 266 - label={_(msg`Show`)} 267 - right 268 - onChange={onChange} 269 - labelGroup={labelGroup} 270 - /> 271 - </View> 272 - ) 273 - } 274 - 275 - interface SelectableBtnProps { 276 - current: string 277 - value: LabelPreference 278 - label: string 279 - left?: boolean 280 - right?: boolean 281 - onChange: (v: LabelPreference) => void 282 - labelGroup: ConfigurableLabelGroup 283 - } 284 - 285 - function SelectableBtn({ 286 - current, 287 - value, 288 - label, 289 - left, 290 - right, 291 - onChange, 292 - labelGroup, 293 - }: SelectableBtnProps) { 294 - const pal = usePalette('default') 295 - const palPrimary = usePalette('inverted') 296 - const {_} = useLingui() 297 - 298 - return ( 299 - <Pressable 300 - style={[ 301 - styles.selectableBtn, 302 - left && styles.selectableBtnLeft, 303 - right && styles.selectableBtnRight, 304 - pal.border, 305 - current === value ? palPrimary.view : pal.view, 306 - ]} 307 - onPress={() => onChange(value)} 308 - accessibilityRole="button" 309 - accessibilityLabel={value} 310 - accessibilityHint={_( 311 - msg`Set ${value} for ${labelGroup} content moderation policy`, 312 - )}> 313 - <Text style={current === value ? palPrimary.text : pal.text}> 314 - {label} 315 - </Text> 316 - </Pressable> 317 - ) 318 - } 319 - 320 - const styles = StyleSheet.create({ 321 - container: { 322 - flex: 1, 323 - }, 324 - title: { 325 - textAlign: 'center', 326 - fontWeight: 'bold', 327 - fontSize: 24, 328 - marginBottom: 12, 329 - }, 330 - description: { 331 - paddingHorizontal: 2, 332 - marginBottom: 10, 333 - }, 334 - scrollContainer: { 335 - flex: 1, 336 - paddingHorizontal: 10, 337 - }, 338 - btnContainer: { 339 - paddingTop: 10, 340 - paddingHorizontal: 10, 341 - }, 342 - btnContainerMobile: { 343 - paddingBottom: 40, 344 - borderTopWidth: 1, 345 - }, 346 - 347 - agePrompt: { 348 - flexDirection: 'row', 349 - justifyContent: 'space-between', 350 - alignItems: 'center', 351 - paddingLeft: 14, 352 - paddingRight: 10, 353 - paddingVertical: 8, 354 - borderRadius: 8, 355 - }, 356 - 357 - contentLabelPref: { 358 - flexDirection: 'row', 359 - justifyContent: 'space-between', 360 - alignItems: 'center', 361 - paddingTop: 14, 362 - paddingLeft: 4, 363 - marginBottom: 14, 364 - borderTopWidth: 1, 365 - }, 366 - 367 - selectableBtns: { 368 - flexDirection: 'row', 369 - marginLeft: 10, 370 - }, 371 - selectableBtn: { 372 - flexDirection: 'row', 373 - justifyContent: 'center', 374 - borderWidth: 1, 375 - borderLeftWidth: 0, 376 - paddingHorizontal: 10, 377 - paddingVertical: 10, 378 - }, 379 - selectableBtnLeft: { 380 - borderTopLeftRadius: 8, 381 - borderBottomLeftRadius: 8, 382 - borderLeftWidth: 1, 383 - }, 384 - selectableBtnRight: { 385 - borderTopRightRadius: 8, 386 - borderBottomRightRadius: 8, 387 - }, 388 - 389 - btn: { 390 - flexDirection: 'row', 391 - alignItems: 'center', 392 - justifyContent: 'center', 393 - width: '100%', 394 - borderRadius: 32, 395 - padding: 14, 396 - backgroundColor: colors.gray1, 397 - }, 398 - toggleBtn: { 399 - paddingHorizontal: 0, 400 - }, 401 - })
-20
src/view/com/modals/Modal.tsx
··· 15 15 import * as ListAddUserModal from './ListAddRemoveUsers' 16 16 import * as AltImageModal from './AltImage' 17 17 import * as EditImageModal from './AltImage' 18 - import * as ReportModal from './report/Modal' 19 - import * as AppealLabelModal from './AppealLabel' 20 18 import * as DeleteAccountModal from './DeleteAccount' 21 19 import * as ChangeHandleModal from './ChangeHandle' 22 20 import * as InviteCodesModal from './InviteCodes' 23 21 import * as AddAppPassword from './AddAppPasswords' 24 - import * as ContentFilteringSettingsModal from './ContentFilteringSettings' 25 22 import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' 26 23 import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' 27 - import * as ModerationDetailsModal from './ModerationDetails' 28 - import * as BirthDateSettingsModal from './BirthDateSettings' 29 24 import * as VerifyEmailModal from './VerifyEmail' 30 25 import * as ChangeEmailModal from './ChangeEmail' 31 26 import * as ChangePasswordModal from './ChangePassword' ··· 68 63 if (activeModal?.name === 'edit-profile') { 69 64 snapPoints = EditProfileModal.snapPoints 70 65 element = <EditProfileModal.Component {...activeModal} /> 71 - } else if (activeModal?.name === 'report') { 72 - snapPoints = ReportModal.snapPoints 73 - element = <ReportModal.Component {...activeModal} /> 74 - } else if (activeModal?.name === 'appeal-label') { 75 - snapPoints = AppealLabelModal.snapPoints 76 - element = <AppealLabelModal.Component {...activeModal} /> 77 66 } else if (activeModal?.name === 'create-or-edit-list') { 78 67 snapPoints = CreateOrEditListModal.snapPoints 79 68 element = <CreateOrEditListModal.Component {...activeModal} /> ··· 110 99 } else if (activeModal?.name === 'add-app-password') { 111 100 snapPoints = AddAppPassword.snapPoints 112 101 element = <AddAppPassword.Component /> 113 - } else if (activeModal?.name === 'content-filtering-settings') { 114 - snapPoints = ContentFilteringSettingsModal.snapPoints 115 - element = <ContentFilteringSettingsModal.Component /> 116 102 } else if (activeModal?.name === 'content-languages-settings') { 117 103 snapPoints = ContentLanguagesSettingsModal.snapPoints 118 104 element = <ContentLanguagesSettingsModal.Component /> 119 105 } else if (activeModal?.name === 'post-languages-settings') { 120 106 snapPoints = PostLanguagesSettingsModal.snapPoints 121 107 element = <PostLanguagesSettingsModal.Component /> 122 - } else if (activeModal?.name === 'moderation-details') { 123 - snapPoints = ModerationDetailsModal.snapPoints 124 - element = <ModerationDetailsModal.Component {...activeModal} /> 125 - } else if (activeModal?.name === 'birth-date-settings') { 126 - snapPoints = BirthDateSettingsModal.snapPoints 127 - element = <BirthDateSettingsModal.Component /> 128 108 } else if (activeModal?.name === 'verify-email') { 129 109 snapPoints = VerifyEmailModal.snapPoints 130 110 element = <VerifyEmailModal.Component {...activeModal} />
-15
src/view/com/modals/Modal.web.tsx
··· 8 8 import {useModals, useModalControls} from '#/state/modals' 9 9 import type {Modal as ModalIface} from '#/state/modals' 10 10 import * as EditProfileModal from './EditProfile' 11 - import * as ReportModal from './report/Modal' 12 - import * as AppealLabelModal from './AppealLabel' 13 11 import * as CreateOrEditListModal from './CreateOrEditList' 14 12 import * as UserAddRemoveLists from './UserAddRemoveLists' 15 13 import * as ListAddUserModal from './ListAddRemoveUsers' ··· 23 21 import * as ChangeHandleModal from './ChangeHandle' 24 22 import * as InviteCodesModal from './InviteCodes' 25 23 import * as AddAppPassword from './AddAppPasswords' 26 - import * as ContentFilteringSettingsModal from './ContentFilteringSettings' 27 24 import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' 28 25 import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' 29 - import * as ModerationDetailsModal from './ModerationDetails' 30 - import * as BirthDateSettingsModal from './BirthDateSettings' 31 26 import * as VerifyEmailModal from './VerifyEmail' 32 27 import * as ChangeEmailModal from './ChangeEmail' 33 28 import * as ChangePasswordModal from './ChangePassword' ··· 79 74 let element 80 75 if (modal.name === 'edit-profile') { 81 76 element = <EditProfileModal.Component {...modal} /> 82 - } else if (modal.name === 'report') { 83 - element = <ReportModal.Component {...modal} /> 84 - } else if (modal.name === 'appeal-label') { 85 - element = <AppealLabelModal.Component {...modal} /> 86 77 } else if (modal.name === 'create-or-edit-list') { 87 78 element = <CreateOrEditListModal.Component {...modal} /> 88 79 } else if (modal.name === 'user-add-remove-lists') { ··· 105 96 element = <InviteCodesModal.Component /> 106 97 } else if (modal.name === 'add-app-password') { 107 98 element = <AddAppPassword.Component /> 108 - } else if (modal.name === 'content-filtering-settings') { 109 - element = <ContentFilteringSettingsModal.Component /> 110 99 } else if (modal.name === 'content-languages-settings') { 111 100 element = <ContentLanguagesSettingsModal.Component /> 112 101 } else if (modal.name === 'post-languages-settings') { ··· 115 104 element = <AltTextImageModal.Component {...modal} /> 116 105 } else if (modal.name === 'edit-image') { 117 106 element = <EditImageModal.Component {...modal} /> 118 - } else if (modal.name === 'moderation-details') { 119 - element = <ModerationDetailsModal.Component {...modal} /> 120 - } else if (modal.name === 'birth-date-settings') { 121 - element = <BirthDateSettingsModal.Component /> 122 107 } else if (modal.name === 'verify-email') { 123 108 element = <VerifyEmailModal.Component {...modal} /> 124 109 } else if (modal.name === 'change-email') {
-142
src/view/com/modals/ModerationDetails.tsx
··· 1 - import React from 'react' 2 - import {StyleSheet, View} from 'react-native' 3 - import {ModerationUI} from '@atproto/api' 4 - import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 5 - import {s} from 'lib/styles' 6 - import {Text} from '../util/text/Text' 7 - import {TextLink} from '../util/Link' 8 - import {usePalette} from 'lib/hooks/usePalette' 9 - import {isWeb} from 'platform/detection' 10 - import {listUriToHref} from 'lib/strings/url-helpers' 11 - import {Button} from '../util/forms/Button' 12 - import {useModalControls} from '#/state/modals' 13 - import {useLingui} from '@lingui/react' 14 - import {Trans, msg} from '@lingui/macro' 15 - 16 - export const snapPoints = [300] 17 - 18 - export function Component({ 19 - context, 20 - moderation, 21 - }: { 22 - context: 'account' | 'content' 23 - moderation: ModerationUI 24 - }) { 25 - const {closeModal} = useModalControls() 26 - const {isMobile} = useWebMediaQueries() 27 - const pal = usePalette('default') 28 - const {_} = useLingui() 29 - 30 - let name 31 - let description 32 - if (!moderation.cause) { 33 - name = _(msg`Content Warning`) 34 - description = _( 35 - msg`Moderator has chosen to set a general warning on the content.`, 36 - ) 37 - } else if (moderation.cause.type === 'blocking') { 38 - if (moderation.cause.source.type === 'list') { 39 - const list = moderation.cause.source.list 40 - name = _(msg`User Blocked by List`) 41 - description = ( 42 - <Trans> 43 - This user is included in the{' '} 44 - <TextLink 45 - type="2xl" 46 - href={listUriToHref(list.uri)} 47 - text={list.name} 48 - style={pal.link} 49 - />{' '} 50 - list which you have blocked. 51 - </Trans> 52 - ) 53 - } else { 54 - name = _(msg`User Blocked`) 55 - description = _( 56 - msg`You have blocked this user. You cannot view their content.`, 57 - ) 58 - } 59 - } else if (moderation.cause.type === 'blocked-by') { 60 - name = _(msg`User Blocks You`) 61 - description = _( 62 - msg`This user has blocked you. You cannot view their content.`, 63 - ) 64 - } else if (moderation.cause.type === 'block-other') { 65 - name = _(msg`Content Not Available`) 66 - description = _( 67 - msg`This content is not available because one of the users involved has blocked the other.`, 68 - ) 69 - } else if (moderation.cause.type === 'muted') { 70 - if (moderation.cause.source.type === 'list') { 71 - const list = moderation.cause.source.list 72 - name = _(msg`Account Muted by List`) 73 - description = ( 74 - <Trans> 75 - This user is included in the{' '} 76 - <TextLink 77 - type="2xl" 78 - href={listUriToHref(list.uri)} 79 - text={list.name} 80 - style={pal.link} 81 - />{' '} 82 - list which you have muted. 83 - </Trans> 84 - ) 85 - } else { 86 - name = _(msg`Account Muted`) 87 - description = _(msg`You have muted this user.`) 88 - } 89 - } else { 90 - name = moderation.cause.labelDef.strings[context].en.name 91 - description = moderation.cause.labelDef.strings[context].en.description 92 - } 93 - 94 - return ( 95 - <View 96 - testID="moderationDetailsModal" 97 - style={[ 98 - styles.container, 99 - { 100 - paddingHorizontal: isMobile ? 14 : 0, 101 - }, 102 - pal.view, 103 - ]}> 104 - <Text type="title-xl" style={[pal.text, styles.title]}> 105 - {name} 106 - </Text> 107 - <Text type="2xl" style={[pal.text, styles.description]}> 108 - {description} 109 - </Text> 110 - <View style={s.flex1} /> 111 - <Button 112 - type="primary" 113 - style={styles.btn} 114 - onPress={() => { 115 - closeModal() 116 - }}> 117 - <Text type="button-lg" style={[pal.textLight, s.textCenter, s.white]}> 118 - Okay 119 - </Text> 120 - </Button> 121 - </View> 122 - ) 123 - } 124 - 125 - const styles = StyleSheet.create({ 126 - container: { 127 - flex: 1, 128 - }, 129 - title: { 130 - textAlign: 'center', 131 - fontWeight: 'bold', 132 - marginBottom: 12, 133 - }, 134 - description: { 135 - textAlign: 'center', 136 - }, 137 - btn: { 138 - paddingVertical: 14, 139 - marginTop: isWeb ? 40 : 0, 140 - marginBottom: isWeb ? 0 : 40, 141 - }, 142 - })
-100
src/view/com/modals/report/InputIssueDetails.tsx
··· 1 - import React from 'react' 2 - import {View, TouchableOpacity, StyleSheet} from 'react-native' 3 - import {TextInput} from '../util' 4 - import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 5 - import {CharProgress} from '../../composer/char-progress/CharProgress' 6 - import {Text} from '../../util/text/Text' 7 - import {usePalette} from 'lib/hooks/usePalette' 8 - import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 9 - import {s} from 'lib/styles' 10 - import {SendReportButton} from './SendReportButton' 11 - import {Trans, msg} from '@lingui/macro' 12 - import {useLingui} from '@lingui/react' 13 - 14 - export function InputIssueDetails({ 15 - details, 16 - setDetails, 17 - goBack, 18 - submitReport, 19 - isProcessing, 20 - }: { 21 - details: string | undefined 22 - setDetails: (v: string) => void 23 - goBack: () => void 24 - submitReport: () => void 25 - isProcessing: boolean 26 - }) { 27 - const pal = usePalette('default') 28 - const {_} = useLingui() 29 - const {isMobile} = useWebMediaQueries() 30 - 31 - return ( 32 - <View 33 - style={{ 34 - marginTop: isMobile ? 12 : 0, 35 - }}> 36 - <TouchableOpacity 37 - testID="addDetailsBtn" 38 - style={[s.mb10, styles.backBtn]} 39 - onPress={goBack} 40 - accessibilityRole="button" 41 - accessibilityLabel={_(msg`Add details`)} 42 - accessibilityHint="Add more details to your report"> 43 - <FontAwesomeIcon size={18} icon="angle-left" style={[pal.link]} /> 44 - <Text style={[pal.text, s.f18, pal.link]}> 45 - {' '} 46 - <Trans>Back</Trans> 47 - </Text> 48 - </TouchableOpacity> 49 - <View style={[pal.btn, styles.detailsInputContainer]}> 50 - <TextInput 51 - accessibilityLabel={_(msg`Text input field`)} 52 - accessibilityHint="Enter a reason for reporting this post." 53 - placeholder="Enter a reason or any other details here." 54 - placeholderTextColor={pal.textLight.color} 55 - value={details} 56 - onChangeText={setDetails} 57 - autoFocus={true} 58 - numberOfLines={3} 59 - multiline={true} 60 - textAlignVertical="top" 61 - maxLength={300} 62 - style={[styles.detailsInput, pal.text]} 63 - /> 64 - <View style={styles.detailsInputBottomBar}> 65 - <View style={styles.charCounter}> 66 - <CharProgress count={details?.length || 0} /> 67 - </View> 68 - </View> 69 - </View> 70 - <SendReportButton onPress={submitReport} isProcessing={isProcessing} /> 71 - </View> 72 - ) 73 - } 74 - 75 - const styles = StyleSheet.create({ 76 - backBtn: { 77 - flexDirection: 'row', 78 - alignItems: 'center', 79 - }, 80 - detailsInputContainer: { 81 - borderRadius: 8, 82 - }, 83 - detailsInput: { 84 - paddingHorizontal: 12, 85 - paddingTop: 12, 86 - paddingBottom: 12, 87 - borderRadius: 8, 88 - minHeight: 100, 89 - fontSize: 16, 90 - }, 91 - detailsInputBottomBar: { 92 - alignSelf: 'flex-end', 93 - }, 94 - charCounter: { 95 - flexDirection: 'row', 96 - alignItems: 'center', 97 - paddingRight: 10, 98 - paddingBottom: 8, 99 - }, 100 - })
-223
src/view/com/modals/report/Modal.tsx
··· 1 - import React, {useState, useMemo} from 'react' 2 - import {Linking, StyleSheet, TouchableOpacity, View} from 'react-native' 3 - import {ScrollView} from 'react-native-gesture-handler' 4 - import {AtUri} from '@atproto/api' 5 - import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 6 - import {s} from 'lib/styles' 7 - import {Text} from '../../util/text/Text' 8 - import * as Toast from '../../util/Toast' 9 - import {ErrorMessage} from '../../util/error/ErrorMessage' 10 - import {cleanError} from 'lib/strings/errors' 11 - import {usePalette} from 'lib/hooks/usePalette' 12 - import {SendReportButton} from './SendReportButton' 13 - import {InputIssueDetails} from './InputIssueDetails' 14 - import {ReportReasonOptions} from './ReasonOptions' 15 - import {CollectionId} from './types' 16 - import {Trans, msg} from '@lingui/macro' 17 - import {useLingui} from '@lingui/react' 18 - import {useModalControls} from '#/state/modals' 19 - import {getAgent} from '#/state/session' 20 - 21 - const DMCA_LINK = 'https://bsky.social/about/support/copyright' 22 - 23 - export const snapPoints = [575] 24 - 25 - const CollectionNames = { 26 - [CollectionId.FeedGenerator]: 'Feed', 27 - [CollectionId.Profile]: 'Profile', 28 - [CollectionId.List]: 'List', 29 - [CollectionId.Post]: 'Post', 30 - } 31 - 32 - type ReportComponentProps = 33 - | { 34 - uri: string 35 - cid: string 36 - } 37 - | { 38 - did: string 39 - } 40 - 41 - export function Component(content: ReportComponentProps) { 42 - const {closeModal} = useModalControls() 43 - const pal = usePalette('default') 44 - const {isMobile} = useWebMediaQueries() 45 - const [isProcessing, setIsProcessing] = useState(false) 46 - const [showDetailsInput, setShowDetailsInput] = useState(false) 47 - const [error, setError] = useState<string>('') 48 - const [issue, setIssue] = useState<string>('') 49 - const [details, setDetails] = useState<string>('') 50 - const isAccountReport = 'did' in content 51 - const subjectKey = isAccountReport ? content.did : content.uri 52 - const atUri = useMemo( 53 - () => (!isAccountReport ? new AtUri(subjectKey) : null), 54 - [isAccountReport, subjectKey], 55 - ) 56 - 57 - const submitReport = async () => { 58 - setError('') 59 - if (!issue) { 60 - return 61 - } 62 - setIsProcessing(true) 63 - try { 64 - if (issue === '__copyright__') { 65 - Linking.openURL(DMCA_LINK) 66 - closeModal() 67 - return 68 - } 69 - const $type = !isAccountReport 70 - ? 'com.atproto.repo.strongRef' 71 - : 'com.atproto.admin.defs#repoRef' 72 - await getAgent().createModerationReport({ 73 - reasonType: issue, 74 - subject: { 75 - $type, 76 - ...content, 77 - }, 78 - reason: details, 79 - }) 80 - Toast.show("Thank you for your report! We'll look into it promptly.") 81 - 82 - closeModal() 83 - return 84 - } catch (e: any) { 85 - setError(cleanError(e)) 86 - setIsProcessing(false) 87 - } 88 - } 89 - 90 - const goBack = () => { 91 - setShowDetailsInput(false) 92 - } 93 - 94 - return ( 95 - <ScrollView testID="reportModal" style={[s.flex1, pal.view]}> 96 - <View 97 - style={[ 98 - styles.container, 99 - isMobile && { 100 - paddingBottom: 40, 101 - }, 102 - ]}> 103 - {showDetailsInput ? ( 104 - <InputIssueDetails 105 - details={details} 106 - setDetails={setDetails} 107 - goBack={goBack} 108 - submitReport={submitReport} 109 - isProcessing={isProcessing} 110 - /> 111 - ) : ( 112 - <SelectIssue 113 - setShowDetailsInput={setShowDetailsInput} 114 - error={error} 115 - issue={issue} 116 - setIssue={setIssue} 117 - submitReport={submitReport} 118 - isProcessing={isProcessing} 119 - atUri={atUri} 120 - /> 121 - )} 122 - </View> 123 - </ScrollView> 124 - ) 125 - } 126 - 127 - // If no atUri is passed, that means the reporting collection is account 128 - const getCollectionNameForReport = (atUri: AtUri | null) => { 129 - if (!atUri) return 'Account' 130 - // Generic fallback for any collection being reported 131 - return CollectionNames[atUri.collection as CollectionId] || 'Content' 132 - } 133 - 134 - const SelectIssue = ({ 135 - error, 136 - setShowDetailsInput, 137 - issue, 138 - setIssue, 139 - submitReport, 140 - isProcessing, 141 - atUri, 142 - }: { 143 - error: string | undefined 144 - setShowDetailsInput: (v: boolean) => void 145 - issue: string | undefined 146 - setIssue: (v: string) => void 147 - submitReport: () => void 148 - isProcessing: boolean 149 - atUri: AtUri | null 150 - }) => { 151 - const pal = usePalette('default') 152 - const {_} = useLingui() 153 - const collectionName = getCollectionNameForReport(atUri) 154 - const onSelectIssue = (v: string) => setIssue(v) 155 - const goToDetails = () => { 156 - if (issue === '__copyright__') { 157 - Linking.openURL(DMCA_LINK) 158 - return 159 - } 160 - setShowDetailsInput(true) 161 - } 162 - 163 - return ( 164 - <> 165 - <Text style={[pal.text, styles.title]}> 166 - <Trans>Report {collectionName}</Trans> 167 - </Text> 168 - <Text style={[pal.textLight, styles.description]}> 169 - <Trans>What is the issue with this {collectionName}?</Trans> 170 - </Text> 171 - <View style={{marginBottom: 10}}> 172 - <ReportReasonOptions 173 - atUri={atUri} 174 - selectedIssue={issue} 175 - onSelectIssue={onSelectIssue} 176 - /> 177 - </View> 178 - {error ? <ErrorMessage message={error} /> : undefined} 179 - {/* If no atUri is present, the report would be for account in which case, we allow sending without specifying a reason */} 180 - {issue || !atUri ? ( 181 - <> 182 - <SendReportButton 183 - onPress={submitReport} 184 - isProcessing={isProcessing} 185 - /> 186 - <TouchableOpacity 187 - testID="addDetailsBtn" 188 - style={styles.addDetailsBtn} 189 - onPress={goToDetails} 190 - accessibilityRole="button" 191 - accessibilityLabel={_(msg`Add details`)} 192 - accessibilityHint="Add more details to your report"> 193 - <Text style={[s.f18, pal.link]}> 194 - <Trans>Add details to report</Trans> 195 - </Text> 196 - </TouchableOpacity> 197 - </> 198 - ) : undefined} 199 - </> 200 - ) 201 - } 202 - 203 - const styles = StyleSheet.create({ 204 - container: { 205 - paddingHorizontal: 10, 206 - }, 207 - title: { 208 - textAlign: 'center', 209 - fontWeight: 'bold', 210 - fontSize: 24, 211 - marginBottom: 12, 212 - }, 213 - description: { 214 - textAlign: 'center', 215 - fontSize: 17, 216 - paddingHorizontal: 22, 217 - marginBottom: 10, 218 - }, 219 - addDetailsBtn: { 220 - padding: 14, 221 - alignSelf: 'center', 222 - }, 223 - })
-123
src/view/com/modals/report/ReasonOptions.tsx
··· 1 - import {View} from 'react-native' 2 - import React, {useMemo} from 'react' 3 - import {AtUri, ComAtprotoModerationDefs} from '@atproto/api' 4 - 5 - import {Text} from '../../util/text/Text' 6 - import {UsePaletteValue, usePalette} from 'lib/hooks/usePalette' 7 - import {RadioGroup, RadioGroupItem} from 'view/com/util/forms/RadioGroup' 8 - import {CollectionId} from './types' 9 - 10 - type ReasonMap = Record<string, {title: string; description: string}> 11 - const CommonReasons = { 12 - [ComAtprotoModerationDefs.REASONRUDE]: { 13 - title: 'Anti-Social Behavior', 14 - description: 'Harassment, trolling, or intolerance', 15 - }, 16 - [ComAtprotoModerationDefs.REASONVIOLATION]: { 17 - title: 'Illegal and Urgent', 18 - description: 'Glaring violations of law or terms of service', 19 - }, 20 - [ComAtprotoModerationDefs.REASONOTHER]: { 21 - title: 'Other', 22 - description: 'An issue not included in these options', 23 - }, 24 - } 25 - const CollectionToReasonsMap: Record<string, ReasonMap> = { 26 - [CollectionId.Post]: { 27 - [ComAtprotoModerationDefs.REASONSPAM]: { 28 - title: 'Spam', 29 - description: 'Excessive mentions or replies', 30 - }, 31 - [ComAtprotoModerationDefs.REASONSEXUAL]: { 32 - title: 'Unwanted Sexual Content', 33 - description: 'Nudity or pornography not labeled as such', 34 - }, 35 - __copyright__: { 36 - title: 'Copyright Violation', 37 - description: 'Contains copyrighted material', 38 - }, 39 - ...CommonReasons, 40 - }, 41 - [CollectionId.List]: { 42 - ...CommonReasons, 43 - [ComAtprotoModerationDefs.REASONVIOLATION]: { 44 - title: 'Name or Description Violates Community Standards', 45 - description: 'Terms used violate community standards', 46 - }, 47 - }, 48 - } 49 - const AccountReportReasons = { 50 - [ComAtprotoModerationDefs.REASONMISLEADING]: { 51 - title: 'Misleading Account', 52 - description: 'Impersonation or false claims about identity or affiliation', 53 - }, 54 - [ComAtprotoModerationDefs.REASONSPAM]: { 55 - title: 'Frequently Posts Unwanted Content', 56 - description: 'Spam; excessive mentions or replies', 57 - }, 58 - [ComAtprotoModerationDefs.REASONVIOLATION]: { 59 - title: 'Name or Description Violates Community Standards', 60 - description: 'Terms used violate community standards', 61 - }, 62 - } 63 - 64 - const Option = ({ 65 - pal, 66 - title, 67 - description, 68 - }: { 69 - pal: UsePaletteValue 70 - description: string 71 - title: string 72 - }) => { 73 - return ( 74 - <View> 75 - <Text style={pal.text} type="md-bold"> 76 - {title} 77 - </Text> 78 - <Text style={pal.textLight}>{description}</Text> 79 - </View> 80 - ) 81 - } 82 - 83 - // This is mostly just content copy without almost any logic 84 - // so this may grow over time and it makes sense to split it up into its own file 85 - // to keep it separate from the actual reporting modal logic 86 - const useReportRadioOptions = (pal: UsePaletteValue, atUri: AtUri | null) => 87 - useMemo(() => { 88 - let items: ReasonMap = {...CommonReasons} 89 - // If no atUri is passed, that means the reporting collection is account 90 - if (!atUri) { 91 - items = {...AccountReportReasons} 92 - } 93 - 94 - if (atUri?.collection && CollectionToReasonsMap[atUri.collection]) { 95 - items = {...CollectionToReasonsMap[atUri.collection]} 96 - } 97 - 98 - return Object.entries(items).map(([key, {title, description}]) => ({ 99 - key, 100 - label: <Option pal={pal} title={title} description={description} />, 101 - })) 102 - }, [pal, atUri]) 103 - 104 - export const ReportReasonOptions = ({ 105 - atUri, 106 - selectedIssue, 107 - onSelectIssue, 108 - }: { 109 - atUri: AtUri | null 110 - selectedIssue?: string 111 - onSelectIssue: (key: string) => void 112 - }) => { 113 - const pal = usePalette('default') 114 - const ITEMS: RadioGroupItem[] = useReportRadioOptions(pal, atUri) 115 - return ( 116 - <RadioGroup 117 - items={ITEMS} 118 - onSelect={onSelectIssue} 119 - testID="reportReasonRadios" 120 - initialSelection={selectedIssue} 121 - /> 122 - ) 123 - }
-62
src/view/com/modals/report/SendReportButton.tsx
··· 1 - import React from 'react' 2 - import LinearGradient from 'react-native-linear-gradient' 3 - import { 4 - ActivityIndicator, 5 - StyleSheet, 6 - TouchableOpacity, 7 - View, 8 - } from 'react-native' 9 - import {Text} from '../../util/text/Text' 10 - import {s, gradients, colors} from 'lib/styles' 11 - import {Trans, msg} from '@lingui/macro' 12 - import {useLingui} from '@lingui/react' 13 - 14 - export function SendReportButton({ 15 - onPress, 16 - isProcessing, 17 - }: { 18 - onPress: () => void 19 - isProcessing: boolean 20 - }) { 21 - const {_} = useLingui() 22 - // loading state 23 - // = 24 - if (isProcessing) { 25 - return ( 26 - <View style={[styles.btn, s.mt10]}> 27 - <ActivityIndicator /> 28 - </View> 29 - ) 30 - } 31 - return ( 32 - <TouchableOpacity 33 - testID="sendReportBtn" 34 - style={s.mt10} 35 - onPress={onPress} 36 - accessibilityRole="button" 37 - accessibilityLabel={_(msg`Report post`)} 38 - accessibilityHint={`Reports post with reason and details`}> 39 - <LinearGradient 40 - colors={[gradients.blueLight.start, gradients.blueLight.end]} 41 - start={{x: 0, y: 0}} 42 - end={{x: 1, y: 1}} 43 - style={[styles.btn]}> 44 - <Text style={[s.white, s.bold, s.f18]}> 45 - <Trans>Send Report</Trans> 46 - </Text> 47 - </LinearGradient> 48 - </TouchableOpacity> 49 - ) 50 - } 51 - 52 - const styles = StyleSheet.create({ 53 - btn: { 54 - flexDirection: 'row', 55 - alignItems: 'center', 56 - justifyContent: 'center', 57 - width: '100%', 58 - borderRadius: 32, 59 - padding: 14, 60 - backgroundColor: colors.gray1, 61 - }, 62 - })
-8
src/view/com/modals/report/types.ts
··· 1 - // TODO: ATM, @atproto/api does not export ids but it does have these listed at @atproto/api/client/lexicons 2 - // once we start exporting the ids from the @atproto/ap package, replace these hardcoded ones 3 - export enum CollectionId { 4 - FeedGenerator = 'app.bsky.feed.generator', 5 - Profile = 'app.bsky.actor.profile', 6 - List = 'app.bsky.graph.list', 7 - Post = 'app.bsky.feed.post', 8 - }
+5 -5
src/view/com/notifications/FeedItem.tsx
··· 11 11 AppBskyFeedDefs, 12 12 AppBskyFeedPost, 13 13 ModerationOpts, 14 - ProfileModeration, 14 + ModerationDecision, 15 15 moderateProfile, 16 16 AppBskyEmbedRecordWithMedia, 17 17 } from '@atproto/api' ··· 54 54 handle: string 55 55 displayName?: string 56 56 avatar?: string 57 - moderation: ProfileModeration 57 + moderation: ModerationDecision 58 58 } 59 59 60 60 let FeedItem = ({ ··· 336 336 did={authors[0].did} 337 337 handle={authors[0].handle} 338 338 avatar={authors[0].avatar} 339 - moderation={authors[0].moderation.avatar} 339 + moderation={authors[0].moderation.ui('avatar')} 340 340 /> 341 341 </View> 342 342 ) ··· 354 354 <UserAvatar 355 355 size={35} 356 356 avatar={author.avatar} 357 - moderation={author.moderation.avatar} 357 + moderation={author.moderation.ui('avatar')} 358 358 /> 359 359 </View> 360 360 ))} ··· 412 412 <UserAvatar 413 413 size={35} 414 414 avatar={author.avatar} 415 - moderation={author.moderation.avatar} 415 + moderation={author.moderation.ui('avatar')} 416 416 /> 417 417 </View> 418 418 <View style={s.flex1}>
+2 -2
src/view/com/post-thread/PostLikedBy.tsx
··· 8 8 import {logger} from '#/logger' 9 9 import {LoadingScreen} from '../util/LoadingScreen' 10 10 import {useResolveUriQuery} from '#/state/queries/resolve-uri' 11 - import {usePostLikedByQuery} from '#/state/queries/post-liked-by' 11 + import {useLikedByQuery} from '#/state/queries/post-liked-by' 12 12 import {cleanError} from '#/lib/strings/errors' 13 13 14 14 export function PostLikedBy({uri}: {uri: string}) { ··· 28 28 isError, 29 29 error, 30 30 refetch, 31 - } = usePostLikedByQuery(resolvedUri?.uri) 31 + } = useLikedByQuery(resolvedUri?.uri) 32 32 const likes = useMemo(() => { 33 33 if (data?.pages) { 34 34 return data.pages.flatMap(page => page.likes)
+6 -5
src/view/com/post-thread/PostThread.tsx
··· 106 106 ? moderatePost(rootPost, moderationOpts) 107 107 : undefined 108 108 109 - const cause = mod?.content.cause 110 - 111 - return cause 112 - ? cause.type === 'label' && cause.labelDef.id === '!no-unauthenticated' 113 - : false 109 + return !!mod 110 + ?.ui('contentList') 111 + .blurs.find( 112 + cause => 113 + cause.type === 'label' && cause.labelDef.id === '!no-unauthenticated', 114 + ) 114 115 }, [rootPost, moderationOpts]) 115 116 116 117 useSetTitle(
+26 -64
src/view/com/post-thread/PostThreadItem.tsx
··· 5 5 AppBskyFeedDefs, 6 6 AppBskyFeedPost, 7 7 RichText as RichTextAPI, 8 - PostModeration, 8 + ModerationDecision, 9 9 } from '@atproto/api' 10 10 import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' 11 11 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' ··· 19 19 import {sanitizeDisplayName} from 'lib/strings/display-names' 20 20 import {sanitizeHandle} from 'lib/strings/handles' 21 21 import {countLines, pluralize} from 'lib/strings/helpers' 22 - import {isEmbedByEmbedder} from 'lib/embeds' 23 22 import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers' 24 23 import {PostMeta} from '../util/PostMeta' 25 24 import {PostEmbeds} from '../util/post-embeds' 26 25 import {PostCtrls} from '../util/post-ctrls/PostCtrls' 27 - import {PostHider} from '../util/moderation/PostHider' 28 - import {ContentHider} from '../util/moderation/ContentHider' 29 - import {PostAlerts} from '../util/moderation/PostAlerts' 26 + import {PostHider} from '../../../components/moderation/PostHider' 27 + import {ContentHider} from '../../../components/moderation/ContentHider' 28 + import {PostAlerts} from '../../../components/moderation/PostAlerts' 29 + import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe' 30 30 import {ErrorMessage} from '../util/error/ErrorMessage' 31 31 import {usePalette} from 'lib/hooks/usePalette' 32 32 import {formatCount} from '../util/numeric/format' ··· 147 147 post: Shadow<AppBskyFeedDefs.PostView> 148 148 record: AppBskyFeedPost.Record 149 149 richText: RichTextAPI 150 - moderation: PostModeration 150 + moderation: ModerationDecision 151 151 treeView: boolean 152 152 depth: number 153 153 prevPost: ThreadPost | undefined ··· 175 175 const itemTitle = _(msg`Post by ${post.author.handle}`) 176 176 const authorHref = makeProfileLink(post.author) 177 177 const authorTitle = post.author.handle 178 - const isAuthorMuted = post.author.viewer?.muted 179 178 const likesHref = React.useMemo(() => { 180 179 const urip = new AtUri(post.uri) 181 180 return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by') ··· 256 255 did={post.author.did} 257 256 handle={post.author.handle} 258 257 avatar={post.author.avatar} 259 - moderation={moderation.avatar} 258 + moderation={moderation.ui('avatar')} 260 259 /> 261 260 </View> 262 261 <View style={styles.layoutContent}> ··· 271 270 {sanitizeDisplayName( 272 271 post.author.displayName || 273 272 sanitizeHandle(post.author.handle), 273 + moderation.ui('displayName'), 274 274 )} 275 275 </Text> 276 276 </Link> 277 277 </View> 278 278 <View style={styles.meta}> 279 - {isAuthorMuted && ( 280 - <View 281 - style={[ 282 - pal.viewLight, 283 - { 284 - flexDirection: 'row', 285 - alignItems: 'center', 286 - gap: 4, 287 - borderRadius: 6, 288 - paddingHorizontal: 6, 289 - paddingVertical: 2, 290 - marginRight: 4, 291 - }, 292 - ]}> 293 - <FontAwesomeIcon 294 - icon={['far', 'eye-slash']} 295 - size={12} 296 - color={pal.colors.textLight} 297 - /> 298 - <Text type="sm-medium" style={pal.textLight}> 299 - Muted 300 - </Text> 301 - </View> 302 - )} 303 279 <Link style={s.flex1} href={authorHref} title={authorTitle}> 304 280 <Text type="md" style={[pal.textLight]} numberOfLines={1}> 305 281 {sanitizeHandle(post.author.handle, '@')} ··· 312 288 )} 313 289 </View> 314 290 <View style={[s.pl10, s.pr10, s.pb10]}> 291 + <LabelsOnMyPost post={post} /> 315 292 <ContentHider 316 - moderation={moderation.content} 293 + modui={moderation.ui('contentView')} 317 294 ignoreMute 318 295 style={styles.contentHider} 319 296 childContainerStyle={styles.contentHiderChild}> 320 297 <PostAlerts 321 - moderation={moderation.content} 298 + modui={moderation.ui('contentView')} 322 299 includeMute 323 - style={styles.alert} 300 + style={[a.pt_2xs, a.pb_sm]} 324 301 /> 325 302 {richText?.text ? ( 326 303 <View ··· 338 315 </View> 339 316 ) : undefined} 340 317 {post.embed && ( 341 - <ContentHider 342 - moderation={moderation.embed} 343 - moderationDecisions={moderation.decisions} 344 - ignoreMute={isEmbedByEmbedder(post.embed, post.author.did)} 345 - ignoreQuoteDecisions 346 - style={s.mb10}> 347 - <PostEmbeds 348 - embed={post.embed} 349 - moderation={moderation.embed} 350 - moderationDecisions={moderation.decisions} 351 - /> 352 - </ContentHider> 318 + <View style={[a.pb_sm]}> 319 + <PostEmbeds embed={post.embed} moderation={moderation} /> 320 + </View> 353 321 )} 354 322 </ContentHider> 355 323 <ExpandedPostDetails ··· 432 400 <PostHider 433 401 testID={`postThreadItem-by-${post.author.handle}`} 434 402 href={postHref} 435 - moderation={moderation.content} 403 + style={[pal.view]} 404 + modui={moderation.ui('contentList')} 436 405 iconSize={isThreadedChild ? 26 : 38} 437 406 iconStyles={ 438 407 isThreadedChild ··· 482 451 did={post.author.did} 483 452 handle={post.author.handle} 484 453 avatar={post.author.avatar} 485 - moderation={moderation.avatar} 454 + moderation={moderation.ui('avatar')} 486 455 /> 487 456 488 457 {showChildReplyLine && ( ··· 508 477 }> 509 478 <PostMeta 510 479 author={post.author} 480 + moderation={moderation} 511 481 authorHasWarning={!!post.author.labels?.length} 512 482 timestamp={post.indexedAt} 513 483 postHref={postHref} 514 484 showAvatar={isThreadedChild} 515 - avatarModeration={moderation.avatar} 485 + avatarModeration={moderation.ui('avatar')} 516 486 avatarSize={28} 517 487 displayNameType="md-bold" 518 488 displayNameStyle={isThreadedChild && s.ml2} 519 489 style={isThreadedChild && s.mb2} 520 490 /> 491 + <LabelsOnMyPost post={post} /> 521 492 <PostAlerts 522 - moderation={moderation.content} 523 - style={styles.alert} 493 + modui={moderation.ui('contentList')} 494 + style={[a.pt_xs, a.pb_sm]} 524 495 /> 525 496 {richText?.text ? ( 526 497 <View style={styles.postTextContainer}> ··· 542 513 /> 543 514 ) : undefined} 544 515 {post.embed && ( 545 - <ContentHider 546 - style={styles.contentHider} 547 - moderation={moderation.embed} 548 - moderationDecisions={moderation.decisions} 549 - ignoreMute={isEmbedByEmbedder(post.embed, post.author.did)} 550 - ignoreQuoteDecisions> 551 - <PostEmbeds 552 - embed={post.embed} 553 - moderation={moderation.embed} 554 - moderationDecisions={moderation.decisions} 555 - /> 556 - </ContentHider> 516 + <View style={[a.pb_xs]}> 517 + <PostEmbeds embed={post.embed} moderation={moderation} /> 518 + </View> 557 519 )} 558 520 <PostCtrls 559 521 post={post}
+14 -18
src/view/com/post/Post.tsx
··· 4 4 AppBskyFeedDefs, 5 5 AppBskyFeedPost, 6 6 AtUri, 7 - PostModeration, 7 + ModerationDecision, 8 8 RichText as RichTextAPI, 9 9 } from '@atproto/api' 10 10 import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' ··· 14 14 import {PostMeta} from '../util/PostMeta' 15 15 import {PostEmbeds} from '../util/post-embeds' 16 16 import {PostCtrls} from '../util/post-ctrls/PostCtrls' 17 - import {ContentHider} from '../util/moderation/ContentHider' 18 - import {PostAlerts} from '../util/moderation/PostAlerts' 17 + import {ContentHider} from '../../../components/moderation/ContentHider' 18 + import {PostAlerts} from '../../../components/moderation/PostAlerts' 19 + import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe' 19 20 import {Text} from '../util/text/Text' 20 21 import {RichText} from '#/components/RichText' 21 22 import {PreviewableUserAvatar} from '../util/UserAvatar' ··· 93 94 post: Shadow<AppBskyFeedDefs.PostView> 94 95 record: AppBskyFeedPost.Record 95 96 richText: RichTextAPI 96 - moderation: PostModeration 97 + moderation: ModerationDecision 97 98 showReplyLine?: boolean 98 99 style?: StyleProp<ViewStyle> 99 100 }) { ··· 142 143 did={post.author.did} 143 144 handle={post.author.handle} 144 145 avatar={post.author.avatar} 145 - moderation={moderation.avatar} 146 + moderation={moderation.ui('avatar')} 146 147 /> 147 148 </View> 148 149 <View style={styles.layoutContent}> 149 150 <PostMeta 150 151 author={post.author} 152 + moderation={moderation} 151 153 authorHasWarning={!!post.author.labels?.length} 152 154 timestamp={post.indexedAt} 153 155 postHref={itemHref} ··· 176 178 </Text> 177 179 </View> 178 180 )} 181 + <LabelsOnMyPost post={post} /> 179 182 <ContentHider 180 - moderation={moderation.content} 183 + modui={moderation.ui('contentView')} 181 184 style={styles.contentHider} 182 185 childContainerStyle={styles.contentHiderChild}> 183 - <PostAlerts moderation={moderation.content} style={styles.alert} /> 186 + <PostAlerts 187 + modui={moderation.ui('contentView')} 188 + style={[a.py_xs]} 189 + /> 184 190 {richText.text ? ( 185 191 <View style={styles.postTextContainer}> 186 192 <RichText ··· 202 208 /> 203 209 ) : undefined} 204 210 {post.embed ? ( 205 - <ContentHider 206 - moderation={moderation.embed} 207 - moderationDecisions={moderation.decisions} 208 - ignoreQuoteDecisions 209 - style={styles.contentHider}> 210 - <PostEmbeds 211 - embed={post.embed} 212 - moderation={moderation.embed} 213 - moderationDecisions={moderation.decisions} 214 - /> 215 - </ContentHider> 211 + <PostEmbeds embed={post.embed} moderation={moderation} /> 216 212 ) : null} 217 213 </ContentHider> 218 214 <PostCtrls
+17 -32
src/view/com/posts/FeedItem.tsx
··· 4 4 AppBskyFeedDefs, 5 5 AppBskyFeedPost, 6 6 AtUri, 7 - PostModeration, 7 + ModerationDecision, 8 8 RichText as RichTextAPI, 9 9 } from '@atproto/api' 10 10 import { ··· 18 18 import {PostMeta} from '../util/PostMeta' 19 19 import {PostCtrls} from '../util/post-ctrls/PostCtrls' 20 20 import {PostEmbeds} from '../util/post-embeds' 21 - import {ContentHider} from '../util/moderation/ContentHider' 22 - import {PostAlerts} from '../util/moderation/PostAlerts' 21 + import {ContentHider} from '#/components/moderation/ContentHider' 22 + import {PostAlerts} from '../../../components/moderation/PostAlerts' 23 + import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe' 23 24 import {RichText} from '#/components/RichText' 24 25 import {PreviewableUserAvatar} from '../util/UserAvatar' 25 26 import {s} from 'lib/styles' ··· 27 28 import {sanitizeDisplayName} from 'lib/strings/display-names' 28 29 import {sanitizeHandle} from 'lib/strings/handles' 29 30 import {makeProfileLink} from 'lib/routes/links' 30 - import {isEmbedByEmbedder} from 'lib/embeds' 31 31 import {MAX_POST_LINES} from 'lib/constants' 32 32 import {countLines} from 'lib/strings/helpers' 33 33 import {useComposerControls} from '#/state/shell/composer' 34 34 import {Shadow, usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow' 35 35 import {FeedNameText} from '../util/FeedInfoText' 36 - import {useSession} from '#/state/session' 37 36 import {Trans, msg} from '@lingui/macro' 38 37 import {useLingui} from '@lingui/react' 39 38 import {atoms as a} from '#/alf' ··· 50 49 post: AppBskyFeedDefs.PostView 51 50 record: AppBskyFeedPost.Record 52 51 reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined 53 - moderation: PostModeration 52 + moderation: ModerationDecision 54 53 isThreadChild?: boolean 55 54 isThreadLastChild?: boolean 56 55 isThreadParent?: boolean ··· 100 99 record: AppBskyFeedPost.Record 101 100 reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined 102 101 richText: RichTextAPI 103 - moderation: PostModeration 102 + moderation: ModerationDecision 104 103 isThreadChild?: boolean 105 104 isThreadLastChild?: boolean 106 105 isThreadParent?: boolean ··· 108 107 const {openComposer} = useComposerControls() 109 108 const pal = usePalette('default') 110 109 const {_} = useLingui() 111 - const {currentAccount} = useSession() 112 110 const href = useMemo(() => { 113 111 const urip = new AtUri(post.uri) 114 112 return makeProfileLink(post.author, 'post', urip.rkey) 115 113 }, [post.uri, post.author]) 116 - const isModeratedPost = 117 - moderation.decisions.post.cause?.type === 'label' && 118 - moderation.decisions.post.cause.label.src !== currentAccount?.did 119 114 120 115 const replyAuthorDid = useMemo(() => { 121 116 if (!record?.reply) { ··· 148 143 borderColor: pal.colors.border, 149 144 paddingBottom: 150 145 isThreadLastChild || (!isThreadChild && !isThreadParent) 151 - ? 6 146 + ? 8 152 147 : undefined, 153 148 }, 154 149 isThreadChild ? styles.outerSmallTop : undefined, ··· 229 224 numberOfLines={1} 230 225 text={sanitizeDisplayName( 231 226 reason.by.displayName || sanitizeHandle(reason.by.handle), 227 + moderation.ui('displayName'), 232 228 )} 233 229 href={makeProfileLink(reason.by)} 234 230 /> ··· 246 242 did={post.author.did} 247 243 handle={post.author.handle} 248 244 avatar={post.author.avatar} 249 - moderation={moderation.avatar} 245 + moderation={moderation.ui('avatar')} 250 246 /> 251 247 {isThreadParent && ( 252 248 <View ··· 264 260 <View style={styles.layoutContent}> 265 261 <PostMeta 266 262 author={post.author} 263 + moderation={moderation} 267 264 authorHasWarning={!!post.author.labels?.length} 268 265 timestamp={post.indexedAt} 269 266 postHref={href} ··· 295 292 </Text> 296 293 </View> 297 294 )} 295 + <LabelsOnMyPost post={post} /> 298 296 <PostContent 299 297 moderation={moderation} 300 298 richText={richText} ··· 306 304 record={record} 307 305 richText={richText} 308 306 onPressReply={onPressReply} 309 - showAppealLabelItem={ 310 - post.author.did === currentAccount?.did && isModeratedPost 311 - } 312 307 logContext="FeedItem" 313 308 /> 314 309 </View> ··· 324 319 postEmbed, 325 320 postAuthor, 326 321 }: { 327 - moderation: PostModeration 322 + moderation: ModerationDecision 328 323 richText: RichTextAPI 329 324 postEmbed: AppBskyFeedDefs.PostView['embed'] 330 325 postAuthor: AppBskyFeedDefs.PostView['author'] ··· 342 337 return ( 343 338 <ContentHider 344 339 testID="contentHider-post" 345 - moderation={moderation.content} 340 + modui={moderation.ui('contentList')} 346 341 ignoreMute 347 342 childContainerStyle={styles.contentHiderChild}> 348 - <PostAlerts moderation={moderation.content} style={styles.alert} /> 343 + <PostAlerts modui={moderation.ui('contentList')} style={[a.py_xs]} /> 349 344 {richText.text ? ( 350 345 <View style={styles.postTextContainer}> 351 346 <RichText ··· 367 362 /> 368 363 ) : undefined} 369 364 {postEmbed ? ( 370 - <ContentHider 371 - testID="contentHider-embed" 372 - moderation={moderation.embed} 373 - moderationDecisions={moderation.decisions} 374 - ignoreMute={isEmbedByEmbedder(postEmbed, postAuthor.did)} 375 - ignoreQuoteDecisions 376 - style={styles.embed}> 377 - <PostEmbeds 378 - embed={postEmbed} 379 - moderation={moderation.embed} 380 - moderationDecisions={moderation.decisions} 381 - /> 382 - </ContentHider> 365 + <View style={[a.pb_sm]}> 366 + <PostEmbeds embed={postEmbed} moderation={moderation} /> 367 + </View> 383 368 ) : null} 384 369 </ContentHider> 385 370 )
+59 -32
src/view/com/profile/ProfileCard.tsx
··· 3 3 import { 4 4 AppBskyActorDefs, 5 5 moderateProfile, 6 - ProfileModeration, 6 + ModerationCause, 7 + ModerationDecision, 7 8 } from '@atproto/api' 8 9 import {Link} from '../util/Link' 9 10 import {Text} from '../util/text/Text' ··· 14 15 import {sanitizeDisplayName} from 'lib/strings/display-names' 15 16 import {sanitizeHandle} from 'lib/strings/handles' 16 17 import {makeProfileLink} from 'lib/routes/links' 17 - import { 18 - describeModerationCause, 19 - getProfileModerationCauses, 20 - getModerationCauseKey, 21 - } from 'lib/moderation' 18 + import {getModerationCauseKey, isJustAMute} from 'lib/moderation' 22 19 import {Shadow} from '#/state/cache/types' 23 20 import {useModerationOpts} from '#/state/queries/preferences' 24 21 import {useProfileShadow} from '#/state/cache/profile-shadow' 25 22 import {useSession} from '#/state/session' 26 23 import {Trans} from '@lingui/macro' 24 + import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription' 27 25 28 26 export function ProfileCard({ 29 27 testID, ··· 33 31 noBorder, 34 32 followers, 35 33 renderButton, 34 + onPress, 36 35 style, 37 36 }: { 38 37 testID?: string ··· 44 43 renderButton?: ( 45 44 profile: Shadow<AppBskyActorDefs.ProfileViewBasic>, 46 45 ) => React.ReactNode 46 + onPress?: () => void 47 47 style?: StyleProp<ViewStyle> 48 48 }) { 49 49 const pal = usePalette('default') ··· 53 53 return null 54 54 } 55 55 const moderation = moderateProfile(profile, moderationOpts) 56 - if ( 57 - !noModFilter && 58 - moderation.account.filter && 59 - moderation.account.cause?.type !== 'muted' 60 - ) { 56 + const modui = moderation.ui('profileList') 57 + if (!noModFilter && modui.filter && !isJustAMute(modui)) { 61 58 return null 62 59 } 63 60 ··· 73 70 ]} 74 71 href={makeProfileLink(profile)} 75 72 title={profile.handle} 73 + onBeforePress={onPress} 76 74 asAnchor 77 75 anchorNoUnderline> 78 76 <View style={styles.layout}> ··· 80 78 <UserAvatar 81 79 size={40} 82 80 avatar={profile.avatar} 83 - moderation={moderation.avatar} 81 + moderation={moderation.ui('avatar')} 84 82 /> 85 83 </View> 86 84 <View style={styles.layoutContent}> ··· 91 89 lineHeight={1.2}> 92 90 {sanitizeDisplayName( 93 91 profile.displayName || sanitizeHandle(profile.handle), 94 - moderation.profile, 92 + moderation.ui('displayName'), 95 93 )} 96 94 </Text> 97 95 <Text type="md" style={[pal.textLight]} numberOfLines={1}> ··· 119 117 ) 120 118 } 121 119 122 - function ProfileCardPills({ 120 + export function ProfileCardPills({ 123 121 followedBy, 124 122 moderation, 125 123 }: { 126 124 followedBy: boolean 127 - moderation: ProfileModeration 125 + moderation: ModerationDecision 128 126 }) { 129 127 const pal = usePalette('default') 130 128 131 - const causes = getProfileModerationCauses(moderation) 132 - if (!followedBy && !causes.length) { 129 + const modui = moderation.ui('profileList') 130 + if (!followedBy && !modui.inform && !modui.alert) { 133 131 return null 134 132 } 135 133 ··· 142 140 </Text> 143 141 </View> 144 142 )} 145 - {causes.map(cause => { 146 - const desc = describeModerationCause(cause, 'account') 147 - return ( 148 - <View 149 - style={[s.mt5, pal.btn, styles.pill]} 150 - key={getModerationCauseKey(cause)}> 151 - <Text type="xs" style={pal.text}> 152 - {cause?.type === 'label' ? '⚠' : ''} 153 - {desc.name} 154 - </Text> 155 - </View> 156 - ) 157 - })} 143 + {modui.alerts.map(alert => ( 144 + <ProfileCardPillModerationCause 145 + key={getModerationCauseKey(alert)} 146 + cause={alert} 147 + severity="alert" 148 + /> 149 + ))} 150 + {modui.informs.map(inform => ( 151 + <ProfileCardPillModerationCause 152 + key={getModerationCauseKey(inform)} 153 + cause={inform} 154 + severity="inform" 155 + /> 156 + ))} 157 + </View> 158 + ) 159 + } 160 + 161 + function ProfileCardPillModerationCause({ 162 + cause, 163 + severity, 164 + }: { 165 + cause: ModerationCause 166 + severity: 'alert' | 'inform' 167 + }) { 168 + const pal = usePalette('default') 169 + const {name} = useModerationCauseDescription(cause) 170 + return ( 171 + <View 172 + style={[s.mt5, pal.btn, styles.pill]} 173 + key={getModerationCauseKey(cause)}> 174 + <Text type="xs" style={pal.text}> 175 + {severity === 'alert' ? '⚠ ' : ''} 176 + {name} 177 + </Text> 158 178 </View> 159 179 ) 160 180 } ··· 177 197 f, 178 198 mod: moderateProfile(f, moderationOpts), 179 199 })) 180 - .filter(({mod}) => !mod.account.filter) 200 + .filter(({mod}) => !mod.ui('profileList').filter) 181 201 }, [followers, moderationOpts]) 182 202 183 203 if (!followersWithMods?.length) { ··· 199 219 {followersWithMods.slice(0, 3).map(({f, mod}) => ( 200 220 <View key={f.did} style={styles.followedByAviContainer}> 201 221 <View style={[styles.followedByAvi, pal.view]}> 202 - <UserAvatar avatar={f.avatar} size={32} moderation={mod.avatar} /> 222 + <UserAvatar 223 + avatar={f.avatar} 224 + size={32} 225 + moderation={mod.ui('avatar')} 226 + /> 203 227 </View> 204 228 </View> 205 229 ))} ··· 212 236 noBg, 213 237 noBorder, 214 238 followers, 239 + onPress, 215 240 }: { 216 241 profile: AppBskyActorDefs.ProfileViewBasic 217 242 noBg?: boolean 218 243 noBorder?: boolean 219 244 followers?: AppBskyActorDefs.ProfileView[] | undefined 245 + onPress?: () => void 220 246 }) { 221 247 const {currentAccount} = useSession() 222 248 const isMe = profile.did === currentAccount?.did ··· 234 260 <FollowButton profile={profileShadow} logContext="ProfileCard" /> 235 261 ) 236 262 } 263 + onPress={onPress} 237 264 /> 238 265 ) 239 266 }
-598
src/view/com/profile/ProfileHeader.tsx
··· 1 - import React, {memo, useMemo} from 'react' 2 - import { 3 - StyleSheet, 4 - TouchableOpacity, 5 - TouchableWithoutFeedback, 6 - View, 7 - } from 'react-native' 8 - import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 9 - import {useNavigation} from '@react-navigation/native' 10 - import { 11 - AppBskyActorDefs, 12 - ModerationOpts, 13 - moderateProfile, 14 - RichText as RichTextAPI, 15 - } from '@atproto/api' 16 - import {Trans, msg} from '@lingui/macro' 17 - import {useLingui} from '@lingui/react' 18 - import {NavigationProp} from 'lib/routes/types' 19 - import {isNative} from 'platform/detection' 20 - import {BlurView} from '../util/BlurView' 21 - import * as Toast from '../util/Toast' 22 - import {LoadingPlaceholder} from '../util/LoadingPlaceholder' 23 - import {Text} from '../util/text/Text' 24 - import {ThemedText} from '../util/text/ThemedText' 25 - import {RichText} from '#/components/RichText' 26 - import {UserAvatar} from '../util/UserAvatar' 27 - import {UserBanner} from '../util/UserBanner' 28 - import {ProfileHeaderAlerts} from '../util/moderation/ProfileHeaderAlerts' 29 - import {formatCount} from '../util/numeric/format' 30 - import {Link} from '../util/Link' 31 - import {ProfileHeaderSuggestedFollows} from './ProfileHeaderSuggestedFollows' 32 - import {useModalControls} from '#/state/modals' 33 - import {useLightboxControls, ProfileImageLightbox} from '#/state/lightbox' 34 - import { 35 - useProfileBlockMutationQueue, 36 - useProfileFollowMutationQueue, 37 - } from '#/state/queries/profile' 38 - import {usePalette} from 'lib/hooks/usePalette' 39 - import {useAnalytics} from 'lib/analytics/analytics' 40 - import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 41 - import {BACK_HITSLOP} from 'lib/constants' 42 - import {isInvalidHandle, sanitizeHandle} from 'lib/strings/handles' 43 - import {makeProfileLink} from 'lib/routes/links' 44 - import {pluralize} from 'lib/strings/helpers' 45 - import {sanitizeDisplayName} from 'lib/strings/display-names' 46 - import {s, colors} from 'lib/styles' 47 - import {logger} from '#/logger' 48 - import {useSession} from '#/state/session' 49 - import {Shadow} from '#/state/cache/types' 50 - import {useRequireAuth} from '#/state/session' 51 - import {LabelInfo} from '../util/moderation/LabelInfo' 52 - import {useProfileShadow} from 'state/cache/profile-shadow' 53 - import {atoms as a} from '#/alf' 54 - import {ProfileMenu} from 'view/com/profile/ProfileMenu' 55 - import * as Prompt from '#/components/Prompt' 56 - 57 - let ProfileHeaderLoading = (_props: {}): React.ReactNode => { 58 - const pal = usePalette('default') 59 - return ( 60 - <View style={pal.view}> 61 - <LoadingPlaceholder width="100%" height={150} style={{borderRadius: 0}} /> 62 - <View 63 - style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}> 64 - <LoadingPlaceholder width={80} height={80} style={styles.br40} /> 65 - </View> 66 - <View style={styles.content}> 67 - <View style={[styles.buttonsLine]}> 68 - <LoadingPlaceholder width={167} height={31} style={styles.br50} /> 69 - </View> 70 - </View> 71 - </View> 72 - ) 73 - } 74 - ProfileHeaderLoading = memo(ProfileHeaderLoading) 75 - export {ProfileHeaderLoading} 76 - 77 - interface Props { 78 - profile: AppBskyActorDefs.ProfileViewDetailed 79 - descriptionRT: RichTextAPI | null 80 - moderationOpts: ModerationOpts 81 - hideBackButton?: boolean 82 - isPlaceholderProfile?: boolean 83 - } 84 - 85 - let ProfileHeader = ({ 86 - profile: profileUnshadowed, 87 - descriptionRT, 88 - moderationOpts, 89 - hideBackButton = false, 90 - isPlaceholderProfile, 91 - }: Props): React.ReactNode => { 92 - const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> = 93 - useProfileShadow(profileUnshadowed) 94 - const pal = usePalette('default') 95 - const palInverted = usePalette('inverted') 96 - const {currentAccount, hasSession} = useSession() 97 - const requireAuth = useRequireAuth() 98 - const {_} = useLingui() 99 - const {openModal} = useModalControls() 100 - const {openLightbox} = useLightboxControls() 101 - const navigation = useNavigation<NavigationProp>() 102 - const {track} = useAnalytics() 103 - const invalidHandle = isInvalidHandle(profile.handle) 104 - const {isDesktop} = useWebMediaQueries() 105 - const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false) 106 - const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( 107 - profile, 108 - 'ProfileHeader', 109 - ) 110 - const [__, queueUnblock] = useProfileBlockMutationQueue(profile) 111 - const unblockPromptControl = Prompt.usePromptControl() 112 - const moderation = useMemo( 113 - () => moderateProfile(profile, moderationOpts), 114 - [profile, moderationOpts], 115 - ) 116 - 117 - const onPressBack = React.useCallback(() => { 118 - if (navigation.canGoBack()) { 119 - navigation.goBack() 120 - } else { 121 - navigation.navigate('Home') 122 - } 123 - }, [navigation]) 124 - 125 - const onPressAvi = React.useCallback(() => { 126 - if ( 127 - profile.avatar && 128 - !(moderation.avatar.blur && moderation.avatar.noOverride) 129 - ) { 130 - openLightbox(new ProfileImageLightbox(profile)) 131 - } 132 - }, [openLightbox, profile, moderation]) 133 - 134 - const onPressFollow = () => { 135 - requireAuth(async () => { 136 - try { 137 - track('ProfileHeader:FollowButtonClicked') 138 - await queueFollow() 139 - Toast.show( 140 - _( 141 - msg`Following ${sanitizeDisplayName( 142 - profile.displayName || profile.handle, 143 - )}`, 144 - ), 145 - ) 146 - } catch (e: any) { 147 - if (e?.name !== 'AbortError') { 148 - logger.error('Failed to follow', {message: String(e)}) 149 - Toast.show(_(msg`There was an issue! ${e.toString()}`)) 150 - } 151 - } 152 - }) 153 - } 154 - 155 - const onPressUnfollow = () => { 156 - requireAuth(async () => { 157 - try { 158 - track('ProfileHeader:UnfollowButtonClicked') 159 - await queueUnfollow() 160 - Toast.show( 161 - _( 162 - msg`No longer following ${sanitizeDisplayName( 163 - profile.displayName || profile.handle, 164 - )}`, 165 - ), 166 - ) 167 - } catch (e: any) { 168 - if (e?.name !== 'AbortError') { 169 - logger.error('Failed to unfollow', {message: String(e)}) 170 - Toast.show(_(msg`There was an issue! ${e.toString()}`)) 171 - } 172 - } 173 - }) 174 - } 175 - 176 - const onPressEditProfile = React.useCallback(() => { 177 - track('ProfileHeader:EditProfileButtonClicked') 178 - openModal({ 179 - name: 'edit-profile', 180 - profile, 181 - }) 182 - }, [track, openModal, profile]) 183 - 184 - const unblockAccount = React.useCallback(async () => { 185 - track('ProfileHeader:UnblockAccountButtonClicked') 186 - try { 187 - await queueUnblock() 188 - Toast.show(_(msg`Account unblocked`)) 189 - } catch (e: any) { 190 - if (e?.name !== 'AbortError') { 191 - logger.error('Failed to unblock account', {message: e}) 192 - Toast.show(_(msg`There was an issue! ${e.toString()}`)) 193 - } 194 - } 195 - }, [_, queueUnblock, track]) 196 - 197 - const isMe = React.useMemo( 198 - () => currentAccount?.did === profile.did, 199 - [currentAccount, profile], 200 - ) 201 - 202 - const blockHide = 203 - !isMe && (profile.viewer?.blocking || profile.viewer?.blockedBy) 204 - const following = formatCount(profile.followsCount || 0) 205 - const followers = formatCount(profile.followersCount || 0) 206 - const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower') 207 - 208 - return ( 209 - <View style={[pal.view]} pointerEvents="box-none"> 210 - <View pointerEvents="none"> 211 - {isPlaceholderProfile ? ( 212 - <LoadingPlaceholder 213 - width="100%" 214 - height={150} 215 - style={{borderRadius: 0}} 216 - /> 217 - ) : ( 218 - <UserBanner banner={profile.banner} moderation={moderation.avatar} /> 219 - )} 220 - </View> 221 - <View style={styles.content} pointerEvents="box-none"> 222 - <View style={[styles.buttonsLine]} pointerEvents="box-none"> 223 - {isMe ? ( 224 - <TouchableOpacity 225 - testID="profileHeaderEditProfileButton" 226 - onPress={onPressEditProfile} 227 - style={[styles.btn, styles.mainBtn, pal.btn]} 228 - accessibilityRole="button" 229 - accessibilityLabel={_(msg`Edit profile`)} 230 - accessibilityHint={_( 231 - msg`Opens editor for profile display name, avatar, background image, and description`, 232 - )}> 233 - <Text type="button" style={pal.text}> 234 - <Trans>Edit Profile</Trans> 235 - </Text> 236 - </TouchableOpacity> 237 - ) : profile.viewer?.blocking ? ( 238 - profile.viewer?.blockingByList ? null : ( 239 - <TouchableOpacity 240 - testID="unblockBtn" 241 - onPress={() => unblockPromptControl.open()} 242 - style={[styles.btn, styles.mainBtn, pal.btn]} 243 - accessibilityRole="button" 244 - accessibilityLabel={_(msg`Unblock`)} 245 - accessibilityHint=""> 246 - <Text type="button" style={[pal.text, s.bold]}> 247 - <Trans context="action">Unblock</Trans> 248 - </Text> 249 - </TouchableOpacity> 250 - ) 251 - ) : !profile.viewer?.blockedBy ? ( 252 - <> 253 - {hasSession && ( 254 - <TouchableOpacity 255 - testID="suggestedFollowsBtn" 256 - onPress={() => setShowSuggestedFollows(!showSuggestedFollows)} 257 - style={[ 258 - styles.btn, 259 - styles.mainBtn, 260 - pal.btn, 261 - { 262 - paddingHorizontal: 10, 263 - backgroundColor: showSuggestedFollows 264 - ? pal.colors.text 265 - : pal.colors.backgroundLight, 266 - }, 267 - ]} 268 - accessibilityRole="button" 269 - accessibilityLabel={_( 270 - msg`Show follows similar to ${profile.handle}`, 271 - )} 272 - accessibilityHint={_( 273 - msg`Shows a list of users similar to this user.`, 274 - )}> 275 - <FontAwesomeIcon 276 - icon="user-plus" 277 - style={[ 278 - pal.text, 279 - { 280 - color: showSuggestedFollows 281 - ? pal.textInverted.color 282 - : pal.text.color, 283 - }, 284 - ]} 285 - size={14} 286 - /> 287 - </TouchableOpacity> 288 - )} 289 - 290 - {profile.viewer?.following ? ( 291 - <TouchableOpacity 292 - testID="unfollowBtn" 293 - onPress={onPressUnfollow} 294 - style={[styles.btn, styles.mainBtn, pal.btn]} 295 - accessibilityRole="button" 296 - accessibilityLabel={_(msg`Unfollow ${profile.handle}`)} 297 - accessibilityHint={_( 298 - msg`Hides posts from ${profile.handle} in your feed`, 299 - )}> 300 - <FontAwesomeIcon 301 - icon="check" 302 - style={[pal.text, s.mr5]} 303 - size={14} 304 - /> 305 - <Text type="button" style={pal.text}> 306 - <Trans>Following</Trans> 307 - </Text> 308 - </TouchableOpacity> 309 - ) : ( 310 - <TouchableOpacity 311 - testID="followBtn" 312 - onPress={onPressFollow} 313 - style={[styles.btn, styles.mainBtn, palInverted.view]} 314 - accessibilityRole="button" 315 - accessibilityLabel={_(msg`Follow ${profile.handle}`)} 316 - accessibilityHint={_( 317 - msg`Shows posts from ${profile.handle} in your feed`, 318 - )}> 319 - <FontAwesomeIcon 320 - icon="plus" 321 - style={[palInverted.text, s.mr5]} 322 - /> 323 - <Text type="button" style={[palInverted.text, s.bold]}> 324 - <Trans>Follow</Trans> 325 - </Text> 326 - </TouchableOpacity> 327 - )} 328 - </> 329 - ) : null} 330 - <ProfileMenu profile={profile} /> 331 - </View> 332 - <View pointerEvents="none"> 333 - <Text 334 - testID="profileHeaderDisplayName" 335 - type="title-2xl" 336 - style={[pal.text, styles.title]}> 337 - {sanitizeDisplayName( 338 - profile.displayName || sanitizeHandle(profile.handle), 339 - moderation.profile, 340 - )} 341 - </Text> 342 - </View> 343 - <View style={styles.handleLine} pointerEvents="none"> 344 - {profile.viewer?.followedBy && !blockHide ? ( 345 - <View style={[styles.pill, pal.btn, s.mr5]}> 346 - <Text type="xs" style={[pal.text]}> 347 - <Trans>Follows you</Trans> 348 - </Text> 349 - </View> 350 - ) : undefined} 351 - <ThemedText 352 - type={invalidHandle ? 'xs' : 'md'} 353 - fg={invalidHandle ? 'error' : 'light'} 354 - border={invalidHandle ? 'error' : undefined} 355 - style={[ 356 - invalidHandle ? styles.invalidHandle : undefined, 357 - styles.handle, 358 - ]}> 359 - {invalidHandle ? _(msg`⚠Invalid Handle`) : `@${profile.handle}`} 360 - </ThemedText> 361 - </View> 362 - {!isPlaceholderProfile && !blockHide && ( 363 - <> 364 - <View style={styles.metricsLine} pointerEvents="box-none"> 365 - <Link 366 - testID="profileHeaderFollowersButton" 367 - style={[s.flexRow, s.mr10]} 368 - href={makeProfileLink(profile, 'followers')} 369 - onPressOut={() => 370 - track(`ProfileHeader:FollowersButtonClicked`, { 371 - handle: profile.handle, 372 - }) 373 - } 374 - asAnchor 375 - accessibilityLabel={`${followers} ${pluralizedFollowers}`} 376 - accessibilityHint={_(msg`Opens followers list`)}> 377 - <Text type="md" style={[s.bold, pal.text]}> 378 - {followers}{' '} 379 - </Text> 380 - <Text type="md" style={[pal.textLight]}> 381 - {pluralizedFollowers} 382 - </Text> 383 - </Link> 384 - <Link 385 - testID="profileHeaderFollowsButton" 386 - style={[s.flexRow, s.mr10]} 387 - href={makeProfileLink(profile, 'follows')} 388 - onPressOut={() => 389 - track(`ProfileHeader:FollowsButtonClicked`, { 390 - handle: profile.handle, 391 - }) 392 - } 393 - asAnchor 394 - accessibilityLabel={_(msg`${following} following`)} 395 - accessibilityHint={_(msg`Opens following list`)}> 396 - <Trans> 397 - <Text type="md" style={[s.bold, pal.text]}> 398 - {following}{' '} 399 - </Text> 400 - <Text type="md" style={[pal.textLight]}> 401 - following 402 - </Text> 403 - </Trans> 404 - </Link> 405 - <Text type="md" style={[s.bold, pal.text]}> 406 - {formatCount(profile.postsCount || 0)}{' '} 407 - <Text type="md" style={[pal.textLight]}> 408 - {pluralize(profile.postsCount || 0, 'post')} 409 - </Text> 410 - </Text> 411 - </View> 412 - {descriptionRT && !moderation.profile.blur ? ( 413 - <View pointerEvents="auto" style={[styles.description]}> 414 - <RichText 415 - testID="profileHeaderDescription" 416 - style={[a.text_md]} 417 - numberOfLines={15} 418 - value={descriptionRT} 419 - /> 420 - </View> 421 - ) : undefined} 422 - </> 423 - )} 424 - <ProfileHeaderAlerts moderation={moderation} /> 425 - {isMe && ( 426 - <LabelInfo details={{did: profile.did}} labels={profile.labels} /> 427 - )} 428 - </View> 429 - 430 - {showSuggestedFollows && ( 431 - <ProfileHeaderSuggestedFollows 432 - actorDid={profile.did} 433 - requestDismiss={() => { 434 - if (showSuggestedFollows) { 435 - setShowSuggestedFollows(false) 436 - } else { 437 - track('ProfileHeader:SuggestedFollowsOpened') 438 - setShowSuggestedFollows(true) 439 - } 440 - }} 441 - /> 442 - )} 443 - 444 - {!isDesktop && !hideBackButton && ( 445 - <TouchableWithoutFeedback 446 - testID="profileHeaderBackBtn" 447 - onPress={onPressBack} 448 - hitSlop={BACK_HITSLOP} 449 - accessibilityRole="button" 450 - accessibilityLabel={_(msg`Back`)} 451 - accessibilityHint=""> 452 - <View style={styles.backBtnWrapper}> 453 - <BlurView style={styles.backBtn} blurType="dark"> 454 - <FontAwesomeIcon size={18} icon="angle-left" style={s.white} /> 455 - </BlurView> 456 - </View> 457 - </TouchableWithoutFeedback> 458 - )} 459 - <TouchableWithoutFeedback 460 - testID="profileHeaderAviButton" 461 - onPress={onPressAvi} 462 - accessibilityRole="image" 463 - accessibilityLabel={_(msg`View ${profile.handle}'s avatar`)} 464 - accessibilityHint=""> 465 - <View 466 - style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}> 467 - <UserAvatar 468 - size={80} 469 - avatar={profile.avatar} 470 - moderation={moderation.avatar} 471 - /> 472 - </View> 473 - </TouchableWithoutFeedback> 474 - <Prompt.Basic 475 - control={unblockPromptControl} 476 - title={_(msg`Unblock Account?`)} 477 - description={_( 478 - msg`The account will be able to interact with you after unblocking.`, 479 - )} 480 - onConfirm={unblockAccount} 481 - confirmButtonCta={ 482 - profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`) 483 - } 484 - confirmButtonColor="negative" 485 - /> 486 - </View> 487 - ) 488 - } 489 - ProfileHeader = memo(ProfileHeader) 490 - export {ProfileHeader} 491 - 492 - const styles = StyleSheet.create({ 493 - banner: { 494 - width: '100%', 495 - height: 120, 496 - }, 497 - backBtnWrapper: { 498 - position: 'absolute', 499 - top: 10, 500 - left: 10, 501 - width: 30, 502 - height: 30, 503 - overflow: 'hidden', 504 - borderRadius: 15, 505 - // @ts-ignore web only 506 - cursor: 'pointer', 507 - }, 508 - backBtn: { 509 - width: 30, 510 - height: 30, 511 - borderRadius: 15, 512 - alignItems: 'center', 513 - justifyContent: 'center', 514 - }, 515 - avi: { 516 - position: 'absolute', 517 - top: 110, 518 - left: 10, 519 - width: 84, 520 - height: 84, 521 - borderRadius: 42, 522 - borderWidth: 2, 523 - }, 524 - content: { 525 - paddingTop: 8, 526 - paddingHorizontal: 14, 527 - paddingBottom: 4, 528 - }, 529 - 530 - buttonsLine: { 531 - flexDirection: 'row', 532 - marginLeft: 'auto', 533 - marginBottom: 12, 534 - }, 535 - primaryBtn: { 536 - backgroundColor: colors.blue3, 537 - paddingHorizontal: 24, 538 - paddingVertical: 6, 539 - }, 540 - mainBtn: { 541 - paddingHorizontal: 24, 542 - }, 543 - secondaryBtn: { 544 - paddingHorizontal: 14, 545 - }, 546 - btn: { 547 - flexDirection: 'row', 548 - alignItems: 'center', 549 - justifyContent: 'center', 550 - paddingVertical: 7, 551 - borderRadius: 50, 552 - marginLeft: 6, 553 - }, 554 - title: {lineHeight: 38}, 555 - 556 - // Word wrapping appears fine on 557 - // mobile but overflows on desktop 558 - handle: isNative 559 - ? {} 560 - : { 561 - // @ts-ignore web only -prf 562 - wordBreak: 'break-all', 563 - }, 564 - invalidHandle: { 565 - borderWidth: 1, 566 - borderRadius: 4, 567 - paddingHorizontal: 4, 568 - }, 569 - 570 - handleLine: { 571 - flexDirection: 'row', 572 - marginBottom: 8, 573 - }, 574 - 575 - metricsLine: { 576 - flexDirection: 'row', 577 - marginBottom: 8, 578 - }, 579 - 580 - description: { 581 - marginBottom: 8, 582 - }, 583 - 584 - detailLine: { 585 - flexDirection: 'row', 586 - alignItems: 'center', 587 - marginBottom: 5, 588 - }, 589 - 590 - pill: { 591 - borderRadius: 4, 592 - paddingHorizontal: 6, 593 - paddingVertical: 2, 594 - }, 595 - 596 - br40: {borderRadius: 40}, 597 - br50: {borderRadius: 50}, 598 - })
+2 -2
src/view/com/profile/ProfileHeaderSuggestedFollows.tsx
··· 217 217 <UserAvatar 218 218 size={60} 219 219 avatar={profile.avatar} 220 - moderation={moderation.avatar} 220 + moderation={moderation.ui('avatar')} 221 221 /> 222 222 223 223 <View style={{width: '100%', paddingVertical: 12}}> ··· 227 227 numberOfLines={1}> 228 228 {sanitizeDisplayName( 229 229 profile.displayName || sanitizeHandle(profile.handle), 230 - moderation.profile, 230 + moderation.ui('displayName'), 231 231 )} 232 232 </Text> 233 233 <Text
+62 -21
src/view/com/profile/ProfileMenu.tsx
··· 17 17 import {makeProfileLink} from 'lib/routes/links' 18 18 import {useAnalytics} from 'lib/analytics/analytics' 19 19 import {useModalControls} from 'state/modals' 20 + import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' 20 21 import { 21 22 RQKEY as profileQueryKey, 22 23 useProfileBlockMutationQueue, ··· 31 32 import {PersonCheck_Stroke2_Corner0_Rounded as PersonCheck} from '#/components/icons/PersonCheck' 32 33 import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/PersonX' 33 34 import {PeopleRemove2_Stroke2_Corner0_Rounded as UserMinus} from '#/components/icons/PeopleRemove2' 35 + import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 34 36 import {logger} from '#/logger' 35 37 import {Shadow} from 'state/cache/types' 36 38 import * as Prompt from '#/components/Prompt' ··· 47 49 const pal = usePalette('default') 48 50 const {track} = useAnalytics() 49 51 const {openModal} = useModalControls() 52 + const reportDialogControl = useReportDialogControl() 50 53 const queryClient = useQueryClient() 51 54 const isSelf = currentAccount?.did === profile.did 55 + const isFollowing = profile.viewer?.following 56 + const isBlocked = profile.viewer?.blocking || profile.viewer?.blockedBy 57 + const isFollowingBlockedAccount = isFollowing && isBlocked 58 + const isLabelerAndNotBlocked = !!profile.associated?.labeler && !isBlocked 52 59 53 60 const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile) 54 61 const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) 55 - const [, queueUnfollow] = useProfileFollowMutationQueue( 62 + const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( 56 63 profile, 57 64 'ProfileMenu', 58 65 ) ··· 139 146 } 140 147 }, [profile.viewer?.blocking, track, _, queueUnblock, queueBlock]) 141 148 149 + const onPressFollowAccount = React.useCallback(async () => { 150 + track('ProfileHeader:FollowButtonClicked') 151 + try { 152 + await queueFollow() 153 + Toast.show(_(msg`Account followed`)) 154 + } catch (e: any) { 155 + if (e?.name !== 'AbortError') { 156 + logger.error('Failed to follow account', {message: e}) 157 + Toast.show(_(msg`There was an issue! ${e.toString()}`)) 158 + } 159 + } 160 + }, [_, queueFollow, track]) 161 + 142 162 const onPressUnfollowAccount = React.useCallback(async () => { 143 163 track('ProfileHeader:UnfollowButtonClicked') 144 164 try { ··· 154 174 155 175 const onPressReportAccount = React.useCallback(() => { 156 176 track('ProfileHeader:ReportAccountButtonClicked') 157 - openModal({ 158 - name: 'report', 159 - did: profile.did, 160 - }) 161 - }, [track, openModal, profile]) 177 + reportDialogControl.open() 178 + }, [track, reportDialogControl]) 162 179 163 180 return ( 164 181 <EventStopper onKeyDown={false}> ··· 175 192 flexDirection: 'row', 176 193 alignItems: 'center', 177 194 justifyContent: 'center', 178 - paddingVertical: 7, 195 + paddingVertical: 10, 179 196 borderRadius: 50, 180 - marginLeft: 6, 181 - paddingHorizontal: 14, 197 + paddingHorizontal: 16, 182 198 }, 183 199 pal.btn, 184 200 ]}> ··· 210 226 <Menu.ItemIcon icon={Share} /> 211 227 </Menu.Item> 212 228 </Menu.Group> 229 + 213 230 {hasSession && ( 214 231 <> 215 232 <Menu.Divider /> 216 233 <Menu.Group> 234 + {!isSelf && ( 235 + <> 236 + {(isLabelerAndNotBlocked || isFollowingBlockedAccount) && ( 237 + <Menu.Item 238 + testID="profileHeaderDropdownFollowBtn" 239 + label={ 240 + isFollowing 241 + ? _(msg`Unfollow Account`) 242 + : _(msg`Follow Account`) 243 + } 244 + onPress={ 245 + isFollowing 246 + ? onPressUnfollowAccount 247 + : onPressFollowAccount 248 + }> 249 + <Menu.ItemText> 250 + {isFollowing ? ( 251 + <Trans>Unfollow Account</Trans> 252 + ) : ( 253 + <Trans>Follow Account</Trans> 254 + )} 255 + </Menu.ItemText> 256 + <Menu.ItemIcon icon={isFollowing ? UserMinus : Plus} /> 257 + </Menu.Item> 258 + )} 259 + </> 260 + )} 217 261 <Menu.Item 218 262 testID="profileHeaderDropdownListAddRemoveBtn" 219 263 label={_(msg`Add to Lists`)} ··· 225 269 </Menu.Item> 226 270 {!isSelf && ( 227 271 <> 228 - {profile.viewer?.following && 229 - (profile.viewer.blocking || profile.viewer.blockedBy) && ( 230 - <Menu.Item 231 - testID="profileHeaderDropdownUnfollowBtn" 232 - label={_(msg`Unfollow Account`)} 233 - onPress={onPressUnfollowAccount}> 234 - <Menu.ItemText> 235 - <Trans>Unfollow Account</Trans> 236 - </Menu.ItemText> 237 - <Menu.ItemIcon icon={UserMinus} /> 238 - </Menu.Item> 239 - )} 240 272 {!profile.viewer?.blocking && 241 273 !profile.viewer?.mutedByList && ( 242 274 <Menu.Item ··· 299 331 </Menu.Outer> 300 332 </Menu.Root> 301 333 334 + <ReportDialog 335 + control={reportDialogControl} 336 + params={{type: 'account', did: profile.did}} 337 + /> 338 + 302 339 <Prompt.Basic 303 340 control={blockPromptControl} 304 341 title={ ··· 310 347 profile.viewer?.blocking 311 348 ? _( 312 349 msg`The account will be able to interact with you after unblocking.`, 350 + ) 351 + : profile.associated?.labeler 352 + ? _( 353 + msg`Blocking will not prevent labels from being applied on your account, but it will stop this account from replying in your threads or interacting with you.`, 313 354 ) 314 355 : _( 315 356 msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`,
+5 -2
src/view/com/util/BottomSheetCustomBackdrop.tsx
··· 6 6 interpolate, 7 7 useAnimatedStyle, 8 8 } from 'react-native-reanimated' 9 - import {t} from '@lingui/macro' 9 + import {msg} from '@lingui/macro' 10 + import {useLingui} from '@lingui/react' 10 11 11 12 export function createCustomBackdrop( 12 13 onClose?: (() => void) | undefined, 13 14 ): React.FC<BottomSheetBackdropProps> { 14 15 const CustomBackdrop = ({animatedIndex, style}: BottomSheetBackdropProps) => { 16 + const {_} = useLingui() 17 + 15 18 // animated variables 16 19 const opacity = useAnimatedStyle(() => ({ 17 20 opacity: interpolate( ··· 30 33 return ( 31 34 <TouchableWithoutFeedback 32 35 onPress={onClose} 33 - accessibilityLabel={t`Close bottom drawer`} 36 + accessibilityLabel={_(msg`Close bottom drawer`)} 34 37 accessibilityHint="" 35 38 onAccessibilityEscape={() => { 36 39 if (onClose !== undefined) {
+17 -6
src/view/com/util/ErrorBoundary.tsx
··· 1 1 import React, {Component, ErrorInfo, ReactNode} from 'react' 2 2 import {ErrorScreen} from './error/ErrorScreen' 3 3 import {CenteredView} from './Views' 4 - import {t} from '@lingui/macro' 4 + import {msg} from '@lingui/macro' 5 5 import {logger} from '#/logger' 6 + import {useLingui} from '@lingui/react' 6 7 7 8 interface Props { 8 9 children?: ReactNode ··· 31 32 if (this.state.hasError) { 32 33 return ( 33 34 <CenteredView style={{height: '100%', flex: 1}}> 34 - <ErrorScreen 35 - title={t`Oh no!`} 36 - message={t`There was an unexpected issue in the application. Please let us know if this happened to you!`} 37 - details={this.state.error.toString()} 38 - /> 35 + <TranslatedErrorScreen details={this.state.error.toString()} /> 39 36 </CenteredView> 40 37 ) 41 38 } ··· 43 40 return this.props.children 44 41 } 45 42 } 43 + 44 + function TranslatedErrorScreen({details}: {details?: string}) { 45 + const {_} = useLingui() 46 + 47 + return ( 48 + <ErrorScreen 49 + title={_(msg`Oh no!`)} 50 + message={_( 51 + msg`There was an unexpected issue in the application. Please let us know if this happened to you!`, 52 + )} 53 + details={details} 54 + /> 55 + ) 56 + }
+7 -1
src/view/com/util/Link.tsx
··· 47 47 anchorNoUnderline?: boolean 48 48 navigationAction?: 'push' | 'replace' | 'navigate' 49 49 onPointerEnter?: () => void 50 + onBeforePress?: () => void 50 51 } 51 52 52 53 export const Link = memo(function Link({ ··· 60 61 accessible, 61 62 anchorNoUnderline, 62 63 navigationAction, 64 + onBeforePress, 63 65 ...props 64 66 }: Props) { 65 67 const t = useTheme() ··· 70 72 71 73 const onPress = React.useCallback( 72 74 (e?: Event) => { 75 + onBeforePress?.() 73 76 if (typeof href === 'string') { 74 77 return onPressInner( 75 78 closeModal, ··· 81 84 ) 82 85 } 83 86 }, 84 - [closeModal, navigation, navigationAction, href, openLink], 87 + [closeModal, navigation, navigationAction, href, openLink, onBeforePress], 85 88 ) 86 89 87 90 if (noFeedback) { ··· 262 265 accessibilityHint?: string 263 266 title?: string 264 267 navigationAction?: 'push' | 'replace' | 'navigate' 268 + disableMismatchWarning?: boolean 265 269 onPointerEnter?: () => void 266 270 } 267 271 export const TextLinkOnWebOnly = memo(function DesktopWebTextLink({ ··· 273 277 numberOfLines, 274 278 lineHeight, 275 279 navigationAction, 280 + disableMismatchWarning, 276 281 ...props 277 282 }: TextLinkOnWebOnlyProps) { 278 283 if (isWeb) { ··· 287 292 lineHeight={lineHeight} 288 293 title={props.title} 289 294 navigationAction={navigationAction} 295 + disableMismatchWarning={disableMismatchWarning} 290 296 {...props} 291 297 /> 292 298 )
+8 -2
src/view/com/util/PostMeta.tsx
··· 11 11 import {isAndroid, isWeb} from 'platform/detection' 12 12 import {TimeElapsed} from './TimeElapsed' 13 13 import {makeProfileLink} from 'lib/routes/links' 14 - import {ModerationUI} from '@atproto/api' 14 + import {ModerationDecision, ModerationUI} from '@atproto/api' 15 15 import {usePrefetchProfileQuery} from '#/state/queries/profile' 16 16 17 17 interface PostMetaOpts { ··· 21 21 handle: string 22 22 displayName?: string | undefined 23 23 } 24 + moderation: ModerationDecision | undefined 24 25 authorHasWarning: boolean 25 26 postHref: string 26 27 timestamp: string ··· 55 56 style={[pal.text, opts.displayNameStyle]} 56 57 numberOfLines={1} 57 58 lineHeight={1.2} 59 + disableMismatchWarning 58 60 text={ 59 61 <> 60 - {sanitizeDisplayName(displayName)}&nbsp; 62 + {sanitizeDisplayName( 63 + displayName, 64 + opts.moderation?.ui('displayName'), 65 + )} 66 + &nbsp; 61 67 <Text 62 68 type="md" 63 69 numberOfLines={1}
+30 -3
src/view/com/util/UserAvatar.tsx
··· 24 24 } from '#/components/icons/Camera' 25 25 import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/icons/StreamingLive' 26 26 import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' 27 - import {useTheme} from '#/alf' 27 + import {useTheme, tokens} from '#/alf' 28 28 29 - export type UserAvatarType = 'user' | 'algo' | 'list' 29 + export type UserAvatarType = 'user' | 'algo' | 'list' | 'labeler' 30 30 31 31 interface BaseUserAvatarProps { 32 32 type?: UserAvatarType ··· 101 101 </Svg> 102 102 ) 103 103 } 104 + if (type === 'labeler') { 105 + return ( 106 + <Svg 107 + testID="userAvatarFallback" 108 + width={size} 109 + height={size} 110 + viewBox="0 0 32 32" 111 + fill="none" 112 + stroke="none"> 113 + <Rect 114 + x="0" 115 + y="0" 116 + width="32" 117 + height="32" 118 + rx="3" 119 + fill={tokens.color.temp_purple} 120 + /> 121 + <Path 122 + d="M24 9.75L16 7L8 9.75V15.9123C8 20.8848 12 23 16 25.1579C20 23 24 20.8848 24 15.9123V9.75Z" 123 + stroke="white" 124 + strokeWidth="2" 125 + strokeLinecap="square" 126 + strokeLinejoin="round" 127 + /> 128 + </Svg> 129 + ) 130 + } 104 131 return ( 105 132 <Svg 106 133 testID="userAvatarFallback" ··· 134 161 const backgroundColor = pal.colors.backgroundLight 135 162 136 163 const aviStyle = useMemo(() => { 137 - if (type === 'algo' || type === 'list') { 164 + if (type === 'algo' || type === 'list' || type === 'labeler') { 138 165 return { 139 166 width: size, 140 167 height: size,
+10 -2
src/view/com/util/UserBanner.tsx
··· 7 7 8 8 import {colors} from 'lib/styles' 9 9 import {useTheme} from 'lib/ThemeContext' 10 - import {useTheme as useAlfTheme} from '#/alf' 10 + import {useTheme as useAlfTheme, tokens} from '#/alf' 11 11 import {openCamera, openCropper, openPicker} from '../../../lib/media/picker' 12 12 import { 13 13 usePhotoLibraryPermission, ··· 26 26 import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' 27 27 28 28 export function UserBanner({ 29 + type, 29 30 banner, 30 31 moderation, 31 32 onSelectNewBanner, 32 33 }: { 34 + type?: 'labeler' | 'default' 33 35 banner?: string | null 34 36 moderation?: ModerationUI 35 37 onSelectNewBanner?: (img: RNImage | null) => void ··· 167 169 ) : ( 168 170 <View 169 171 testID="userBannerFallback" 170 - style={[styles.bannerImage, styles.defaultBanner]} 172 + style={[ 173 + styles.bannerImage, 174 + type === 'labeler' ? styles.labelerBanner : styles.defaultBanner, 175 + ]} 171 176 /> 172 177 ) 173 178 } ··· 190 195 }, 191 196 defaultBanner: { 192 197 backgroundColor: '#0070ff', 198 + }, 199 + labelerBanner: { 200 + backgroundColor: tokens.color.temp_purple, 193 201 }, 194 202 })
+12 -34
src/view/com/util/forms/PostDropdownBtn.tsx
··· 16 16 import {EventStopper} from '../EventStopper' 17 17 import {useDialogControl} from '#/components/Dialog' 18 18 import * as Prompt from '#/components/Prompt' 19 - import {useModalControls} from '#/state/modals' 20 19 import {makeProfileLink} from '#/lib/routes/links' 21 20 import {CommonNavigatorParams} from '#/lib/routes/types' 22 21 import {getCurrentRoute} from 'lib/routes/helpers' ··· 33 32 import {isWeb} from '#/platform/detection' 34 33 import {richTextToString} from '#/lib/strings/rich-text-helpers' 35 34 import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' 35 + import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' 36 36 37 37 import {atoms as a, useTheme as useAlf} from '#/alf' 38 38 import * as Menu from '#/components/Menu' ··· 45 45 import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble' 46 46 import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' 47 47 import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' 48 - import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 49 48 50 49 let PostDropdownBtn = ({ 51 50 testID, ··· 55 54 record, 56 55 richText, 57 56 style, 58 - showAppealLabelItem, 59 57 hitSlop, 60 58 }: { 61 59 testID: string ··· 65 63 record: AppBskyFeedPost.Record 66 64 richText: RichTextAPI 67 65 style?: StyleProp<ViewStyle> 68 - showAppealLabelItem?: boolean 69 66 hitSlop?: PressableProps['hitSlop'] 70 67 }): React.ReactNode => { 71 68 const {hasSession, currentAccount} = useSession() ··· 73 70 const alf = useAlf() 74 71 const {_} = useLingui() 75 72 const defaultCtrlColor = theme.palette.default.postCtrl 76 - const {openModal} = useModalControls() 77 73 const langPrefs = useLanguagePrefs() 78 74 const mutedThreads = useMutedThreads() 79 75 const toggleThreadMute = useToggleThreadMute() ··· 83 79 const openLink = useOpenLink() 84 80 const navigation = useNavigation() 85 81 const {mutedWordsDialogControl} = useGlobalDialogsControlContext() 82 + const reportDialogControl = useReportDialogControl() 86 83 const deletePromptControl = useDialogControl() 87 84 const hidePromptControl = useDialogControl() 88 85 const loggedOutWarningPromptControl = useDialogControl() ··· 293 290 <Menu.Item 294 291 testID="postDropdownReportBtn" 295 292 label={_(msg`Report post`)} 296 - onPress={() => { 297 - openModal({ 298 - name: 'report', 299 - uri: postUri, 300 - cid: postCid, 301 - }) 302 - }}> 293 + onPress={() => reportDialogControl.open()}> 303 294 <Menu.ItemText>{_(msg`Report post`)}</Menu.ItemText> 304 295 <Menu.ItemIcon icon={Warning} position="right" /> 305 296 </Menu.Item> ··· 314 305 <Menu.ItemIcon icon={Trash} position="right" /> 315 306 </Menu.Item> 316 307 )} 317 - 318 - {showAppealLabelItem && ( 319 - <> 320 - <Menu.Divider /> 321 - 322 - <Menu.Item 323 - testID="postDropdownAppealBtn" 324 - label={_(msg`Appeal content warning`)} 325 - onPress={() => { 326 - openModal({ 327 - name: 'appeal-label', 328 - uri: postUri, 329 - cid: postCid, 330 - }) 331 - }}> 332 - <Menu.ItemText> 333 - {_(msg`Appeal content warning`)} 334 - </Menu.ItemText> 335 - <Menu.ItemIcon icon={CircleInfo} position="right" /> 336 - </Menu.Item> 337 - </> 338 - )} 339 308 </Menu.Group> 340 309 </Menu.Outer> 341 310 </Menu.Root> ··· 357 326 description={_(msg`This post will be hidden from feeds.`)} 358 327 onConfirm={onHidePost} 359 328 confirmButtonCta={_(msg`Hide`)} 329 + /> 330 + 331 + <ReportDialog 332 + control={reportDialogControl} 333 + params={{ 334 + type: 'post', 335 + uri: postUri, 336 + cid: postCid, 337 + }} 360 338 /> 361 339 362 340 <Prompt.Basic
-145
src/view/com/util/moderation/ContentHider.tsx
··· 1 - import React from 'react' 2 - import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native' 3 - import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 4 - import {usePalette} from 'lib/hooks/usePalette' 5 - import {ModerationUI, PostModeration} from '@atproto/api' 6 - import {Text} from '../text/Text' 7 - import {ShieldExclamation} from 'lib/icons' 8 - import {describeModerationCause} from 'lib/moderation' 9 - import {useLingui} from '@lingui/react' 10 - import {msg, Trans} from '@lingui/macro' 11 - import {useModalControls} from '#/state/modals' 12 - import {isPostMediaBlurred} from 'lib/moderation' 13 - 14 - export function ContentHider({ 15 - testID, 16 - moderation, 17 - moderationDecisions, 18 - ignoreMute, 19 - ignoreQuoteDecisions, 20 - style, 21 - childContainerStyle, 22 - children, 23 - }: React.PropsWithChildren<{ 24 - testID?: string 25 - moderation: ModerationUI 26 - moderationDecisions?: PostModeration['decisions'] 27 - ignoreMute?: boolean 28 - ignoreQuoteDecisions?: boolean 29 - style?: StyleProp<ViewStyle> 30 - childContainerStyle?: StyleProp<ViewStyle> 31 - }>) { 32 - const pal = usePalette('default') 33 - const {_} = useLingui() 34 - const [override, setOverride] = React.useState(false) 35 - const {openModal} = useModalControls() 36 - 37 - if ( 38 - !moderation.blur || 39 - (ignoreMute && moderation.cause?.type === 'muted') || 40 - shouldIgnoreQuote(moderationDecisions, ignoreQuoteDecisions) 41 - ) { 42 - return ( 43 - <View testID={testID} style={[styles.outer, style]}> 44 - {children} 45 - </View> 46 - ) 47 - } 48 - 49 - const isMute = ['muted', 'muted-word'].includes(moderation.cause?.type || '') 50 - const desc = describeModerationCause(moderation.cause, 'content') 51 - return ( 52 - <View testID={testID} style={[styles.outer, style]}> 53 - <Pressable 54 - onPress={() => { 55 - if (!moderation.noOverride) { 56 - setOverride(v => !v) 57 - } else { 58 - openModal({ 59 - name: 'moderation-details', 60 - context: 'content', 61 - moderation, 62 - }) 63 - } 64 - }} 65 - accessibilityRole="button" 66 - accessibilityHint={ 67 - override ? _(msg`Hide the content`) : _(msg`Show the content`) 68 - } 69 - accessibilityLabel="" 70 - style={[ 71 - styles.cover, 72 - moderation.noOverride 73 - ? {borderWidth: 1, borderColor: pal.colors.borderDark} 74 - : pal.viewLight, 75 - ]}> 76 - <Pressable 77 - onPress={() => { 78 - openModal({ 79 - name: 'moderation-details', 80 - context: 'content', 81 - moderation, 82 - }) 83 - }} 84 - accessibilityRole="button" 85 - accessibilityLabel={_(msg`Learn more about this warning`)} 86 - accessibilityHint=""> 87 - {isMute ? ( 88 - <FontAwesomeIcon 89 - icon={['far', 'eye-slash']} 90 - size={18} 91 - color={pal.colors.textLight} 92 - /> 93 - ) : ( 94 - <ShieldExclamation size={18} style={pal.textLight} /> 95 - )} 96 - </Pressable> 97 - <Text type="md" style={[pal.text, {flex: 1}]} numberOfLines={2}> 98 - {desc.name} 99 - </Text> 100 - <View style={styles.showBtn}> 101 - <Text type="lg" style={pal.link}> 102 - {moderation.noOverride ? ( 103 - <Trans>Learn more</Trans> 104 - ) : override ? ( 105 - <Trans>Hide</Trans> 106 - ) : ( 107 - <Trans>Show</Trans> 108 - )} 109 - </Text> 110 - </View> 111 - </Pressable> 112 - {override && <View style={childContainerStyle}>{children}</View>} 113 - </View> 114 - ) 115 - } 116 - 117 - function shouldIgnoreQuote( 118 - decisions: PostModeration['decisions'] | undefined, 119 - ignore: boolean | undefined, 120 - ): boolean { 121 - if (!decisions || !ignore) { 122 - return false 123 - } 124 - return !isPostMediaBlurred(decisions) 125 - } 126 - 127 - const styles = StyleSheet.create({ 128 - outer: { 129 - overflow: 'hidden', 130 - }, 131 - cover: { 132 - flexDirection: 'row', 133 - alignItems: 'center', 134 - gap: 6, 135 - borderRadius: 8, 136 - marginTop: 4, 137 - paddingVertical: 14, 138 - paddingLeft: 14, 139 - paddingRight: 18, 140 - }, 141 - showBtn: { 142 - marginLeft: 'auto', 143 - alignSelf: 'center', 144 - }, 145 - })
-61
src/view/com/util/moderation/LabelInfo.tsx
··· 1 - import React from 'react' 2 - import {Pressable, StyleProp, View, ViewStyle} from 'react-native' 3 - import {ComAtprotoLabelDefs} from '@atproto/api' 4 - import {Text} from '../text/Text' 5 - import {usePalette} from 'lib/hooks/usePalette' 6 - import {msg, Trans} from '@lingui/macro' 7 - import {useLingui} from '@lingui/react' 8 - import {useModalControls} from '#/state/modals' 9 - 10 - export function LabelInfo({ 11 - details, 12 - labels, 13 - style, 14 - }: { 15 - details: {did: string} | {uri: string; cid: string} 16 - labels: ComAtprotoLabelDefs.Label[] | undefined 17 - style?: StyleProp<ViewStyle> 18 - }) { 19 - const pal = usePalette('default') 20 - const {_} = useLingui() 21 - const {openModal} = useModalControls() 22 - 23 - if (!labels) { 24 - return null 25 - } 26 - labels = labels.filter(l => !l.val.startsWith('!')) 27 - if (!labels.length) { 28 - return null 29 - } 30 - 31 - return ( 32 - <View 33 - style={[ 34 - pal.viewLight, 35 - { 36 - flexDirection: 'row', 37 - flexWrap: 'wrap', 38 - paddingHorizontal: 12, 39 - paddingVertical: 10, 40 - borderRadius: 8, 41 - }, 42 - style, 43 - ]}> 44 - <Text type="sm" style={pal.text}> 45 - <Trans> 46 - A content warning has been applied to this{' '} 47 - {'did' in details ? 'account' : 'post'}. 48 - </Trans>{' '} 49 - </Text> 50 - <Pressable 51 - accessibilityRole="button" 52 - accessibilityLabel={_(msg`Appeal this decision`)} 53 - accessibilityHint="" 54 - onPress={() => openModal({name: 'appeal-label', ...details})}> 55 - <Text type="sm" style={pal.link}> 56 - <Trans>Appeal this decision.</Trans> 57 - </Text> 58 - </Pressable> 59 - </View> 60 - ) 61 - }
-67
src/view/com/util/moderation/PostAlerts.tsx
··· 1 - import React from 'react' 2 - import {Pressable, StyleProp, StyleSheet, ViewStyle} from 'react-native' 3 - import {ModerationUI} from '@atproto/api' 4 - import {Text} from '../text/Text' 5 - import {usePalette} from 'lib/hooks/usePalette' 6 - import {ShieldExclamation} from 'lib/icons' 7 - import {describeModerationCause} from 'lib/moderation' 8 - import {Trans, msg} from '@lingui/macro' 9 - import {useLingui} from '@lingui/react' 10 - import {useModalControls} from '#/state/modals' 11 - 12 - export function PostAlerts({ 13 - moderation, 14 - style, 15 - }: { 16 - moderation: ModerationUI 17 - includeMute?: boolean 18 - style?: StyleProp<ViewStyle> 19 - }) { 20 - const pal = usePalette('default') 21 - const {_} = useLingui() 22 - const {openModal} = useModalControls() 23 - 24 - const shouldAlert = !!moderation.cause && moderation.alert 25 - if (!shouldAlert) { 26 - return null 27 - } 28 - 29 - const desc = describeModerationCause(moderation.cause, 'content') 30 - return ( 31 - <Pressable 32 - onPress={() => { 33 - openModal({ 34 - name: 'moderation-details', 35 - context: 'content', 36 - moderation, 37 - }) 38 - }} 39 - accessibilityRole="button" 40 - accessibilityLabel={_(msg`Learn more about this warning`)} 41 - accessibilityHint="" 42 - style={[styles.container, pal.viewLight, style]}> 43 - <ShieldExclamation style={pal.text} size={16} /> 44 - <Text type="lg" style={[pal.text]}> 45 - {desc.name}{' '} 46 - <Text type="lg" style={[pal.link, styles.learnMoreBtn]}> 47 - <Trans>Learn More</Trans> 48 - </Text> 49 - </Text> 50 - </Pressable> 51 - ) 52 - } 53 - 54 - const styles = StyleSheet.create({ 55 - container: { 56 - flexDirection: 'row', 57 - alignItems: 'center', 58 - gap: 4, 59 - paddingVertical: 8, 60 - paddingLeft: 14, 61 - paddingHorizontal: 16, 62 - borderRadius: 8, 63 - }, 64 - learnMoreBtn: { 65 - marginLeft: 'auto', 66 - }, 67 - })
-142
src/view/com/util/moderation/PostHider.tsx
··· 1 - import React, {ComponentProps} from 'react' 2 - import {StyleSheet, Pressable, View, ViewStyle, StyleProp} from 'react-native' 3 - import {ModerationUI} from '@atproto/api' 4 - import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 5 - import {usePalette} from 'lib/hooks/usePalette' 6 - import {Link} from '../Link' 7 - import {Text} from '../text/Text' 8 - import {addStyle} from 'lib/styles' 9 - import {describeModerationCause} from 'lib/moderation' 10 - import {ShieldExclamation} from 'lib/icons' 11 - import {useLingui} from '@lingui/react' 12 - import {Trans, msg} from '@lingui/macro' 13 - import {useModalControls} from '#/state/modals' 14 - 15 - interface Props extends ComponentProps<typeof Link> { 16 - iconSize: number 17 - iconStyles: StyleProp<ViewStyle> 18 - moderation: ModerationUI 19 - } 20 - 21 - export function PostHider({ 22 - testID, 23 - href, 24 - moderation, 25 - style, 26 - children, 27 - iconSize, 28 - iconStyles, 29 - ...props 30 - }: Props) { 31 - const pal = usePalette('default') 32 - const {_} = useLingui() 33 - const [override, setOverride] = React.useState(false) 34 - const {openModal} = useModalControls() 35 - 36 - if (!moderation.blur) { 37 - return ( 38 - <Link 39 - testID={testID} 40 - style={style} 41 - href={href} 42 - noFeedback 43 - accessible={false} 44 - {...props}> 45 - {children} 46 - </Link> 47 - ) 48 - } 49 - 50 - const isMute = ['muted', 'muted-word'].includes(moderation.cause?.type || '') 51 - const desc = describeModerationCause(moderation.cause, 'content') 52 - return !override ? ( 53 - <Pressable 54 - onPress={() => { 55 - if (!moderation.noOverride) { 56 - setOverride(v => !v) 57 - } 58 - }} 59 - accessibilityRole="button" 60 - accessibilityHint={ 61 - override ? _(msg`Hide the content`) : _(msg`Show the content`) 62 - } 63 - accessibilityLabel="" 64 - style={[ 65 - styles.description, 66 - override ? {paddingBottom: 0} : undefined, 67 - pal.view, 68 - ]}> 69 - <Pressable 70 - onPress={() => { 71 - openModal({ 72 - name: 'moderation-details', 73 - context: 'content', 74 - moderation, 75 - }) 76 - }} 77 - accessibilityRole="button" 78 - accessibilityLabel={_(msg`Learn more about this warning`)} 79 - accessibilityHint=""> 80 - <View 81 - style={[ 82 - pal.viewLight, 83 - { 84 - width: iconSize, 85 - height: iconSize, 86 - borderRadius: iconSize, 87 - alignItems: 'center', 88 - justifyContent: 'center', 89 - }, 90 - iconStyles, 91 - ]}> 92 - {isMute ? ( 93 - <FontAwesomeIcon 94 - icon={['far', 'eye-slash']} 95 - size={14} 96 - color={pal.colors.textLight} 97 - /> 98 - ) : ( 99 - <ShieldExclamation size={14} style={pal.textLight} /> 100 - )} 101 - </View> 102 - </Pressable> 103 - <Text type="sm" style={[{flex: 1}, pal.textLight]} numberOfLines={1}> 104 - {desc.name} 105 - </Text> 106 - {!moderation.noOverride && ( 107 - <Text type="sm" style={[styles.showBtn, pal.link]}> 108 - {override ? <Trans>Hide</Trans> : <Trans>Show</Trans>} 109 - </Text> 110 - )} 111 - </Pressable> 112 - ) : ( 113 - <Link 114 - testID={testID} 115 - style={addStyle(style, styles.child)} 116 - href={href} 117 - noFeedback> 118 - {children} 119 - </Link> 120 - ) 121 - } 122 - 123 - const styles = StyleSheet.create({ 124 - description: { 125 - flexDirection: 'row', 126 - alignItems: 'center', 127 - gap: 4, 128 - paddingVertical: 10, 129 - paddingLeft: 6, 130 - paddingRight: 18, 131 - marginTop: 1, 132 - }, 133 - showBtn: { 134 - marginLeft: 'auto', 135 - alignSelf: 'center', 136 - }, 137 - child: { 138 - borderWidth: 0, 139 - borderTopWidth: 0, 140 - borderRadius: 8, 141 - }, 142 - })
-89
src/view/com/util/moderation/ProfileHeaderAlerts.tsx
··· 1 - import React from 'react' 2 - import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native' 3 - import {ProfileModeration} from '@atproto/api' 4 - import {Text} from '../text/Text' 5 - import {usePalette} from 'lib/hooks/usePalette' 6 - import {ShieldExclamation} from 'lib/icons' 7 - import { 8 - describeModerationCause, 9 - getProfileModerationCauses, 10 - } from 'lib/moderation' 11 - import {msg, Trans} from '@lingui/macro' 12 - import {useLingui} from '@lingui/react' 13 - import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 14 - import {useModalControls} from '#/state/modals' 15 - 16 - export function ProfileHeaderAlerts({ 17 - moderation, 18 - style, 19 - }: { 20 - moderation: ProfileModeration 21 - style?: StyleProp<ViewStyle> 22 - }) { 23 - const pal = usePalette('default') 24 - const {_} = useLingui() 25 - const {openModal} = useModalControls() 26 - 27 - const causes = getProfileModerationCauses(moderation) 28 - if (!causes.length) { 29 - return null 30 - } 31 - 32 - return ( 33 - <View style={styles.grid}> 34 - {causes.map(cause => { 35 - const isMute = cause.type === 'muted' 36 - const desc = describeModerationCause(cause, 'account') 37 - return ( 38 - <Pressable 39 - testID="profileHeaderAlert" 40 - key={desc.name} 41 - onPress={() => { 42 - openModal({ 43 - name: 'moderation-details', 44 - context: 'content', 45 - moderation: {cause}, 46 - }) 47 - }} 48 - accessibilityRole="button" 49 - accessibilityLabel={_(msg`Learn more about this warning`)} 50 - accessibilityHint="" 51 - style={[styles.container, pal.viewLight, style]}> 52 - {isMute ? ( 53 - <FontAwesomeIcon 54 - icon={['far', 'eye-slash']} 55 - size={14} 56 - color={pal.colors.textLight} 57 - /> 58 - ) : ( 59 - <ShieldExclamation style={pal.text} size={18} /> 60 - )} 61 - <Text type="sm" style={[{flex: 1}, pal.text]}> 62 - {desc.name} 63 - </Text> 64 - <Text type="sm" style={[pal.link, styles.learnMoreBtn]}> 65 - <Trans>Learn More</Trans> 66 - </Text> 67 - </Pressable> 68 - ) 69 - })} 70 - </View> 71 - ) 72 - } 73 - 74 - const styles = StyleSheet.create({ 75 - grid: { 76 - gap: 4, 77 - }, 78 - container: { 79 - flexDirection: 'row', 80 - alignItems: 'center', 81 - gap: 8, 82 - paddingVertical: 12, 83 - paddingHorizontal: 16, 84 - borderRadius: 8, 85 - }, 86 - learnMoreBtn: { 87 - marginLeft: 'auto', 88 - }, 89 - })
-180
src/view/com/util/moderation/ScreenHider.tsx
··· 1 - import React from 'react' 2 - import { 3 - TouchableWithoutFeedback, 4 - StyleProp, 5 - StyleSheet, 6 - View, 7 - ViewStyle, 8 - } from 'react-native' 9 - import { 10 - FontAwesomeIcon, 11 - FontAwesomeIconStyle, 12 - } from '@fortawesome/react-native-fontawesome' 13 - import {useNavigation} from '@react-navigation/native' 14 - import {ModerationUI} from '@atproto/api' 15 - import {usePalette} from 'lib/hooks/usePalette' 16 - import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 17 - import {NavigationProp} from 'lib/routes/types' 18 - import {Text} from '../text/Text' 19 - import {Button} from '../forms/Button' 20 - import {describeModerationCause} from 'lib/moderation' 21 - import {Trans, msg} from '@lingui/macro' 22 - import {useLingui} from '@lingui/react' 23 - import {useModalControls} from '#/state/modals' 24 - import {s} from '#/lib/styles' 25 - import {CenteredView} from '../Views' 26 - 27 - export function ScreenHider({ 28 - testID, 29 - screenDescription, 30 - moderation, 31 - style, 32 - containerStyle, 33 - children, 34 - }: React.PropsWithChildren<{ 35 - testID?: string 36 - screenDescription: string 37 - moderation: ModerationUI 38 - style?: StyleProp<ViewStyle> 39 - containerStyle?: StyleProp<ViewStyle> 40 - }>) { 41 - const pal = usePalette('default') 42 - const palInverted = usePalette('inverted') 43 - const {_} = useLingui() 44 - const [override, setOverride] = React.useState(false) 45 - const navigation = useNavigation<NavigationProp>() 46 - const {isMobile} = useWebMediaQueries() 47 - const {openModal} = useModalControls() 48 - 49 - if (!moderation.blur || override) { 50 - return ( 51 - <View testID={testID} style={style}> 52 - {children} 53 - </View> 54 - ) 55 - } 56 - 57 - const isNoPwi = 58 - moderation.cause?.type === 'label' && 59 - moderation.cause?.labelDef.id === '!no-unauthenticated' 60 - const desc = describeModerationCause(moderation.cause, 'account') 61 - return ( 62 - <CenteredView 63 - style={[styles.container, pal.view, containerStyle]} 64 - sideBorders> 65 - <View style={styles.iconContainer}> 66 - <View style={[styles.icon, palInverted.view]}> 67 - <FontAwesomeIcon 68 - icon={isNoPwi ? ['far', 'eye-slash'] : 'exclamation'} 69 - style={pal.textInverted as FontAwesomeIconStyle} 70 - size={24} 71 - /> 72 - </View> 73 - </View> 74 - <Text type="title-2xl" style={[styles.title, pal.text]}> 75 - {isNoPwi ? ( 76 - <Trans>Sign-in Required</Trans> 77 - ) : ( 78 - <Trans>Content Warning</Trans> 79 - )} 80 - </Text> 81 - <Text type="2xl" style={[styles.description, pal.textLight]}> 82 - {isNoPwi ? ( 83 - <Trans> 84 - This account has requested that users sign in to view their profile. 85 - </Trans> 86 - ) : ( 87 - <> 88 - <Trans>This {screenDescription} has been flagged:</Trans> 89 - <Text type="2xl-medium" style={[pal.text, s.ml5]}> 90 - {desc.name}. 91 - </Text> 92 - <TouchableWithoutFeedback 93 - onPress={() => { 94 - openModal({ 95 - name: 'moderation-details', 96 - context: 'account', 97 - moderation, 98 - }) 99 - }} 100 - accessibilityRole="button" 101 - accessibilityLabel={_(msg`Learn more about this warning`)} 102 - accessibilityHint=""> 103 - <Text type="2xl" style={pal.link}> 104 - <Trans>Learn More</Trans> 105 - </Text> 106 - </TouchableWithoutFeedback> 107 - </> 108 - )}{' '} 109 - </Text> 110 - {isMobile && <View style={styles.spacer} />} 111 - <View style={styles.btnContainer}> 112 - <Button 113 - type="inverted" 114 - onPress={() => { 115 - if (navigation.canGoBack()) { 116 - navigation.goBack() 117 - } else { 118 - navigation.navigate('Home') 119 - } 120 - }} 121 - style={styles.btn}> 122 - <Text type="button-lg" style={pal.textInverted}> 123 - <Trans>Go back</Trans> 124 - </Text> 125 - </Button> 126 - {!moderation.noOverride && ( 127 - <Button 128 - type="default" 129 - onPress={() => setOverride(v => !v)} 130 - style={styles.btn}> 131 - <Text type="button-lg" style={pal.text}> 132 - <Trans>Show anyway</Trans> 133 - </Text> 134 - </Button> 135 - )} 136 - </View> 137 - </CenteredView> 138 - ) 139 - } 140 - 141 - const styles = StyleSheet.create({ 142 - spacer: { 143 - flex: 1, 144 - }, 145 - container: { 146 - flex: 1, 147 - paddingTop: 100, 148 - paddingBottom: 150, 149 - }, 150 - iconContainer: { 151 - alignItems: 'center', 152 - marginBottom: 10, 153 - }, 154 - icon: { 155 - borderRadius: 25, 156 - width: 50, 157 - height: 50, 158 - alignItems: 'center', 159 - justifyContent: 'center', 160 - }, 161 - title: { 162 - textAlign: 'center', 163 - marginBottom: 10, 164 - }, 165 - description: { 166 - marginBottom: 10, 167 - paddingHorizontal: 20, 168 - textAlign: 'center', 169 - }, 170 - btnContainer: { 171 - flexDirection: 'row', 172 - justifyContent: 'center', 173 - marginVertical: 10, 174 - gap: 10, 175 - }, 176 - btn: { 177 - paddingHorizontal: 20, 178 - paddingVertical: 14, 179 - }, 180 - })
-3
src/view/com/util/post-ctrls/PostCtrls.tsx
··· 41 41 post, 42 42 record, 43 43 richText, 44 - showAppealLabelItem, 45 44 style, 46 45 onPressReply, 47 46 logContext, ··· 50 49 post: Shadow<AppBskyFeedDefs.PostView> 51 50 record: AppBskyFeedPost.Record 52 51 richText: RichTextAPI 53 - showAppealLabelItem?: boolean 54 52 style?: StyleProp<ViewStyle> 55 53 onPressReply: () => void 56 54 logContext: 'FeedItem' | 'PostThreadItem' | 'Post' ··· 232 230 postUri={post.uri} 233 231 record={record} 234 232 richText={richText} 235 - showAppealLabelItem={showAppealLabelItem} 236 233 style={styles.btnPad} 237 234 hitSlop={big ? HITSLOP_20 : HITSLOP_10} 238 235 />
+83 -46
src/view/com/util/post-embeds/QuoteEmbed.tsx
··· 1 1 import React from 'react' 2 2 import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' 3 3 import { 4 + AppBskyFeedDefs, 4 5 AppBskyEmbedRecord, 5 6 AppBskyFeedPost, 6 7 AppBskyEmbedImages, 7 8 AppBskyEmbedRecordWithMedia, 8 - ModerationUI, 9 9 AppBskyEmbedExternal, 10 10 RichText as RichTextAPI, 11 + moderatePost, 12 + ModerationDecision, 11 13 } from '@atproto/api' 12 14 import {AtUri} from '@atproto/api' 13 15 import {PostMeta} from '../PostMeta' ··· 16 18 import {usePalette} from 'lib/hooks/usePalette' 17 19 import {ComposerOptsQuote} from 'state/shell/composer' 18 20 import {PostEmbeds} from '.' 19 - import {PostAlerts} from '../moderation/PostAlerts' 21 + import {PostAlerts} from '../../../../components/moderation/PostAlerts' 20 22 import {makeProfileLink} from 'lib/routes/links' 21 23 import {InfoCircleIcon} from 'lib/icons' 22 24 import {Trans} from '@lingui/macro' 25 + import {useModerationOpts} from '#/state/queries/preferences' 26 + import {ContentHider} from '../../../../components/moderation/ContentHider' 23 27 import {RichText} from '#/components/RichText' 24 28 import {atoms as a} from '#/alf' 25 29 26 30 export function MaybeQuoteEmbed({ 27 31 embed, 28 - moderation, 29 32 style, 30 33 }: { 31 34 embed: AppBskyEmbedRecord.View 32 - moderation: ModerationUI 33 35 style?: StyleProp<ViewStyle> 34 36 }) { 35 37 const pal = usePalette('default') ··· 39 41 AppBskyFeedPost.validateRecord(embed.record.value).success 40 42 ) { 41 43 return ( 42 - <QuoteEmbed 43 - quote={{ 44 - author: embed.record.author, 45 - cid: embed.record.cid, 46 - uri: embed.record.uri, 47 - indexedAt: embed.record.indexedAt, 48 - text: embed.record.value.text, 49 - facets: embed.record.value.facets, 50 - embeds: embed.record.embeds, 51 - }} 52 - moderation={moderation} 44 + <QuoteEmbedModerated 45 + viewRecord={embed.record} 46 + postRecord={embed.record.value} 53 47 style={style} 54 48 /> 55 49 ) ··· 75 69 return null 76 70 } 77 71 72 + function QuoteEmbedModerated({ 73 + viewRecord, 74 + postRecord, 75 + style, 76 + }: { 77 + viewRecord: AppBskyEmbedRecord.ViewRecord 78 + postRecord: AppBskyFeedPost.Record 79 + style?: StyleProp<ViewStyle> 80 + }) { 81 + const moderationOpts = useModerationOpts() 82 + const moderation = React.useMemo(() => { 83 + return moderationOpts 84 + ? moderatePost(viewRecordToPostView(viewRecord), moderationOpts) 85 + : undefined 86 + }, [viewRecord, moderationOpts]) 87 + 88 + const quote = { 89 + author: viewRecord.author, 90 + cid: viewRecord.cid, 91 + uri: viewRecord.uri, 92 + indexedAt: viewRecord.indexedAt, 93 + text: postRecord.text, 94 + facets: postRecord.facets, 95 + embeds: viewRecord.embeds, 96 + } 97 + 98 + return <QuoteEmbed quote={quote} moderation={moderation} style={style} /> 99 + } 100 + 78 101 export function QuoteEmbed({ 79 102 quote, 80 103 moderation, 81 104 style, 82 105 }: { 83 106 quote: ComposerOptsQuote 84 - moderation?: ModerationUI 107 + moderation?: ModerationDecision 85 108 style?: StyleProp<ViewStyle> 86 109 }) { 87 110 const pal = usePalette('default') 88 111 const itemUrip = new AtUri(quote.uri) 89 112 const itemHref = makeProfileLink(quote.author, 'post', itemUrip.rkey) 90 113 const itemTitle = `Post by ${quote.author.handle}` 114 + 91 115 const richText = React.useMemo( 92 116 () => 93 117 quote.text.trim() ··· 95 119 : undefined, 96 120 [quote.text, quote.facets], 97 121 ) 122 + 98 123 const embed = React.useMemo(() => { 99 124 const e = quote.embeds?.[0] 100 125 ··· 108 133 return e.media 109 134 } 110 135 }, [quote.embeds]) 136 + 111 137 return ( 112 - <Link 113 - style={[styles.container, pal.borderDark, style]} 114 - hoverStyle={{borderColor: pal.colors.borderLinkHover}} 115 - href={itemHref} 116 - title={itemTitle}> 117 - <View pointerEvents="none"> 118 - <PostMeta 119 - author={quote.author} 120 - showAvatar 121 - authorHasWarning={false} 122 - postHref={itemHref} 123 - timestamp={quote.indexedAt} 124 - /> 125 - </View> 126 - {moderation ? ( 127 - <PostAlerts moderation={moderation} style={styles.alert} /> 128 - ) : null} 129 - {richText ? ( 130 - <RichText 131 - enableTags 132 - value={richText} 133 - style={[a.text_md]} 134 - numberOfLines={20} 135 - disableLinks 136 - authorHandle={quote.author.handle} 137 - /> 138 - ) : null} 139 - {embed && <PostEmbeds embed={embed} moderation={{}} />} 140 - </Link> 138 + <ContentHider modui={moderation?.ui('contentList')}> 139 + <Link 140 + style={[styles.container, pal.borderDark, style]} 141 + hoverStyle={{borderColor: pal.colors.borderLinkHover}} 142 + href={itemHref} 143 + title={itemTitle}> 144 + <View pointerEvents="none"> 145 + <PostMeta 146 + author={quote.author} 147 + moderation={moderation} 148 + showAvatar 149 + authorHasWarning={false} 150 + postHref={itemHref} 151 + timestamp={quote.indexedAt} 152 + /> 153 + </View> 154 + {moderation ? ( 155 + <PostAlerts modui={moderation.ui('contentView')} style={[a.py_xs]} /> 156 + ) : null} 157 + {richText ? ( 158 + <RichText 159 + value={richText} 160 + style={[a.text_md]} 161 + numberOfLines={20} 162 + disableLinks 163 + /> 164 + ) : null} 165 + {embed && <PostEmbeds embed={embed} moderation={moderation} />} 166 + </Link> 167 + </ContentHider> 141 168 ) 142 169 } 143 170 144 - export default QuoteEmbed 171 + function viewRecordToPostView( 172 + viewRecord: AppBskyEmbedRecord.ViewRecord, 173 + ): AppBskyFeedDefs.PostView { 174 + const {value, embeds, ...rest} = viewRecord 175 + return { 176 + ...rest, 177 + $type: 'app.bsky.feed.defs#postView', 178 + record: value, 179 + embed: embeds?.[0], 180 + } 181 + } 145 182 146 183 const styles = StyleSheet.create({ 147 184 container: {
+50 -56
src/view/com/util/post-embeds/index.tsx
··· 15 15 AppBskyEmbedRecordWithMedia, 16 16 AppBskyFeedDefs, 17 17 AppBskyGraphDefs, 18 - ModerationUI, 19 - PostModeration, 18 + ModerationDecision, 20 19 } from '@atproto/api' 21 20 import {Link} from '../Link' 22 21 import {ImageLayoutGrid} from '../images/ImageLayoutGrid' ··· 26 25 import {MaybeQuoteEmbed} from './QuoteEmbed' 27 26 import {AutoSizedImage} from '../images/AutoSizedImage' 28 27 import {ListEmbed} from './ListEmbed' 29 - import {isCauseALabelOnUri, isQuoteBlurred} from 'lib/moderation' 30 28 import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' 31 - import {ContentHider} from '../moderation/ContentHider' 29 + import {ContentHider} from '../../../../components/moderation/ContentHider' 32 30 import {isNative} from '#/platform/detection' 33 31 import {shareUrl} from '#/lib/sharing' 34 32 ··· 42 40 export function PostEmbeds({ 43 41 embed, 44 42 moderation, 45 - moderationDecisions, 46 43 style, 47 44 }: { 48 45 embed?: Embed 49 - moderation: ModerationUI 50 - moderationDecisions?: PostModeration['decisions'] 46 + moderation?: ModerationDecision 51 47 style?: StyleProp<ViewStyle> 52 48 }) { 53 49 const pal = usePalette('default') ··· 66 62 // quote post with media 67 63 // = 68 64 if (AppBskyEmbedRecordWithMedia.isView(embed)) { 69 - const isModOnQuote = 70 - (AppBskyEmbedRecord.isViewRecord(embed.record.record) && 71 - isCauseALabelOnUri(moderation.cause, embed.record.record.uri)) || 72 - (moderationDecisions && isQuoteBlurred(moderationDecisions)) 73 - const mediaModeration = isModOnQuote ? {} : moderation 74 - const quoteModeration = isModOnQuote ? moderation : {} 75 65 return ( 76 66 <View style={style}> 77 - <PostEmbeds embed={embed.media} moderation={mediaModeration} /> 78 - <ContentHider moderation={quoteModeration}> 79 - <MaybeQuoteEmbed embed={embed.record} moderation={quoteModeration} /> 80 - </ContentHider> 67 + <PostEmbeds embed={embed.media} moderation={moderation} /> 68 + <MaybeQuoteEmbed embed={embed.record} /> 81 69 </View> 82 70 ) 83 71 } ··· 86 74 // custom feed embed (i.e. generator view) 87 75 // = 88 76 if (AppBskyFeedDefs.isGeneratorView(embed.record)) { 77 + // TODO moderation 89 78 return ( 90 79 <FeedSourceCard 91 80 feedUri={embed.record.uri} ··· 97 86 98 87 // list embed 99 88 if (AppBskyGraphDefs.isListView(embed.record)) { 89 + // TODO moderation 100 90 return <ListEmbed item={embed.record} /> 101 91 } 102 92 103 93 // quote post 104 94 // = 105 - return ( 106 - <ContentHider moderation={moderation}> 107 - <MaybeQuoteEmbed embed={embed} style={style} moderation={moderation} /> 108 - </ContentHider> 109 - ) 95 + return <MaybeQuoteEmbed embed={embed} style={style} /> 110 96 } 111 97 112 98 // image embed ··· 132 118 if (images.length === 1) { 133 119 const {alt, thumb, aspectRatio} = images[0] 134 120 return ( 135 - <View style={[styles.imagesContainer, style]}> 136 - <AutoSizedImage 137 - alt={alt} 138 - uri={thumb} 139 - dimensionsHint={aspectRatio} 140 - onPress={() => _openLightbox(0)} 141 - onPressIn={() => onPressIn(0)} 142 - style={[styles.singleImage]}> 143 - {alt === '' ? null : ( 144 - <View style={styles.altContainer}> 145 - <Text style={styles.alt} accessible={false}> 146 - ALT 147 - </Text> 148 - </View> 149 - )} 150 - </AutoSizedImage> 151 - </View> 121 + <ContentHider modui={moderation?.ui('contentMedia')}> 122 + <View style={[styles.imagesContainer, style]}> 123 + <AutoSizedImage 124 + alt={alt} 125 + uri={thumb} 126 + dimensionsHint={aspectRatio} 127 + onPress={() => _openLightbox(0)} 128 + onPressIn={() => onPressIn(0)} 129 + style={[styles.singleImage]}> 130 + {alt === '' ? null : ( 131 + <View style={styles.altContainer}> 132 + <Text style={styles.alt} accessible={false}> 133 + ALT 134 + </Text> 135 + </View> 136 + )} 137 + </AutoSizedImage> 138 + </View> 139 + </ContentHider> 152 140 ) 153 141 } 154 142 155 143 return ( 156 - <View style={[styles.imagesContainer, style]}> 157 - <ImageLayoutGrid 158 - images={embed.images} 159 - onPress={_openLightbox} 160 - onPressIn={onPressIn} 161 - style={embed.images.length === 1 ? [styles.singleImage] : undefined} 162 - /> 163 - </View> 144 + <ContentHider modui={moderation?.ui('contentMedia')}> 145 + <View style={[styles.imagesContainer, style]}> 146 + <ImageLayoutGrid 147 + images={embed.images} 148 + onPress={_openLightbox} 149 + onPressIn={onPressIn} 150 + style={ 151 + embed.images.length === 1 ? [styles.singleImage] : undefined 152 + } 153 + /> 154 + </View> 155 + </ContentHider> 164 156 ) 165 157 } 166 158 } ··· 171 163 const link = embed.external 172 164 173 165 return ( 174 - <Link 175 - asAnchor 176 - anchorNoUnderline 177 - href={link.uri} 178 - style={[styles.extOuter, pal.view, pal.borderDark, style]} 179 - hoverStyle={{borderColor: pal.colors.borderLinkHover}} 180 - onLongPress={onShareExternal}> 181 - <ExternalLinkEmbed link={link} /> 182 - </Link> 166 + <ContentHider modui={moderation?.ui('contentMedia')}> 167 + <Link 168 + asAnchor 169 + anchorNoUnderline 170 + href={link.uri} 171 + style={[styles.extOuter, pal.view, pal.borderDark, style]} 172 + hoverStyle={{borderColor: pal.colors.borderLinkHover}} 173 + onLongPress={onShareExternal}> 174 + <ExternalLinkEmbed link={link} /> 175 + </Link> 176 + </ContentHider> 183 177 ) 184 178 } 185 179
+923
src/view/screens/DebugMod.tsx
··· 1 + import React from 'react' 2 + import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' 3 + import {View} from 'react-native' 4 + import { 5 + LABELS, 6 + mock, 7 + moderatePost, 8 + moderateProfile, 9 + ModerationOpts, 10 + AppBskyActorDefs, 11 + AppBskyFeedDefs, 12 + AppBskyFeedPost, 13 + LabelPreference, 14 + ModerationDecision, 15 + ModerationBehavior, 16 + RichText, 17 + ComAtprotoLabelDefs, 18 + interpretLabelValueDefinition, 19 + } from '@atproto/api' 20 + import {msg} from '@lingui/macro' 21 + import {useLingui} from '@lingui/react' 22 + import {moderationOptsOverrideContext} from '#/state/queries/preferences' 23 + import {useSession} from '#/state/session' 24 + import {FeedNotification} from '#/state/queries/notifications/types' 25 + import { 26 + groupNotifications, 27 + shouldFilterNotif, 28 + } from '#/state/queries/notifications/util' 29 + 30 + import {atoms as a, useTheme} from '#/alf' 31 + import {CenteredView, ScrollView} from '#/view/com/util/Views' 32 + import {H1, H3, P, Text} from '#/components/Typography' 33 + import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings' 34 + import * as Toggle from '#/components/forms/Toggle' 35 + import * as ToggleButton from '#/components/forms/ToggleButton' 36 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 37 + import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 38 + import { 39 + ChevronBottom_Stroke2_Corner0_Rounded as ChevronBottom, 40 + ChevronTop_Stroke2_Corner0_Rounded as ChevronTop, 41 + } from '#/components/icons/Chevron' 42 + import {ScreenHider} from '../../components/moderation/ScreenHider' 43 + import {ProfileHeaderStandard} from '#/screens/Profile/Header/ProfileHeaderStandard' 44 + import {ProfileCard} from '../com/profile/ProfileCard' 45 + import {FeedItem} from '../com/posts/FeedItem' 46 + import {FeedItem as NotifFeedItem} from '../com/notifications/FeedItem' 47 + import {PostThreadItem} from '../com/post-thread/PostThreadItem' 48 + import {Divider} from '#/components/Divider' 49 + 50 + const LABEL_VALUES: (keyof typeof LABELS)[] = Object.keys( 51 + LABELS, 52 + ) as (keyof typeof LABELS)[] 53 + 54 + export const DebugModScreen = ({}: NativeStackScreenProps< 55 + CommonNavigatorParams, 56 + 'DebugMod' 57 + >) => { 58 + const t = useTheme() 59 + const [scenario, setScenario] = React.useState<string[]>(['label']) 60 + const [scenarioSwitches, setScenarioSwitches] = React.useState<string[]>([]) 61 + const [label, setLabel] = React.useState<string[]>([LABEL_VALUES[0]]) 62 + const [target, setTarget] = React.useState<string[]>(['account']) 63 + const [visibility, setVisiblity] = React.useState<string[]>(['warn']) 64 + const [customLabelDef, setCustomLabelDef] = 65 + React.useState<ComAtprotoLabelDefs.LabelValueDefinition>({ 66 + identifier: 'custom', 67 + blurs: 'content', 68 + severity: 'alert', 69 + defaultSetting: 'warn', 70 + locales: [ 71 + { 72 + lang: 'en', 73 + name: 'Custom label', 74 + description: 'A custom label created in this test environment', 75 + }, 76 + ], 77 + }) 78 + const [view, setView] = React.useState<string[]>(['post']) 79 + const labelStrings = useGlobalLabelStrings() 80 + const {currentAccount} = useSession() 81 + 82 + const isTargetMe = 83 + scenario[0] === 'label' && scenarioSwitches.includes('targetMe') 84 + const isSelfLabel = 85 + scenario[0] === 'label' && scenarioSwitches.includes('selfLabel') 86 + const noAdult = 87 + scenario[0] === 'label' && scenarioSwitches.includes('noAdult') 88 + const isLoggedOut = 89 + scenario[0] === 'label' && scenarioSwitches.includes('loggedOut') 90 + const isFollowing = scenarioSwitches.includes('following') 91 + 92 + const did = 93 + isTargetMe && currentAccount ? currentAccount.did : 'did:web:bob.test' 94 + 95 + const profile = React.useMemo(() => { 96 + const mockedProfile = mock.profileViewBasic({ 97 + handle: `bob.test`, 98 + displayName: 'Bob Robertson', 99 + description: 'User with this as their bio', 100 + labels: 101 + scenario[0] === 'label' && target[0] === 'account' 102 + ? [ 103 + mock.label({ 104 + src: isSelfLabel ? did : undefined, 105 + val: label[0], 106 + uri: `at://${did}/`, 107 + }), 108 + ] 109 + : scenario[0] === 'label' && target[0] === 'profile' 110 + ? [ 111 + mock.label({ 112 + src: isSelfLabel ? did : undefined, 113 + val: label[0], 114 + uri: `at://${did}/app.bsky.actor.profile/self`, 115 + }), 116 + ] 117 + : undefined, 118 + viewer: mock.actorViewerState({ 119 + following: isFollowing 120 + ? `at://${currentAccount?.did || ''}/app.bsky.graph.follow/1234` 121 + : undefined, 122 + muted: scenario[0] === 'mute', 123 + mutedByList: undefined, 124 + blockedBy: undefined, 125 + blocking: 126 + scenario[0] === 'block' 127 + ? `at://did:web:alice.test/app.bsky.actor.block/fake` 128 + : undefined, 129 + blockingByList: undefined, 130 + }), 131 + }) 132 + mockedProfile.did = did 133 + mockedProfile.avatar = 'https://bsky.social/about/images/favicon-32x32.png' 134 + mockedProfile.banner = 135 + 'https://bsky.social/about/images/social-card-default-gradient.png' 136 + return mockedProfile 137 + }, [scenario, target, label, isSelfLabel, did, isFollowing, currentAccount]) 138 + 139 + const post = React.useMemo(() => { 140 + return mock.postView({ 141 + record: mock.post({ 142 + text: "This is the body of the post. It's where the text goes. You get the idea.", 143 + }), 144 + author: profile, 145 + labels: 146 + scenario[0] === 'label' && target[0] === 'post' 147 + ? [ 148 + mock.label({ 149 + src: isSelfLabel ? did : undefined, 150 + val: label[0], 151 + uri: `at://${did}/app.bsky.feed.post/fake`, 152 + }), 153 + ] 154 + : undefined, 155 + embed: 156 + target[0] === 'embed' 157 + ? mock.embedRecordView({ 158 + record: mock.post({ 159 + text: 'Embed', 160 + }), 161 + labels: 162 + scenario[0] === 'label' && target[0] === 'embed' 163 + ? [ 164 + mock.label({ 165 + src: isSelfLabel ? did : undefined, 166 + val: label[0], 167 + uri: `at://${did}/app.bsky.feed.post/fake`, 168 + }), 169 + ] 170 + : undefined, 171 + author: profile, 172 + }) 173 + : { 174 + $type: 'app.bsky.embed.images#view', 175 + images: [ 176 + { 177 + thumb: 178 + 'https://bsky.social/about/images/social-card-default-gradient.png', 179 + fullsize: 180 + 'https://bsky.social/about/images/social-card-default-gradient.png', 181 + alt: '', 182 + }, 183 + ], 184 + }, 185 + }) 186 + }, [scenario, label, target, profile, isSelfLabel, did]) 187 + 188 + const replyNotif = React.useMemo(() => { 189 + const notif = mock.replyNotification({ 190 + record: mock.post({ 191 + text: "This is the body of the post. It's where the text goes. You get the idea.", 192 + reply: { 193 + parent: { 194 + uri: `at://${did}/app.bsky.feed.post/fake-parent`, 195 + cid: 'bafyreiclp443lavogvhj3d2ob2cxbfuscni2k5jk7bebjzg7khl3esabwq', 196 + }, 197 + root: { 198 + uri: `at://${did}/app.bsky.feed.post/fake-parent`, 199 + cid: 'bafyreiclp443lavogvhj3d2ob2cxbfuscni2k5jk7bebjzg7khl3esabwq', 200 + }, 201 + }, 202 + }), 203 + author: profile, 204 + labels: 205 + scenario[0] === 'label' && target[0] === 'post' 206 + ? [ 207 + mock.label({ 208 + src: isSelfLabel ? did : undefined, 209 + val: label[0], 210 + uri: `at://${did}/app.bsky.feed.post/fake`, 211 + }), 212 + ] 213 + : undefined, 214 + }) 215 + const [item] = groupNotifications([notif]) 216 + item.subject = mock.postView({ 217 + record: notif.record as AppBskyFeedPost.Record, 218 + author: profile, 219 + labels: notif.labels, 220 + }) 221 + return item 222 + }, [scenario, label, target, profile, isSelfLabel, did]) 223 + 224 + const followNotif = React.useMemo(() => { 225 + const notif = mock.followNotification({ 226 + author: profile, 227 + subjectDid: currentAccount?.did || '', 228 + }) 229 + const [item] = groupNotifications([notif]) 230 + return item 231 + }, [profile, currentAccount]) 232 + 233 + const modOpts = React.useMemo(() => { 234 + return { 235 + userDid: isLoggedOut ? '' : isTargetMe ? did : 'did:web:alice.test', 236 + prefs: { 237 + adultContentEnabled: !noAdult, 238 + labels: { 239 + [label[0]]: visibility[0] as LabelPreference, 240 + }, 241 + labelers: [ 242 + { 243 + did: 'did:plc:fake-labeler', 244 + labels: {[label[0]]: visibility[0] as LabelPreference}, 245 + }, 246 + ], 247 + mutedWords: [], 248 + hiddenPosts: [], 249 + }, 250 + labelDefs: { 251 + 'did:plc:fake-labeler': [ 252 + interpretLabelValueDefinition(customLabelDef, 'did:plc:fake-labeler'), 253 + ], 254 + }, 255 + } 256 + }, [label, visibility, noAdult, isLoggedOut, isTargetMe, did, customLabelDef]) 257 + 258 + const profileModeration = React.useMemo(() => { 259 + return moderateProfile(profile, modOpts) 260 + }, [profile, modOpts]) 261 + const postModeration = React.useMemo(() => { 262 + return moderatePost(post, modOpts) 263 + }, [post, modOpts]) 264 + 265 + return ( 266 + <moderationOptsOverrideContext.Provider value={modOpts}> 267 + <ScrollView> 268 + <CenteredView style={[t.atoms.bg, a.px_lg, a.py_lg]}> 269 + <H1 style={[a.text_5xl, a.font_bold, a.pb_lg]}>Moderation states</H1> 270 + 271 + <Heading title="" subtitle="Scenario" /> 272 + <ToggleButton.Group 273 + label="Scenario" 274 + values={scenario} 275 + onChange={setScenario}> 276 + <ToggleButton.Button name="label" label="Label"> 277 + Label 278 + </ToggleButton.Button> 279 + <ToggleButton.Button name="block" label="Block"> 280 + Block 281 + </ToggleButton.Button> 282 + <ToggleButton.Button name="mute" label="Mute"> 283 + Mute 284 + </ToggleButton.Button> 285 + </ToggleButton.Group> 286 + 287 + {scenario[0] === 'label' && ( 288 + <> 289 + <View 290 + style={[ 291 + a.border, 292 + a.rounded_sm, 293 + a.mt_lg, 294 + a.mb_lg, 295 + a.p_lg, 296 + t.atoms.border_contrast_medium, 297 + ]}> 298 + <Toggle.Group 299 + label="Toggle" 300 + type="radio" 301 + values={label} 302 + onChange={setLabel}> 303 + <View style={[a.flex_row, a.gap_md, a.flex_wrap]}> 304 + {LABEL_VALUES.map(labelValue => { 305 + let targetFixed = target[0] 306 + if ( 307 + targetFixed !== 'account' && 308 + targetFixed !== 'profile' 309 + ) { 310 + targetFixed = 'content' 311 + } 312 + const disabled = 313 + isSelfLabel && 314 + LABELS[labelValue].flags.includes('no-self') 315 + return ( 316 + <Toggle.Item 317 + key={labelValue} 318 + name={labelValue} 319 + label={labelStrings[labelValue].name} 320 + disabled={disabled} 321 + style={disabled ? {opacity: 0.5} : undefined}> 322 + <Toggle.Radio /> 323 + <Toggle.Label>{labelValue}</Toggle.Label> 324 + </Toggle.Item> 325 + ) 326 + })} 327 + <Toggle.Item 328 + name="custom" 329 + label="Custom label" 330 + disabled={isSelfLabel} 331 + style={isSelfLabel ? {opacity: 0.5} : undefined}> 332 + <Toggle.Radio /> 333 + <Toggle.Label>Custom label</Toggle.Label> 334 + </Toggle.Item> 335 + </View> 336 + </Toggle.Group> 337 + 338 + {label[0] === 'custom' ? ( 339 + <CustomLabelForm 340 + def={customLabelDef} 341 + setDef={setCustomLabelDef} 342 + /> 343 + ) : ( 344 + <> 345 + <View style={{height: 10}} /> 346 + <Divider /> 347 + </> 348 + )} 349 + 350 + <View style={{height: 10}} /> 351 + 352 + <SmallToggler label="Advanced"> 353 + <Toggle.Group 354 + label="Toggle" 355 + type="checkbox" 356 + values={scenarioSwitches} 357 + onChange={setScenarioSwitches}> 358 + <View style={[a.gap_md, a.flex_row, a.flex_wrap, a.pt_md]}> 359 + <Toggle.Item name="targetMe" label="Target is me"> 360 + <Toggle.Checkbox /> 361 + <Toggle.Label>Target is me</Toggle.Label> 362 + </Toggle.Item> 363 + <Toggle.Item name="following" label="Following target"> 364 + <Toggle.Checkbox /> 365 + <Toggle.Label>Following target</Toggle.Label> 366 + </Toggle.Item> 367 + <Toggle.Item name="selfLabel" label="Self label"> 368 + <Toggle.Checkbox /> 369 + <Toggle.Label>Self label</Toggle.Label> 370 + </Toggle.Item> 371 + <Toggle.Item name="noAdult" label="Adult disabled"> 372 + <Toggle.Checkbox /> 373 + <Toggle.Label>Adult disabled</Toggle.Label> 374 + </Toggle.Item> 375 + <Toggle.Item name="loggedOut" label="Logged out"> 376 + <Toggle.Checkbox /> 377 + <Toggle.Label>Logged out</Toggle.Label> 378 + </Toggle.Item> 379 + </View> 380 + </Toggle.Group> 381 + 382 + {LABELS[label[0] as keyof typeof LABELS]?.configurable !== 383 + false && ( 384 + <View style={[a.mt_md]}> 385 + <Text 386 + style={[a.font_bold, a.text_xs, t.atoms.text, a.pb_sm]}> 387 + Preference 388 + </Text> 389 + <Toggle.Group 390 + label="Preference" 391 + type="radio" 392 + values={visibility} 393 + onChange={setVisiblity}> 394 + <View 395 + style={[ 396 + a.flex_row, 397 + a.gap_md, 398 + a.flex_wrap, 399 + a.align_center, 400 + ]}> 401 + <Toggle.Item name="hide" label="Hide"> 402 + <Toggle.Radio /> 403 + <Toggle.Label>Hide</Toggle.Label> 404 + </Toggle.Item> 405 + <Toggle.Item name="warn" label="Warn"> 406 + <Toggle.Radio /> 407 + <Toggle.Label>Warn</Toggle.Label> 408 + </Toggle.Item> 409 + <Toggle.Item name="ignore" label="Ignore"> 410 + <Toggle.Radio /> 411 + <Toggle.Label>Ignore</Toggle.Label> 412 + </Toggle.Item> 413 + </View> 414 + </Toggle.Group> 415 + </View> 416 + )} 417 + </SmallToggler> 418 + </View> 419 + 420 + <View style={[a.flex_row, a.flex_wrap, a.gap_md]}> 421 + <View> 422 + <Text 423 + style={[ 424 + a.font_bold, 425 + a.text_xs, 426 + t.atoms.text, 427 + a.pl_md, 428 + a.pb_xs, 429 + ]}> 430 + Target 431 + </Text> 432 + <View 433 + style={[ 434 + a.border, 435 + a.rounded_full, 436 + a.px_md, 437 + a.py_sm, 438 + t.atoms.border_contrast_medium, 439 + t.atoms.bg, 440 + ]}> 441 + <Toggle.Group 442 + label="Target" 443 + type="radio" 444 + values={target} 445 + onChange={setTarget}> 446 + <View style={[a.flex_row, a.gap_md, a.flex_wrap]}> 447 + <Toggle.Item name="account" label="Account"> 448 + <Toggle.Radio /> 449 + <Toggle.Label>Account</Toggle.Label> 450 + </Toggle.Item> 451 + <Toggle.Item name="profile" label="Profile"> 452 + <Toggle.Radio /> 453 + <Toggle.Label>Profile</Toggle.Label> 454 + </Toggle.Item> 455 + <Toggle.Item name="post" label="Post"> 456 + <Toggle.Radio /> 457 + <Toggle.Label>Post</Toggle.Label> 458 + </Toggle.Item> 459 + <Toggle.Item name="embed" label="Embed"> 460 + <Toggle.Radio /> 461 + <Toggle.Label>Embed</Toggle.Label> 462 + </Toggle.Item> 463 + </View> 464 + </Toggle.Group> 465 + </View> 466 + </View> 467 + </View> 468 + </> 469 + )} 470 + 471 + <Spacer /> 472 + 473 + <Heading title="" subtitle="Results" /> 474 + 475 + <ToggleButton.Group label="Results" values={view} onChange={setView}> 476 + <ToggleButton.Button name="post" label="Post"> 477 + Post 478 + </ToggleButton.Button> 479 + <ToggleButton.Button name="notifications" label="Notifications"> 480 + Notifications 481 + </ToggleButton.Button> 482 + <ToggleButton.Button name="account" label="Account"> 483 + Account 484 + </ToggleButton.Button> 485 + <ToggleButton.Button name="data" label="Data"> 486 + Data 487 + </ToggleButton.Button> 488 + </ToggleButton.Group> 489 + 490 + <View 491 + style={[ 492 + a.border, 493 + a.rounded_sm, 494 + a.mt_lg, 495 + a.p_md, 496 + t.atoms.border_contrast_medium, 497 + ]}> 498 + {view[0] === 'post' && ( 499 + <> 500 + <Heading title="Post" subtitle="in feed" /> 501 + <MockPostFeedItem post={post} moderation={postModeration} /> 502 + 503 + <Heading title="Post" subtitle="viewed directly" /> 504 + <MockPostThreadItem post={post} moderation={postModeration} /> 505 + 506 + <Heading title="Post" subtitle="reply in thread" /> 507 + <MockPostThreadItem 508 + post={post} 509 + moderation={postModeration} 510 + reply 511 + /> 512 + </> 513 + )} 514 + 515 + {view[0] === 'notifications' && ( 516 + <> 517 + <Heading title="Notification" subtitle="quote or reply" /> 518 + <MockNotifItem notif={replyNotif} moderationOpts={modOpts} /> 519 + <View style={{height: 20}} /> 520 + <Heading title="Notification" subtitle="follow or like" /> 521 + <MockNotifItem notif={followNotif} moderationOpts={modOpts} /> 522 + </> 523 + )} 524 + 525 + {view[0] === 'account' && ( 526 + <> 527 + <Heading title="Account" subtitle="in listing" /> 528 + <MockAccountCard 529 + profile={profile} 530 + moderation={profileModeration} 531 + /> 532 + 533 + <Heading title="Account" subtitle="viewing directly" /> 534 + <MockAccountScreen 535 + profile={profile} 536 + moderation={profileModeration} 537 + moderationOpts={modOpts} 538 + /> 539 + </> 540 + )} 541 + 542 + {view[0] === 'data' && ( 543 + <> 544 + <ModerationUIView 545 + label="Profile Moderation UI" 546 + mod={profileModeration} 547 + /> 548 + <ModerationUIView 549 + label="Post Moderation UI" 550 + mod={postModeration} 551 + /> 552 + <DataView 553 + label={label[0]} 554 + data={LABELS[label[0] as keyof typeof LABELS]} 555 + /> 556 + <DataView 557 + label="Profile Moderation Data" 558 + data={profileModeration} 559 + /> 560 + <DataView label="Post Moderation Data" data={postModeration} /> 561 + </> 562 + )} 563 + </View> 564 + 565 + <View style={{height: 400}} /> 566 + </CenteredView> 567 + </ScrollView> 568 + </moderationOptsOverrideContext.Provider> 569 + ) 570 + } 571 + 572 + function Heading({title, subtitle}: {title: string; subtitle?: string}) { 573 + const t = useTheme() 574 + return ( 575 + <H3 style={[a.text_3xl, a.font_bold, a.pb_md]}> 576 + {title}{' '} 577 + {!!subtitle && ( 578 + <H3 style={[t.atoms.text_contrast_medium, a.text_lg]}>{subtitle}</H3> 579 + )} 580 + </H3> 581 + ) 582 + } 583 + 584 + function CustomLabelForm({ 585 + def, 586 + setDef, 587 + }: { 588 + def: ComAtprotoLabelDefs.LabelValueDefinition 589 + setDef: React.Dispatch< 590 + React.SetStateAction<ComAtprotoLabelDefs.LabelValueDefinition> 591 + > 592 + }) { 593 + const t = useTheme() 594 + return ( 595 + <View 596 + style={[ 597 + a.flex_row, 598 + a.flex_wrap, 599 + a.gap_md, 600 + t.atoms.bg_contrast_25, 601 + a.rounded_md, 602 + a.p_md, 603 + a.mt_md, 604 + ]}> 605 + <View> 606 + <Text style={[a.font_bold, a.text_xs, t.atoms.text, a.pl_md, a.pb_xs]}> 607 + Blurs 608 + </Text> 609 + <View 610 + style={[ 611 + a.border, 612 + a.rounded_full, 613 + a.px_md, 614 + a.py_sm, 615 + t.atoms.border_contrast_medium, 616 + t.atoms.bg, 617 + ]}> 618 + <Toggle.Group 619 + label="Blurs" 620 + type="radio" 621 + values={[def.blurs]} 622 + onChange={values => setDef(v => ({...v, blurs: values[0]}))}> 623 + <View style={[a.flex_row, a.gap_md, a.flex_wrap]}> 624 + <Toggle.Item name="content" label="Content"> 625 + <Toggle.Radio /> 626 + <Toggle.Label>Content</Toggle.Label> 627 + </Toggle.Item> 628 + <Toggle.Item name="media" label="Media"> 629 + <Toggle.Radio /> 630 + <Toggle.Label>Media</Toggle.Label> 631 + </Toggle.Item> 632 + <Toggle.Item name="none" label="None"> 633 + <Toggle.Radio /> 634 + <Toggle.Label>None</Toggle.Label> 635 + </Toggle.Item> 636 + </View> 637 + </Toggle.Group> 638 + </View> 639 + </View> 640 + <View> 641 + <Text style={[a.font_bold, a.text_xs, t.atoms.text, a.pl_md, a.pb_xs]}> 642 + Severity 643 + </Text> 644 + <View 645 + style={[ 646 + a.border, 647 + a.rounded_full, 648 + a.px_md, 649 + a.py_sm, 650 + t.atoms.border_contrast_medium, 651 + t.atoms.bg, 652 + ]}> 653 + <Toggle.Group 654 + label="Severity" 655 + type="radio" 656 + values={[def.severity]} 657 + onChange={values => setDef(v => ({...v, severity: values[0]}))}> 658 + <View style={[a.flex_row, a.gap_md, a.flex_wrap, a.align_center]}> 659 + <Toggle.Item name="alert" label="Alert"> 660 + <Toggle.Radio /> 661 + <Toggle.Label>Alert</Toggle.Label> 662 + </Toggle.Item> 663 + <Toggle.Item name="inform" label="Inform"> 664 + <Toggle.Radio /> 665 + <Toggle.Label>Inform</Toggle.Label> 666 + </Toggle.Item> 667 + <Toggle.Item name="none" label="None"> 668 + <Toggle.Radio /> 669 + <Toggle.Label>None</Toggle.Label> 670 + </Toggle.Item> 671 + </View> 672 + </Toggle.Group> 673 + </View> 674 + </View> 675 + </View> 676 + ) 677 + } 678 + 679 + function Toggler({label, children}: React.PropsWithChildren<{label: string}>) { 680 + const t = useTheme() 681 + const [show, setShow] = React.useState(false) 682 + return ( 683 + <View style={a.mb_md}> 684 + <View 685 + style={[ 686 + t.atoms.border_contrast_medium, 687 + a.border, 688 + a.rounded_sm, 689 + a.p_xs, 690 + ]}> 691 + <Button 692 + variant="solid" 693 + color="secondary" 694 + label="Toggle visibility" 695 + size="small" 696 + onPress={() => setShow(!show)}> 697 + <ButtonText>{label}</ButtonText> 698 + <ButtonIcon 699 + icon={show ? ChevronTop : ChevronBottom} 700 + position="right" 701 + /> 702 + </Button> 703 + {show && children} 704 + </View> 705 + </View> 706 + ) 707 + } 708 + 709 + function SmallToggler({ 710 + label, 711 + children, 712 + }: React.PropsWithChildren<{label: string}>) { 713 + const [show, setShow] = React.useState(false) 714 + return ( 715 + <View> 716 + <View style={[a.flex_row]}> 717 + <Button 718 + variant="ghost" 719 + color="secondary" 720 + label="Toggle visibility" 721 + size="tiny" 722 + onPress={() => setShow(!show)}> 723 + <ButtonText>{label}</ButtonText> 724 + <ButtonIcon 725 + icon={show ? ChevronTop : ChevronBottom} 726 + position="right" 727 + /> 728 + </Button> 729 + </View> 730 + {show && children} 731 + </View> 732 + ) 733 + } 734 + 735 + function DataView({label, data}: {label: string; data: any}) { 736 + return ( 737 + <Toggler label={label}> 738 + <Text style={[{fontFamily: 'monospace'}, a.p_md]}> 739 + {JSON.stringify(data, null, 2)} 740 + </Text> 741 + </Toggler> 742 + ) 743 + } 744 + 745 + function ModerationUIView({ 746 + mod, 747 + label, 748 + }: { 749 + mod: ModerationDecision 750 + label: string 751 + }) { 752 + return ( 753 + <Toggler label={label}> 754 + <View style={a.p_lg}> 755 + {[ 756 + 'profileList', 757 + 'profileView', 758 + 'avatar', 759 + 'banner', 760 + 'displayName', 761 + 'contentList', 762 + 'contentView', 763 + 'contentMedia', 764 + ].map(key => { 765 + const ui = mod.ui(key as keyof ModerationBehavior) 766 + return ( 767 + <View key={key} style={[a.flex_row, a.gap_md]}> 768 + <Text style={[a.font_bold, {width: 100}]}>{key}</Text> 769 + <Flag v={ui.filter} label="Filter" /> 770 + <Flag v={ui.blur} label="Blur" /> 771 + <Flag v={ui.alert} label="Alert" /> 772 + <Flag v={ui.inform} label="Inform" /> 773 + <Flag v={ui.noOverride} label="No-override" /> 774 + </View> 775 + ) 776 + })} 777 + </View> 778 + </Toggler> 779 + ) 780 + } 781 + 782 + function Spacer() { 783 + return <View style={{height: 30}} /> 784 + } 785 + 786 + function MockPostFeedItem({ 787 + post, 788 + moderation, 789 + }: { 790 + post: AppBskyFeedDefs.PostView 791 + moderation: ModerationDecision 792 + }) { 793 + const t = useTheme() 794 + if (moderation.ui('contentList').filter) { 795 + return ( 796 + <P style={[t.atoms.bg_contrast_25, a.px_lg, a.py_md, a.mb_lg]}> 797 + Filtered from the feed 798 + </P> 799 + ) 800 + } 801 + return ( 802 + <FeedItem 803 + post={post} 804 + record={post.record as AppBskyFeedPost.Record} 805 + moderation={moderation} 806 + reason={undefined} 807 + /> 808 + ) 809 + } 810 + 811 + function MockPostThreadItem({ 812 + post, 813 + reply, 814 + }: { 815 + post: AppBskyFeedDefs.PostView 816 + moderation: ModerationDecision 817 + reply?: boolean 818 + }) { 819 + return ( 820 + <PostThreadItem 821 + // @ts-ignore 822 + post={post} 823 + record={post.record as AppBskyFeedPost.Record} 824 + depth={reply ? 1 : 0} 825 + isHighlightedPost={!reply} 826 + treeView={false} 827 + prevPost={undefined} 828 + nextPost={undefined} 829 + hasPrecedingItem={false} 830 + onPostReply={() => {}} 831 + /> 832 + ) 833 + } 834 + 835 + function MockNotifItem({ 836 + notif, 837 + moderationOpts, 838 + }: { 839 + notif: FeedNotification 840 + moderationOpts: ModerationOpts 841 + }) { 842 + const t = useTheme() 843 + if (shouldFilterNotif(notif.notification, moderationOpts)) { 844 + return ( 845 + <P style={[t.atoms.bg_contrast_25, a.px_lg, a.py_md]}> 846 + Filtered from the feed 847 + </P> 848 + ) 849 + } 850 + return <NotifFeedItem item={notif} moderationOpts={moderationOpts} /> 851 + } 852 + 853 + function MockAccountCard({ 854 + profile, 855 + moderation, 856 + }: { 857 + profile: AppBskyActorDefs.ProfileViewBasic 858 + moderation: ModerationDecision 859 + }) { 860 + const t = useTheme() 861 + 862 + if (moderation.ui('profileList').filter) { 863 + return ( 864 + <P style={[t.atoms.bg_contrast_25, a.px_lg, a.py_md, a.mb_lg]}> 865 + Filtered from the listing 866 + </P> 867 + ) 868 + } 869 + 870 + return <ProfileCard profile={profile} /> 871 + } 872 + 873 + function MockAccountScreen({ 874 + profile, 875 + moderation, 876 + moderationOpts, 877 + }: { 878 + profile: AppBskyActorDefs.ProfileViewBasic 879 + moderation: ModerationDecision 880 + moderationOpts: ModerationOpts 881 + }) { 882 + const t = useTheme() 883 + const {_} = useLingui() 884 + return ( 885 + <View style={[t.atoms.border_contrast_medium, a.border, a.mb_md]}> 886 + <ScreenHider 887 + style={{}} 888 + screenDescription={_(msg`profile`)} 889 + modui={moderation.ui('profileView')}> 890 + <ProfileHeaderStandard 891 + // @ts-ignore ProfileViewBasic is close enough -prf 892 + profile={profile} 893 + moderationOpts={moderationOpts} 894 + descriptionRT={new RichText({text: profile.description as string})} 895 + /> 896 + </ScreenHider> 897 + </View> 898 + ) 899 + } 900 + 901 + function Flag({v, label}: {v: boolean | undefined; label: string}) { 902 + const t = useTheme() 903 + return ( 904 + <View style={[a.flex_row, a.align_center, a.gap_xs]}> 905 + <View 906 + style={[ 907 + a.justify_center, 908 + a.align_center, 909 + a.rounded_xs, 910 + a.border, 911 + t.atoms.border_contrast_medium, 912 + { 913 + backgroundColor: t.palette.contrast_25, 914 + width: 14, 915 + height: 14, 916 + }, 917 + ]}> 918 + {v && <Check size="xs" fill={t.palette.contrast_900} />} 919 + </View> 920 + <P style={a.text_xs}>{label}</P> 921 + </View> 922 + ) 923 + }
-304
src/view/screens/Moderation.tsx
··· 1 - import React from 'react' 2 - import { 3 - ActivityIndicator, 4 - StyleSheet, 5 - TouchableOpacity, 6 - View, 7 - } from 'react-native' 8 - import {useFocusEffect} from '@react-navigation/native' 9 - import { 10 - FontAwesomeIcon, 11 - FontAwesomeIconStyle, 12 - } from '@fortawesome/react-native-fontawesome' 13 - import {ComAtprotoLabelDefs} from '@atproto/api' 14 - import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' 15 - import {s} from 'lib/styles' 16 - import {CenteredView} from '../com/util/Views' 17 - import {ViewHeader} from '../com/util/ViewHeader' 18 - import {Link, TextLink} from '../com/util/Link' 19 - import {Text} from '../com/util/text/Text' 20 - import {usePalette} from 'lib/hooks/usePalette' 21 - import {useAnalytics} from 'lib/analytics/analytics' 22 - import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 23 - import {useSetMinimalShellMode} from '#/state/shell' 24 - import {useModalControls} from '#/state/modals' 25 - import {Trans, msg} from '@lingui/macro' 26 - import {useLingui} from '@lingui/react' 27 - import {ToggleButton} from '../com/util/forms/ToggleButton' 28 - import {useSession} from '#/state/session' 29 - import { 30 - useProfileQuery, 31 - useProfileUpdateMutation, 32 - } from '#/state/queries/profile' 33 - import {ScrollView} from '../com/util/Views' 34 - import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' 35 - 36 - type Props = NativeStackScreenProps<CommonNavigatorParams, 'Moderation'> 37 - export function ModerationScreen({}: Props) { 38 - const pal = usePalette('default') 39 - const {_} = useLingui() 40 - const setMinimalShellMode = useSetMinimalShellMode() 41 - const {screen, track} = useAnalytics() 42 - const {isTabletOrDesktop} = useWebMediaQueries() 43 - const {openModal} = useModalControls() 44 - const {mutedWordsDialogControl} = useGlobalDialogsControlContext() 45 - 46 - useFocusEffect( 47 - React.useCallback(() => { 48 - screen('Moderation') 49 - setMinimalShellMode(false) 50 - }, [screen, setMinimalShellMode]), 51 - ) 52 - 53 - const onPressContentFiltering = React.useCallback(() => { 54 - track('Moderation:ContentfilteringButtonClicked') 55 - openModal({name: 'content-filtering-settings'}) 56 - }, [track, openModal]) 57 - 58 - return ( 59 - <CenteredView 60 - style={[ 61 - s.hContentRegion, 62 - pal.border, 63 - isTabletOrDesktop ? styles.desktopContainer : pal.viewLight, 64 - ]} 65 - testID="moderationScreen"> 66 - <ViewHeader title={_(msg`Moderation`)} showOnDesktop /> 67 - <ScrollView contentContainerStyle={[styles.noBorder]}> 68 - <View style={styles.spacer} /> 69 - <TouchableOpacity 70 - testID="contentFilteringBtn" 71 - style={[styles.linkCard, pal.view]} 72 - onPress={onPressContentFiltering} 73 - accessibilityRole="tab" 74 - accessibilityHint="" 75 - accessibilityLabel={_(msg`Open content filtering settings`)}> 76 - <View style={[styles.iconContainer, pal.btn]}> 77 - <FontAwesomeIcon 78 - icon="eye" 79 - style={pal.text as FontAwesomeIconStyle} 80 - /> 81 - </View> 82 - <Text type="lg" style={pal.text}> 83 - <Trans>Content filtering</Trans> 84 - </Text> 85 - </TouchableOpacity> 86 - <TouchableOpacity 87 - testID="mutedWordsBtn" 88 - style={[styles.linkCard, pal.view]} 89 - onPress={() => mutedWordsDialogControl.open()} 90 - accessibilityRole="tab" 91 - accessibilityHint="" 92 - accessibilityLabel={_(msg`Open muted words settings`)}> 93 - <View style={[styles.iconContainer, pal.btn]}> 94 - <FontAwesomeIcon 95 - icon="filter" 96 - style={pal.text as FontAwesomeIconStyle} 97 - /> 98 - </View> 99 - <Text type="lg" style={pal.text}> 100 - <Trans>Muted words & tags</Trans> 101 - </Text> 102 - </TouchableOpacity> 103 - <Link 104 - testID="moderationlistsBtn" 105 - style={[styles.linkCard, pal.view]} 106 - href="/moderation/modlists"> 107 - <View style={[styles.iconContainer, pal.btn]}> 108 - <FontAwesomeIcon 109 - icon="users-slash" 110 - style={pal.text as FontAwesomeIconStyle} 111 - /> 112 - </View> 113 - <Text type="lg" style={pal.text}> 114 - <Trans>Moderation lists</Trans> 115 - </Text> 116 - </Link> 117 - <Link 118 - testID="mutedAccountsBtn" 119 - style={[styles.linkCard, pal.view]} 120 - href="/moderation/muted-accounts"> 121 - <View style={[styles.iconContainer, pal.btn]}> 122 - <FontAwesomeIcon 123 - icon="user-slash" 124 - style={pal.text as FontAwesomeIconStyle} 125 - /> 126 - </View> 127 - <Text type="lg" style={pal.text}> 128 - <Trans>Muted accounts</Trans> 129 - </Text> 130 - </Link> 131 - <Link 132 - testID="blockedAccountsBtn" 133 - style={[styles.linkCard, pal.view]} 134 - href="/moderation/blocked-accounts"> 135 - <View style={[styles.iconContainer, pal.btn]}> 136 - <FontAwesomeIcon 137 - icon="ban" 138 - style={pal.text as FontAwesomeIconStyle} 139 - /> 140 - </View> 141 - <Text type="lg" style={pal.text}> 142 - <Trans>Blocked accounts</Trans> 143 - </Text> 144 - </Link> 145 - <Text 146 - type="xl-bold" 147 - style={[ 148 - pal.text, 149 - { 150 - paddingHorizontal: 18, 151 - paddingTop: 18, 152 - paddingBottom: 6, 153 - }, 154 - ]}> 155 - <Trans>Logged-out visibility</Trans> 156 - </Text> 157 - <PwiOptOut /> 158 - </ScrollView> 159 - </CenteredView> 160 - ) 161 - } 162 - 163 - function PwiOptOut() { 164 - const pal = usePalette('default') 165 - const {_} = useLingui() 166 - const {currentAccount} = useSession() 167 - const {data: profile} = useProfileQuery({did: currentAccount?.did}) 168 - const updateProfile = useProfileUpdateMutation() 169 - 170 - const isOptedOut = 171 - profile?.labels?.some(l => l.val === '!no-unauthenticated') || false 172 - const canToggle = profile && !updateProfile.isPending 173 - 174 - const onToggleOptOut = React.useCallback(() => { 175 - if (!profile) { 176 - return 177 - } 178 - let wasAdded = false 179 - updateProfile.mutate({ 180 - profile, 181 - updates: existing => { 182 - // create labels attr if needed 183 - existing.labels = ComAtprotoLabelDefs.isSelfLabels(existing.labels) 184 - ? existing.labels 185 - : { 186 - $type: 'com.atproto.label.defs#selfLabels', 187 - values: [], 188 - } 189 - 190 - // toggle the label 191 - const hasLabel = existing.labels.values.some( 192 - l => l.val === '!no-unauthenticated', 193 - ) 194 - if (hasLabel) { 195 - wasAdded = false 196 - existing.labels.values = existing.labels.values.filter( 197 - l => l.val !== '!no-unauthenticated', 198 - ) 199 - } else { 200 - wasAdded = true 201 - existing.labels.values.push({val: '!no-unauthenticated'}) 202 - } 203 - 204 - // delete if no longer needed 205 - if (existing.labels.values.length === 0) { 206 - delete existing.labels 207 - } 208 - return existing 209 - }, 210 - checkCommitted: res => { 211 - const exists = !!res.data.labels?.some( 212 - l => l.val === '!no-unauthenticated', 213 - ) 214 - return exists === wasAdded 215 - }, 216 - }) 217 - }, [updateProfile, profile]) 218 - 219 - return ( 220 - <View style={[pal.view, styles.toggleCard]}> 221 - <View 222 - style={{flexDirection: 'row', alignItems: 'center', paddingRight: 14}}> 223 - <ToggleButton 224 - type="default-light" 225 - label={_( 226 - msg`Discourage apps from showing my account to logged-out users`, 227 - )} 228 - labelType="lg" 229 - isSelected={isOptedOut} 230 - onPress={canToggle ? onToggleOptOut : undefined} 231 - style={[canToggle ? undefined : {opacity: 0.5}, {flex: 1}]} 232 - /> 233 - {updateProfile.isPending && <ActivityIndicator />} 234 - </View> 235 - <View 236 - style={{ 237 - flexDirection: 'column', 238 - gap: 10, 239 - paddingLeft: 66, 240 - paddingRight: 12, 241 - paddingBottom: 10, 242 - marginBottom: 64, 243 - }}> 244 - <Text style={pal.textLight}> 245 - <Trans> 246 - Bluesky will not show your profile and posts to logged-out users. 247 - Other apps may not honor this request. This does not make your 248 - account private. 249 - </Trans> 250 - </Text> 251 - <Text style={[pal.textLight, {fontWeight: '500'}]}> 252 - <Trans> 253 - Note: Bluesky is an open and public network. This setting only 254 - limits the visibility of your content on the Bluesky app and 255 - website, and other apps may not respect this setting. Your content 256 - may still be shown to logged-out users by other apps and websites. 257 - </Trans> 258 - </Text> 259 - <TextLink 260 - style={pal.link} 261 - href="https://blueskyweb.zendesk.com/hc/en-us/articles/15835264007693-Data-Privacy" 262 - text={_(msg`Learn more about what is public on Bluesky.`)} 263 - /> 264 - </View> 265 - </View> 266 - ) 267 - } 268 - 269 - const styles = StyleSheet.create({ 270 - desktopContainer: { 271 - borderLeftWidth: 1, 272 - borderRightWidth: 1, 273 - }, 274 - spacer: { 275 - height: 6, 276 - }, 277 - linkCard: { 278 - flexDirection: 'row', 279 - alignItems: 'center', 280 - paddingVertical: 12, 281 - paddingHorizontal: 18, 282 - marginBottom: 1, 283 - }, 284 - toggleCard: { 285 - paddingVertical: 8, 286 - paddingTop: 2, 287 - paddingHorizontal: 6, 288 - marginBottom: 1, 289 - }, 290 - iconContainer: { 291 - alignItems: 'center', 292 - justifyContent: 'center', 293 - width: 40, 294 - height: 40, 295 - borderRadius: 30, 296 - marginRight: 12, 297 - }, 298 - noBorder: { 299 - borderBottomWidth: 0, 300 - borderRightWidth: 0, 301 - borderLeftWidth: 0, 302 - borderTopWidth: 0, 303 - }, 304 - })
+125 -128
src/view/screens/Profile.tsx
··· 1 1 import React, {useMemo} from 'react' 2 - import {StyleSheet, View} from 'react-native' 2 + import {StyleSheet} from 'react-native' 3 3 import {useFocusEffect} from '@react-navigation/native' 4 4 import { 5 5 AppBskyActorDefs, ··· 7 7 ModerationOpts, 8 8 RichText as RichTextAPI, 9 9 } from '@atproto/api' 10 - import {msg, Trans} from '@lingui/macro' 10 + import {msg} from '@lingui/macro' 11 11 import {useLingui} from '@lingui/react' 12 12 import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' 13 13 import {CenteredView} from '../com/util/Views' 14 14 import {ListRef} from '../com/util/List' 15 - import {ScreenHider} from 'view/com/util/moderation/ScreenHider' 16 - import {Feed} from 'view/com/posts/Feed' 15 + import {ScreenHider} from '#/components/moderation/ScreenHider' 17 16 import {ProfileLists} from '../com/lists/ProfileLists' 18 17 import {ProfileFeedgens} from '../com/feeds/ProfileFeedgens' 19 - import {ProfileHeader, ProfileHeaderLoading} from '../com/profile/ProfileHeader' 20 18 import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' 21 19 import {ErrorScreen} from '../com/util/error/ErrorScreen' 22 - import {EmptyState} from '../com/util/EmptyState' 23 20 import {FAB} from '../com/util/fab/FAB' 24 21 import {s, colors} from 'lib/styles' 25 22 import {useAnalytics} from 'lib/analytics/analytics' 26 23 import {ComposeIcon2} from 'lib/icons' 27 24 import {useSetTitle} from 'lib/hooks/useSetTitle' 28 25 import {combinedDisplayName} from 'lib/strings/display-names' 29 - import { 30 - FeedDescriptor, 31 - resetProfilePostsQueries, 32 - } from '#/state/queries/post-feed' 26 + import {resetProfilePostsQueries} from '#/state/queries/post-feed' 33 27 import {useResolveDidQuery} from '#/state/queries/resolve-uri' 34 28 import {useProfileQuery} from '#/state/queries/profile' 35 29 import {useProfileShadow} from '#/state/cache/profile-shadow' 36 30 import {useSession, getAgent} from '#/state/session' 37 31 import {useModerationOpts} from '#/state/queries/preferences' 38 - import {useProfileExtraInfoQuery} from '#/state/queries/profile-extra-info' 39 - import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' 32 + import {useLabelerInfoQuery} from '#/state/queries/labeler' 40 33 import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell' 41 34 import {cleanError} from '#/lib/strings/errors' 42 - import {LoadLatestBtn} from '../com/util/load-latest/LoadLatestBtn' 43 - import {useQueryClient} from '@tanstack/react-query' 44 35 import {useComposerControls} from '#/state/shell/composer' 45 36 import {listenSoftReset} from '#/state/events' 46 - import {truncateAndInvalidate} from '#/state/queries/util' 47 - import {Text} from '#/view/com/util/text/Text' 48 - import {usePalette} from 'lib/hooks/usePalette' 49 - import {isNative} from '#/platform/detection' 50 37 import {isInvalidHandle} from '#/lib/strings/handles' 38 + 39 + import {ProfileFeedSection} from '#/screens/Profile/Sections/Feed' 40 + import {ProfileLabelsSection} from '#/screens/Profile/Sections/Labels' 41 + import {ProfileHeader, ProfileHeaderLoading} from '#/screens/Profile/Header' 51 42 52 43 interface SectionRef { 53 44 scrollToTop: () => void ··· 148 139 const setMinimalShellMode = useSetMinimalShellMode() 149 140 const {openComposer} = useComposerControls() 150 141 const {screen, track} = useAnalytics() 142 + const { 143 + data: labelerInfo, 144 + error: labelerError, 145 + isLoading: isLabelerLoading, 146 + } = useLabelerInfoQuery({ 147 + did: profile.did, 148 + enabled: !!profile.associated?.labeler, 149 + }) 151 150 const [currentPage, setCurrentPage] = React.useState(0) 152 151 const {_} = useLingui() 153 152 const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() 154 - const extraInfoQuery = useProfileExtraInfoQuery(profile.did) 155 153 const postsSectionRef = React.useRef<SectionRef>(null) 156 154 const repliesSectionRef = React.useRef<SectionRef>(null) 157 155 const mediaSectionRef = React.useRef<SectionRef>(null) 158 156 const likesSectionRef = React.useRef<SectionRef>(null) 159 157 const feedsSectionRef = React.useRef<SectionRef>(null) 160 158 const listsSectionRef = React.useRef<SectionRef>(null) 159 + const labelsSectionRef = React.useRef<SectionRef>(null) 161 160 162 161 useSetTitle(combinedDisplayName(profile)) 163 162 ··· 171 170 ) 172 171 173 172 const isMe = profile.did === currentAccount?.did 173 + const hasLabeler = !!profile.associated?.labeler 174 + const showFiltersTab = hasLabeler 175 + const showPostsTab = true 174 176 const showRepliesTab = hasSession 177 + const showMediaTab = !hasLabeler 175 178 const showLikesTab = isMe 176 - const showFeedsTab = hasSession && (isMe || extraInfoQuery.data?.hasFeedgens) 177 - const showListsTab = hasSession && (isMe || extraInfoQuery.data?.hasLists) 179 + const showFeedsTab = 180 + hasSession && (isMe || (profile.associated?.feedgens || 0) > 0) 181 + const showListsTab = 182 + hasSession && (isMe || (profile.associated?.lists || 0) > 0) 183 + 178 184 const sectionTitles = useMemo<string[]>(() => { 179 185 return [ 180 - _(msg`Posts`), 186 + showFiltersTab ? _(msg`Labels`) : undefined, 187 + showListsTab && hasLabeler ? _(msg`Lists`) : undefined, 188 + showPostsTab ? _(msg`Posts`) : undefined, 181 189 showRepliesTab ? _(msg`Replies`) : undefined, 182 - _(msg`Media`), 190 + showMediaTab ? _(msg`Media`) : undefined, 183 191 showLikesTab ? _(msg`Likes`) : undefined, 184 192 showFeedsTab ? _(msg`Feeds`) : undefined, 185 - showListsTab ? _(msg`Lists`) : undefined, 193 + showListsTab && !hasLabeler ? _(msg`Lists`) : undefined, 186 194 ].filter(Boolean) as string[] 187 - }, [showRepliesTab, showLikesTab, showFeedsTab, showListsTab, _]) 195 + }, [ 196 + showPostsTab, 197 + showRepliesTab, 198 + showMediaTab, 199 + showLikesTab, 200 + showFeedsTab, 201 + showListsTab, 202 + showFiltersTab, 203 + hasLabeler, 204 + _, 205 + ]) 188 206 189 207 let nextIndex = 0 190 - const postsIndex = nextIndex++ 208 + let filtersIndex: number | null = null 209 + let postsIndex: number | null = null 191 210 let repliesIndex: number | null = null 211 + let mediaIndex: number | null = null 212 + let likesIndex: number | null = null 213 + let feedsIndex: number | null = null 214 + let listsIndex: number | null = null 215 + if (showFiltersTab) { 216 + filtersIndex = nextIndex++ 217 + } 218 + if (showPostsTab) { 219 + postsIndex = nextIndex++ 220 + } 192 221 if (showRepliesTab) { 193 222 repliesIndex = nextIndex++ 194 223 } 195 - const mediaIndex = nextIndex++ 196 - let likesIndex: number | null = null 224 + if (showMediaTab) { 225 + mediaIndex = nextIndex++ 226 + } 197 227 if (showLikesTab) { 198 228 likesIndex = nextIndex++ 199 229 } 200 - let feedsIndex: number | null = null 201 230 if (showFeedsTab) { 202 231 feedsIndex = nextIndex++ 203 232 } 204 - let listsIndex: number | null = null 205 233 if (showListsTab) { 206 234 listsIndex = nextIndex++ 207 235 } 208 236 209 237 const scrollSectionToTop = React.useCallback( 210 238 (index: number) => { 211 - if (index === postsIndex) { 239 + if (index === filtersIndex) { 240 + labelsSectionRef.current?.scrollToTop() 241 + } else if (index === postsIndex) { 212 242 postsSectionRef.current?.scrollToTop() 213 243 } else if (index === repliesIndex) { 214 244 repliesSectionRef.current?.scrollToTop() ··· 222 252 listsSectionRef.current?.scrollToTop() 223 253 } 224 254 }, 225 - [postsIndex, repliesIndex, mediaIndex, likesIndex, feedsIndex, listsIndex], 255 + [ 256 + filtersIndex, 257 + postsIndex, 258 + repliesIndex, 259 + mediaIndex, 260 + likesIndex, 261 + feedsIndex, 262 + listsIndex, 263 + ], 226 264 ) 227 265 228 266 useFocusEffect( ··· 278 316 return ( 279 317 <ProfileHeader 280 318 profile={profile} 319 + labeler={labelerInfo} 281 320 descriptionRT={hasDescription ? descriptionRT : null} 282 321 moderationOpts={moderationOpts} 283 322 hideBackButton={hideBackButton} ··· 286 325 ) 287 326 }, [ 288 327 profile, 328 + labelerInfo, 289 329 descriptionRT, 290 330 hasDescription, 291 331 moderationOpts, ··· 297 337 <ScreenHider 298 338 testID="profileView" 299 339 style={styles.container} 300 - screenDescription="profile" 301 - moderation={moderation.account}> 340 + screenDescription={_(msg`profile`)} 341 + modui={moderation.ui('profileView')}> 302 342 <PagerWithHeader 303 343 testID="profilePager" 304 344 isHeaderReady={!showPlaceholder} ··· 306 346 onPageSelected={onPageSelected} 307 347 onCurrentPageSelected={onCurrentPageSelected} 308 348 renderHeader={renderHeader}> 309 - {({headerHeight, isFocused, scrollElRef}) => ( 310 - <FeedSection 311 - ref={postsSectionRef} 312 - feed={`author|${profile.did}|posts_and_author_threads`} 313 - headerHeight={headerHeight} 314 - isFocused={isFocused} 315 - scrollElRef={scrollElRef as ListRef} 316 - ignoreFilterFor={profile.did} 317 - /> 318 - )} 349 + {showFiltersTab 350 + ? ({headerHeight, scrollElRef}) => ( 351 + <ProfileLabelsSection 352 + ref={labelsSectionRef} 353 + labelerInfo={labelerInfo} 354 + labelerError={labelerError} 355 + isLabelerLoading={isLabelerLoading} 356 + moderationOpts={moderationOpts} 357 + scrollElRef={scrollElRef as ListRef} 358 + headerHeight={headerHeight} 359 + /> 360 + ) 361 + : null} 362 + {showListsTab && !!profile.associated?.labeler 363 + ? ({headerHeight, isFocused, scrollElRef}) => ( 364 + <ProfileLists 365 + ref={listsSectionRef} 366 + did={profile.did} 367 + scrollElRef={scrollElRef as ListRef} 368 + headerOffset={headerHeight} 369 + enabled={isFocused} 370 + /> 371 + ) 372 + : null} 373 + {showPostsTab 374 + ? ({headerHeight, isFocused, scrollElRef}) => ( 375 + <ProfileFeedSection 376 + ref={postsSectionRef} 377 + feed={`author|${profile.did}|posts_and_author_threads`} 378 + headerHeight={headerHeight} 379 + isFocused={isFocused} 380 + scrollElRef={scrollElRef as ListRef} 381 + ignoreFilterFor={profile.did} 382 + /> 383 + ) 384 + : null} 319 385 {showRepliesTab 320 386 ? ({headerHeight, isFocused, scrollElRef}) => ( 321 - <FeedSection 387 + <ProfileFeedSection 322 388 ref={repliesSectionRef} 323 389 feed={`author|${profile.did}|posts_with_replies`} 324 390 headerHeight={headerHeight} ··· 328 394 /> 329 395 ) 330 396 : null} 331 - {({headerHeight, isFocused, scrollElRef}) => ( 332 - <FeedSection 333 - ref={mediaSectionRef} 334 - feed={`author|${profile.did}|posts_with_media`} 335 - headerHeight={headerHeight} 336 - isFocused={isFocused} 337 - scrollElRef={scrollElRef as ListRef} 338 - ignoreFilterFor={profile.did} 339 - /> 340 - )} 397 + {showMediaTab 398 + ? ({headerHeight, isFocused, scrollElRef}) => ( 399 + <ProfileFeedSection 400 + ref={mediaSectionRef} 401 + feed={`author|${profile.did}|posts_with_media`} 402 + headerHeight={headerHeight} 403 + isFocused={isFocused} 404 + scrollElRef={scrollElRef as ListRef} 405 + ignoreFilterFor={profile.did} 406 + /> 407 + ) 408 + : null} 341 409 {showLikesTab 342 410 ? ({headerHeight, isFocused, scrollElRef}) => ( 343 - <FeedSection 411 + <ProfileFeedSection 344 412 ref={likesSectionRef} 345 413 feed={`likes|${profile.did}`} 346 414 headerHeight={headerHeight} ··· 361 429 /> 362 430 ) 363 431 : null} 364 - {showListsTab 432 + {showListsTab && !profile.associated?.labeler 365 433 ? ({headerHeight, isFocused, scrollElRef}) => ( 366 434 <ProfileLists 367 435 ref={listsSectionRef} ··· 384 452 /> 385 453 )} 386 454 </ScreenHider> 387 - ) 388 - } 389 - 390 - interface FeedSectionProps { 391 - feed: FeedDescriptor 392 - headerHeight: number 393 - isFocused: boolean 394 - scrollElRef: ListRef 395 - ignoreFilterFor?: string 396 - } 397 - const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( 398 - function FeedSectionImpl( 399 - {feed, headerHeight, isFocused, scrollElRef, ignoreFilterFor}, 400 - ref, 401 - ) { 402 - const {_} = useLingui() 403 - const queryClient = useQueryClient() 404 - const [hasNew, setHasNew] = React.useState(false) 405 - const [isScrolledDown, setIsScrolledDown] = React.useState(false) 406 - 407 - const onScrollToTop = React.useCallback(() => { 408 - scrollElRef.current?.scrollToOffset({ 409 - animated: isNative, 410 - offset: -headerHeight, 411 - }) 412 - truncateAndInvalidate(queryClient, FEED_RQKEY(feed)) 413 - setHasNew(false) 414 - }, [scrollElRef, headerHeight, queryClient, feed, setHasNew]) 415 - React.useImperativeHandle(ref, () => ({ 416 - scrollToTop: onScrollToTop, 417 - })) 418 - 419 - const renderPostsEmpty = React.useCallback(() => { 420 - return <EmptyState icon="feed" message={_(msg`This feed is empty!`)} /> 421 - }, [_]) 422 - 423 - return ( 424 - <View> 425 - <Feed 426 - testID="postsFeed" 427 - enabled={isFocused} 428 - feed={feed} 429 - scrollElRef={scrollElRef} 430 - onHasNew={setHasNew} 431 - onScrolledDownChange={setIsScrolledDown} 432 - renderEmptyState={renderPostsEmpty} 433 - headerOffset={headerHeight} 434 - renderEndOfFeed={ProfileEndOfFeed} 435 - ignoreFilterFor={ignoreFilterFor} 436 - /> 437 - {(isScrolledDown || hasNew) && ( 438 - <LoadLatestBtn 439 - onPress={onScrollToTop} 440 - label={_(msg`Load new posts`)} 441 - showIndicator={hasNew} 442 - /> 443 - )} 444 - </View> 445 - ) 446 - }, 447 - ) 448 - 449 - function ProfileEndOfFeed() { 450 - const pal = usePalette('default') 451 - 452 - return ( 453 - <View style={[pal.border, {paddingTop: 32, borderTopWidth: 1}]}> 454 - <Text style={[pal.textLight, pal.border, {textAlign: 'center'}]}> 455 - <Trans>End of feed</Trans> 456 - </Text> 457 - </View> 458 455 ) 459 456 } 460 457
+12 -9
src/view/screens/ProfileFeed.tsx
··· 35 35 import {logger} from '#/logger' 36 36 import {Trans, msg} from '@lingui/macro' 37 37 import {useLingui} from '@lingui/react' 38 - import {useModalControls} from '#/state/modals' 38 + import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' 39 39 import {useFeedSourceInfoQuery, FeedSourceFeedInfo} from '#/state/queries/feed' 40 40 import {useResolveUriQuery} from '#/state/queries/resolve-uri' 41 41 import { ··· 155 155 const {_} = useLingui() 156 156 const t = useTheme() 157 157 const {hasSession, currentAccount} = useSession() 158 - const {openModal} = useModalControls() 158 + const reportDialogControl = useReportDialogControl() 159 159 const {openComposer} = useComposerControls() 160 160 const {track} = useAnalytics() 161 161 const feedSectionRef = React.useRef<SectionRef>(null) ··· 253 253 }, [feedInfo, track]) 254 254 255 255 const onPressReport = React.useCallback(() => { 256 - if (!feedInfo) return 257 - openModal({ 258 - name: 'report', 259 - uri: feedInfo.uri, 260 - cid: feedInfo.cid, 261 - }) 262 - }, [openModal, feedInfo]) 256 + reportDialogControl.open() 257 + }, [reportDialogControl]) 263 258 264 259 const onCurrentPageSelected = React.useCallback( 265 260 (index: number) => { ··· 400 395 401 396 return ( 402 397 <View style={s.hContentRegion}> 398 + <ReportDialog 399 + control={reportDialogControl} 400 + params={{ 401 + type: 'feedgen', 402 + uri: feedInfo.uri, 403 + cid: feedInfo.cid, 404 + }} 405 + /> 403 406 <PagerWithHeader 404 407 items={SECTION_TITLES} 405 408 isHeaderReady={true}
+12 -6
src/view/screens/ProfileList.tsx
··· 39 39 import {useLingui} from '@lingui/react' 40 40 import {useSetMinimalShellMode} from '#/state/shell' 41 41 import {useModalControls} from '#/state/modals' 42 + import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' 42 43 import {useResolveUriQuery} from '#/state/queries/resolve-uri' 43 44 import { 44 45 useListQuery, ··· 236 237 const {_} = useLingui() 237 238 const navigation = useNavigation<NavigationProp>() 238 239 const {currentAccount} = useSession() 240 + const reportDialogControl = useReportDialogControl() 239 241 const {openModal} = useModalControls() 240 242 const listMuteMutation = useListMuteMutation() 241 243 const listBlockMutation = useListBlockMutation() ··· 370 372 ]) 371 373 372 374 const onPressReport = useCallback(() => { 373 - openModal({ 374 - name: 'report', 375 - uri: list.uri, 376 - cid: list.cid, 377 - }) 378 - }, [openModal, list]) 375 + reportDialogControl.open() 376 + }, [reportDialogControl]) 379 377 380 378 const onPressShare = useCallback(() => { 381 379 const url = toShareUrl(`/profile/${list.creator.did}/lists/${rkey}`) ··· 550 548 isOwner={list.creator.did === currentAccount?.did} 551 549 creator={list.creator} 552 550 avatarType="list"> 551 + <ReportDialog 552 + control={reportDialogControl} 553 + params={{ 554 + type: 'list', 555 + uri: list.uri, 556 + cid: list.cid, 557 + }} 558 + /> 553 559 {isCurateList || isPinned ? ( 554 560 <Button 555 561 testID={isPinned ? 'unpinBtn' : 'pinBtn'}
+30 -2
src/view/screens/Settings/index.tsx
··· 40 40 } from '#/state/preferences' 41 41 import {useSession, useSessionApi, SessionAccount} from '#/state/session' 42 42 import {useProfileQuery} from '#/state/queries/profile' 43 - import {useClearPreferencesMutation} from '#/state/queries/preferences' 43 + import { 44 + useClearPreferencesMutation, 45 + usePreferencesQuery, 46 + } from '#/state/queries/preferences' 44 47 // TODO import {useInviteCodesQuery} from '#/state/queries/invites' 45 48 import {clear as clearStorage} from '#/state/persisted/store' 46 49 import {clearLegacyStorage} from '#/state/persisted/legacy' ··· 68 71 import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn' 69 72 import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader' 70 73 import {ExportCarDialog} from './ExportCarDialog' 74 + import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' 71 75 72 76 function SettingsAccountCard({account}: {account: SessionAccount}) { 73 77 const pal = usePalette('default') ··· 152 156 const {screen, track} = useAnalytics() 153 157 const {openModal} = useModalControls() 154 158 const {isSwitchingAccounts, accounts, currentAccount} = useSession() 159 + const {data: preferences} = usePreferencesQuery() 155 160 const {mutate: clearPreferences} = useClearPreferencesMutation() 156 161 // TODO 157 162 // const {data: invites} = useInviteCodesQuery() ··· 159 164 const {setShowLoggedOut} = useLoggedOutViewControls() 160 165 const closeAllActiveElements = useCloseAllActiveElements() 161 166 const exportCarControl = useDialogControl() 167 + const birthdayControl = useDialogControl() 162 168 163 169 // const primaryBg = useCustomPalette<ViewStyle>({ 164 170 // light: {backgroundColor: colors.blue0}, ··· 261 267 navigation.navigate('Debug') 262 268 }, [navigation]) 263 269 270 + const onPressDebugModeration = React.useCallback(() => { 271 + navigation.navigate('DebugMod') 272 + }, [navigation]) 273 + 264 274 const onPressSavedFeeds = React.useCallback(() => { 265 275 navigation.navigate('SavedFeeds') 266 276 }, [navigation]) ··· 268 278 const onPressStatusPage = React.useCallback(() => { 269 279 Linking.openURL(STATUS_PAGE_URL) 270 280 }, []) 281 + 282 + const onPressBirthday = React.useCallback(() => { 283 + birthdayControl.open() 284 + }, [birthdayControl]) 271 285 272 286 const clearAllStorage = React.useCallback(async () => { 273 287 await clearStorage() ··· 281 295 return ( 282 296 <View style={s.hContentRegion} testID="settingsScreen"> 283 297 <ExportCarDialog control={exportCarControl} /> 298 + <BirthDateSettingsDialog 299 + control={birthdayControl} 300 + preferences={preferences} 301 + /> 284 302 285 303 <SimpleViewHeader 286 304 showBackButton={isMobile} ··· 339 357 <Text type="lg-medium" style={pal.text}> 340 358 <Trans>Birthday:</Trans>{' '} 341 359 </Text> 342 - <Link onPress={() => openModal({name: 'birth-date-settings'})}> 360 + <Link onPress={onPressBirthday}> 343 361 <Text type="lg" style={pal.link}> 344 362 <Trans>Show</Trans> 345 363 </Text> ··· 805 823 accessibilityHint={_(msg`Opens the storybook page`)}> 806 824 <Text type="lg" style={pal.text}> 807 825 <Trans>Storybook</Trans> 826 + </Text> 827 + </TouchableOpacity> 828 + <TouchableOpacity 829 + style={[pal.view, styles.linkCardNoIcon]} 830 + onPress={onPressDebugModeration} 831 + accessibilityRole="button" 832 + accessibilityLabel={_(msg`Open storybook page`)} 833 + accessibilityHint={_(msg`Opens the storybook page`)}> 834 + <Text type="lg" style={pal.text}> 835 + <Trans>Debug Moderation</Trans> 808 836 </Text> 809 837 </TouchableOpacity> 810 838 <TouchableOpacity
+41
src/view/screens/Storybook/Buttons.tsx
··· 129 129 <ButtonIcon icon={Globe} position="left" /> 130 130 <ButtonText>Link out</ButtonText> 131 131 </Button> 132 + 133 + <Button 134 + variant="gradient" 135 + color="gradient_sky" 136 + size="tiny" 137 + label="Link out"> 138 + <ButtonIcon icon={Globe} position="left" /> 139 + <ButtonText>Link out</ButtonText> 140 + </Button> 132 141 </View> 133 142 134 143 <View style={[a.flex_row, a.gap_md, a.align_start]}> ··· 149 158 <ButtonIcon icon={ChevronLeft} /> 150 159 </Button> 151 160 <Button 161 + variant="gradient" 162 + color="gradient_sunset" 163 + size="tiny" 164 + shape="round" 165 + label="Link out"> 166 + <ButtonIcon icon={ChevronLeft} /> 167 + </Button> 168 + <Button 152 169 variant="outline" 153 170 color="primary" 154 171 size="large" ··· 164 181 label="Link out"> 165 182 <ButtonIcon icon={ChevronLeft} /> 166 183 </Button> 184 + <Button 185 + variant="ghost" 186 + color="primary" 187 + size="tiny" 188 + shape="round" 189 + label="Link out"> 190 + <ButtonIcon icon={ChevronLeft} /> 191 + </Button> 167 192 </View> 168 193 169 194 <View style={[a.flex_row, a.gap_md, a.align_start]}> ··· 184 209 <ButtonIcon icon={ChevronLeft} /> 185 210 </Button> 186 211 <Button 212 + variant="gradient" 213 + color="gradient_sunset" 214 + size="tiny" 215 + shape="square" 216 + label="Link out"> 217 + <ButtonIcon icon={ChevronLeft} /> 218 + </Button> 219 + <Button 187 220 variant="outline" 188 221 color="primary" 189 222 size="large" ··· 195 228 variant="ghost" 196 229 color="primary" 197 230 size="small" 231 + shape="square" 232 + label="Link out"> 233 + <ButtonIcon icon={ChevronLeft} /> 234 + </Button> 235 + <Button 236 + variant="ghost" 237 + color="primary" 238 + size="tiny" 198 239 shape="square" 199 240 label="Link out"> 200 241 <ButtonIcon icon={ChevronLeft} />
+1
src/view/screens/Storybook/index.tsx
··· 67 67 </Button> 68 68 </View> 69 69 70 + <Dialogs /> 70 71 <ThemeProvider theme="light"> 71 72 <Theming /> 72 73 </ThemeProvider>
+4 -4
src/view/shell/desktop/Search.tsx
··· 11 11 import { 12 12 AppBskyActorDefs, 13 13 moderateProfile, 14 - ProfileModeration, 14 + ModerationDecision, 15 15 } from '@atproto/api' 16 16 import {Trans, msg} from '@lingui/macro' 17 17 import {useLingui} from '@lingui/react' ··· 86 86 moderation, 87 87 }: { 88 88 profile: AppBskyActorDefs.ProfileViewBasic 89 - moderation: ProfileModeration 89 + moderation: ModerationDecision 90 90 }) { 91 91 const pal = usePalette('default') 92 92 ··· 111 111 <UserAvatar 112 112 size={40} 113 113 avatar={profile.avatar} 114 - moderation={moderation.avatar} 114 + moderation={moderation.ui('avatar')} 115 115 /> 116 116 <View style={{flex: 1}}> 117 117 <Text ··· 121 121 lineHeight={1.2}> 122 122 {sanitizeDisplayName( 123 123 profile.displayName || sanitizeHandle(profile.handle), 124 - moderation.profile, 124 + moderation.ui('displayName'), 125 125 )} 126 126 </Text> 127 127 <Text type="md" style={[pal.textLight]} numberOfLines={1}>
+1 -1
src/view/shell/index.tsx
··· 101 101 <Composer winHeight={winDim.height} /> 102 102 <ModalsContainer /> 103 103 <MutedWordsDialog /> 104 - <PortalOutlet /> 105 104 <Lightbox /> 105 + <PortalOutlet /> 106 106 </> 107 107 ) 108 108 }
+9 -5
src/view/shell/index.web.tsx
··· 1 1 import React, {useEffect} from 'react' 2 2 import {View, StyleSheet, TouchableOpacity} from 'react-native' 3 + import {useNavigation} from '@react-navigation/native' 4 + import {msg} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + 3 7 import {ErrorBoundary} from '../com/util/ErrorBoundary' 4 8 import {Lightbox} from '../com/lightbox/Lightbox' 5 9 import {ModalsContainer} from '../com/modals/Modal' ··· 9 13 import {RoutesContainer, FlatNavigator} from '../../Navigation' 10 14 import {DrawerContent} from './Drawer' 11 15 import {useWebMediaQueries} from '../../lib/hooks/useWebMediaQueries' 12 - import {useNavigation} from '@react-navigation/native' 13 16 import {NavigationProp} from 'lib/routes/types' 14 - import {t} from '@lingui/macro' 15 17 import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell' 16 18 import {useCloseAllActiveElements} from '#/state/util' 17 19 import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock' ··· 24 26 const {isDesktop} = useWebMediaQueries() 25 27 const navigator = useNavigation<NavigationProp>() 26 28 const closeAllActiveElements = useCloseAllActiveElements() 29 + const {_} = useLingui() 27 30 28 31 useWebBodyScrollLock(isDrawerOpen) 29 32 ··· 42 45 <Composer winHeight={0} /> 43 46 <ModalsContainer /> 44 47 <MutedWordsDialog /> 48 + <Lightbox /> 45 49 <PortalOutlet /> 46 - <Lightbox /> 50 + 47 51 {!isDesktop && isDrawerOpen && ( 48 52 <TouchableOpacity 49 53 onPress={() => setDrawerOpen(false)} 50 54 style={styles.drawerMask} 51 - accessibilityLabel={t`Close navigation footer`} 52 - accessibilityHint={t`Closes bottom navigation bar`}> 55 + accessibilityLabel={_(msg`Close navigation footer`)} 56 + accessibilityHint={_(msg`Closes bottom navigation bar`)}> 53 57 <View style={styles.drawerContainer}> 54 58 <DrawerContent /> 55 59 </View>
+59 -27
yarn.lock
··· 34 34 jsonpointer "^5.0.0" 35 35 leven "^3.1.0" 36 36 37 - "@atproto/api@^0.10.5": 38 - version "0.10.5" 39 - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.10.5.tgz#e778e2843d08690df8df81f24028a7578e9b3cb4" 40 - integrity sha512-GYdST5sPKU2JnPmm8x3KqjOSlDiYXrp4GkW7bpQTVLPabnUNq5NLN6HJEoJABjjOAsaLF12rBoV+JpRb1UjNsQ== 37 + "@atproto/api@^0.12.0": 38 + version "0.12.0" 39 + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.0.tgz#69e52f8761dc7d76c675fa7284bd49240bb0df64" 40 + integrity sha512-nSWiad1Z6IC/oVFSVxD5gZLhkD+J4EW2CFqAqIhklJNc0cjFKdmf8D56Pac6Ktm1sJoM6TVZ8GEeuEG6bJS/aQ== 41 41 dependencies: 42 - "@atproto/common-web" "^0.2.3" 43 - "@atproto/lexicon" "^0.3.2" 44 - "@atproto/syntax" "^0.2.0" 45 - "@atproto/xrpc" "^0.4.2" 42 + "@atproto/common-web" "^0.3.0" 43 + "@atproto/lexicon" "^0.4.0" 44 + "@atproto/syntax" "^0.3.0" 45 + "@atproto/xrpc" "^0.5.0" 46 46 multiformats "^9.9.0" 47 47 tlds "^1.234.0" 48 - typed-emitter "^2.1.0" 49 - zod "^3.21.4" 50 48 51 49 "@atproto/api@^0.9.5": 52 50 version "0.9.5" ··· 138 136 version "0.2.3" 139 137 resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.2.3.tgz#c44c1e177ae8309d5116347d49850209e8e478cc" 140 138 integrity sha512-k9VKGYUqjsRlI3wS31XyCbeb2U7ddS4X/eFgzos2CE5rIbk/uQGyKH+0Jcn1JIwRkvI1BemyNuUVrS8Ok3wiuw== 139 + dependencies: 140 + graphemer "^1.4.0" 141 + multiformats "^9.9.0" 142 + uint8arrays "3.0.0" 143 + zod "^3.21.4" 144 + 145 + "@atproto/common-web@^0.3.0": 146 + version "0.3.0" 147 + resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.3.0.tgz#36da8c2c31d8cf8a140c3c8f03223319bf4430bb" 148 + integrity sha512-67VnV6JJyX+ZWyjV7xFQMypAgDmjVaR9ZCuU/QW+mqlqI7fex2uL4Fv+7/jHadgzhuJHVd6OHOvNn0wR5WZYtA== 141 149 dependencies: 142 150 graphemer "^1.4.0" 143 151 multiformats "^9.9.0" ··· 245 253 multiformats "^9.9.0" 246 254 zod "^3.21.4" 247 255 248 - "@atproto/lexicon@^0.3.2": 249 - version "0.3.2" 250 - resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.3.2.tgz#0085a3acd3a77867b8efe188297a1bbacc55ce5c" 251 - integrity sha512-kmGCkrRwpWIqmn/KO4BZwUf8Nmfndk3XvFC06V0ygCWc42g6+t4QP/6ywNW4PgqfZY0Q5aW4EuDfD7KjAFkFtQ== 256 + "@atproto/lexicon@^0.4.0": 257 + version "0.4.0" 258 + resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.0.tgz#63e8829945d80c25524882caa8ed27b1151cc576" 259 + integrity sha512-RvCBKdSI4M8qWm5uTNz1z3R2yIvIhmOsMuleOj8YR6BwRD+QbtUBy3l+xQ7iXf4M5fdfJFxaUNa6Ty0iRwdKqQ== 252 260 dependencies: 253 - "@atproto/common-web" "^0.2.3" 254 - "@atproto/syntax" "^0.2.0" 261 + "@atproto/common-web" "^0.3.0" 262 + "@atproto/syntax" "^0.3.0" 255 263 iso-datestring-validator "^2.2.2" 256 264 multiformats "^9.9.0" 257 265 zod "^3.21.4" ··· 351 359 dependencies: 352 360 "@atproto/common-web" "^0.2.3" 353 361 354 - "@atproto/syntax@^0.2.0": 355 - version "0.2.0" 356 - resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.2.0.tgz#4bab724c02e11f8943b8ec101251082cf55067e9" 357 - integrity sha512-K+9jl6mtxC9ytlR7msSiP9jVNqtdxEBSt0kOfsC924lqGwuD8nlUAMi1GSMgAZJGg/Rd+0MKXh789heTdeL3HQ== 358 - dependencies: 359 - "@atproto/common-web" "^0.2.3" 362 + "@atproto/syntax@^0.3.0": 363 + version "0.3.0" 364 + resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.3.0.tgz#fafa2dbea9add37253005cb663e7373e05e618b3" 365 + integrity sha512-Weq0ZBxffGHDXHl9U7BQc2BFJi/e23AL+k+i5+D9hUq/bzT4yjGsrCejkjq0xt82xXDjmhhvQSZ0LqxyZ5woxA== 360 366 361 367 "@atproto/xrpc-server@^0.4.2": 362 368 version "0.4.2" ··· 383 389 "@atproto/lexicon" "^0.3.1" 384 390 zod "^3.21.4" 385 391 386 - "@atproto/xrpc@^0.4.2": 387 - version "0.4.2" 388 - resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.4.2.tgz#57812e0624be597b85f21471acf336513f35ccda" 389 - integrity sha512-x4x2QB4nWmLjIpz2Ue9n/QVbVyJkk6tQMhvmDQaVFF89E3FcVI4rxF4uhzSxaLpbNtyVQBNEEmNHOr5EJLeHVA== 392 + "@atproto/xrpc@^0.5.0": 393 + version "0.5.0" 394 + resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.5.0.tgz#dacbfd8f7b13f0ab5bd56f8fdd4b460e132a6032" 395 + integrity sha512-swu+wyOLvYW4l3n+VAuJbHcPcES+tin2Lsrp8Bw5aIXIICiuFn1YMFlwK9JwVUzTH21Py1s1nHEjr4CJeElJog== 390 396 dependencies: 391 - "@atproto/lexicon" "^0.3.2" 397 + "@atproto/lexicon" "^0.4.0" 392 398 zod "^3.21.4" 393 399 394 400 "@aws-crypto/crc32@3.0.0": ··· 7244 7250 dependencies: 7245 7251 "@tamagui/constants" "1.84.1" 7246 7252 7253 + "@tanstack/query-async-storage-persister@^5.25.0": 7254 + version "5.25.0" 7255 + resolved "https://registry.yarnpkg.com/@tanstack/query-async-storage-persister/-/query-async-storage-persister-5.25.0.tgz#0e8a2a781b8e32a81a5d02a688d6fcdfd055235b" 7256 + integrity sha512-58UTp1CuLr2mehsJRMOd8IZPtYGHFeL+uHnHyRd1kmbwo7wDaa8HXstiBdTRq5KokxIXy9FiFbA06LtKuOiwMQ== 7257 + dependencies: 7258 + "@tanstack/query-persist-client-core" "5.25.0" 7259 + 7260 + "@tanstack/query-core@5.25.0": 7261 + version "5.25.0" 7262 + resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.25.0.tgz#e08ed0a9fad34c8005d1a282e57280031ac50cdc" 7263 + integrity sha512-vlobHP64HTuSE68lWF1mEhwSRC5Q7gaT+a/m9S+ItuN+ruSOxe1rFnR9j0ACWQ314BPhBEVKfBQ6mHL0OWfdbQ== 7264 + 7247 7265 "@tanstack/query-core@5.8.1": 7248 7266 version "5.8.1" 7249 7267 resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.8.1.tgz#5215a028370d9b2f32e83787a0ea119e2f977996" 7250 7268 integrity sha512-Y0enatz2zQXBAsd7XmajlCs+WaitdR7dIFkqz9Xd7HL4KV04JOigWVreYseTmNH7YFSBSC/BJ9uuNp1MAf+GfA== 7269 + 7270 + "@tanstack/query-persist-client-core@5.25.0": 7271 + version "5.25.0" 7272 + resolved "https://registry.yarnpkg.com/@tanstack/query-persist-client-core/-/query-persist-client-core-5.25.0.tgz#52fa634a8067d7b965854a532a33077fd4df0eff" 7273 + integrity sha512-sEUsEZ/XWkOosO45CDBI5nj5woCS+DUd9Dk8pGpU8MkeH0EVd3p4N5CdbjNhrreyy5Krf3rpNaiRN9ygLX/rWA== 7274 + dependencies: 7275 + "@tanstack/query-core" "5.25.0" 7276 + 7277 + "@tanstack/react-query-persist-client@^5.25.0": 7278 + version "5.25.0" 7279 + resolved "https://registry.yarnpkg.com/@tanstack/react-query-persist-client/-/react-query-persist-client-5.25.0.tgz#ecbd1362cd6fd94e723d54f5af477d0812852dab" 7280 + integrity sha512-j1+GyFj4UQGWuiFZoDUVJZS+wxqKd9SGvPlyHG619zzYNN+QQu4B5uvvHc6U8MroM377EOBOuLKK3W6qsAdahQ== 7281 + dependencies: 7282 + "@tanstack/query-persist-client-core" "5.25.0" 7251 7283 7252 7284 "@tanstack/react-query@^5.8.1": 7253 7285 version "5.8.1"