Compare commits

..

100 Commits

Author SHA1 Message Date
Dan Abramov 34d38573b1 wip 2024-01-19 05:02:23 +00:00
Dan Abramov 296ab76535 Rm pointer events 2024-01-19 04:16:32 +00:00
Dan Abramov 7975a04441 flatter 2024-01-19 03:15:08 +00:00
Dan Abramov 84c4148600 undo unrel 2024-01-19 03:14:24 +00:00
Dan Abramov 358841eaa5 cleanup unrelated 2024-01-19 03:11:15 +00:00
Dan Abramov f773ee7ad1 scrollbar 2024-01-19 02:28:30 +00:00
Dan Abramov 073fac6330 Merge branch 'main' into web-layout 2024-01-19 02:12:06 +00:00
Dan Abramov 8129b8353c scroll 2024-01-19 02:11:49 +00:00
Dan Abramov 532c924544 fixes 2024-01-19 01:38:08 +00:00
Dan Abramov 502afc9724 Revert "Patch react navigation"
This reverts commit 62516ed9c2.
2024-01-18 22:35:01 +00:00
Dan Abramov 62516ed9c2 Patch react navigation 2024-01-16 22:38:05 +00:00
Dan Abramov dee13742d6 Merge branch 'main' into web-layout 2024-01-16 21:46:25 +00:00
Dan Abramov c68d7cb5e1 Add border 2024-01-12 22:45:17 +00:00
Dan Abramov 99f67e721d Merge branch 'main' into web-layout 2024-01-12 21:55:03 +00:00
Dan Abramov 90d5d8dc53 Fix search positioning 2024-01-12 21:54:43 +00:00
Dan Abramov 547004424b Merge branch 'main' into web-layout 2024-01-11 22:47:17 +00:00
Dan Abramov 67420895ea Fix Safari overflow 2023-12-23 20:08:51 +00:00
Dan Abramov fe554b400d Typos 2023-12-23 19:51:27 +00:00
Dan Abramov 4764a56e78 Fix types 2023-12-23 19:40:26 +00:00
Dan Abramov 0f2f890fee Scroll to top on search 2023-12-23 19:38:22 +00:00
Dan Abramov 4a3a45dfdf Add body scroll lock 2023-12-23 19:30:59 +00:00
Dan Abramov e582713e23 Snap the header scroll on the web 2023-12-23 18:16:44 +00:00
Dan Abramov d9c44c373a Match FL semantics 2023-12-23 02:33:10 +00:00
Dan Abramov d6e9ce83fb Fix crash 2023-12-23 02:12:46 +00:00
Dan Abramov f6e921efbd Naming etc 2023-12-23 02:01:55 +00:00
Dan Abramov b7b46ea6fb Fixes and types 2023-12-23 01:53:46 +00:00
Dan Abramov d83a9aceab Cleanup 2023-12-23 01:40:01 +00:00
Dan Abramov 90b0a69c0e Merge branch 'main' into web-layout 2023-12-23 01:18:54 +00:00
Dan Abramov 5cecdcce53 Oops 2023-12-23 01:18:27 +00:00
Dan Abramov 24f5dd593d Cleanup 2023-12-23 01:17:39 +00:00
Dan Abramov 85fcff8f7f Factor out navigation stuff 2023-12-23 00:59:32 +00:00
Dan Abramov af254768a4 Layout fixes 2023-12-22 22:38:33 +00:00
Dan Abramov a76746d3c7 Better positioning 2023-12-22 21:57:05 +00:00
Dan Abramov f3972c44f7 Scroll to post 2023-12-22 20:41:07 +00:00
Dan Abramov 57782eb54d onContentSizeChange 2023-12-22 20:12:10 +00:00
Dan Abramov 7ec80fbd8f Undo accidental locale changes 2023-12-22 02:57:38 +00:00
Dan Abramov 05d584cb33 Merge branch 'main' into web-layout 2023-12-22 02:53:34 +00:00
Dan Abramov 72d01685b0 Fix pager scroll restoration 2023-12-22 02:52:23 +00:00
Dan Abramov 79eadf5141 Remove bad idea 2023-12-21 17:19:55 +00:00
Dan Abramov e09b3185bb Fix some issues with restoration 2023-12-21 02:46:30 +00:00
Dan Abramov 749b6845c5 Merge branch 'main' into web-layout 2023-12-21 02:38:54 +00:00
Dan Abramov 3075683d30 Route scroll restoration POC 2023-12-21 02:37:46 +00:00
Dan Abramov 998d08f9bc Merge branch 'main' into web-layout 2023-12-19 02:54:41 +00:00
Dan Abramov 4bd43e4fbf Scroll restore for tabs 2023-12-19 02:52:29 +00:00
Dan Abramov 634b76dc3d Hook up context scroll handlers 2023-12-15 06:21:55 +00:00
Dan Abramov 534427d234 Fixes 2023-12-15 06:07:43 +00:00
Dan Abramov c01243620a Oops 2023-12-15 05:52:45 +00:00
Dan Abramov af03785aaf Make isScrolledDown work 2023-12-15 05:32:33 +00:00
Dan Abramov 4939766676 Simplify 2023-12-15 05:04:18 +00:00
Dan Abramov 4d74a91936 Rm unused 2023-12-15 04:52:28 +00:00
Dan Abramov 290b085de1 More stuff 2023-12-15 04:43:14 +00:00
Dan Abramov 60584cb7ce Merge branch 'consolidate-list-props' into web-layout 2023-12-15 04:27:26 +00:00
Dan Abramov d90088f998 Consolidate List props a bit 2023-12-15 02:48:50 +00:00
Dan Abramov bf4c279b96 Add scroll handler 2023-12-14 19:32:11 +00:00
Dan Abramov a132040a76 Move the fork to List.web 2023-12-14 19:02:57 +00:00
Dan Abramov 90ea1288f2 Merge branch 'scroll-refactor' into web-layout 2023-12-14 02:39:43 +00:00
Dan Abramov 659e574ea3 Also memo it 2023-12-14 01:29:16 +00:00
Dan Abramov f8b3740ed9 Keep value updated even if no handler 2023-12-14 01:29:16 +00:00
Dan Abramov 2756511600 Enforce that onScroll cannot be passed 2023-12-14 01:29:16 +00:00
Dan Abramov 0192031f28 Optimize a bit 2023-12-14 01:29:16 +00:00
Dan Abramov a4473c91b7 Fix the remaining special case 2023-12-14 01:29:16 +00:00
Dan Abramov 0c0ea088fc Hook ScrollProvider to pager 2023-12-14 01:29:16 +00:00
Dan Abramov 2970b1e515 Replace onMainScroll with MainScrollProvider 2023-12-14 01:29:16 +00:00
Dan Abramov 7c06a4eeb9 Remove resetMainScroll 2023-12-14 01:29:16 +00:00
Dan Abramov 9e12ea5143 Don't pass down onScroll (replacement TBD) 2023-12-14 01:29:16 +00:00
Dan Abramov b1cb4ef4de Fix on mobile 2023-12-14 01:29:16 +00:00
Dan Abramov b93ab428c3 Port pager to use onScrolledDownChange 2023-12-14 01:29:16 +00:00
Dan Abramov b4de3dda7d Add onScrolledDownChange 2023-12-14 01:29:16 +00:00
Dan Abramov c0eaaf8360 Fix type 2023-12-14 01:29:16 +00:00
Dan Abramov 7eb4ad6c29 Add an intermediate List component 2023-12-14 01:29:16 +00:00
Dan Abramov 6b701d447e Display: none inactive pages 2023-12-12 22:15:50 +00:00
Dan Abramov 339bfaae2d Remove onScroll 2023-12-12 22:15:50 +00:00
Dan Abramov ed7deadf02 Fix profile layout 2023-12-12 22:15:50 +00:00
Dan Abramov 840c07ddf2 More layouts 2023-12-12 22:15:50 +00:00
Dan Abramov d3ed9abd8d More layout 2023-12-12 22:15:50 +00:00
Dan Abramov a71f96a527 Fix more layout 2023-12-12 22:15:50 +00:00
Dan Abramov ccc287274e Layout fixes 2023-12-12 22:15:50 +00:00
Dan Abramov f58487efc9 More FlatList parity 2023-12-12 22:15:50 +00:00
Dan Abramov 2ab2f8ab5a Fixes to layout 2023-12-12 22:15:50 +00:00
Dan Abramov 163c2d25c1 Fix borked scroll animation 2023-12-12 22:15:50 +00:00
Dan Abramov cc34ae9b82 Fix types 2023-12-12 22:15:50 +00:00
Dan Abramov 6b9aaa7726 Fix composer 2023-12-12 22:15:50 +00:00
Dan Abramov 86ed4b8f6a Add content-visibility for rows 2023-12-12 22:15:50 +00:00
Dan Abramov 4112b02cd5 Don't scroll away modal or lightbox 2023-12-12 22:15:50 +00:00
Dan Abramov 539e009bb8 Memoize, lint 2023-12-12 22:15:50 +00:00
Dan Abramov a3a29556e9 Cleanup 3 2023-12-12 22:15:50 +00:00
Dan Abramov 68cdd9897c Trim down both versions 2023-12-12 22:15:50 +00:00
Dan Abramov cd9a92fcd7 Undo PagerWithHeader changes and fork it 2023-12-12 22:15:50 +00:00
Dan Abramov 9020c3ba2d Clean up 2 2023-12-12 22:15:50 +00:00
Dan Abramov 113e70e176 Clean up 1 2023-12-12 22:15:50 +00:00
Dan Abramov 8b23b73aec position: sticky 2023-12-12 22:15:50 +00:00
Dan Abramov 87e40a6bac Fixes 2023-12-12 22:15:50 +00:00
Dan Abramov e4cc7ea409 More scroll 2023-12-12 22:15:50 +00:00
Dan Abramov 8c81407ca9 Shell bars 2023-12-12 22:15:50 +00:00
Dan Abramov a23929238b onScroll 2023-12-12 22:15:50 +00:00
Dan Abramov 69a1830727 Fix background 2023-12-12 22:15:50 +00:00
Dan Abramov 8dd695b672 scrollToOffset 2023-12-12 22:15:50 +00:00
Dan Abramov 8bfbfd2c07 onEndReached 2023-12-12 22:15:50 +00:00
Dan Abramov d290dda4d8 Screw around with layout 2023-12-12 22:15:50 +00:00
Dan Abramov 2df4db31d3 Rip out virtualization on the web 2023-12-12 22:15:50 +00:00
690 changed files with 29890 additions and 106273 deletions
+2 -2
View File
@@ -91,8 +91,8 @@ web-build/
# Android & iOS folders
/android/
/ios/
android/
ios/
# environment variables
.env
+1 -82
View File
@@ -1,5 +1,3 @@
const bskyEslint = require('./eslint')
module.exports = {
root: true,
extends: [
@@ -9,85 +7,10 @@ module.exports = {
'prettier',
],
parser: '@typescript-eslint/parser',
plugins: [
'@typescript-eslint',
'detox',
'react',
'lingui',
'simple-import-sort',
'bsky-internal',
],
plugins: ['@typescript-eslint', 'detox', 'react', 'lingui'],
rules: {
// Temporary until https://github.com/facebook/react-native/pull/43756 gets into a release.
'prettier/prettier': 0,
'react/no-unescaped-entities': 0,
'react-native/no-inline-styles': 0,
'bsky-internal/avoid-unwrapped-text': [
'error',
{
impliedTextComponents: [
'Button', // TODO: Not always safe.
'ButtonText',
'DateField.Label',
'Description',
'H1',
'H2',
'H3',
'H4',
'H5',
'H6',
'InlineLink',
'Label',
'P',
'Prompt.Title',
'Prompt.Description',
'Prompt.Cancel', // TODO: Not always safe.
'Prompt.Action', // TODO: Not always safe.
'TextField.Label',
'TextField.Suffix',
'Title',
'Toggle.Label',
'ToggleButton.Button', // TODO: Not always safe.
],
impliedTextProps: ['FormContainer title'],
},
],
'simple-import-sort/imports': [
'warn',
{
groups: [
// Side effect imports.
['^\\u0000'],
// Node.js builtins prefixed with `node:`.
['^node:'],
// Packages.
// Things that start with a letter (or digit or underscore), or `@` followed by a letter.
// React/React Native priortized, followed by expo
// Followed by all packages excluding unprefixed relative ones
[
'^(react\\/(.*)$)|^(react$)|^(react-native(.*)$)',
'^(expo(.*)$)|^(expo$)',
'^(?!(?:alf|components|lib|locale|logger|platform|screens|state|view)(?:$|\\/))@?\\w',
],
// Relative imports.
// Ideally, anything that starts with a dot or #
// due to unprefixed relative imports being used, we whitelist the relative paths we use
// (?:$|\\/) matches end of string or /
[
'^(?:#\\/)?(?:lib|state|logger|platform|locale)(?:$|\\/)',
'^(?:#\\/)?view(?:$|\\/)',
'^(?:#\\/)?screens(?:$|\\/)',
'^(?:#\\/)?alf(?:$|\\/)',
'^(?:#\\/)?components(?:$|\\/)',
'^#\\/',
'^\\.',
],
// anything else - hopefully we don't have any of these
['^'],
],
},
],
'simple-import-sort/exports': 'warn',
},
ignorePatterns: [
'**/__mocks__/*.ts',
@@ -108,8 +31,4 @@ module.exports = {
settings: {
componentWrapperFunctions: ['observer'],
},
parserOptions: {
sourceType: 'module',
ecmaVersion: 'latest',
},
}
@@ -3,8 +3,8 @@ on:
push:
branches:
- main
- 3p-moderators
- traffic-reduction
- respect-optout-for-embeds
env:
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }}
-100
View File
@@ -1,100 +0,0 @@
---
name: Build and Submit Android
on:
workflow_dispatch:
inputs:
profile:
type: choice
description: Build profile to use
options:
- testflight-android
- production
jobs:
build:
name: Build and Submit Android
runs-on: ubuntu-latest
steps:
- name: Check for EXPO_TOKEN
run: >
if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then
echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions"
exit 1
fi
- name: ⬇️ Checkout
uses: actions/checkout@v4
- name: 🔧 Setup Node
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: yarn
- name: 🔨 Setup EAS
uses: expo/expo-github-action@v8
with:
expo-version: latest
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: ⛏️ Setup EAS local builds
run: yarn global add eas-cli-local-build-plugin
- uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: ⚙️ Install dependencies
run: yarn install
- name: 🔤 Compile translations
run: yarn intl:build
- name: ✏️ Write environment variables
run: |
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
echo "${{ secrets.ENV_TOKEN }}" > .env
echo "$json" > google-services.json
- name: 🏗️ EAS Build
run: yarn use-build-number-with-bump eas build -p android --profile ${{ inputs.profile || 'testflight-android' }} --local --output build.aab --non-interactive
- name: 🚀 Deploy
if: ${{ inputs.profile == 'production' }}
run: eas submit -p android --non-interactive --path build.aab
- name: ✍️ Rename bundle
if: ${{ inputs.profile != 'production' }}
run: mv build.aab build.apk
- name: ⏰ Get a timestamp
id: timestamp
if: ${{ inputs.profile != 'production' }}
uses: nanzm/get-time-action@master
with:
format: 'MM-DD-HH-mm-ss'
- name: 🚀 Upload Artifact
id: upload-artifact
if: ${{ inputs.profile != 'production' }}
uses: actions/upload-artifact@v4
with:
retention-days: 30
compression-level: 0
name: build-${{ steps.timestamp.outputs.time }}.apk
path: build.apk
- name: 🔔 Notify Slack
if: ${{ inputs.profile != 'production' }}
uses: slackapi/slack-github-action@v1.25.0
with:
payload: |
{
"text": "Android build is ready for testing. Download the artifact here: ${{ steps.upload-artifact.outputs.artifact-url }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
-74
View File
@@ -1,74 +0,0 @@
---
name: Build and Submit iOS
on:
workflow_dispatch:
inputs:
profile:
type: choice
description: Build profile to use
options:
- testflight
- production
jobs:
build:
name: Build and Submit iOS
runs-on: macos-14
steps:
- name: Check for EXPO_TOKEN
run: >
if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then
echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions"
exit 1
fi
- name: ⬇️ Checkout
uses: actions/checkout@v4
- name: 🔧 Setup Node
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: yarn
- name: 🔨 Setup EAS
uses: expo/expo-github-action@v8
with:
expo-version: latest
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: ⛏️ Setup EAS local builds
run: yarn global add eas-cli-local-build-plugin
- name: ⚙️ Install dependencies
run: yarn install
- name: ☕️ Setup Cocoapods
uses: maxim-lobanov/setup-cocoapods@v1
with:
version: 1.14.3
- name: 💾 Cache Pods
uses: actions/cache@v3
id: pods-cache
with:
path: ./ios/Pods
# We'll use the yarn.lock for our hash since we don't yet have a Podfile.lock. Pod versions will not
# change unless the yarn version changes as well.
key: ${{ runner.os }}-pods-${{ hashFiles('yarn.lock') }}
- name: 🔤 Compile translations
run: yarn intl:build
- name: ✏️ Write environment variables
run: |
echo "${{ secrets.ENV_TOKEN }}" > .env
echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json
- name: 🏗️ EAS Build
run: yarn use-build-number-with-bump eas build -p ios --profile ${{ inputs.profile || 'testflight' }} --local --output build.ipa --non-interactive
- name: 🚀 Deploy
run: eas submit -p ios --non-interactive --path build.ipa
@@ -1,285 +0,0 @@
---
name: Bundle and Deploy EAS Update
on:
push:
branches:
- main
workflow_dispatch:
inputs:
channel:
type: choice
description: Deployment channel to use
options:
- testflight
- production
runtimeVersion:
type: string
description: Runtime version (in x.x.x format) that this update is for
required: true
jobs:
bundleDeploy:
name: Bundle and Deploy EAS Update
runs-on: ubuntu-latest
outputs:
fingerprint-is-different: ${{ steps.fingerprint-debug.outputs.fingerprint-is-different }}
steps:
- name: Check for EXPO_TOKEN
run: >
if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then
echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions"
exit 1
fi
# Validate the version if one is supplied. This should generally happen if the update is for a production client
- name: 🧐 Validate version
if: ${{ inputs.runtimeVersion }}
run: |
if [ -z "${{ inputs.runtimeVersion }}" ]; then
[[ "${{ inputs.runtimeVersion }}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] && echo "Version is valid" || exit 1
fi
- name: ⬇️ Checkout
uses: actions/checkout@v4
with:
fetch-depth: 100
- name: ⬇️ Fetch commits from base branch
if: ${{ github.ref != 'refs/heads/main' }}
run: git fetch origin main:main --depth 100
# This should get the current production release's commit's hash to see if the update is compatible
- name: 🕵️ Get the base commit
id: base-commit
run: |
if [ -z "${{ inputs.channel == 'production' }}" ]; then
echo base-commit=$(git show-ref -s ${{ inputs.runtimeVersion }}) >> "$GITHUB_OUTPUT"
else
echo base-commit=$(git log -n 1 --skip 1 main --pretty=format:'%H') >> "$GITHUB_OUTPUT"
fi
- name: ✓ Make sure we found a base commit
run: |
if [ -z "${{ steps.base-commit.outputs.base-commit }}" ]; then
echo "Could not find a base commit for this release. Exiting."
exit 1
fi
- name: 🔧 Setup Node
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: yarn
- name: ⚙️ Install Dependencies
run: yarn install
# Run the fingerprint
- name: 📷 Check fingerprint
id: fingerprint
uses: expo/expo-github-action/fingerprint@main
with:
previous-git-commit: ${{ steps.base-commit.outputs.base-commit }}
args:
- name: 👀 Debug fingerprint
id: fingerprint-debug
run: |
echo "previousGitCommit=${{ steps.fingerprint.outputs.previous-git-commit }} currentGitCommit=${{ steps.fingerprint.outputs.current-git-commit }}"
echo "isPreviousFingerprintEmpty=${{ steps.fingerprint.outputs.previous-fingerprint == '' }}"
fingerprintDiff="${{ steps.fingerprint.outputs.fingerprint-diff }}"
if [[ $fingerprintDiff =~ "bareRncliAutolinking" || $fingerprintDiff =~ "expoAutolinkingAndroid" || $fingerprintDiff =~ "expoAutolinkingIos" ]]; then
echo fingerprint-is-different="true" >> "$GITHUB_OUTPUT"
else
echo fingerprint-is-different="false" >> "$GITHUB_OUTPUT"
fi
- name: 🔨 Setup EAS
uses: expo/expo-github-action@v8
if: ${{ steps.fingerprint-debug.outputs.fingerprint-is-different == 'false'}}
with:
expo-version: latest
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: ⛏️ Setup Expo
if: ${{ steps.fingerprint-debug.outputs.fingerprint-is-different == 'false'}}
run: yarn global add eas-cli-local-build-plugin
- name: 🪛 Setup jq
if: ${{ steps.fingerprint-debug.outputs.fingerprint-is-different == 'false'}}
uses: dcarbone/install-jq-action@v2
- name: 🔤 Compile Translations
if: ${{ steps.fingerprint-debug.outputs.fingerprint-is-different == 'false'}}
run: yarn intl:build
- name: ✏️ Write environment variables
if: ${{ steps.fingerprint-debug.outputs.fingerprint-is-different == 'false'}}
run: |
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
echo "${{ secrets.ENV_TOKEN }}" > .env
echo "$json" > google-services.json
- name: 🏗️ Create Bundle
if: ${{ steps.fingerprint-debug.outputs.fingerprint-is-different == 'false'}}
run: EXPO_PUBLIC_ENV="${{ inputs.channel || 'testflight' }}" yarn export
- name: 📦 Package Bundle and 🚀 Deploy
if: ${{ steps.fingerprint-debug.outputs.fingerprint-is-different == 'false'}}
run: yarn use-build-number bash scripts/bundleUpdate.sh
env:
DENIS_API_KEY: ${{ secrets.DENIS_API_KEY }}
RUNTIME_VERSION: ${{ inputs.runtimeVersion }}
CHANNEL_NAME: ${{ inputs.channel || 'testflight' }}
# GitHub actions are horrible so let's just copy paste this in
buildIfNecessaryIOS:
name: Build and Submit iOS
runs-on: macos-14
needs: [bundleDeploy]
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
# available here
if: ${{ inputs.channel != 'production' && needs.bundleDeploy.outputs.fingerprint-is-different == 'true' }}
steps:
- name: Check for EXPO_TOKEN
run: >
if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then
echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions"
exit 1
fi
- name: ⬇️ Checkout
uses: actions/checkout@v4
- name: 🔧 Setup Node
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: yarn
- name: 🔨 Setup EAS
uses: expo/expo-github-action@v8
with:
expo-version: latest
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: ⛏️ Setup EAS local builds
run: yarn global add eas-cli-local-build-plugin
- name: ⚙️ Install dependencies
run: yarn install
- name: ☕️ Setup Cocoapods
uses: maxim-lobanov/setup-cocoapods@v1
with:
version: 1.14.3
- name: 💾 Cache Pods
uses: actions/cache@v3
id: pods-cache
with:
path: ./ios/Pods
# We'll use the yarn.lock for our hash since we don't yet have a Podfile.lock. Pod versions will not
# change unless the yarn version changes as well.
key: ${{ runner.os }}-pods-${{ hashFiles('yarn.lock') }}
- name: 🔤 Compile translations
run: yarn intl:build
- name: ✏️ Write environment variables
run: |
echo "${{ secrets.ENV_TOKEN }}" > .env
echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json
- name: 🏗️ EAS Build
run: yarn use-build-number-with-bump eas build -p ios --profile testflight --local --output build.ipa --non-interactive
- name: 🚀 Deploy
run: eas submit -p ios --non-interactive --path build.ipa
buildIfNecessaryAndroid:
name: Build and Submit Android
runs-on: ubuntu-latest
needs: [ bundleDeploy ]
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
# available here
if: ${{ inputs.channel != 'production' && needs.bundleDeploy.outputs.fingerprint-is-different == 'true' }}
steps:
- name: Check for EXPO_TOKEN
run: >
if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then
echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions"
exit 1
fi
- name: ⬇️ Checkout
uses: actions/checkout@v4
- name: 🔧 Setup Node
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: yarn
- name: 🔨 Setup EAS
uses: expo/expo-github-action@v8
with:
expo-version: latest
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: ⛏️ Setup EAS local builds
run: yarn global add eas-cli-local-build-plugin
- uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: ⚙️ Install dependencies
run: yarn install
- name: 🔤 Compile translations
run: yarn intl:build
- name: ✏️ Write environment variables
run: |
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
echo "${{ secrets.ENV_TOKEN }}" > .env
echo "$json" > google-services.json
- name: 🏗️ EAS Build
run: yarn use-build-number-with-bump eas build -p android --profile testflight-android --local --output build.apk --non-interactive
- name: ⏰ Get a timestamp
id: timestamp
uses: nanzm/get-time-action@master
with:
format: 'MM-DD-HH-mm-ss'
- name: 🚀 Upload Artifact
id: upload-artifact
uses: actions/upload-artifact@v4
with:
retention-days: 30
compression-level: 0
name: build-${{ steps.timestamp.outputs.time }}.apk
path: build.apk
- name: 🔔 Notify Slack
uses: slackapi/slack-github-action@v1.25.0
with:
payload: |
{
"text": "Android build is ready for testing. Download the artifact here: ${{ steps.upload-artifact.outputs.artifact-url }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
+4 -6
View File
@@ -24,8 +24,6 @@ jobs:
attempt_delay: 2000
- name: Lint check
run: yarn lint
- name: Prettier check
run: yarn prettier --check .
- name: Check & compile i18n
run: yarn intl:build
- name: Type check
@@ -34,12 +32,12 @@ jobs:
name: Run tests
runs-on: ubuntu-latest
steps:
- name: Check out Git repository
uses: actions/checkout@v3
- name: Install node
- name: Install node 18
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
node-version: 18
- name: Check out Git repository
uses: actions/checkout@v3
- name: Yarn install
uses: Wandalen/wretry.action@master
with:
-297
View File
@@ -1,297 +0,0 @@
# Credit for fingerprint action https://github.com/expo/expo
# https://github.com/expo/expo/blob/main/.github/workflows/pr-labeler.yml
---
name: PR labeler
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize]
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: true
jobs:
webpack-analyzer:
runs-on: ubuntu-22.04
if: ${{ github.event_name == 'pull_request' }}
permissions:
pull-requests: write
steps:
- name: ⬇️ Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: 🔧 Setup Node
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: yarn
- name: ⚙️ Install Dependencies
run: yarn install
- name: Ensure tracking relevant branches and checkout base
run: |
git checkout ${{ github.head_ref }}
git checkout ${{ github.base_ref }}
- name: Get the base commit
id: base-commit
run: echo base-commit=$(git log -n 1 ${{ github.base_ref }} --pretty=format:'%H') >> "$GITHUB_OUTPUT"
- name: Merge PR commit
run: |
# Have to set a git config for the merge to work
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git config --global user.name "github-actions[bot]"
git merge --no-edit ${{ github.head_ref }}
- name: 🔦 Generate stats file for PR
run: |
yarn generate-webpack-stats-file
mv stats.json ../stats-new.json
- name: ⬇️ Get base stats from cache
id: get-base-stats
uses: actions/cache@v4
with:
path: stats-base.json
key: stats-base-${{ steps.base-commit.outputs.base-commit }}
- name: Restore to base commit
if: ${{ !steps.get-base-stats.outputs.cache-hit }}
run: |
git reset HEAD~
git restore .
- name: 🔦 Generate stats file from base commit
if: ${{ !steps.get-base-stats.outputs.cache-hit }}
run: |
yarn generate-webpack-stats-file
mv stats.json stats-base.json
- name: Get diff
id: get-diff
uses: NejcZdovc/bundle-size-diff@v1
with:
base_path: 'stats-base.json'
pr_path: '../stats-new.json'
excluded_assets: '(.+).chunk.js|(.+).js.map|(.+).json|(.+).png'
- name: 🔍 Find old comment if it exists
uses: peter-evans/find-comment@v2
if: ${{ github.event_name == 'pull_request' }}
id: old_comment
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: 'github-actions[bot]'
body-includes: <!-- webpack-analyzer comment -->
- name: 💬 Add comment with diff
uses: actions/github-script@v6
if: ${{ steps.old_comment.outputs.comment-id == '' }}
with:
script: |
const body = `<!-- webpack-analyzer comment -->
| Old size | New size | Diff |
|----------|----------|-----------------------|
| ${{ steps.get-diff.outputs.base_file_string }} | ${{ steps.get-diff.outputs.pr_file_string }} | ${{ steps.get-diff.outputs.diff_file_string }} (${{ steps.get-diff.outputs.percent }}%) |
`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: body,
});
- name: 💬 Update comment with fingerprint
if: ${{ steps.old_comment.outputs.comment-id != '' }}
uses: actions/github-script@v6
with:
script: |
const body = `<!-- webpack-analyzer comment -->
| Old size | New size | Diff |
|----------|----------|-----------------------|
| ${{ steps.get-diff.outputs.base_file_string }} | ${{ steps.get-diff.outputs.pr_file_string }} | ${{ steps.get-diff.outputs.diff_file_string }} (${{ steps.get-diff.outputs.percent }}%) |
`;
github.rest.issues.updateComment({
issue_number: context.issue.number,
comment_id: '${{ steps.old_comment.outputs.comment-id }}',
owner: context.repo.owner,
repo: context.repo.repo,
body: body,
});
test-suite-fingerprint:
runs-on: ubuntu-22.04
if: ${{ github.event_name == 'pull_request' || github.event_name == 'push' }}
# REQUIRED: limit concurrency when pushing main(default) branch to prevent conflict for this action to update its fingerprint database
concurrency: fingerprint-${{ github.event_name != 'pull_request' && 'main' || github.run_id }}
permissions:
# REQUIRED: Allow comments of PRs
pull-requests: write
# REQUIRED: Allow updating fingerprint in acton caches
actions: write
steps:
- name: ⬇️ Checkout
uses: actions/checkout@v4
with:
fetch-depth: 100
- name: ⬇️ Fetch commits from base branch
run: git fetch origin main:main --depth 100
if: github.event_name == 'pull_request'
- name: 🔧 Setup Node
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: yarn
- name: ⚙️ Install Dependencies
run: yarn install
- name: Get the base commit
id: base-commit
run: echo base-commit=$(git log -n 1 main --pretty=format:'%H') >> "$GITHUB_OUTPUT"
- name: 📷 Check fingerprint
id: fingerprint
uses: expo/expo-github-action/fingerprint@main
with:
previous-git-commit: ${{ steps.base-commit.outputs.base-commit }}
- name: 👀 Debug fingerprint
run: |
echo "previousGitCommit=${{ steps.fingerprint.outputs.previous-git-commit }} currentGitCommit=${{ steps.fingerprint.outputs.current-git-commit }}"
echo "isPreviousFingerprintEmpty=${{ steps.fingerprint.outputs.previous-fingerprint == '' }}"
- name: 🏷️ Labeling PR
uses: actions/github-script@v6
if: ${{ github.event_name == 'pull_request' && steps.fingerprint.outputs.fingerprint-diff == '[]' }}
with:
script: |
try {
await github.rest.issues.removeLabel({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
name: ['bot: fingerprint changed']
})
} catch (e) {
if (e.status != 404) {
throw e;
}
}
github.rest.issues.addLabels({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
labels: ['bot: fingerprint compatible']
})
- name: 🏷️ Labeling PR
uses: actions/github-script@v6
if: ${{ github.event_name == 'pull_request' && steps.fingerprint.outputs.fingerprint-diff != '[]' }}
with:
script: |
try {
await github.rest.issues.removeLabel({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
name: ['bot: fingerprint compatible']
})
} catch (e) {
if (e.status != 404) {
throw e;
}
}
github.rest.issues.addLabels({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
labels: ['bot: fingerprint changed']
})
- name: 🔍 Find old comment if it exists
uses: peter-evans/find-comment@v2
if: ${{ github.event_name == 'pull_request' }}
id: old_comment
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: 'github-actions[bot]'
body-includes: <!-- pr-labeler comment -->
- name: 💬 Add comment with fingerprint
if: ${{ github.event_name == 'pull_request' && steps.fingerprint.outputs.fingerprint-diff != '[]' && steps.old_comment.outputs.comment-id == '' }}
uses: actions/github-script@v6
with:
script: |
const diff = JSON.stringify(${{ steps.fingerprint.outputs.fingerprint-diff}}, null, 2);
const body = `<!-- pr-labeler comment -->
The Pull Request introduced fingerprint changes against the base commit: ${{ steps.fingerprint.outputs.previous-git-commit }}
<details><summary>Fingerprint diff</summary>
\`\`\`json
${diff}
\`\`\`
</details>
---
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: body,
});
- name: 💬 Update comment with fingerprint
if: ${{ github.event_name == 'pull_request' && steps.fingerprint.outputs.fingerprint-diff != '[]' && steps.old_comment.outputs.comment-id != '' }}
uses: actions/github-script@v6
with:
script: |
const diff = JSON.stringify(${{ steps.fingerprint.outputs.fingerprint-diff}}, null, 2);
const body = `<!-- pr-labeler comment -->
The Pull Request introduced fingerprint changes against the base commit: ${{ steps.fingerprint.outputs.previous-git-commit }}
<details><summary>Fingerprint diff</summary>
\`\`\`json
${diff}
\`\`\`
</details>
---
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
`;
github.rest.issues.updateComment({
issue_number: context.issue.number,
comment_id: '${{ steps.old_comment.outputs.comment-id }}',
owner: context.repo.owner,
repo: context.repo.repo,
body: body,
});
- name: 💬 Delete comment with fingerprint
if: ${{ github.event_name == 'pull_request' && steps.fingerprint.outputs.fingerprint-diff == '[]' && steps.old_comment.outputs.comment-id != '' }}
uses: actions/github-script@v6
with:
script: |
github.rest.issues.deleteComment({
issue_number: context.issue.number,
comment_id: '${{ steps.old_comment.outputs.comment-id }}',
owner: context.repo.owner,
repo: context.repo.repo,
});
+3 -11
View File
@@ -18,6 +18,7 @@ xcuserdata
*.moved-aside
DerivedData
*.hmap
*.ipa
*.xcuserstate
# Android/IntelliJ
@@ -77,7 +78,6 @@ dist/
*.mobileprovision
*.orig.*
web-build/
stats.json
# Temporary files created by Metro to check the health of the file watcher
.metro-health-check*
@@ -91,8 +91,8 @@ stats.json
# Android & iOS folders
/android/
/ios/
android/
ios/
# environment variables
.env
@@ -104,14 +104,6 @@ google-services.json
# Performance results (Flashlight)
.perf/
# ESLint
.eslintcache
# i18n
src/locale/locales/_build/
src/locale/locales/**/*.js
# local builds
*.apk
*.aab
*.ipa
-1
View File
@@ -1 +0,0 @@
18
+11 -13
View File
@@ -1,14 +1,12 @@
# Ignore everything except JS-ey code.
# Based on https://stackoverflow.com/a/70715829/458193
*
!**/*.js
!**/*.jsx
!**/*.ts
!**/*.tsx
!*/
# More specific ignores go below.
.expo
android
ios
src/locale/locales
android
src/third-party
src/app.json
public
/bskyweb/templates
/dist/
/.watchmanconfig
/app.json
web/index.html
web-build/*
+2 -2
View File
@@ -23,7 +23,7 @@ COPY . .
RUN mkdir --parents $NVM_DIR && \
wget \
--output-document=/tmp/nvm-install.sh \
https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh && \
https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.3/install.sh && \
bash /tmp/nvm-install.sh
RUN \. "$NVM_DIR/nvm.sh" && \
@@ -31,7 +31,7 @@ RUN \. "$NVM_DIR/nvm.sh" && \
nvm use $NODE_VERSION && \
npm install --global yarn && \
yarn && \
yarn intl:build && \
yarn intl:compile && \
yarn build-web
# DEBUG
+1 -1
View File
@@ -1,4 +1,4 @@
Copyright 20232024 Bluesky PBC
Copyright 2023 Bluesky PBC
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+4 -2
View File
@@ -6,7 +6,7 @@ Get the app itself:
- **Web: [bsky.app](https://bsky.app)**
- **iOS: [App Store](https://apps.apple.com/us/app/bluesky-social/id6444370199)**
- **Android: [Play Store](https://play.google.com/store/apps/details?id=xyz.blueskyweb.app)**
- **Android: [Play Store](https://play.google.com/store/apps/details?id=xyz.blueskyweb.app&hl=en_US&gl=US)**
## Development Resources
@@ -21,7 +21,7 @@ The Authenticated Transfer Protocol ("AT Protocol" or "atproto") is a decentrali
- [Overview and Guides](https://atproto.com/guides/overview)
- [Github Discussions](https://github.com/bluesky-social/atproto/discussions) 👈 Great place to ask questions
- [Protocol Specifications](https://atproto.com/specs/atp)
- [Blogpost on self-authenticating data structures](https://bsky.social/about/blog/3-6-2022-a-self-authenticating-social-protocol)
- [Blogpost on self-authenticating data structures](https://blueskyweb.xyz/blog/3-6-2022-a-self-authenticating-social-protocol)
The Bluesky Social application encompasses a set of schemas and APIs built in the overall AT Protocol framework. The namespace for these "Lexicons" is `app.bsky.*`.
@@ -65,6 +65,8 @@ If you discover any security issues, please send an email to security@bsky.app.
Bluesky is an open social network built on the AT Protocol, a flexible technology that will never lock developers out of the ecosystems that they help build. With atproto, third-party can be as seamless as first-party through custom feeds, federated services, clients, and more.
If you're a developer interested in building on atproto, we'd love to email you a Bluesky invite code. Simply share your GitHub (or similar) profile with us via [this form](https://forms.gle/BF21oxVNZiDjDhXF9).
## License (MIT)
See [./LICENSE](./LICENSE) for the full license.
+1 -2
View File
@@ -14,8 +14,7 @@ async function main() {
await server?.close()
console.log('Starting new server')
const inviteRequired = url?.query && 'invite' in url.query
const phoneRequired = url?.query && 'phone' in url.query
server = await createServer({inviteRequired, phoneRequired})
server = await createServer({inviteRequired})
console.log('Listening at', server.pdsUrl)
if (url?.query) {
if ('users' in url.query) {
+14 -7
View File
@@ -16,13 +16,14 @@ describe('Create account', () => {
await element(by.id('createAccountButton')).tap()
await device.takeScreenshot('1- opened create account screen')
await element(by.id('selectServiceButton')).tap()
await element(by.id('otherServerBtn')).tap()
await device.takeScreenshot('2- selected other server')
await element(by.id('customSelectBtn')).tap()
await element(by.id('customServerTextInput')).typeText(service)
await element(by.id('customServerTextInput')).tapReturnKey()
await element(by.id('doneBtn')).tap()
await element(by.id('customServerInput')).clearText()
await element(by.id('customServerInput')).typeText(service)
await device.takeScreenshot('3- input test server URL')
await element(by.id('nextBtn')).tap()
await element(by.id('emailInput')).typeText('example@test.com')
await element(by.id('passwordInput')).typeText('hunter2')
await device.takeScreenshot('4- entered account details')
@@ -30,10 +31,16 @@ describe('Create account', () => {
await element(by.id('nextBtn')).tap()
await element(by.id('handleInput')).typeText('e2e-test')
await device.takeScreenshot('5- entered handle')
await device.takeScreenshot('4- entered handle')
await element(by.id('nextBtn')).tap()
await expect(element(by.id('onboardingInterests'))).toBeVisible()
await expect(element(by.id('welcomeOnboarding'))).toBeVisible()
await element(by.id('continueBtn')).tap()
await expect(element(by.id('recommendedFeedsOnboarding'))).toBeVisible()
await element(by.id('continueBtn')).tap()
await expect(element(by.id('recommendedFollowsOnboarding'))).toBeVisible()
await element(by.id('continueBtn')).tap()
await expect(element(by.id('homeScreen'))).toBeVisible()
})
})
+2 -2
View File
@@ -64,7 +64,7 @@ describe('Curate lists', () => {
await element(by.text('Edit list details')).tap()
await expect(element(by.id('createOrEditListModal'))).toBeVisible()
await element(by.id('changeAvatarBtn')).tap()
await element(by.text('Upload from Library')).tap()
await element(by.text('Library')).tap()
await sleep(3e3)
await element(by.id('saveBtn')).tap()
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
@@ -81,7 +81,7 @@ describe('Curate lists', () => {
await element(by.text('Edit list details')).tap()
await expect(element(by.id('createOrEditListModal'))).toBeVisible()
await element(by.id('changeAvatarBtn')).tap()
await element(by.text('Remove Avatar')).tap()
await element(by.text('Remove')).tap()
await element(by.id('saveBtn')).tap()
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
await expect(element(by.id('userAvatarFallback'))).toExist()
+5 -5
View File
@@ -17,7 +17,7 @@ describe('Home screen', () => {
it('Can go to feeds page using feeds button in tab bar', async () => {
await element(by.id('homeScreenFeedTabs-Feeds ✨')).tap()
await expect(element(by.text('Discover New Feeds'))).toBeVisible()
await expect(element(by.text('Discover new feeds'))).toBeVisible()
})
it('Feeds button disappears after pinning a feed', async () => {
@@ -36,7 +36,7 @@ describe('Home screen', () => {
const carlaPosts = by.id('feedItem-by-carla.test')
await expect(
element(by.id('likeCount').withAncestor(carlaPosts)).atIndex(0),
).not.toExist()
).toHaveText('0')
await element(by.id('likeBtn').withAncestor(carlaPosts)).atIndex(0).tap()
await expect(
element(by.id('likeCount').withAncestor(carlaPosts)).atIndex(0),
@@ -44,14 +44,14 @@ describe('Home screen', () => {
await element(by.id('likeBtn').withAncestor(carlaPosts)).atIndex(0).tap()
await expect(
element(by.id('likeCount').withAncestor(carlaPosts)).atIndex(0),
).not.toExist()
).toHaveText('0')
})
it('Can repost posts', async () => {
const carlaPosts = by.id('feedItem-by-carla.test')
await expect(
element(by.id('repostCount').withAncestor(carlaPosts)).atIndex(0),
).not.toExist()
).toHaveText('0')
await element(by.id('repostBtn').withAncestor(carlaPosts)).atIndex(0).tap()
await expect(element(by.id('repostModal'))).toBeVisible()
await element(by.id('repostBtn').withAncestor(by.id('repostModal'))).tap()
@@ -65,7 +65,7 @@ describe('Home screen', () => {
await expect(element(by.id('repostModal'))).not.toBeVisible()
await expect(
element(by.id('repostCount').withAncestor(carlaPosts)).atIndex(0),
).not.toExist()
).toHaveText('0')
})
it('Can report posts', async () => {
+16 -6
View File
@@ -1,5 +1,10 @@
/* eslint-env detox/detox */
/**
* This test is being skipped until we can resolve the detox crash issue
* with the side drawer.
*/
import {describe, beforeAll, it} from '@jest/globals'
import {expect} from 'detox'
import {openApp, loginAsAlice, createServer} from '../util'
@@ -26,13 +31,12 @@ describe('invite-codes', () => {
await element(by.id('e2eOpenLoggedOutView')).tap()
await element(by.id('createAccountButton')).tap()
await device.takeScreenshot('1- opened create account screen')
await element(by.id('selectServiceButton')).tap()
await element(by.id('otherServerBtn')).tap()
await device.takeScreenshot('2- selected other server')
await element(by.id('customSelectBtn')).tap()
await element(by.id('customServerTextInput')).typeText(service)
await element(by.id('customServerTextInput')).tapReturnKey()
await element(by.id('doneBtn')).tap()
await element(by.id('customServerInput')).clearText()
await element(by.id('customServerInput')).typeText(service)
await device.takeScreenshot('3- input test server URL')
await element(by.id('nextBtn')).tap()
await element(by.id('inviteCodeInput')).typeText(inviteCode)
await element(by.id('emailInput')).typeText('example@test.com')
await element(by.id('passwordInput')).typeText('hunter2')
@@ -41,6 +45,12 @@ describe('invite-codes', () => {
await element(by.id('handleInput')).typeText('e2e-test')
await device.takeScreenshot('4- entered handle')
await element(by.id('nextBtn')).tap()
await expect(element(by.id('onboardingInterests'))).toBeVisible()
await expect(element(by.id('welcomeOnboarding'))).toBeVisible()
await element(by.id('continueBtn')).tap()
await expect(element(by.id('recommendedFeedsOnboarding'))).toBeVisible()
await element(by.id('continueBtn')).tap()
await expect(element(by.id('recommendedFollowsOnboarding'))).toBeVisible()
await element(by.id('continueBtn')).tap()
await expect(element(by.id('homeScreen'))).toBeVisible()
})
})
@@ -1,52 +0,0 @@
/* eslint-env detox/detox */
import {describe, beforeAll, it} from '@jest/globals'
import {expect} from 'detox'
import {openApp, loginAsAlice, createServer} from '../util'
describe('invite-codes', () => {
let service: string
let inviteCode = ''
beforeAll(async () => {
service = await createServer('?users&invite&phone')
await openApp({permissions: {notifications: 'YES'}})
})
it('I can fetch invite codes', async () => {
await loginAsAlice()
await element(by.id('e2eOpenInviteCodesModal')).tap()
await expect(element(by.id('inviteCodesModal'))).toBeVisible()
const attrs = await element(by.id('inviteCode-0-code')).getAttributes()
inviteCode = attrs.text
await element(by.id('closeBtn')).tap()
await element(by.id('e2eSignOut')).tap()
})
it('I can create a new account with the invite code', async () => {
await element(by.id('e2eOpenLoggedOutView')).tap()
await element(by.id('createAccountButton')).tap()
await device.takeScreenshot('1- opened create account screen')
await element(by.id('selectServiceButton')).tap()
await device.takeScreenshot('2- selected other server')
await element(by.id('customSelectBtn')).tap()
await element(by.id('customServerTextInput')).typeText(service)
await element(by.id('customServerTextInput')).tapReturnKey()
await element(by.id('doneBtn')).tap()
await device.takeScreenshot('3- input test server URL')
await element(by.id('inviteCodeInput')).typeText(inviteCode)
await element(by.id('emailInput')).typeText('example@test.com')
await element(by.id('passwordInput')).typeText('hunter2')
await device.takeScreenshot('4- entered account details')
await element(by.id('nextBtn')).tap()
await element(by.id('phoneInput')).typeText('2345551234')
await element(by.id('requestCodeBtn')).tap()
await device.takeScreenshot('5- requested code')
await element(by.id('codeInput')).typeText('000000')
await device.takeScreenshot('6- entered code')
await element(by.id('nextBtn')).tap()
await element(by.id('handleInput')).typeText('e2e-test')
await device.takeScreenshot('7- entered handle')
await element(by.id('nextBtn')).tap()
await expect(element(by.id('onboardingInterests'))).toBeVisible()
})
})
+32 -33
View File
@@ -2,7 +2,7 @@
import {describe, beforeAll, it} from '@jest/globals'
import {expect} from 'detox'
import {openApp, loginAsAlice, loginAsBob, createServer} from '../util'
import {openApp, loginAsAlice, loginAsBob, createServer, sleep} from '../util'
describe('Mod lists', () => {
beforeAll(async () => {
@@ -62,39 +62,38 @@ describe('Mod lists', () => {
.withTimeout(5000)
})
// DISABLED e2e environment is real finicky about avatar uploads -prf
// it('Set avi via the edit modlist modal', async () => {
// await expect(element(by.id('userAvatarFallback'))).toExist()
// await element(by.id('headerDropdownBtn')).tap()
// await element(by.text('Edit list details')).tap()
// await expect(element(by.id('createOrEditListModal'))).toBeVisible()
// await element(by.id('changeAvatarBtn')).tap()
// await element(by.text('Library')).tap()
// await sleep(3e3)
// await element(by.id('saveBtn')).tap()
// await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
// await expect(element(by.id('userAvatarImage'))).toExist()
// // have to wait for the toast to clear
// await waitFor(element(by.id('headerDropdownBtn')))
// .toBeVisible()
// .withTimeout(5000)
// })
it('Set avi via the edit modlist modal', async () => {
await expect(element(by.id('userAvatarFallback'))).toExist()
await element(by.id('headerDropdownBtn')).tap()
await element(by.text('Edit list details')).tap()
await expect(element(by.id('createOrEditListModal'))).toBeVisible()
await element(by.id('changeAvatarBtn')).tap()
await element(by.text('Library')).tap()
await sleep(3e3)
await element(by.id('saveBtn')).tap()
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
await expect(element(by.id('userAvatarImage'))).toExist()
// have to wait for the toast to clear
await waitFor(element(by.id('headerDropdownBtn')))
.toBeVisible()
.withTimeout(5000)
})
// it('Remove avi via the edit modlist modal', async () => {
// await expect(element(by.id('userAvatarImage'))).toExist()
// await element(by.id('headerDropdownBtn')).tap()
// await element(by.text('Edit list details')).tap()
// await expect(element(by.id('createOrEditListModal'))).toBeVisible()
// await element(by.id('changeAvatarBtn')).tap()
// await element(by.text('Remove')).tap()
// await element(by.id('saveBtn')).tap()
// await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
// await expect(element(by.id('userAvatarFallback'))).toExist()
// // have to wait for the toast to clear
// await waitFor(element(by.id('headerDropdownBtn')))
// .toBeVisible()
// .withTimeout(5000)
// })
it('Remove avi via the edit modlist modal', async () => {
await expect(element(by.id('userAvatarImage'))).toExist()
await element(by.id('headerDropdownBtn')).tap()
await element(by.text('Edit list details')).tap()
await expect(element(by.id('createOrEditListModal'))).toBeVisible()
await element(by.id('changeAvatarBtn')).tap()
await element(by.text('Remove')).tap()
await element(by.id('saveBtn')).tap()
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
await expect(element(by.id('userAvatarFallback'))).toExist()
// have to wait for the toast to clear
await waitFor(element(by.id('headerDropdownBtn')))
.toBeVisible()
.withTimeout(5000)
})
it('Delete the modlist', async () => {
await element(by.id('headerDropdownBtn')).tap()
+8 -8
View File
@@ -70,10 +70,10 @@ describe('Profile screen', () => {
await element(by.id('profileHeaderEditProfileButton')).tap()
await expect(element(by.id('editProfileModal'))).toBeVisible()
await element(by.id('changeBannerBtn')).tap()
await element(by.text('Upload from Library')).tap()
await element(by.text('Library')).tap()
await sleep(3e3)
await element(by.id('changeAvatarBtn')).tap()
await element(by.text('Upload from Library')).tap()
await element(by.text('Library')).tap()
await sleep(3e3)
await element(by.id('editProfileSaveBtn')).tap()
await expect(element(by.id('editProfileModal'))).not.toBeVisible()
@@ -87,9 +87,9 @@ describe('Profile screen', () => {
await element(by.id('profileHeaderEditProfileButton')).tap()
await expect(element(by.id('editProfileModal'))).toBeVisible()
await element(by.id('changeBannerBtn')).tap()
await element(by.text('Remove Banner')).tap()
await element(by.text('Remove')).tap()
await element(by.id('changeAvatarBtn')).tap()
await element(by.text('Remove Avatar')).tap()
await element(by.text('Remove')).tap()
await element(by.id('editProfileSaveBtn')).tap()
await expect(element(by.id('editProfileModal'))).not.toBeVisible()
await expect(element(by.id('userBannerFallback'))).toExist()
@@ -147,7 +147,7 @@ describe('Profile screen', () => {
const posts = by.id('feedItem-by-bob.test')
await expect(
element(by.id('likeCount').withAncestor(posts)).atIndex(0),
).not.toExist()
).toHaveText('0')
await element(by.id('likeBtn').withAncestor(posts)).atIndex(0).tap()
await expect(
element(by.id('likeCount').withAncestor(posts)).atIndex(0),
@@ -155,14 +155,14 @@ describe('Profile screen', () => {
await element(by.id('likeBtn').withAncestor(posts)).atIndex(0).tap()
await expect(
element(by.id('likeCount').withAncestor(posts)).atIndex(0),
).not.toExist()
).toHaveText('0')
})
it('Can repost posts', async () => {
const posts = by.id('feedItem-by-bob.test')
await expect(
element(by.id('repostCount').withAncestor(posts)).atIndex(0),
).not.toExist()
).toHaveText('0')
await element(by.id('repostBtn').withAncestor(posts)).atIndex(0).tap()
await expect(element(by.id('repostModal'))).toBeVisible()
await element(by.id('repostBtn').withAncestor(by.id('repostModal'))).tap()
@@ -176,7 +176,7 @@ describe('Profile screen', () => {
await expect(element(by.id('repostModal'))).not.toBeVisible()
await expect(
element(by.id('repostCount').withAncestor(posts)).atIndex(0),
).not.toExist()
).toHaveText('0')
})
it('Can report posts', async () => {
-1
View File
@@ -28,7 +28,6 @@ describe('Self-labeling', () => {
await element(by.id('composerPublishBtn')).tap()
await expect(element(by.id('composeFAB'))).toBeVisible()
const posts = by.id('feedItem-by-alice.test')
await element(by.id('e2eRefreshHome')).tap()
await expect(
element(by.id('contentHider-embed').withAncestor(posts)).atIndex(0),
).toExist()
@@ -1,83 +0,0 @@
/* eslint-env detox/detox */
import {describe, beforeAll, it} from '@jest/globals'
import {expect} from 'detox'
import {openApp, createServer} from '../util'
describe('Create account', () => {
let service: string
beforeAll(async () => {
service = await createServer('?phone')
await openApp({permissions: {notifications: 'YES'}})
})
it('I can create a new account with text verification', async () => {
console.log('SERVICE IS', service)
await element(by.id('e2eOpenLoggedOutView')).tap()
await element(by.id('createAccountButton')).tap()
await device.takeScreenshot('1- opened create account screen')
await element(by.id('selectServiceButton')).tap()
await device.takeScreenshot('2- selected other server')
await element(by.id('customSelectBtn')).tap()
await element(by.id('customServerTextInput')).typeText(service)
await element(by.id('customServerTextInput')).tapReturnKey()
await element(by.id('doneBtn')).tap()
await device.takeScreenshot('3- input test server URL')
await element(by.id('emailInput')).typeText('text-verification@test.com')
await element(by.id('passwordInput')).typeText('hunter2')
await device.takeScreenshot('4- entered account details')
await element(by.id('nextBtn')).tap()
await element(by.id('handleInput')).typeText('text-verification-test')
await device.takeScreenshot('5- entered handle')
await element(by.id('nextBtn')).tap()
await element(by.id('phoneInput')).typeText('8042221111')
await element(by.id('requestCodeBtn')).tap()
await device.takeScreenshot('6- requested code')
await element(by.id('codeInput')).typeText('000000')
await device.takeScreenshot('7- entered code')
await element(by.id('nextBtn')).tap()
await element(by.id('nextBtn')).tap()
await expect(element(by.id('onboardingInterests'))).toBeVisible()
})
it('failed text verification correctly goes back to the code input screen', async () => {
await element(by.id('e2eSignOut')).tap()
await element(by.id('e2eOpenLoggedOutView')).tap()
await element(by.id('createAccountButton')).tap()
await device.takeScreenshot('1- opened create account screen')
await element(by.id('selectServiceButton')).tap()
await device.takeScreenshot('2- selected other server')
await element(by.id('customSelectBtn')).tap()
await element(by.id('customServerTextInput')).typeText(service)
await element(by.id('customServerTextInput')).tapReturnKey()
await element(by.id('doneBtn')).tap()
await device.takeScreenshot('3- input test server URL')
await element(by.id('emailInput')).typeText('text-verification2@test.com')
await element(by.id('passwordInput')).typeText('hunter2')
await device.takeScreenshot('4- entered account details')
await element(by.id('nextBtn')).tap()
await element(by.id('phoneInput')).typeText('8042221111')
await element(by.id('requestCodeBtn')).tap()
await device.takeScreenshot('5- requested code')
await element(by.id('codeInput')).typeText('111111')
await device.takeScreenshot('6- entered code')
await element(by.id('nextBtn')).tap()
await element(by.id('handleInput')).typeText('text-verification-test2')
await device.takeScreenshot('7- entered handle')
await element(by.id('nextBtn')).tap()
await expect(element(by.id('codeInput'))).toBeVisible()
await device.takeScreenshot('8- got error')
})
})
+4 -4
View File
@@ -49,7 +49,7 @@ describe('Thread screen', () => {
const post = by.id('postThreadItem-by-carla.test')
await expect(
element(by.id('likeCount').withAncestor(post)).atIndex(0),
).not.toExist()
).toHaveText('0')
await element(by.id('likeBtn').withAncestor(post)).atIndex(0).tap()
await expect(
element(by.id('likeCount').withAncestor(post)).atIndex(0),
@@ -57,7 +57,7 @@ describe('Thread screen', () => {
await element(by.id('likeBtn').withAncestor(post)).atIndex(0).tap()
await expect(
element(by.id('likeCount').withAncestor(post)).atIndex(0),
).not.toExist()
).toHaveText('0')
})
it('Can repost the root post', async () => {
@@ -85,7 +85,7 @@ describe('Thread screen', () => {
const post = by.id('postThreadItem-by-carla.test')
await expect(
element(by.id('repostCount').withAncestor(post)).atIndex(0),
).not.toExist()
).toHaveText('0')
await element(by.id('repostBtn').withAncestor(post)).atIndex(0).tap()
await expect(element(by.id('repostModal'))).toBeVisible()
await element(by.id('repostBtn').withAncestor(by.id('repostModal'))).tap()
@@ -99,7 +99,7 @@ describe('Thread screen', () => {
await expect(element(by.id('repostModal'))).not.toBeVisible()
await expect(
element(by.id('repostCount').withAncestor(post)).atIndex(0),
).not.toExist()
).toHaveText('0')
})
it('Can report the root post', async () => {
+2 -3
View File
@@ -56,10 +56,9 @@ export async function login(
if (takeScreenshots) {
await device.takeScreenshot('2- opened service selector')
}
await element(by.id('customSelectBtn')).tap()
await element(by.id('customServerTextInput')).typeText(service)
await element(by.id('customServerTextInput')).tapReturnKey()
await element(by.id('doneBtn')).tap()
await element(by.id('customServerSelectBtn')).tap()
if (takeScreenshots) {
await device.takeScreenshot('3- input custom service')
}
@@ -106,7 +105,7 @@ async function openAppForDebugBuild(platform: string, opts: any) {
await sleep(3000)
}
export async function createServer(path = ''): Promise<string> {
export async function createServer(path = '') {
return new Promise(function (resolve, reject) {
var req = http.request(
{
+16 -7
View File
@@ -462,38 +462,45 @@ describe('parseEmbedPlayerFromUrl', () => {
{
type: 'youtube_video',
source: 'youtube',
playerUri: 'https://bsky.app/iframe/youtube.html?videoId=videoId&start=0',
playerUri:
'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1',
},
{
type: 'youtube_video',
source: 'youtube',
playerUri: 'https://bsky.app/iframe/youtube.html?videoId=videoId&start=0',
playerUri:
'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1',
},
{
type: 'youtube_video',
source: 'youtube',
playerUri: 'https://bsky.app/iframe/youtube.html?videoId=videoId&start=0',
playerUri:
'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1',
},
{
type: 'youtube_video',
source: 'youtube',
playerUri: 'https://bsky.app/iframe/youtube.html?videoId=videoId&start=0',
playerUri:
'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1',
},
{
type: 'youtube_video',
source: 'youtube',
playerUri: 'https://bsky.app/iframe/youtube.html?videoId=videoId&start=0',
playerUri:
'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1',
},
{
type: 'youtube_short',
source: 'youtubeShorts',
hideDetails: true,
playerUri: 'https://bsky.app/iframe/youtube.html?videoId=videoId&start=0',
playerUri:
'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1',
},
{
type: 'youtube_video',
source: 'youtube',
playerUri: 'https://bsky.app/iframe/youtube.html?videoId=videoId&start=0',
playerUri:
'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1',
},
undefined,
@@ -752,6 +759,8 @@ describe('parseEmbedPlayerFromUrl', () => {
const res = parseEmbedPlayerFromUrl(input)
console.log(input)
expect(res).toEqual(output)
}
})
-38
View File
@@ -4,7 +4,6 @@ import {
linkRequiresWarning,
isPossiblyAUrl,
splitApexDomain,
isTrustedUrl,
} from '../../../src/lib/strings/url-helpers'
describe('linkRequiresWarning', () => {
@@ -75,10 +74,6 @@ describe('linkRequiresWarning', () => {
// bad uri inputs, default to true
['', '', true],
['example.com', 'example.com', true],
['/profile', 'Username', false],
['#', 'Show More', false],
['https://docs.bsky.app', 'https://docs.bsky.app', false],
['https://bsky.app/compose/intent?text=test', 'Compose a post', false],
]
it.each(cases)(
@@ -144,36 +139,3 @@ describe('splitApexDomain', () => {
},
)
})
describe('isTrustedUrl', () => {
const cases = [
['#', true],
['#profile', true],
['/', true],
['/profile', true],
['/profile/', true],
['/profile/bob.test', true],
['https://bsky.app', true],
['https://bsky.app/', true],
['https://bsky.app/profile/bob.test', true],
['https://www.bsky.app', true],
['https://www.bsky.app/', true],
['https://docs.bsky.app', true],
['https://bsky.social', true],
['https://bsky.social/blog', true],
['https://blueskyweb.xyz', true],
['https://blueskyweb.zendesk.com', true],
['http://bsky.app', true],
['http://bsky.social', true],
['http://blueskyweb.xyz', true],
['http://blueskyweb.zendesk.com', true],
['https://google.com', false],
['https://docs.google.com', false],
['https://google.com/#', false],
]
it.each(cases)('given input uri %p, returns %p', (str, expected) => {
const output = isTrustedUrl(str)
expect(output).toEqual(expected)
})
})
+26 -70
View File
@@ -11,23 +11,22 @@ const DARK_SPLASH_CONFIG = {
resizeMode: 'cover',
}
const SPLASH_CONFIG_ANDROID = {
backgroundColor: '#0c7cff',
image: './assets/splash.png',
resizeMode: 'cover',
}
const DARK_SPLASH_CONFIG_ANDROID = {
backgroundColor: '#0f141b',
image: './assets/splash-dark.png',
resizeMode: 'cover',
}
module.exports = function (config) {
module.exports = function () {
/**
* App version number. Should be incremented as part of a release cycle.
*/
const VERSION = pkg.version
/**
* iOS build number. Must be incremented for each TestFlight version.
*/
const IOS_BUILD_NUMBER = '1'
/**
* Android build number. Must be incremented for each release.
*/
const ANDROID_VERSION_CODE = 57
/**
* Uses built-in Expo env vars
*
@@ -35,15 +34,11 @@ module.exports = function (config) {
*/
const PLATFORM = process.env.EAS_BUILD_PLATFORM
/**
* Additional granularity for the `dist` field
*/
const DIST_BUILD_NUMBER =
PLATFORM === 'android'
? process.env.BSKY_ANDROID_VERSION_CODE
: process.env.BSKY_IOS_BUILD_NUMBER
const IS_DEV = process.env.EXPO_PUBLIC_ENV === 'development'
const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight'
const UPDATES_CHANNEL = IS_TESTFLIGHT ? 'testflight' : 'production'
PLATFORM === 'android' ? ANDROID_VERSION_CODE : IOS_BUILD_NUMBER
return {
expo: {
@@ -60,6 +55,7 @@ module.exports = function (config) {
userInterfaceStyle: 'automatic',
splash: SPLASH_CONFIG,
ios: {
buildNumber: IOS_BUILD_NUMBER,
supportsTablet: false,
bundleIdentifier: 'xyz.blueskyweb.app',
config: {
@@ -81,15 +77,13 @@ module.exports = function (config) {
...SPLASH_CONFIG,
dark: DARK_SPLASH_CONFIG,
},
entitlements: {
'com.apple.security.application-groups': 'group.app.bsky',
},
},
androidStatusBar: {
barStyle: 'light-content',
backgroundColor: '#00000000',
barStyle: 'dark-content',
backgroundColor: '#ffffff',
},
android: {
versionCode: ANDROID_VERSION_CODE,
icon: './assets/icon.png',
adaptiveIcon: {
foregroundImage: './assets/icon-android-foreground.png',
@@ -108,37 +102,23 @@ module.exports = function (config) {
scheme: 'https',
host: 'bsky.app',
},
IS_DEV && {
scheme: 'http',
host: 'localhost:19006',
},
],
category: ['BROWSABLE', 'DEFAULT'],
},
],
splash: {
...SPLASH_CONFIG_ANDROID,
dark: DARK_SPLASH_CONFIG_ANDROID,
...SPLASH_CONFIG,
dark: DARK_SPLASH_CONFIG,
},
},
web: {
favicon: './assets/favicon.png',
},
updates: {
url: 'https://updates.bsky.app/manifest',
// TODO Eventually we want to enable this for all environments, but for now it will only be used for
// TestFlight builds
enabled: IS_TESTFLIGHT,
fallbackToCacheTimeout: 30000,
codeSigningCertificate: './code-signing/certificate.pem',
codeSigningMetadata: {
keyid: 'main',
alg: 'rsa-v1_5-sha256',
},
checkAutomatically: 'NEVER',
channel: UPDATES_CHANNEL,
enabled: true,
fallbackToCacheTimeout: 1000,
url: 'https://u.expo.dev/55bd077a-d905-4184-9c7f-94789ba0f302',
},
assetBundlePatterns: ['**/*'],
plugins: [
'expo-localization',
Boolean(process.env.SENTRY_AUTH_TOKEN) && 'sentry-expo',
@@ -147,49 +127,25 @@ module.exports = function (config) {
{
ios: {
deploymentTarget: '13.4',
newArchEnabled: false,
},
android: {
compileSdkVersion: 34,
targetSdkVersion: 34,
buildToolsVersion: '34.0.0',
kotlinVersion: '1.8.0',
newArchEnabled: false,
},
},
],
[
'expo-notifications',
'expo-updates',
{
icon: './assets/icon-android-notification.png',
color: '#1185fe',
username: 'blueskysocial',
},
],
'./plugins/withAndroidManifestPlugin.js',
'./plugins/withAndroidManifestFCMIconPlugin.js',
'./plugins/withAndroidStylesWindowBackgroundPlugin.js',
'./plugins/withAndroidSplashScreenStatusBarTranslucentPlugin.js',
'./plugins/shareExtension/withShareExtensions.js',
].filter(Boolean),
extra: {
eas: {
build: {
experimental: {
ios: {
appExtensions: [
{
targetName: 'Share-with-Bluesky',
bundleIdentifier: 'xyz.blueskyweb.app.Share-with-Bluesky',
entitlements: {
'com.apple.security.application-groups': [
'group.app.bsky',
],
},
},
],
},
},
},
projectId: '55bd077a-d905-4184-9c7f-94789ba0f302',
},
},
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12.707 3.293a1 1 0 0 0-1.414 0l-4.5 4.5a1 1 0 0 0 1.414 1.414L11 6.414v8.836a1 1 0 1 0 2 0V6.414l2.793 2.793a1 1 0 1 0 1.414-1.414l-4.5-4.5ZM5 12.75a1 1 0 1 0-2 0V20a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1v-7.25a1 1 0 1 0-2 0V19H5v-6.25Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 372 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M5 3a1 1 0 0 1 1 1v1.423c.498-.46 1.02-.869 1.58-1.213C8.863 3.423 10.302 3 12.028 3a9 9 0 1 1-8.487 12 1 1 0 0 1 1.885-.667A7 7 0 1 0 12.028 5c-1.37 0-2.444.327-3.402.915-.474.29-.93.652-1.383 1.085H9a1 1 0 0 1 0 2H5a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M8 6a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1v9a1 1 0 1 1-2 0V8.414l-9.793 9.793a1 1 0 0 1-1.414-1.414L15.586 7H9a1 1 0 0 1-1-1Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 260 B

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 240 B

@@ -1 +0,0 @@
<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 1 0 4.21 14.804 1 1 0 0 1 1.054 1.7A9.958 9.958 0 0 1 12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10c0 1.104-.27 2.31-.949 3.243-.716.984-1.849 1.6-3.331 1.465a4.207 4.207 0 0 1-2.93-1.585c-.94 1.21-2.388 1.94-3.985 1.715-2.53-.356-4.04-2.91-3.682-5.458.358-2.547 2.514-4.586 5.044-4.23.905.127 1.68.536 2.286 1.126a1 1 0 0 1 1.964.368l-.515 3.545v.002a2.222 2.222 0 0 0 1.999 2.526c.75.068 1.212-.21 1.533-.65.358-.493.566-1.245.566-2.067a8 8 0 0 0-8-8Zm-.112 5.13c-1.195-.168-2.544.819-2.784 2.529-.24 1.71.784 3.03 1.98 3.198 1.195.168 2.543-.819 2.784-2.529.24-1.71-.784-3.03-1.98-3.198Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 756 B

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 286 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M5.002 17.036V5h14v12.036h-3.986a1 1 0 0 0-.639.23l-2.375 1.968-2.344-1.965a1 1 0 0 0-.643-.233H5.002ZM20.002 3h-16a1 1 0 0 0-1 1v14.036a1 1 0 0 0 1 1h4.65l2.704 2.266a1 1 0 0 0 1.28.004l2.74-2.27h4.626a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1Zm-7.878 3.663c-1.39 0-2.5 1.135-2.5 2.515a1 1 0 0 0 2 0c0-.294.232-.515.5-.515a.507.507 0 0 1 .489.6.174.174 0 0 1-.027.048 1.1 1.1 0 0 1-.267.226c-.508.345-1.128.923-1.286 1.978a1 1 0 1 0 1.978.297.762.762 0 0 1 .14-.359c.063-.086.155-.169.293-.262.436-.297 1.18-.885 1.18-2.013 0-1.38-1.11-2.515-2.5-2.515ZM12 15.75a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 738 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M4 3a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H4Zm1 16V9h14v10H5ZM5 7h14V5H5v2Zm3 10.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5ZM17.25 12a1.25 1.25 0 1 1-2.5 0 1.25 1.25 0 0 1 2.5 0ZM12 13.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5ZM9.25 12a1.25 1.25 0 1 1-2.5 0 1.25 1.25 0 0 1 2.5 0ZM12 17.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 512 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M8 2a1 1 0 0 1 1 1v1h6V3a1 1 0 1 1 2 0v1h2a2 2 0 0 1 2 2v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2V3a1 1 0 0 1 1-1ZM5 6v3h14V6H5Zm14 5H5v8h14v-8Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 296 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M8.371 3.89A2 2 0 0 1 10.035 3h3.93a2 2 0 0 1 1.664.89L17.035 6H20a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2.965L8.37 3.89ZM12 9a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 330 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M8.371 3.89A2 2 0 0 1 10.035 3h3.93a2 2 0 0 1 1.664.89L17.035 6H20a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2.965L8.37 3.89ZM13.965 5h-3.93L8.63 7.11A2 2 0 0 1 6.965 8H4v11h16V8h-2.965a2 2 0 0 1-1.664-.89L13.965 5ZM12 11a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm-4 2a4 4 0 1 1 8 0 4 4 0 0 1-8 0Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 447 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M21.474 2.98a2.5 2.5 0 0 1 .545 3.494l-10.222 14a2.5 2.5 0 0 1-3.528.52L2.49 16.617a2.5 2.5 0 0 1 3.018-3.986l3.75 2.84L17.98 3.525a2.5 2.5 0 0 1 3.493-.545Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 300 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M21.59 3.193a1 1 0 0 1 .217 1.397l-11.706 16a1 1 0 0 1-1.429.193l-6.294-5a1 1 0 1 1 1.244-1.566l5.48 4.353 11.09-15.16a1 1 0 0 1 1.398-.217Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 283 B

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 263 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M15.707 3.293a1 1 0 0 1 0 1.414L8.414 12l7.293 7.293a1 1 0 0 1-1.414 1.414l-8-8a1 1 0 0 1 0-1.414l8-8a1 1 0 0 1 1.414 0Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 263 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 263 B

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 256 B

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 409 B

@@ -1 +0,0 @@
<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 1 0 0 16 8 8 0 0 0 0-16ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm8-1a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-4a1 1 0 0 1-1-1Zm1-3a1 1 0 1 0 2 0 1 1 0 0 0-2 0Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 351 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M8.17 4A3.001 3.001 0 0 1 11 2h2c1.306 0 2.418.835 2.83 2H17a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3V7a3 3 0 0 1 3-3h1.17ZM8 6H7a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1h-1v1a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V6Zm6 0V5a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v1h4Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 422 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M4 12c0-4.09 3.527-7.5 8-7.5s8 3.41 8 7.5c0 1.579-.419 2.056-.708 2.236-.388.241-1.031.286-2.058.153-.33-.043-.652-.096-.991-.152a65.905 65.905 0 0 0-.531-.087c-.52-.081-1.077-.156-1.61-.164-1.065-.016-2.336.245-2.996 1.567-.418.834-.295 1.67-.078 2.314.18.534.47 1.055.683 1.437v.001l.097.175.01.018C7.432 19.407 4 16.033 4 12Zm8-9.5C6.532 2.5 2 6.7 2 12s4.532 9.5 10 9.5c.401 0 .812-.04 1.166-.193.41-.176.761-.517.866-1.028.085-.416-.03-.796-.118-1.029a5.981 5.981 0 0 0-.351-.73l-.12-.215c-.215-.392-.403-.73-.52-1.078-.13-.387-.111-.614-.029-.78.146-.291.404-.473 1.178-.461.385.005.825.06 1.329.14.15.023.308.05.47.077.36.059.742.122 1.105.17 1.021.132 2.325.213 3.373-.439C21.496 15.22 22 13.874 22 12c0-5.3-4.532-9.5-10-9.5Zm3.5 8.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM9 12.25a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm1.5-2.75a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 1011 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M2 12a2 2 0 1 1 4 0 2 2 0 0 1-4 0Zm16 0a2 2 0 1 1 4 0 2 2 0 0 1-4 0Zm-6-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 245 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M6.343 6.343a8 8 0 1 1 11.314 11.314A8 8 0 0 1 6.343 6.343ZM19.071 4.93c-3.905-3.905-10.237-3.905-14.142 0-3.905 3.905-3.905 10.237 0 14.142 3.905 3.905 10.237 3.905 14.142 0 3.905-3.905 3.905-10.237 0-14.142Zm-3.537 9.535a5 5 0 0 0-7.07 0 1 1 0 1 0 1.413 1.415 3 3 0 0 1 4.243 0 1 1 0 0 0 1.414-1.415ZM16 9.5c0 .828-.56 1.5-1.25 1.5s-1.25-.672-1.25-1.5.56-1.5 1.25-1.5S16 8.672 16 9.5ZM9.25 11c.69 0 1.25-.672 1.25-1.5S9.94 8 9.25 8 8 8.672 8 9.5 8.56 11 9.25 11Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 607 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M4.568 4h14.864c.252 0 .498 0 .706.017.229.019.499.063.77.201a2 2 0 0 1 .874.874c.138.271.182.541.201.77.017.208.017.454.017.706v10.864c0 .252 0 .498-.017.706a2.022 2.022 0 0 1-.201.77 2 2 0 0 1-.874.874 2.022 2.022 0 0 1-.77.201c-.208.017-.454.017-.706.017H4.568c-.252 0-.498 0-.706-.017a2.022 2.022 0 0 1-.77-.201 2 2 0 0 1-.874-.874 2.022 2.022 0 0 1-.201-.77C2 17.93 2 17.684 2 17.432V6.568c0-.252 0-.498.017-.706.019-.229.063-.499.201-.77a2 2 0 0 1 .874-.874c.271-.138.541-.182.77-.201C4.07 4 4.316 4 4.568 4Zm.456 2L12 11.708 18.976 6H5.024ZM20 7.747l-6.733 5.509a2 2 0 0 1-2.534 0L4 7.746V17.4a8.187 8.187 0 0 0 .011.589h.014c.116.01.278.011.575.011h14.8a8.207 8.207 0 0 0 .589-.012v-.013c.01-.116.011-.279.011-.575V7.747Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 871 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M2.293 2.293a1 1 0 0 1 1.414 0L7.335 5.92l.03.03 3.22 3.222 4.243 4.242 3.22 3.22.03.03 3.63 3.629a1 1 0 0 1-1.415 1.414l-3.09-3.09c-2.65 1.478-5.625 1.778-8.421.869-3.039-.987-5.779-3.37-7.67-7.027a1 1 0 0 1 0-.918c1.086-2.1 2.452-3.78 3.996-5.019L2.293 3.707a1 1 0 0 1 0-1.414Zm4.24 5.654 2.021 2.021a4 4 0 0 0 5.478 5.478l1.688 1.688c-2.042.982-4.246 1.124-6.32.45-2.34-.76-4.594-2.586-6.265-5.584.97-1.739 2.135-3.083 3.398-4.053Zm3.535 3.535 2.45 2.45a2 2 0 0 1-2.45-2.45Zm.81-5.405c3.573-.49 7.45 1.369 9.987 5.923a14.797 14.797 0 0 1-1.347 2.02 1 1 0 1 0 1.564 1.247 17.078 17.078 0 0 0 1.806-2.808 1 1 0 0 0 0-.918c-2.833-5.479-7.584-8.088-12.281-7.446a1 1 0 0 0 .271 1.982Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 825 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M7.002 5a1 1 0 0 0-2 0v11.587l-1.295-1.294a1 1 0 0 0-1.414 1.414l3.002 3a1 1 0 0 0 1.414 0l2.998-3a1 1 0 0 0-1.414-1.414l-1.291 1.292V5ZM16 16a1 1 0 1 0 0 2h4a1 1 0 1 0 0-2h-4Zm-3-4a1 1 0 0 1 1-1h6a1 1 0 1 1 0 2h-6a1 1 0 0 1-1-1Zm-1-6a1 1 0 1 0 0 2h8a1 1 0 1 0 0-2h-8Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 411 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v4a1 1 0 0 1-.293.707L15 14.414V20a1 1 0 0 1-.758.97l-4 1A1 1 0 0 1 9 21v-6.586L3.293 8.707A1 1 0 0 1 3 8V4Zm2 1v2.586l5.707 5.707A1 1 0 0 1 11 14v5.72l2-.5V14a1 1 0 0 1 .293-.707L19 7.586V5H5Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 371 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M4 4a2 2 0 0 1 2-2h13.131c1.598 0 2.55 1.78 1.665 3.11L18.202 9l2.594 3.89c.886 1.33-.067 3.11-1.665 3.11H6v5a1 1 0 1 1-2 0V4Zm2 10h13.131l-2.593-3.89a2 2 0 0 1 0-2.22L19.13 4H6v10Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 323 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M4.062 11h2.961c.103-2.204.545-4.218 1.235-5.77.06-.136.123-.269.188-.399A8.007 8.007 0 0 0 4.062 11ZM12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2Zm0 2c-.227 0-.518.1-.868.432-.354.337-.719.872-1.047 1.61-.561 1.263-.958 2.991-1.06 4.958h5.95c-.102-1.967-.499-3.695-1.06-4.958-.328-.738-.693-1.273-1.047-1.61C12.518 4.099 12.227 4 12 4Zm4.977 7c-.103-2.204-.545-4.218-1.235-5.77a9.78 9.78 0 0 0-.188-.399A8.006 8.006 0 0 1 19.938 11h-2.961Zm-2.003 2H9.026c.101 1.966.498 3.695 1.06 4.958.327.738.692 1.273 1.046 1.61.35.333.641.432.868.432.227 0 .518-.1.868-.432.354-.337.719-.872 1.047-1.61.561-1.263.958-2.991 1.06-4.958Zm.58 6.169c.065-.13.128-.263.188-.399.69-1.552 1.132-3.566 1.235-5.77h2.961a8.006 8.006 0 0 1-4.384 6.169Zm-7.108 0a9.877 9.877 0 0 1-.188-.399c-.69-1.552-1.132-3.566-1.235-5.77H4.062a8.006 8.006 0 0 0 4.384 6.169Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 1005 B

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 674 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M3 4a1 1 0 0 1 1-1h1a8.003 8.003 0 0 1 7.75 6.006A7.985 7.985 0 0 1 19 6h1a1 1 0 0 1 1 1v1a8 8 0 0 1-8 8v4a1 1 0 1 1-2 0v-7a8 8 0 0 1-8-8V4Zm2 1a6 6 0 0 1 6 6 6 6 0 0 1-6-6Zm8 9a6 6 0 0 1 6-6 6 6 0 0 1-6 6Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 349 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M9.124 3.008a1 1 0 0 1 .868 1.116L9.632 7h5.985l.39-3.124a1 1 0 0 1 1.985.248L17.632 7H20a1 1 0 1 1 0 2h-2.617l-.75 6H20a1 1 0 1 1 0 2h-3.617l-.39 3.124a1 1 0 1 1-1.985-.248l.36-2.876H8.382l-.39 3.124a1 1 0 1 1-1.985-.248L6.368 17H4a1 1 0 1 1 0-2h2.617l.75-6H4a1 1 0 1 1 0-2h3.617l.39-3.124a1 1 0 0 1 1.117-.868ZM9.383 9l-.75 6h5.984l.75-6H9.383Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 489 B

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 336 B

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 608 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M3 4a1 1 0 0 1 1-1h13a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1Zm1 4a1 1 0 0 0 0 2h5a1 1 0 0 0 0-2H4Zm-1 7a1 1 0 0 1 1-1h5a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1Zm0 5a1 1 0 0 1 1-1h13a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1Zm9-8a4 4 0 1 1 7.446 2.032l.99.989a1 1 0 1 1-1.415 1.414l-.99-.989A4 4 0 0 1 12 12Zm4-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 452 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M4 5a1 1 0 0 0 0 2h16a1 1 0 1 0 0-2H4Zm0 12a1 1 0 1 0 0 2h3a1 1 0 1 0 0-2H4Zm-1-5a1 1 0 0 1 1-1h5a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1Zm14-3a1 1 0 0 1 .92.606l1.342 3.132 3.132 1.343a1 1 0 0 1 0 1.838l-3.132 1.343-1.343 3.132a1 1 0 0 1-1.838 0l-1.343-3.132-3.132-1.343a1 1 0 0 1 0-1.838l3.132-1.343 1.343-3.132A1 1 0 0 1 17 9Zm0 3.539-.58 1.355a1 1 0 0 1-.526.525L14.539 15l1.355.58a1 1 0 0 1 .525.526L17 17.461l.58-1.355a1 1 0 0 1 .526-.525L19.461 15l-1.355-.58a1 1 0 0 1-.525-.526L17 12.539Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 629 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 5a7 7 0 0 0-5.218 11.666A1 1 0 0 1 5.292 18a9 9 0 1 1 13.416 0 1 1 0 1 1-1.49-1.334A7 7 0 0 0 12 5Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 246 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M7 7a5 5 0 0 1 10 0v2h1a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-9a2 2 0 0 1 2-2h1V7Zm-1 4v9h12v-9H6Zm9-2H9V7a3 3 0 1 1 6 0v2Zm-3 4a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 327 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M11 5a6 6 0 1 0 0 12 6 6 0 0 0 0-12Zm-8 6a8 8 0 1 1 14.32 4.906l3.387 3.387a1 1 0 0 1-1.414 1.414l-3.387-3.387A8 8 0 0 1 3 11Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 269 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M20.707 3.293a1 1 0 0 1 0 1.414l-16 16a1 1 0 0 1-1.414-1.414l2.616-2.616A1.998 1.998 0 0 1 5 15V9a2 2 0 0 1 2-2h2.697l5.748-3.832A1 1 0 0 1 17 4v1.586l2.293-2.293a1 1 0 0 1 1.414 0ZM15 7.586 7.586 15H7V9h2.697a2 2 0 0 0 1.11-.336L15 5.87v1.717Zm2 3.657-2 2v4.888l-2.933-1.955-1.442 1.442 4.82 3.214A1 1 0 0 0 17 20v-8.757Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 465 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M1 5a1 1 0 0 1 1-1h7a3.99 3.99 0 0 1 3 1.354A3.99 3.99 0 0 1 15 4h7a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1h-6.723c-.52 0-1 .125-1.4.372-.421.26-.761.633-.983 1.075a1 1 0 0 1-1.788 0 2.664 2.664 0 0 0-.983-1.075c-.4-.247-.88-.372-1.4-.372H2a1 1 0 0 1-1-1V5Zm10 3a2 2 0 0 0-2-2H3v12h5.723c.776 0 1.564.173 2.277.569V8Zm2 10.569V8a2 2 0 0 1 2-2h6v12h-5.723c-.776 0-1.564.173-2.277.569Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 517 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M5 2a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H5Zm1 18V4h12v16H6Zm3-6a1 1 0 1 0 0 2h2a1 1 0 1 0 0-2H9Zm-1-3a1 1 0 0 1 1-1h6a1 1 0 1 1 0 2H9a1 1 0 0 1-1-1Zm1-5a1 1 0 0 0 0 2h6a1 1 0 1 0 0-2H9Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 356 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M15.586 2.5a2 2 0 0 1 2.828 0L21.5 5.586a2 2 0 0 1 0 2.828l-13 13A2 2 0 0 1 7.086 22H3a1 1 0 0 1-1-1v-4.086a2 2 0 0 1 .586-1.414l13-13ZM17 3.914l-13 13V20h3.086l13-13L17 3.914ZM13 21a1 1 0 0 1 1-1h7a1 1 0 1 1 0 2h-7a1 1 0 0 1-1-1Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 373 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#000" fill-rule="evenodd" d="M10 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM5.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM16 11a1 1 0 0 1 1-1h5a1 1 0 1 1 0 2h-5a1 1 0 0 1-1-1ZM3.678 19h12.644c-.71-2.909-3.092-5-6.322-5s-5.613 2.091-6.322 5Zm-2.174.906C1.917 15.521 5.242 12 10 12c4.758 0 8.083 3.521 8.496 7.906A1 1 0 0 1 17.5 21h-15a1 1 0 0 1-.996-1.094Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 466 B

@@ -1 +0,0 @@
<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.679 19c.709-2.902 3.079-5 6.321-5a6.69 6.69 0 0 1 2.612.51 1 1 0 0 0 .776-1.844A8.687 8.687 0 0 0 12 12c-4.3 0-7.447 2.884-8.304 6.696-.29 1.29.767 2.304 1.902 2.304H11a1 1 0 1 0 0-2H5.679Zm14.835-4.857a1 1 0 0 1 .344 1.371l-3 5a1 1 0 0 1-1.458.286l-2-1.5a1 1 0 0 1 1.2-1.6l1.113.835 2.43-4.05a1 1 0 0 1 1.372-.342Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 547 B

@@ -1 +0,0 @@
<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.679 19c.709-2.902 3.079-5 6.321-5 .302 0 .595.018.878.053a1 1 0 0 0 .243-1.985A9.235 9.235 0 0 0 12 12c-4.3 0-7.447 2.884-8.304 6.696-.29 1.29.767 2.304 1.902 2.304H12a1 1 0 1 0 0-2H5.679Zm9.614-3.707a1 1 0 0 1 1.414 0L18 16.586l1.293-1.293a1 1 0 0 1 1.414 1.414L19.414 18l1.293 1.293a1 1 0 0 1-1.414 1.414L18 19.414l-1.293 1.293a1 1 0 0 1-1.414-1.414L16.586 18l-1.293-1.293a1 1 0 0 1 0-1.414Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 625 B

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 410 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 3a1 1 0 0 1 1 1v7h7a1 1 0 1 1 0 2h-7v7a1 1 0 1 1-2 0v-7H4a1 1 0 1 1 0-2h7V4a1 1 0 0 1 1-1Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 237 B

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 599 B

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.1 KiB

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 572 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12.472 3.118A1 1 0 0 1 13 4v16a1 1 0 0 1-1.555.832L5.697 17H2a1 1 0 0 1-1-1V8a1 1 0 0 1 1-1h3.697l5.748-3.832a1 1 0 0 1 1.027-.05ZM11 5.868 6.555 8.833A1 1 0 0 1 6 9H3v6h3a1 1 0 0 1 .555.168L11 18.131V5.87Zm7.364-1.645a1 1 0 0 1 1.414 0A10.969 10.969 0 0 1 23 12c0 3.037-1.232 5.788-3.222 7.778a1 1 0 1 1-1.414-1.414A8.969 8.969 0 0 0 21 12a8.969 8.969 0 0 0-2.636-6.364 1 1 0 0 1 0-1.414Zm-3.182 3.181a1 1 0 0 1 1.414 0A6.483 6.483 0 0 1 18.5 12a6.483 6.483 0 0 1-1.904 4.597 1 1 0 0 1-1.414-1.415A4.483 4.483 0 0 0 16.5 12a4.483 4.483 0 0 0-1.318-3.182 1 1 0 0 1 0-1.414Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 717 B

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 342 B

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 286 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M4 4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H4Zm8 12.5c1.253 0 2.197.609 2.674 1.5H9.326c.477-.891 1.42-1.5 2.674-1.5Zm0-2c2.404 0 4.235 1.475 4.822 3.5H20V6H4v12h3.178c.587-2.025 2.418-3.5 4.822-3.5Zm-1.25-3.75a1.25 1.25 0 1 1 2.5 0 1.25 1.25 0 0 1-2.5 0ZM12 7.5a3.25 3.25 0 1 0 0 6.5 3.25 3.25 0 0 0 0-6.5Zm5.75 2a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 527 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" stroke="#000" stroke-linejoin="round" d="M4 5.5a.5.5 0 0 0-.5.5v2.535a.5.5 0 0 0 .25.433A3.498 3.498 0 0 1 5.5 12a3.498 3.498 0 0 1-1.75 3.032.5.5 0 0 0-.25.433V18a.5.5 0 0 0 .5.5h16a.5.5 0 0 0 .5-.5v-2.535a.5.5 0 0 0-.25-.433A3.498 3.498 0 0 1 18.5 12a3.5 3.5 0 0 1 1.75-3.032.5.5 0 0 0 .25-.433V6a.5.5 0 0 0-.5-.5H4ZM2.5 6A1.5 1.5 0 0 1 4 4.5h16A1.5 1.5 0 0 1 21.5 6v3.17a.5.5 0 0 1-.333.472 2.501 2.501 0 0 0 0 4.716.5.5 0 0 1 .333.471V18a1.5 1.5 0 0 1-1.5 1.5H4A1.5 1.5 0 0 1 2.5 18v-3.17a.5.5 0 0 1 .333-.472 2.501 2.501 0 0 0 0-4.716.5.5 0 0 1-.333-.471V6Zm12 2a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Zm0 4a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Zm0 4a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Z"/></svg>

Before

Width:  |  Height:  |  Size: 768 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M7.416 5H3a1 1 0 0 0 0 2h1.064l.938 14.067A1 1 0 0 0 6 22h12a1 1 0 0 0 .998-.933L19.936 7H21a1 1 0 1 0 0-2h-4.416a5 5 0 0 0-9.168 0Zm2.348 0h4.472c-.55-.614-1.348-1-2.236-1-.888 0-1.687.386-2.236 1Zm6.087 2H6.07l.867 13h10.128l.867-13h-2.036a1 1 0 0 1-.044 0ZM10 10a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-5a1 1 0 0 1 1-1Zm4 0a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-5a1 1 0 0 1 1-1Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 508 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="m18.192 5.004 1.864 5.31a1 1 0 0 0 1.887-.662L20.08 4.34c-.665-1.893-3.378-1.741-3.834.207l-3.381 14.449-2.985-9.605C9.3 7.531 6.684 7.506 6.07 9.355l-1.18 3.56-.969-2.312a1 1 0 0 0-1.844.772l.97 2.315c.715 1.71 3.159 1.613 3.741-.144l1.18-3.56 2.985 9.605c.607 1.952 3.392 1.848 3.857-.138l3.381-14.449Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 447 B

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 537 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M11.14 4.494a.995.995 0 0 1 1.72 0l7.001 12.008a.996.996 0 0 1-.86 1.498H4.999a.996.996 0 0 1-.86-1.498L11.14 4.494Zm3.447-1.007c-1.155-1.983-4.019-1.983-5.174 0L2.41 15.494C1.247 17.491 2.686 20 4.998 20h14.004c2.312 0 3.751-2.509 2.587-4.506L14.587 3.487ZM13 9.019a1 1 0 1 0-2 0v2.994a1 1 0 1 0 2 0V9.02Zm-1 4.731a1.25 1.25 0 1 0 0 2.5 1.25 1.25 0 0 0 0-2.5Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 503 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 606 KiB

After

Width:  |  Height:  |  Size: 991 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 563 KiB

After

Width:  |  Height:  |  Size: 1.4 MiB

+3 -3
View File
@@ -6,9 +6,9 @@ To build the SPA bundle (`bundle.web.js`), first get a JavaScript development
environment set up. Either follow the top-level README, or something quick
like:
# install nodejs
nvm install
nvm use
# install nodejs 18 (specifically)
nvm install 18
nvm use 18
npm install --global yarn
# setup tools and deps (in top level of this repo)
-57
View File
@@ -1,57 +0,0 @@
package main
import (
"fmt"
"slices"
"strings"
appbsky "github.com/bluesky-social/indigo/api/bsky"
)
// Function to expand shortened links in rich text back to full urls, replacing shortened urls in social card meta tags and the noscript output.
//
// This essentially reverses the effect of the typescript function `shortenLinks()` in `src/lib/strings/rich-text-manip.ts`
func ExpandPostText(post *appbsky.FeedPost) string {
postText := post.Text
var charsAdded int = 0
// iterate over facets, check if they're link facets, and if found, grab the uri
for _, facet := range post.Facets {
linkUri := ""
if slices.ContainsFunc(facet.Features, func(feat *appbsky.RichtextFacet_Features_Elem) bool {
if feat.RichtextFacet_Link == nil || feat.RichtextFacet_Link.LexiconTypeID != "app.bsky.richtext.facet#link" {
return false
}
// bail out if bounds checks fail
if int(facet.Index.ByteStart)+charsAdded > len(postText) || int(facet.Index.ByteEnd)+charsAdded > len(postText) {
return false
}
linkText := postText[int(facet.Index.ByteStart)+charsAdded : int(facet.Index.ByteEnd)+charsAdded]
linkUri = feat.RichtextFacet_Link.Uri
// only expand uris that have been shortened (as opposed to those with non-uri anchor text)
if strings.HasSuffix(linkText, "...") && strings.Contains(linkUri, linkText[0:len(linkText)-3]) {
return true
}
return false
}) {
// replace the shortened uri with the full length one from the facet using utf8 byte offsets
// NOTE: we already did bounds check above
postText = postText[0:int(facet.Index.ByteStart)+charsAdded] + linkUri + postText[int(facet.Index.ByteEnd)+charsAdded:]
charsAdded += len(linkUri) - int(facet.Index.ByteEnd-facet.Index.ByteStart)
}
}
// if the post has an embeded link and its url doesn't already appear in postText, append it to
// the end to avoid social cards with missing links
if post.Embed != nil && post.Embed.EmbedExternal != nil && post.Embed.EmbedExternal.External != nil {
externalURI := post.Embed.EmbedExternal.External.Uri
if !strings.Contains(postText, externalURI) {
postText = fmt.Sprintf("%s\n%s", postText, externalURI)
}
}
// TODO: could embed the actual post text?
if post.Embed != nil && (post.Embed.EmbedRecord != nil || post.Embed.EmbedRecordWithMedia != nil) {
postText = fmt.Sprintf("%s\n\n[contains quote post or other embedded content]", postText)
}
return postText
}
-39
View File
@@ -1,39 +0,0 @@
package main
import (
"encoding/json"
"io"
"os"
"strings"
"testing"
appbsky "github.com/bluesky-social/indigo/api/bsky"
)
func loadPost(t *testing.T, p string) appbsky.FeedPost {
f, err := os.Open(p)
if err != nil {
t.Fatal(err)
}
defer func() { _ = f.Close() }()
postBytes, err := io.ReadAll(f)
if err != nil {
t.Fatal(err)
}
var post appbsky.FeedPost
if err := json.Unmarshal(postBytes, &post); err != nil {
t.Fatal(err)
}
return post
}
func TestExpandPostText(t *testing.T) {
post := loadPost(t, "testdata/atproto_embed_post.json")
text := ExpandPostText(&post)
if !strings.Contains(text, "https://github.com/snarfed/bridgy-fed") {
t.Fail()
}
}
+70
View File
@@ -0,0 +1,70 @@
package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"net/http"
"time"
)
type Mailmodo struct {
httpClient *http.Client
APIKey string
BaseURL string
ListName string
}
func NewMailmodo(apiKey, listName string) *Mailmodo {
return &Mailmodo{
APIKey: apiKey,
BaseURL: "https://api.mailmodo.com/api/v1",
httpClient: &http.Client{},
ListName: listName,
}
}
func (m *Mailmodo) request(ctx context.Context, httpMethod string, apiMethod string, data any) error {
endpoint := fmt.Sprintf("%s/%s", m.BaseURL, apiMethod)
js, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("Mailmodo JSON encoding failed: %w", err)
}
req, err := http.NewRequestWithContext(ctx, httpMethod, endpoint, bytes.NewBuffer(js))
if err != nil {
return fmt.Errorf("Mailmodo HTTP creating request %s %s failed: %w", httpMethod, apiMethod, err)
}
req.Header.Set("mmApiKey", m.APIKey)
req.Header.Set("Content-Type", "application/json")
res, err := m.httpClient.Do(req)
if err != nil {
return fmt.Errorf("Mailmodo HTTP making request %s %s failed: %w", httpMethod, apiMethod, err)
}
defer res.Body.Close()
status := struct {
Success bool `json:"success"`
Message string `json:"message"`
}{}
if err := json.NewDecoder(res.Body).Decode(&status); err != nil {
return fmt.Errorf("Mailmodo HTTP parsing response %s %s failed: %w", httpMethod, apiMethod, err)
}
if !status.Success {
return fmt.Errorf("Mailmodo API response %s %s failed: %s", httpMethod, apiMethod, status.Message)
}
return nil
}
func (m *Mailmodo) AddToList(ctx context.Context, email string) error {
return m.request(ctx, "POST", "addToList", map[string]any{
"listName": m.ListName,
"email": email,
"data": map[string]any{
"email_hashed": fmt.Sprintf("%x", sha256.Sum256([]byte(email))),
},
"created_at": time.Now().UTC().Format(time.RFC3339),
})
}
+12
View File
@@ -40,6 +40,18 @@ func run(args []string) {
// retain old PDS env var for easy transition
EnvVars: []string{"ATP_APPVIEW_HOST", "ATP_PDS_HOST"},
},
&cli.StringFlag{
Name: "mailmodo-api-key",
Usage: "Mailmodo API key",
Required: false,
EnvVars: []string{"MAILMODO_API_KEY"},
},
&cli.StringFlag{
Name: "mailmodo-list-name",
Usage: "Mailmodo contact list to add email addresses to",
Required: false,
EnvVars: []string{"MAILMODO_LIST_NAME"},
},
&cli.StringFlag{
Name: "http-address",
Usage: "Specify the local IP/port to bind to",
+2 -5
View File
@@ -96,10 +96,7 @@ func (srv *Server) WebProfileRSS(c echo.Context) error {
if err != nil {
return err
}
rec, ok := p.Post.Record.Val.(*appbsky.FeedPost)
if !ok {
continue
}
rec := p.Post.Record.Val.(*appbsky.FeedPost)
// only top-level posts in RSS (no replies)
if rec.Reply != nil {
continue
@@ -111,7 +108,7 @@ func (srv *Server) WebProfileRSS(c echo.Context) error {
}
posts = append(posts, Item{
Link: fmt.Sprintf("https://%s/profile/%s/post/%s", req.Host, pv.Handle, aturi.RecordKey().String()),
Description: ExpandPostText(rec),
Description: rec.Text,
PubDate: pubDate,
GUID: ItemGUID{
Value: aturi.String(),
+75 -52
View File
@@ -2,9 +2,11 @@ package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"io/ioutil"
"net/http"
"os"
"os/signal"
@@ -27,19 +29,25 @@ import (
)
type Server struct {
echo *echo.Echo
httpd *http.Server
xrpcc *xrpc.Client
echo *echo.Echo
httpd *http.Server
mailmodo *Mailmodo
xrpcc *xrpc.Client
}
func serve(cctx *cli.Context) error {
debug := cctx.Bool("debug")
httpAddress := cctx.String("http-address")
appviewHost := cctx.String("appview-host")
mailmodoAPIKey := cctx.String("mailmodo-api-key")
mailmodoListName := cctx.String("mailmodo-list-name")
// Echo
e := echo.New()
// Mailmodo client.
mailmodo := NewMailmodo(mailmodoAPIKey, mailmodoListName)
// create a new session (no auth)
xrpcc := &xrpc.Client{
Client: cliutil.NewHttpClient(),
@@ -69,8 +77,9 @@ func serve(cctx *cli.Context) error {
// server
//
server := &Server{
echo: e,
xrpcc: xrpcc,
echo: e,
mailmodo: mailmodo,
xrpcc: xrpcc,
}
// Create the HTTP server.
@@ -150,7 +159,6 @@ func serve(cctx *cli.Context) error {
e.GET("/security.txt", func(c echo.Context) error {
return c.Redirect(http.StatusMovedPermanently, "/.well-known/security.txt")
})
e.GET("/iframe/youtube.html", echo.WrapHandler(staticHandler))
e.GET("/static/*", echo.WrapHandler(http.StripPrefix("/static/", staticHandler)), func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
path := c.Request().URL.Path
@@ -171,7 +179,6 @@ func serve(cctx *cli.Context) error {
e.GET("/", server.WebHome)
// generic routes
e.GET("/hashtag/:tag", server.WebGeneric)
e.GET("/search", server.WebGeneric)
e.GET("/feeds", server.WebGeneric)
e.GET("/notifications", server.WebGeneric)
@@ -183,36 +190,36 @@ func serve(cctx *cli.Context) error {
e.GET("/settings", server.WebGeneric)
e.GET("/settings/language", server.WebGeneric)
e.GET("/settings/app-passwords", server.WebGeneric)
e.GET("/settings/following-feed", server.WebGeneric)
e.GET("/settings/home-feed", server.WebGeneric)
e.GET("/settings/saved-feeds", server.WebGeneric)
e.GET("/settings/threads", server.WebGeneric)
e.GET("/settings/external-embeds", server.WebGeneric)
e.GET("/sys/debug", server.WebGeneric)
e.GET("/sys/debug-mod", server.WebGeneric)
e.GET("/sys/log", server.WebGeneric)
e.GET("/support", server.WebGeneric)
e.GET("/support/privacy", server.WebGeneric)
e.GET("/support/tos", server.WebGeneric)
e.GET("/support/community-guidelines", server.WebGeneric)
e.GET("/support/copyright", server.WebGeneric)
e.GET("/intent/compose", server.WebGeneric)
// profile endpoints; only first populates info
e.GET("/profile/:handleOrDID", server.WebProfile)
e.GET("/profile/:handleOrDID/follows", server.WebGeneric)
e.GET("/profile/:handleOrDID/followers", server.WebGeneric)
e.GET("/profile/:handleOrDID/lists/:rkey", server.WebGeneric)
e.GET("/profile/:handleOrDID/feed/:rkey", server.WebGeneric)
e.GET("/profile/:handleOrDID/feed/:rkey/liked-by", server.WebGeneric)
e.GET("/profile/:handleOrDID/labeler/liked-by", server.WebGeneric)
e.GET("/profile/:handle", server.WebProfile)
e.GET("/profile/:handle/follows", server.WebGeneric)
e.GET("/profile/:handle/followers", server.WebGeneric)
e.GET("/profile/:handle/lists/:rkey", server.WebGeneric)
e.GET("/profile/:handle/feed/:rkey", server.WebGeneric)
e.GET("/profile/:handle/feed/:rkey/liked-by", server.WebGeneric)
// profile RSS feed (DID not handle)
e.GET("/profile/:ident/rss", server.WebProfileRSS)
// post endpoints; only first populates info
e.GET("/profile/:handleOrDID/post/:rkey", server.WebPost)
e.GET("/profile/:handleOrDID/post/:rkey/liked-by", server.WebGeneric)
e.GET("/profile/:handleOrDID/post/:rkey/reposted-by", server.WebGeneric)
e.GET("/profile/:handle/post/:rkey", server.WebPost)
e.GET("/profile/:handle/post/:rkey/liked-by", server.WebGeneric)
e.GET("/profile/:handle/post/:rkey/reposted-by", server.WebGeneric)
// Mailmodo
e.POST("/api/waitlist", server.apiWaitlist)
// Start the server.
log.Infof("starting server address=%s", httpAddress)
@@ -292,18 +299,17 @@ func (srv *Server) WebPost(c echo.Context) error {
if err != nil {
return c.Render(http.StatusOK, "post.html", data)
}
handleOrDIDParam := c.Param("handleOrDID")
handleOrDID, err := syntax.ParseAtIdentifier(handleOrDIDParam)
handleParam := c.Param("handle")
handle, err := syntax.ParseHandle(handleParam)
if err != nil {
return c.Render(http.StatusOK, "post.html", data)
}
identifier := handleOrDID.Normalize().String()
handle = handle.Normalize()
// requires two fetches: first fetch profile (!)
pv, err := appbsky.ActorGetProfile(ctx, srv.xrpcc, identifier)
pv, err := appbsky.ActorGetProfile(ctx, srv.xrpcc, handle.String())
if err != nil {
log.Warnf("failed to fetch profile for: %s\t%v", identifier, err)
log.Warnf("failed to fetch handle: %s\t%v", handle, err)
return c.Render(http.StatusOK, "post.html", data)
}
unauthedViewingOkay := true
@@ -330,29 +336,13 @@ func (srv *Server) WebPost(c echo.Context) error {
postView := tpv.Thread.FeedDefs_ThreadViewPost.Post
data["postView"] = postView
data["requestURI"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
if postView.Embed != nil {
if postView.Embed.EmbedImages_View != nil {
var thumbUrls []string
for i := range postView.Embed.EmbedImages_View.Images {
thumbUrls = append(thumbUrls, postView.Embed.EmbedImages_View.Images[i].Thumb)
}
data["imgThumbUrls"] = thumbUrls
} else if postView.Embed.EmbedRecordWithMedia_View != nil && postView.Embed.EmbedRecordWithMedia_View.Media != nil && postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View != nil {
var thumbUrls []string
for i := range postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View.Images {
thumbUrls = append(thumbUrls, postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View.Images[i].Thumb)
}
data["imgThumbUrls"] = thumbUrls
if postView.Embed != nil && postView.Embed.EmbedImages_View != nil {
var thumbUrls []string
for i := range postView.Embed.EmbedImages_View.Images {
thumbUrls = append(thumbUrls, postView.Embed.EmbedImages_View.Images[i].Thumb)
}
data["imgThumbUrls"] = thumbUrls
}
if postView.Record != nil {
postRecord, ok := postView.Record.Val.(*appbsky.FeedPost)
if ok {
data["postText"] = ExpandPostText(postRecord)
}
}
return c.Render(http.StatusOK, "post.html", data)
}
@@ -361,16 +351,16 @@ func (srv *Server) WebProfile(c echo.Context) error {
data := pongo2.Context{}
// sanity check arguments. don't 4xx, just let app handle if not expected format
handleOrDIDParam := c.Param("handleOrDID")
handleOrDID, err := syntax.ParseAtIdentifier(handleOrDIDParam)
handleParam := c.Param("handle")
handle, err := syntax.ParseHandle(handleParam)
if err != nil {
return c.Render(http.StatusOK, "profile.html", data)
}
identifier := handleOrDID.Normalize().String()
handle = handle.Normalize()
pv, err := appbsky.ActorGetProfile(ctx, srv.xrpcc, identifier)
pv, err := appbsky.ActorGetProfile(ctx, srv.xrpcc, handle.String())
if err != nil {
log.Warnf("failed to fetch profile for: %s\t%v", identifier, err)
log.Warnf("failed to fetch handle: %s\t%v", handle, err)
return c.Render(http.StatusOK, "profile.html", data)
}
unauthedViewingOkay := true
@@ -388,3 +378,36 @@ func (srv *Server) WebProfile(c echo.Context) error {
data["requestHost"] = req.Host
return c.Render(http.StatusOK, "profile.html", data)
}
func (srv *Server) apiWaitlist(c echo.Context) error {
type jsonError struct {
Error string `json:"error"`
}
// Read the API request.
type apiRequest struct {
Email string `json:"email"`
}
bodyReader := http.MaxBytesReader(c.Response(), c.Request().Body, 16*1024)
payload, err := ioutil.ReadAll(bodyReader)
if err != nil {
return err
}
var req apiRequest
if err := json.Unmarshal(payload, &req); err != nil {
return c.JSON(http.StatusBadRequest, jsonError{Error: "Invalid API request"})
}
if req.Email == "" {
return c.JSON(http.StatusBadRequest, jsonError{Error: "Please enter a valid email address."})
}
if err := srv.mailmodo.AddToList(c.Request().Context(), req.Email); err != nil {
log.Errorf("adding email to waitlist failed: %s", err)
return c.JSON(http.StatusBadRequest, jsonError{
Error: "Storing email in waitlist failed. Please enter a valid email address.",
})
}
return c.JSON(http.StatusOK, map[string]bool{"success": true})
}
-60
View File
@@ -1,60 +0,0 @@
{
"$type": "app.bsky.feed.post",
"createdAt": "2023-12-04T19:30:03.024Z",
"embed": {
"$type": "app.bsky.embed.external",
"external": {
"description": "🕸 Bridges the IndieWeb to Mastodon and the fediverse via ActivityPub. - GitHub - snarfed/bridgy-fed: 🕸 Bridges the IndieWeb to Mastodon and the fediverse via ActivityPub.",
"thumb": {
"$type": "blob",
"ref": {
"$link": "bafkreidplhjcnrl2c74r3xs7nh7k7q3ny6ul7cgxr2fophblvdeky6t64e"
},
"mimeType": "image/jpeg",
"size": 347998
},
"title": "GitHub - snarfed/bridgy-fed: 🕸 Bridges the IndieWeb to Mastodon and the fediverse via ActivityPub...",
"uri": "https://github.com/snarfed/bridgy-fed"
}
},
"facets": [
{
"features": [
{
"$type": "app.bsky.richtext.facet#link",
"uri": "https://github.com/snarfed/bridgy-fed"
}
],
"index": {
"byteEnd": 92,
"byteStart": 66
}
},
{
"features": [
{
"$type": "app.bsky.richtext.facet#mention",
"did": "did:plc:fdme4gb7mu7zrie7peay7tst"
}
],
"index": {
"byteEnd": 149,
"byteStart": 137
}
}
],
"langs": [
"en"
],
"reply": {
"parent": {
"cid": "bafyreifaidyl62p4snkdwsygviemsxyidi3cd7dxvjomh5644sovxhsppa",
"uri": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3kfqklhpalh2c"
},
"root": {
"cid": "bafyreibiimdwmsp5mqpm7utqcdmvo6fdqmofblp5obs3h7ub6652zyooci",
"uri": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3kfqkkjdkic2e"
}
},
"text": "Bridgy Fed is an open-source project — check out the code here: github.com/snarfed/brid...\n\nStay updated with the project by following @snarfed.org!"
}

Some files were not shown because too many files have changed in this diff Show More